Skip to content
Open
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
b8eaa53
add properties to event model for EAR
David-Henner Jan 29, 2026
efabd78
inject EARService
David-Henner Feb 4, 2026
113f121
add protobuf message decoder
David-Henner Feb 13, 2026
e0d40b3
add encryption helper
David-Henner Feb 13, 2026
986d2d4
event envelope background accessible helper
David-Henner Feb 13, 2026
7a4e6f5
sendable keys
David-Henner Feb 13, 2026
0e0c7c5
sendable update event coder
David-Henner Feb 13, 2026
7889005
WIP
David-Henner Feb 13, 2026
e8f2f24
disable support for sync v2
David-Henner Feb 13, 2026
3405047
update checking isLocked
David-Henner Feb 13, 2026
f289f9e
update predicate; throw errors; logs
David-Henner Feb 13, 2026
1f896ee
fix; update logs
David-Henner Feb 13, 2026
2701c91
fix sync vs database unlock timing issue
David-Henner Feb 16, 2026
2529cf3
support decrypting legacy events when migrating to sync v2
David-Henner Feb 16, 2026
df66095
format
David-Henner Feb 16, 2026
38b668f
fix tests build
David-Henner Feb 17, 2026
7689b41
add check of isLocked after timeout
David-Henner Feb 17, 2026
d1ae697
mocks
David-Henner Feb 17, 2026
ae38662
set properties even when not encrypted
David-Henner Feb 17, 2026
7b487c5
tests for events store
David-Henner Feb 17, 2026
c43dc40
fix typo
David-Henner Feb 17, 2026
0242f6d
check lock status before resuming continuation
David-Henner Feb 17, 2026
af717ce
fix database unlock timeout task
David-Henner Feb 17, 2026
d5a0821
add incremental sync tests
David-Henner Feb 17, 2026
93052a3
cleanup
David-Henner Feb 17, 2026
1326a1b
format
David-Henner Feb 17, 2026
b04d292
update sync behaviour when in background
David-Henner Feb 18, 2026
18f1687
fix live event processing while in background
David-Henner Feb 18, 2026
f526676
update tests
David-Henner Feb 18, 2026
bbf10c7
format
David-Henner Feb 18, 2026
a6dc55f
update `backgroundAccessibleOnly` to account for EAR status
David-Henner Feb 18, 2026
7293043
revert changes to database notification
David-Henner Feb 18, 2026
fbb565b
Merge branch 'feat/ear-support-without-new-sync' into feat/ear-with-n…
David-Henner Feb 20, 2026
2ea7235
update logs
David-Henner Feb 20, 2026
6a2e408
throw encryption errors and treat missing private keys as critical
David-Henner Feb 20, 2026
34f71b9
add comments for unchecked sendables
David-Henner Feb 20, 2026
c192c71
update migration path
David-Henner Feb 20, 2026
927a65c
add descriptive comment
David-Henner Feb 20, 2026
dc10036
handle database locked error in syncAgent
David-Henner Feb 20, 2026
87b3a0a
properly set `isBackgroundAccessible`
David-Henner Feb 20, 2026
e82398e
log error
David-Henner Feb 20, 2026
65fd560
add a trigger for incremental sync specific for call events
David-Henner Feb 20, 2026
e0296f0
use `isLocked` instead of `backgroundAccessibleOnly` to handle backgr…
David-Henner Feb 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>ZMEventModel6.0.xcdatamodel</string>
<string>ZMEventModel7.0.xcdatamodel</string>
</dict>
</plist>
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24411" systemVersion="24G419" minimumToolsVersion="Xcode 7.0" sourceLanguage="Objective-C" userDefinedModelVersionIdentifier="7.0">
<entity name="StoredUpdateEvent" representedClassName="StoredUpdateEvent" syncable="YES">
<attribute name="debugInformation" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="eventHash" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="isCallEvent" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="NO" syncable="YES"/>
<attribute name="isEncrypted" optional="YES" attributeType="Boolean" usesScalarValueType="NO" syncable="YES"/>
<attribute name="isTransient" optional="YES" attributeType="Boolean" usesScalarValueType="NO" syncable="YES"/>
<attribute name="payload" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" syncable="YES"/>
<attribute name="sortIndex" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="NO" indexed="YES" syncable="YES"/>
<attribute name="source" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="uuidString" optional="YES" attributeType="String" syncable="YES"/>
</entity>
<entity name="StoredUpdateEventEnvelope" representedClassName="WireDataModel.StoredUpdateEventEnvelope" syncable="YES">
<attribute name="data" attributeType="Binary" syncable="YES"/>
<attribute name="isBackgroundAccessible" optional="YES" attributeType="Boolean" usesScalarValueType="NO" syncable="YES"/>
<attribute name="isEncrypted" optional="YES" attributeType="Boolean" usesScalarValueType="NO" syncable="YES"/>
<attribute name="sortIndex" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="NO" indexed="YES" syncable="YES"/>
</entity>
</model>
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
private let syncContext: NSManagedObjectContext
private let eventContext: NSManagedObjectContext

