diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj
index 0e97c11ec..cf4cfcecd 100644
--- a/ElementX.xcodeproj/project.pbxproj
+++ b/ElementX.xcodeproj/project.pbxproj
@@ -10303,9 +10303,9 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CLASSIC_APP_DEEP_LINK_URL = "element://open";
- CLASSIC_APP_GROUP_IDENTIFIER = group.im.vector;
- CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER = "$(DEVELOPMENT_TEAM).im.vector.app.keychain.shared";
- CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER = "im.vector.app.encryption-manager-service";
+ CLASSIC_APP_GROUP_IDENTIFIER = group.ch.healthchat;
+ CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER = "$(DEVELOPMENT_TEAM).ch.healthchat.element.keychain.shared";
+ CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER = "ch.healthchat.element.encryption-manager-service";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
@@ -10378,9 +10378,9 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CLASSIC_APP_DEEP_LINK_URL = "element://open";
- CLASSIC_APP_GROUP_IDENTIFIER = group.im.vector;
- CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER = "$(DEVELOPMENT_TEAM).im.vector.app.keychain.shared";
- CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER = "im.vector.app.encryption-manager-service";
+ CLASSIC_APP_GROUP_IDENTIFIER = group.ch.healthchat;
+ CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER = "$(DEVELOPMENT_TEAM).ch.healthchat.element.keychain.shared";
+ CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER = "ch.healthchat.element.encryption-manager-service";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
diff --git a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift
index 31a30d796..c366b94bc 100644
--- a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift
+++ b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift
@@ -40,30 +40,27 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
canReportProblem = isBugReportServiceEnabled
let isQRCodeScanningSupported = !ProcessInfo.processInfo.isiOSAppOnMac
- let isClassicAppAccountAllowed = if let classicAppAccount = authenticationService.classicAppAccount {
- appSettings.accountProviders.contains(serverName: classicAppAccount.serverName, orBaseURL: classicAppAccount.homeserverURL)
- } else {
- false
- }
+ let classicAppAccountProvider = authenticationService.classicAppAccount?.serverName
+ let isClassicAppAccountAllowed = classicAppAccountProvider.map { appSettings.accountProviders.contains($0) } ?? false
let initialViewState = if !appSettings.allowOtherAccountProviders {
// We don't show the create account button when custom providers are disallowed.
// The assumption here being that if you're running a custom app, your users will already be created.
- AuthenticationStartScreenViewState(serverNameOrBaseURL: appSettings.accountProviders.count == 1 ? appSettings.accountProviders[0].serverNameOrBaseURL : nil,
+ AuthenticationStartScreenViewState(serverName: appSettings.accountProviders.count == 1 ? appSettings.accountProviders[0] : nil,
showCreateAccountButton: false,
showQRCodeLoginButton: isQRCodeScanningSupported,
classicAppMode: isClassicAppAccountAllowed ? authenticationService.classicAppAccount.map { .welcomeBack($0) } : nil,
hideBrandChrome: appSettings.hideBrandChrome)
} else if let provisioningParameters {
// We only show the "Sign in to …" button when using a provisioning link.
- AuthenticationStartScreenViewState(serverNameOrBaseURL: provisioningParameters.accountProvider,
+ AuthenticationStartScreenViewState(serverName: provisioningParameters.accountProvider,
showCreateAccountButton: false,
showQRCodeLoginButton: false,
classicAppMode: nil,
hideBrandChrome: appSettings.hideBrandChrome)
} else {
// The default configuration.
- AuthenticationStartScreenViewState(serverNameOrBaseURL: nil,
+ AuthenticationStartScreenViewState(serverName: nil,
showCreateAccountButton: appSettings.showCreateAccountButton,
showQRCodeLoginButton: isQRCodeScanningSupported,
classicAppMode: authenticationService.classicAppAccount.map { .welcomeBack($0) },
@@ -114,25 +111,26 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
private func login(classicAppAccount: ClassicAppAccount? = nil) async {
if let classicAppAccount {
- if classicAppAccount.state.availableSecrets == .requiresBackup {
- state.bindings.showClassicAppBackupInstructions = true
- } else {
- await loginDirectly(using: classicAppAccount.serverName,
- loginHint: "mxid:\(classicAppAccount.userID)",
- fallbackHomeserverURL: classicAppAccount.homeserverURL)
- }
- } else if let serverNameOrBaseURL = state.serverNameOrBaseURL {
- await loginDirectly(using: serverNameOrBaseURL, loginHint: provisioningParameters?.loginHint)
+ // HealthChat diverges from upstream here. Upstream sends `.requiresBackup` accounts to a sheet
+ // asking the user to set up key backup in Element Classic first. HealthChat ships with the same
+ // bundle id as the legacy app, so this update *replaces* it — there is no Classic app left to
+ // open, and the sheet's only button resolves to nothing. Fall through to a normal sign-in
+ // instead: the user re-verifies and loses only history that by definition has no backup.
+ await configureAccountProvider(classicAppAccount.serverName,
+ loginHint: "mxid:\(classicAppAccount.userID)",
+ fallbackHomeserverURL: classicAppAccount.homeserverURL)
+ } else if let serverName = state.serverName {
+ await configureAccountProvider(serverName, loginHint: provisioningParameters?.loginHint)
} else {
actionsSubject.send(.login) // No need to configure anything here, continue the flow.
}
}
- private func loginDirectly(using serverNameOrBaseURL: String, loginHint: String? = nil, fallbackHomeserverURL: URL? = nil) async {
+ private func configureAccountProvider(_ accountProvider: String, loginHint: String? = nil, fallbackHomeserverURL: URL? = nil) async {
startLoading()
defer { stopLoading() }
- if case .failure = await authenticationService.configure(for: serverNameOrBaseURL, flow: .login) {
+ if case .failure = await authenticationService.configure(for: accountProvider, flow: .login) {
// Try the fallback URL before showing an error.
if let fallbackHomeserverURL,
case .success = await authenticationService.configure(for: fallbackHomeserverURL.absoluteString, flow: .login) {
diff --git a/ElementX/SupportingFiles/ElementX.entitlements b/ElementX/SupportingFiles/ElementX.entitlements
index 6bb0cab4f..8758015f2 100644
--- a/ElementX/SupportingFiles/ElementX.entitlements
+++ b/ElementX/SupportingFiles/ElementX.entitlements
@@ -23,6 +23,7 @@
keychain-access-groups
$(KEYCHAIN_ACCESS_GROUP_IDENTIFIER)
+ $(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER)
diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml
index 85dc30454..2f903f83a 100644
--- a/ElementX/SupportingFiles/target.yml
+++ b/ElementX/SupportingFiles/target.yml
@@ -129,12 +129,17 @@ targets:
com.apple.developer.usernotifications.communication: true
com.apple.security.app-sandbox: true
com.apple.security.application-groups:
- # HealthChat: dropped $(CLASSIC_APP_GROUP_IDENTIFIER) (group.im.vector) — owned by Element's team, not provisionable here.
+ # HealthChat: dropped $(CLASSIC_APP_GROUP_IDENTIFIER) — not because it is un-provisionable,
+ # but because it resolves to group.ch.healthchat, the same value as $(APP_GROUP_IDENTIFIER)
+ # below. The legacy container is already entitled; listing it twice would duplicate the entry.
- $(APP_GROUP_IDENTIFIER)
com.apple.security.network.client: true
keychain-access-groups:
- # HealthChat: dropped $(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER) (im.vector.app.*) — not provisionable here.
- $(KEYCHAIN_ACCESS_GROUP_IDENTIFIER)
+ # HealthChat: the legacy app's keychain group. ClassicAppManager reads the account and
+ # crypto-store keys from here to migrate existing users. Same team prefix, so it is
+ # provisionable — unlike Element's im.vector.app.* default in app.yml.
+ - $(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER)
settings:
base:
diff --git a/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift b/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift
index 000d560cb..ea5c2d9bd 100644
--- a/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift
+++ b/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift
@@ -238,8 +238,8 @@ final class AuthenticationStartScreenViewModelTests {
}
@Test
- func classicAppAccountRequiresBackup() async throws {
- // Given a view model with a Classic app account that requires backup before signing in.
+ func classicAppAccountRequiresBackupFallsThroughToLogin() async throws {
+ // Given a view model with a Classic app account that has no usable key backup.
let classicAppAccount = makeClassicAppAccount()
await setupViewModel(classicAppAccount: classicAppAccount, availableSecrets: .requiresBackup)
guard case .welcomeBack(let account) = context.viewState.classicAppMode else {
@@ -248,27 +248,15 @@ final class AuthenticationStartScreenViewModelTests {
}
#expect(account.state.availableSecrets == .requiresBackup)
- // When continuing with the Classic account while backup is required.
- var deferred = deferFulfillment(context.observe(\.viewState.bindings.showClassicAppBackupInstructions)) { $0 }
+ // When continuing with the Classic account.
+ let deferred = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithOAuth }
context.send(viewAction: .continueWithClassic(classicAppAccount))
- // Then the backup instructions should be shown.
+ // Then the flow continues to a normal sign-in rather than the backup instructions sheet.
+ // HealthChat replaces the Classic app in place, so those instructions could never be followed.
try await deferred.fulfill()
-
- // When the user completes the backup in the Classic app and the app returns to the foreground.
- classicAppManager?.availableSecretsForReturnValue = .complete
- deferred = deferFulfillment(context.observe(\.viewState.bindings.showClassicAppBackupInstructions)) { !$0 }
- notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
-
- // Then the backup instructions sheet should be dismissed.
- try await deferred.fulfill()
-
- // When the user continues with the Classic account again.
- let deferredAction = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithOAuth }
- context.send(viewAction: .continueWithClassic(classicAppAccount))
-
- // Then the flow should continue the login process.
- try await deferredAction.fulfill()
+ #expect(context.viewState.bindings.showClassicAppBackupInstructions == false)
+ #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.loginHint == "mxid:\(classicAppAccount.userID)")
}
// MARK: - Helpers
diff --git a/Variants/HealthChat/healthchat.yml b/Variants/HealthChat/healthchat.yml
index c03356763..aabf7145d 100644
--- a/Variants/HealthChat/healthchat.yml
+++ b/Variants/HealthChat/healthchat.yml
@@ -11,3 +11,11 @@ settings:
APP_GROUP_IDENTIFIER: group.ch.healthchat
BASE_BUNDLE_IDENTIFIER: ch.healthchat.element
DEVELOPMENT_TEAM: 6G37K72W82
+
+ # The legacy element-ios HealthChat's identifiers. ClassicAppManager reads the account and
+ # crypto-store keys from these to migrate existing users on an in-place update. Upstream's
+ # defaults in app.yml point at Element's own (group.im.vector / im.vector.app.*), which this
+ # team cannot provision — repoint, never remove. Failure here is silent.
+ CLASSIC_APP_GROUP_IDENTIFIER: group.ch.healthchat
+ CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER: ch.healthchat.element.encryption-manager-service
+ CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER: "$(DEVELOPMENT_TEAM).ch.healthchat.element.keychain.shared"