Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ final class NSEUserScope: Component<NSEUserScopeDependency> {

enum Failure: Error {

case mainAppRequired(message: String)
case mainAppRequired(message: String, accountID: UUID)
case failedToFetchBackendEnvironment(any Error)
case failedToFetchProxyCredentials(any Error)
case failedToStoreMetadata(any Error)
Expand Down Expand Up @@ -100,9 +100,14 @@ final class NSEUserScope: Component<NSEUserScopeDependency> {
eventID: UUID,
contentHandler: @escaping (UNNotificationContent) -> Void
) async throws {

if DeveloperFlag.simulateMainAppRequiredError.isOn {
throw NSEUserScope.Failure.mainAppRequired(message: "simulated developer flag", accountID: accountID)
}

// Set up network stack.
guard let environment = try fetchBackendEnvironment() else {
throw Failure.mainAppRequired(message: "no stored backend for account")
throw Failure.mainAppRequired(message: "no stored backend for account", accountID: accountID)
}

var proxyCredentials: WireNetwork.ProxyCredentials?
Expand Down Expand Up @@ -132,15 +137,15 @@ final class NSEUserScope: Component<NSEUserScopeDependency> {
}

guard journal[.isSyncV2Enabled] else {
throw Failure.mainAppRequired(message: "sync v2 should be enabled")
throw Failure.mainAppRequired(message: "sync v2 should be enabled", accountID: accountID)
}

guard try await isAuthenticated() else {
throw Failure.userNotAuthenticated
}

guard !coreCryptoKeyMigrationManager.isAnyMigrationRequired else {
throw Failure.mainAppRequired(message: "core crypto key migration required")
throw Failure.mainAppRequired(message: "core crypto key migration required", accountID: accountID)
}

// TODO: [WPB-19778] guard no app version migration needed.
Expand All @@ -150,7 +155,7 @@ final class NSEUserScope: Component<NSEUserScopeDependency> {
let selfUser = ZMUser.selfUser(in: context)
return selfUser.selfClient()?.remoteIdentifier
}) else {
throw Failure.mainAppRequired(message: "no self client id")
throw Failure.mainAppRequired(message: "no self client id", accountID: accountID)
}

let earService = await EARServiceFactory.createEARService(
Expand Down Expand Up @@ -214,7 +219,7 @@ final class NSEUserScope: Component<NSEUserScopeDependency> {
private func resolveBackendMetadata(with networkStack: NetworkStack) async throws -> ResolvedBackendMetadata {
// Get the last known metadata.
guard let prevMetadata = try dependency.backendStore.fetchBackendMetadata(accountID: accountID) else {
throw Failure.mainAppRequired(message: "no previous backend metadata")
throw Failure.mainAppRequired(message: "no previous backend metadata", accountID: accountID)
}

// Get new metadata.
Expand Down Expand Up @@ -269,7 +274,7 @@ final class NSEUserScope: Component<NSEUserScopeDependency> {
}

guard !coreDataStack.needsMigration else {
throw Failure.mainAppRequired(message: "database migration required")
throw Failure.mainAppRequired(message: "database migration required", accountID: accountID)
}

do {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// 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

struct MainAppRequiredGate {

static let defaultInterval: TimeInterval = 60 * 60

private let userDefaults: UserDefaults
Comment thread
netbe marked this conversation as resolved.
private let interval: TimeInterval

init(
userDefaults: UserDefaults,
interval: TimeInterval = defaultInterval
) {
self.userDefaults = userDefaults
self.interval = interval
}

func markNotified(accountID: UUID, now: Date = .now) {
let journal = Journal(userID: accountID, storage: userDefaults)
journal[.mainAppRequiredNotificationLastNotifiedDate] = now
}

func shouldNotify(accountID: UUID, now: Date = .now) -> Bool {
let journal = Journal(userID: accountID, storage: userDefaults)

guard let lastNotifiedDate = journal[.mainAppRequiredNotificationLastNotifiedDate] else {
return true
}

return now.timeIntervalSince(lastNotifiedDate) >= interval
}

static func isMainAppRequiredErrorFoAccount(_ error: any Error) -> UUID? {
guard let nseUserError = error as? NSEUserScope.Failure,
case let .mainAppRequired(_, accountID) = nseUserError else {
return nil
}

return accountID
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public final class NotificationServiceExtension: NotificationServiceProtocol {
private let cookieEncryptionKey: Data
private let minTLSVersion: String?
private let preferredAPIVersion: UInt?
private let mainAppRequiredGate: MainAppRequiredGate

public init(
currentAppVersion: String,
Expand All @@ -62,6 +63,7 @@ public final class NotificationServiceExtension: NotificationServiceProtocol {
self.cookieEncryptionKey = cookieEncryptionKey
self.minTLSVersion = minTLSVersion
self.preferredAPIVersion = preferredAPIVersion
self.mainAppRequiredGate = MainAppRequiredGate(userDefaults: sharedUserDefaults)
registerProviderFactories()
logger.info("initializing new notification service", attributes: .newNSE, .safePublic)
}
Expand Down Expand Up @@ -96,6 +98,7 @@ public final class NotificationServiceExtension: NotificationServiceProtocol {
}

do {

let nseFlow = try NSEFlow(
currentAppVersion: currentAppVersion,
currentBuildNumber: currentBuildNumber,
Expand All @@ -112,7 +115,12 @@ public final class NotificationServiceExtension: NotificationServiceProtocol {
)
} catch {
logError(error)
if DeveloperFlag.showNSEErrors.isOn {

if let accountID = MainAppRequiredGate.isMainAppRequiredErrorFoAccount(error),
mainAppRequiredGate.shouldNotify(accountID: accountID) {

notificationContentHandler(mainAppRequiredNotification(for: request, accountID: accountID))
} else if DeveloperFlag.showNSEErrors.isOn {
notificationContentHandler(errorNotification(for: error))
} else {
notificationContentHandler(.emptyNotification)
Expand All @@ -130,6 +138,20 @@ public final class NotificationServiceExtension: NotificationServiceProtocol {
// MARK: - Error notification

extension NotificationServiceExtension {
private func mainAppRequiredNotification(
for request: UNNotificationRequest,
accountID: UUID
) -> UNMutableNotificationContent {
mainAppRequiredGate.markNotified(accountID: accountID)

let content = UNMutableNotificationContent()
content.title = String(localized: "notification_service_extension.error.open_app.title", bundle: .module)
content.body = String(localized: "notification_service_extension.error.open_app.message", bundle: .module)
Comment thread
David-Henner marked this conversation as resolved.
content.interruptionLevel = .active
content.sound = request.content.sound
return content
}

private func errorNotification(for error: any Error) -> UNMutableNotificationContent {
let content = UNMutableNotificationContent()
content.title = "NSE Error"
Expand Down Expand Up @@ -202,7 +224,7 @@ extension NotificationServiceExtension {
private func logUserError(_ error: NSEUserScope.Failure) {
switch error {
case let .mainAppRequired(message):
logger.error(
logger.warn(
"Main app required, need to open main app: \(message)",
attributes: .newNSE, .safePublic
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
{
"sourceLanguage" : "en",
"strings" : {
"notification_service_extension.error.open_app.message" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Open Wire to get future notifications"
}
}
}
},
"notification_service_extension.error.open_app.title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "New version installed"
}
}
}
},
"push.notification.action.acceptConnection" : {
"extractionState" : "manual",
"localizations" : {
Expand Down Expand Up @@ -6128,4 +6150,4 @@
}
},
"version" : "1.0"
}
}
11 changes: 11 additions & 0 deletions WireDomain/Sources/WireDomain/Utilities/Journal/Journal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,17 @@ public struct Journal: JournalProtocol {
}
}

/// Get or set an optional date value.

public subscript(_ key: JournalKey<Date?>) -> Date? {
get {
storage.object(forKey: rawKey(for: key)) as? Date? ?? key.defaultValue
}
nonmutating set {
storage.set(newValue, forKey: rawKey(for: key))
}
}

/// Delete all values in the journal.

public func erase() {
Expand Down
12 changes: 12 additions & 0 deletions WireDomain/Sources/WireDomain/Utilities/Journal/JournalKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import Foundation
import WireFoundation

/// A key that pairs a raw string with a specific value in
Expand Down Expand Up @@ -137,3 +138,14 @@ public extension JournalKey where Value == Set<String> {
)

}

public extension JournalKey where Value == Date? {

/// Last notified date the user was notified to open the main app in NSE

static let mainAppRequiredNotificationLastNotifiedDate = Self(
"mainAppRequiredNotificationLastNotifiedDate",
defaultValue: Date?.none
)

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@
// 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

public protocol JournalProtocol {

subscript(_ key: JournalKey<Bool>) -> Bool { get nonmutating set }
subscript(_ key: JournalKey<String?>) -> String? { get nonmutating set }
subscript(_ key: JournalKey<Date?>) -> Date? { get nonmutating set }

Check warning on line 24 in WireDomain/Sources/WireDomain/Utilities/Journal/JournalProtocol.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "key" or name it "_".

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-ios&issues=AZ6ST3UxsIEvOGOzzdtU&open=AZ6ST3UxsIEvOGOzzdtU&pullRequest=4786
subscript(_ key: JournalKey<Set<String>>) -> Set<String> { get nonmutating set }
nonmutating func erase()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//
// 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 Testing
import WireSystem
@testable import WireDomain

struct MainAppRequiredGateTests {
private let userDefaults = UserDefaults.temporary()
private let accountID = UUID()

private var sut: MainAppRequiredGate {
MainAppRequiredGate(userDefaults: userDefaults)
}

@Test("It notifies on first main-app-required error")
func notifiesOnFirstMainAppRequiredError() {
#expect(sut.shouldNotify(accountID: accountID, now: Date(timeIntervalSince1970: 1000)))
}

@Test("It suppresses notifications within one hour")
func suppressesNotificationsWithinOneHour() {
let now = Date(timeIntervalSince1970: 2000)

sut.markNotified(accountID: accountID, now: now)

#expect(!sut.shouldNotify(accountID: accountID, now: now.addingTimeInterval(3599)))
}

@Test("It notifies again after one hour")
func notifiesAgainAfterOneHour() {
let now = Date(timeIntervalSince1970: 2000)

sut.markNotified(accountID: accountID, now: now)

#expect(sut.shouldNotify(accountID: accountID, now: now.addingTimeInterval(3600)))
}

@Test("It scopes notifications by account")
func scopesNotificationsByAccount() {
let now = Date(timeIntervalSince1970: 2000)
let otherAccountID = UUID()

sut.markNotified(accountID: accountID, now: now)

// The marked account is suppressed, but a different account still notifies.
#expect(!sut.shouldNotify(accountID: accountID, now: now.addingTimeInterval(3599)))
#expect(sut.shouldNotify(accountID: otherAccountID, now: now.addingTimeInterval(3599)))
}

@Test("It extracts the account ID from a main-app-required error")
func extractsAccountIDFromMainAppRequiredError() {
let error = NSEUserScope.Failure.mainAppRequired(message: "test", accountID: accountID)

#expect(MainAppRequiredGate.isMainAppRequiredErrorFoAccount(error) == accountID)
}

@Test("It ignores non main-app-required errors")
func ignoresNonMainAppRequiredErrors() {
let error = TestError(message: "test")

#expect(MainAppRequiredGate.isMainAppRequiredErrorFoAccount(error) == nil)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1658,6 +1658,10 @@ extension SessionManager {
private func applicationDidBecomeActive(_ note: Notification) {
guard let session = activeUserSession, session.isLoggedIn else { return }
session.checkE2EICertificateExpiryStatus()

// In order to test the behaviour, assume here the user did open the main app
// and extensions are working again
DeveloperFlag.simulateMainAppRequiredError.enable(false)
Comment thread
David-Henner marked this conversation as resolved.
}

}
Expand Down
4 changes: 4 additions & 0 deletions wire-ios-utilities/Source/DeveloperFlag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public enum DeveloperFlag: String, CaseIterable {
case enabledCCDebugLogs
case shakeToReport
case showNSEErrors
case simulateMainAppRequiredError
// TODO: [WPB-25941] Remove drive permissions flag when feature is complete
case enableDrivePermissions
case unSafeLogsForPublic
Expand Down Expand Up @@ -108,6 +109,9 @@ public enum DeveloperFlag: String, CaseIterable {
case .showNSEErrors:
"Turn on to show Notification Service Extension errors as notifications"

case .simulateMainAppRequiredError:
"Turn on to force a 'main app required' error in the Notification Service and Share Extensions"

case .enableDrivePermissions:
"Turn on to enable drive permissions"

Expand Down
Loading
Loading