private let earService: any EARServiceInterface
private let mlsService: any MLSServiceInterface
private let mlsDecryptionService: any MLSDecryptionServiceInterface
private let proteusService: any ProteusServiceInterface
Expand All @@ -80,6 +81,7 @@
sharedUserDefaults: UserDefaults,
syncContext: NSManagedObjectContext,
eventContext: NSManagedObjectContext,
earService: any EARServiceInterface,
mlsService: any MLSServiceInterface,
mlsDecryptionService: any MLSDecryptionServiceInterface,
proteusService: any ProteusServiceInterface,
Expand All @@ -97,6 +99,7 @@
self.sharedUserDefaults = sharedUserDefaults
self.syncContext = syncContext
self.eventContext = eventContext
self.earService = earService
self.mlsService = mlsService
self.mlsDecryptionService = mlsDecryptionService
self.proteusService = proteusService
Expand Down Expand Up @@ -405,7 +408,8 @@
syncStateSubject: syncStateSubject,
liveBrokenGroupSubject: liveBrokenGroupSubject,
journal: journal,
mlsGroupRepairAgent: mlsGroupRepairAgent
mlsGroupRepairAgent: mlsGroupRepairAgent,
earService: earService
)

public lazy var incrementalSyncV2: IncrementalSyncV2 = if let sharedContainerURL {
Expand All @@ -423,6 +427,7 @@
coreCryptoProvider: coreCryptoProvider,
journal: journal,
mlsGroupRepairAgent: mlsGroupRepairAgent,
earService: earService,
createPushChannelState: { [selfClientID] in
PushChannelState(sharedContainerURL: sharedContainerURL, clientID: selfClientID)
}
Expand Down Expand Up @@ -842,7 +847,7 @@
)

public lazy var workAgent: WorkAgent = .init(scheduler: PriorityOrderWorkItemScheduler())

Check warning on line 850 in WireDomain/Sources/WireDomain/Components/ClientSessionComponent.swift

View workflow job for this annotation

GitHub Actions / Test Results

Actor-isolated default value in a nonisolated context; this is an error in the Swift 6 language mode

Actor-isolated default value in a nonisolated context; this is an error in the Swift 6 language mode
public lazy var conversationUpdatesGenerator: IncrementalGeneratorProtocol = ConversationUpdatesGenerator(
repository: conversationRepository,
context: syncContext,
Expand All @@ -851,7 +856,7 @@
self?.workAgent.submitItem(workItem)
}
)

Check warning on line 859 in WireDomain/Sources/WireDomain/Components/ClientSessionComponent.swift

View workflow job for this annotation

GitHub Actions / Test Results

Actor-isolated default value in a nonisolated context; this is an error in the Swift 6 language mode

Actor-isolated default value in a nonisolated context; this is an error in the Swift 6 language mode
public lazy var commitPendingProposalsGenerator: LiveGeneratorProtocol = CommitPendingProposalsGenerator(
repository: conversationRepository,
mlsService: mlsService,
Expand Down Expand Up @@ -888,7 +893,7 @@
liveBrokenGroupSubject.sink { [weak self] liveMLSBrokenGroups in
guard let self else { return }
WireLogger.mls.debug("detected during live sync \(liveMLSBrokenGroups.count) broken MLS groups")
let item = RepairBrokenMLSGroupsItem(repairAgent: mlsGroupRepairAgent)

Check warning on line 896 in WireDomain/Sources/WireDomain/Components/ClientSessionComponent.swift

View workflow job for this annotation

GitHub Actions / Test Results

Passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure; this is an error in the Swift 6 language mode

Passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure; this is an error in the Swift 6 language mode
Task {
await self.workAgent.submitItem(item)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public final class UserSessionComponent {
private let syncContext: NSManagedObjectContext
private let eventContext: NSManagedObjectContext

private let earService: any EARServiceInterface
private let mlsService: any MLSServiceInterface
private let mlsDecryptionService: any MLSDecryptionServiceInterface
private let proteusService: any ProteusServiceInterface
Expand All @@ -58,6 +59,7 @@ public final class UserSessionComponent {
sharedContainerURL: URL?,
syncContext: NSManagedObjectContext,
eventContext: NSManagedObjectContext,
earService: any EARServiceInterface,
mlsService: any MLSServiceInterface,
mlsDecryptionService: any MLSDecryptionServiceInterface,
proteusService: any ProteusServiceInterface,
Expand All @@ -75,6 +77,7 @@ public final class UserSessionComponent {
self.sharedUserDefaults = sharedUserDefaults
self.syncContext = syncContext
self.eventContext = eventContext
self.earService = earService
self.mlsService = mlsService
self.mlsDecryptionService = mlsDecryptionService
self.proteusService = proteusService
Expand Down Expand Up @@ -103,6 +106,7 @@ public final class UserSessionComponent {
sharedUserDefaults: sharedUserDefaults,
syncContext: syncContext,
eventContext: eventContext,
earService: earService,
mlsService: mlsService,
mlsDecryptionService: mlsDecryptionService,
proteusService: proteusService,
Expand Down
47 changes: 47 additions & 0 deletions WireDomain/Sources/WireDomain/Helpers/EAREncryptionHelper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// Wire
// Copyright (C) 2026 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import Foundation

enum EAREncryptionHelper {
static func encrypt(
data: Data,
publicKey: SecKey
) -> Data? {

SecKeyCreateEncryptedData(
publicKey,
.eciesEncryptionCofactorX963SHA256AESGCM,
data as CFData,
nil
) as? Data
}

static func decrypt(
data: Data,
privateKey: SecKey
) -> Data? {

SecKeyCreateDecryptedData(
privateKey,
.eciesEncryptionCofactorX963SHA256AESGCM,
data as CFData,
nil
) as? Data
}
}
78 changes: 78 additions & 0 deletions WireDomain/Sources/WireDomain/Helpers/ProtobufMessageDecoder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//
// Wire
// Copyright (C) 2026 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import Foundation
import GenericMessageProtocol

struct ProtobufMessageDecoder {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: it looks like this was restored to its original implementation, but changes were made recently to throw errors. I think we should preserve these changes.


private init() {}

static func getProtobufMessage(
from base64Message: String,
externalData: String? = nil
) -> GenericMessage? {
var genericMessage = GenericMessage(from: base64Message, validate: true)

// If the encrypted payload is bigger than a certain size, an External Message is sent instead of a regular
// message.
// See `External` section from https://github.com/wireapp/generic-message-proto
// See `External messages` section from
// https://wearezeta.atlassian.net/wiki/spaces/ENGINEERIN/pages/20545866/Messages
if let externalData,
case let .some(.external(external)) = genericMessage?.content {

// Content message is external, we decrypt the external payload
// and turns it back into a generic non-external content message.
if let decryptedGenericMessage = decryptExternalMessage(
externalData: externalData,
external: external
) {
genericMessage = decryptedGenericMessage
} else {
return nil
}
}

return genericMessage
}

private static func decryptExternalMessage(
externalData: String,
external: External
) -> GenericMessage? {
let externalData = Data(base64Encoded: externalData)
let externalSha256 = externalData?.zmSHA256Digest()

guard externalSha256 == external.sha256 else {
return nil
}

let decryptedData = externalData?.zmDecryptPrefixedPlainTextIV(
key: external.otrKey
)

guard
let base64String = decryptedData?.base64String(),
let message = GenericMessage(from: base64String, validate: true)
else { return nil }

return message
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
private let localDomain: String
private let isFederationEnabled: Bool
private let coreDataStack: CoreDataStack
private let earService: EARServiceInterface

private let pushChannelCoordinator: AppExtensionPushChannelCoordinator
private var currentTask: Task<Void, any Error>?
Expand All @@ -68,7 +69,8 @@
apiVersion: WireNetwork.APIVersion,
localDomain: String,
isFederationEnabled: Bool,
coreDataStack: CoreDataStack
coreDataStack: CoreDataStack,
earService: EARServiceInterface
) {
self.clientID = clientID
self.restNetworkService = restNetworkService
Expand All @@ -78,6 +80,7 @@
self.isFederationEnabled = isFederationEnabled
self.coreDataStack = coreDataStack
self.pushChannelCoordinator = AppExtensionPushChannelCoordinator(clientID: clientID)
self.earService = earService

super.init(parent: parent)
}
Expand All @@ -88,6 +91,7 @@
) async throws {
// Pull pending update events.
let eventStream: AsyncStream<[UpdateEvent]>
let publicKeys = try earService.fetchPublicKeys()

if dependency.journal[.isConsumableNotificationsEnabled] {
let (useCase, stream) = syncEventsUseCase()
Expand All @@ -107,8 +111,8 @@
} catch {
throw Failure.pushChannelAlreadyOpened
}

Check warning on line 114 in WireDomain/Sources/WireDomain/Notifications/Components/NSEClientScope.swift

View workflow job for this annotation

GitHub Actions / Test Results

Passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure; this is an error in the Swift 6 language mode

Passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure; this is an error in the Swift 6 language mode
monitoringTask = Task { [weak self] in

Check warning on line 115 in WireDomain/Sources/WireDomain/Notifications/Components/NSEClientScope.swift

View workflow job for this annotation

GitHub Actions / Test Results

Variable 'request' was never mutated; consider changing to 'let' constant

Variable 'request' was never mutated; consider changing to 'let' constant
var request = await self?.pushChannelCoordinator.listenForYieldRequests()
if Task.isCancelled {
return
Expand All @@ -118,7 +122,7 @@
request?.acknowledge()
WireLogger.sync.debug("notified main App to resume sync", attributes: .incrementalSync, .newNSE)
}

Check warning on line 125 in WireDomain/Sources/WireDomain/Notifications/Components/NSEClientScope.swift

View workflow job for this annotation

GitHub Actions / Test Results

Passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure; this is an error in the Swift 6 language mode

Passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure; this is an error in the Swift 6 language mode
currentTask = Task {
do {
try Task.checkCancellation()
Expand All @@ -144,7 +148,7 @@
}

} else {
eventStream = try await pullEventsUseCase.invoke()
eventStream = try await pullEventsUseCase.invoke(publicKeys: publicKeys)
}

// Generate notifications from events.
Expand Down Expand Up @@ -245,7 +249,7 @@
}

private var coreCryptoProvider: CoreCryptoProvider {
shared {

Check warning on line 252 in WireDomain/Sources/WireDomain/Notifications/Components/NSEClientScope.swift

View workflow job for this annotation

GitHub Actions / Test Results

Sending value of non-Sendable type 'UserDefaults' risks causing data races; this is an error in the Swift 6 language mode

Sending value of non-Sendable type 'UserDefaults' risks causing data races; this is an error in the Swift 6 language mode

Check warning on line 252 in WireDomain/Sources/WireDomain/Notifications/Components/NSEClientScope.swift

View workflow job for this annotation

GitHub Actions / Test Results

Sending 'self.coreCryptoMigrationManager' risks causing data races; this is an error in the Swift 6 language mode

Sending 'self.coreCryptoMigrationManager' risks causing data races; this is an error in the Swift 6 language mode
CoreCryptoProvider(
selfUserID: dependency.accountID,
sharedContainerURL: dependency.appContainerURL,
Expand Down Expand Up @@ -303,7 +307,7 @@
}

private var mlsActionExecutor: MLSActionExecutor {
shared {

Check warning on line 310 in WireDomain/Sources/WireDomain/Notifications/Components/NSEClientScope.swift

View workflow job for this annotation

GitHub Actions / Test Results

Sending 'self.featureRepository' risks causing data races; this is an error in the Swift 6 language mode

Sending 'self.featureRepository' risks causing data races; this is an error in the Swift 6 language mode
MLSActionExecutor(
coreCryptoProvider: coreCryptoProvider,
featureRepository: featureRepository
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,13 @@
throw Failure.mainAppRequired(message: "no self client id")
}

let earService = await EARService(
accountID: accountID,
coreDataStack: coreDataStack,
sharedUserDefaults: dependency.sharedUserDefaults,
authenticationContext: AuthenticationContext(storage: LAContextStorage())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: isn't the LAContextStorage() requiring a biometric prompt?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's LAContext() that does IIRC

)

// Continue with client.
let clientScope = clientScope(
clientID: clientID,
Expand All @@ -163,7 +170,8 @@
apiVersion: metadata.apiVersion,
localDomain: metadata.domain,
isFederationEnabled: metadata.isFederationEnabled,
coreDataStack: coreDataStack
coreDataStack: coreDataStack,
earService: earService
)

try await clientScope.processPayload(
Expand Down Expand Up @@ -266,7 +274,7 @@
throw Failure.mainAppRequired(message: "database migration required")
}

do {

Check warning on line 277 in WireDomain/Sources/WireDomain/Notifications/Components/NSEUserScope.swift

View workflow job for this annotation

GitHub Actions / Test Results

Sending 'coreDataStack' risks causing data races; this is an error in the Swift 6 language mode

Sending 'coreDataStack' risks causing data races; this is an error in the Swift 6 language mode
try await coreDataStack.load()
} catch {
throw Failure.failedToLoadPersistenceStack(error)
Expand Down Expand Up @@ -304,7 +312,8 @@
apiVersion: WireNetwork.APIVersion,
localDomain: String,
isFederationEnabled: Bool,
coreDataStack: CoreDataStack
coreDataStack: CoreDataStack,
earService: EARServiceInterface
) -> NSEClientScope {
NSEClientScope(
parent: self,
Expand All @@ -314,7 +323,8 @@
apiVersion: apiVersion,
localDomain: localDomain,
isFederationEnabled: isFederationEnabled,
coreDataStack: coreDataStack
coreDataStack: coreDataStack,
earService: earService
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import WireNetwork

// sourcery: AutoMockable
protocol PullEventsUseCaseProtocol {
func invoke() async throws -> AsyncStream<[UpdateEvent]>
func invoke(publicKeys: EARPublicKeys?) async throws -> AsyncStream<[UpdateEvent]>
}

struct PullEventsUseCase: PullEventsUseCaseProtocol {
Expand All @@ -40,14 +40,14 @@ struct PullEventsUseCase: PullEventsUseCaseProtocol {
self.pendingEventsSync = pendingEventsSync
}

func invoke() async throws -> AsyncStream<[UpdateEvent]> {
func invoke(publicKeys: EARPublicKeys?) async throws -> AsyncStream<[UpdateEvent]> {
logger.info(
"Attempting to fetch pending events",
attributes: .newNSE, .safePublic
)

do {
return try await pendingEventsSync.pull()
return try await pendingEventsSync.pull(publicKeys: publicKeys)

} catch {
throw Failure.unableToPullPendingEvents(error)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
//

import CoreData
import WireDataModel
import WireNetwork

// sourcery: AutoMockable
Expand Down Expand Up @@ -48,7 +49,8 @@ public protocol UpdateEventsLocalStoreProtocol {

func persistEventEnvelope(
_ eventEnvelope: UpdateEventEnvelope,
index: Int64
index: Int64,
publicKeys: EARPublicKeys?
) async throws

/// Persists an event envelopes locally.
Expand All @@ -58,15 +60,19 @@ public protocol UpdateEventsLocalStoreProtocol {

func persistEventEnvelopes(
_ eventEnvelopes: [UpdateEventEnvelope],
index: Int64
index: Int64,
publicKeys: EARPublicKeys?
) async throws

/// Fetches stored event envelopes.
/// - parameter limit: A fetch limit.
/// - parameter privateKeys: The private keys to use for decryption (if needed).
/// - returns: A list of decoded event envelopes and their related object IDs.

func fetchStoredEventEnvelopes(
limit: UInt
limit: UInt,
privateKeys: EARPrivateKeys?,
backgroundAccessibleOnly: Bool
) async throws -> [(envelope: UpdateEventEnvelope, objectID: NSManagedObjectID)]

/// Deletes next pending events locally.
Expand Down
Loading
Loading