diff --git a/android/app/src/main/java/com/ethosprotocol/models/Models.kt b/android/app/src/main/java/com/ethosprotocol/models/Models.kt index a987084..2813a85 100644 --- a/android/app/src/main/java/com/ethosprotocol/models/Models.kt +++ b/android/app/src/main/java/com/ethosprotocol/models/Models.kt @@ -56,7 +56,10 @@ data class PasskeyVerifyRequest( @Serializable data class PasskeyRegisterRequest( @SerialName("credential_id") val credentialId: String, - @SerialName("public_key") val publicKey: String, + // Field name is `attestation_object` per shared/api-contract.md — the backend + // extracts the COSE-encoded public key from this object. The legacy `public_key` + // field is not accepted by the server. (#108) + @SerialName("attestation_object") val attestationObject: String, @SerialName("client_data_json") val clientDataJson: String ) diff --git a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt index a5a5684..3b8e1fd 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt @@ -250,7 +250,8 @@ data class VaultUiState( val vaults: List = emptyList(), val isLoading: Boolean = false, val error: String? = null, - val isOffline: Boolean = false + val isOffline: Boolean = false, + val beneficiaryUpdated: Boolean = false ) @HiltViewModel diff --git a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt index 07a7576..b319c9c 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt @@ -474,6 +474,115 @@ fun BeneficiaryAcceptanceScreenContent( } } +// MARK: - Manage Beneficiary Screen + +@Composable +fun ManageBeneficiaryScreen( + vault: com.ethosprotocol.models.Vault, + onDone: () -> Unit, + vm: VaultViewModel = hiltViewModel() +) { + val state by vm.state.collectAsStateWithLifecycle() + var newBeneficiary by remember { mutableStateOf("") } + var showConfirmation by remember { mutableStateOf(false) } + + // The server rejects an address that is empty or unchanged. Mirror the same + // validation used by iOS BeneficiaryUpdate.isValidNewBeneficiary(). + val isAddressValid = newBeneficiary.trim().isNotEmpty() && newBeneficiary.trim() != vault.beneficiary + + LaunchedEffect(state.beneficiaryUpdated) { + if (state.beneficiaryUpdated) { + vm.clearBeneficiaryUpdated() + onDone() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center + ) { + Icon(Icons.Default.Person, contentDescription = null, + modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(16.dp)) + Text("Manage Beneficiary", style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(8.dp)) + Text("Current beneficiary:", style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(vault.beneficiary, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace) + Spacer(Modifier.height(16.dp)) + + if (showConfirmation) { + // Confirmation step — mirrors iOS ManageBeneficiaryView.confirmationContent + Text("Confirm Change", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + Text("From:", style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(vault.beneficiary, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace) + Spacer(Modifier.height(4.dp)) + Text("To:", style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(newBeneficiary.trim(), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace) + state.error?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(24.dp)) + Button( + onClick = { vm.updateBeneficiary(vault.id, newBeneficiary.trim()) }, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isLoading + ) { + if (state.isLoading) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + else Text("Confirm Change") + } + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = { showConfirmation = false }, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isLoading + ) { Text("Back") } + } else { + // Input step + OutlinedTextField( + value = newBeneficiary, + onValueChange = { newBeneficiary = it }, + label = { Text("New Beneficiary (Stellar address)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + isError = newBeneficiary.isNotEmpty() && !isAddressValid, + supportingText = if (newBeneficiary.isNotEmpty() && !isAddressValid) { + { Text("Enter a non-empty address that differs from the current beneficiary.") } + } else null + ) + state.error?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(24.dp)) + Button( + onClick = { showConfirmation = true }, + modifier = Modifier.fillMaxWidth(), + enabled = isAddressValid + ) { Text("Continue") } + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = onDone, + modifier = Modifier.fillMaxWidth() + ) { Text("Cancel") } + } + } +} + // MARK: - Vault Deep Link Screen @Composable diff --git a/android/app/src/main/java/com/ethosprotocol/widget/VaultStatusWidget.kt b/android/app/src/main/java/com/ethosprotocol/widget/VaultStatusWidget.kt index d975a5d..36378c0 100644 --- a/android/app/src/main/java/com/ethosprotocol/widget/VaultStatusWidget.kt +++ b/android/app/src/main/java/com/ethosprotocol/widget/VaultStatusWidget.kt @@ -13,6 +13,7 @@ import androidx.work.* import com.ethosprotocol.R import com.ethosprotocol.api.ApiClient import com.ethosprotocol.api.ApiResult +import com.ethosprotocol.models.VaultStatus import com.ethosprotocol.ui.MainActivity import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -105,7 +106,14 @@ class VaultWidgetUpdateWorker @AssistedInject constructor( override suspend fun doWork(): Result { val result = apiClient.listVaults() if (result is ApiResult.Success) { - val vault = result.data.firstOrNull() ?: return Result.success() + // Pick the active vault with the lowest ttlRemaining — the same + // "most urgent vault" selection that iOS TTLTimelineProvider uses + // (min(by:) on ttlRemaining). Using firstOrNull() would show a + // different vault than iOS for accounts with multiple vaults. + val vault = result.data + .filter { it.status == VaultStatus.active } + .minByOrNull { it.ttlRemaining ?: Long.MAX_VALUE } + ?: return Result.success() val ttl = formatTtl(vault.ttlRemaining) VaultStatusWidget.saveVaultData( applicationContext, diff --git a/docs/mobile-passkey-flow.md b/docs/mobile-passkey-flow.md index 743e344..0e54359 100644 --- a/docs/mobile-passkey-flow.md +++ b/docs/mobile-passkey-flow.md @@ -32,12 +32,14 @@ The registration process establishes a new passkey credential for vault owner au └────────────┬─────────────────────┘ │ ┌────────────▼──────────────────────┐ - │ Send attestation + public key │ - │ to Ethos-Protocol backend │ + │ Send attestation_object │ + │ + credential_id + client_data_json│ + │ to Ethos-Protocol backend │ └────────────┬─────────────────────┘ │ ┌────────────▼──────────────────────┐ - │ Backend stores public key │ + │ Backend extracts public key from │ + │ attestation_object and stores it │ │ for vault owner │ └────────────▼──────────────────────┘ │ @@ -144,20 +146,18 @@ func authorizationController( didCompleteWithAuthorization authorization: ASAuthorization ) { if let credential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration { - // 1. Extract public key from credential - let publicKeyDer = credential.credentialPublicKey // DER-encoded public key - - // 2. Extract attestation object (proof of credential creation) - let attestationObject = credential.attestationObject - - // 3. Extract client data (challenge proof) + // 1. Extract the raw WebAuthn attestation object (CBOR-encoded COSE key + proof). + // The backend parses the public key out of this object — do NOT try to extract + // the public key client-side and send it separately. + let attestationObject = credential.rawAttestationObject // base64url-encode before sending + + // 2. Extract client data (challenge proof) let clientDataJSON = credential.rawClientDataJSON - - // 4. Send to backend for verification and storage + + // 3. Send to backend. Field name MUST be `attestation_object` (not `public_key`). try await registerCredentialWithBackend( vaultId: vaultId, - publicKey: publicKeyDer, - attestation: attestationObject, + attestationObject: attestationObject, clientData: clientDataJSON ) } @@ -264,12 +264,17 @@ suspend fun registerPasskey(vaultId: String) { activity = this ) - // 5. Extract registration response + // 5. Extract registration response and build request val credentialResponse = result.credential as PublicKeyCredential - val registrationResponse = credentialResponse.registrationResponseJson - - // 6. Send to backend - registerCredentialWithBackend(vaultId, registrationResponse) + val json = JSONObject(credentialResponse.registrationResponseJson) + // Send `attestation_object` (not `public_key`) — the backend extracts the + // COSE public key from the attestation object server-side. (#108) + val attestationObject = json.getJSONObject("response").getString("attestationObject") + val clientDataJson = json.getJSONObject("response").getString("clientDataJSON") + val credentialId = json.getString("id") + + // 6. Send to backend with correct field names + registerCredentialWithBackend(vaultId, credentialId, attestationObject, clientDataJson) } catch (e: CreateCredentialException) { handleError(e) } @@ -385,22 +390,21 @@ pub async fn verify_registration( ) -> Result { // 1. Verify attestation challenge matches what was sent verify_challenge(®istration_response.client_data_json)?; - + // 2. Verify origin matches expected domain verify_origin(®istration_response.client_data_json, "ethos-protocol.app")?; - - // 3. For optional attestation verification: - // Verify the credential is from a trusted authenticator (e.g., Apple/Google) - if let Some(attestation_object) = registration_response.attestation_object { - verify_attestation(&attestation_object)?; - } - - // 4. Extract and validate public key - let public_key = extract_public_key(®istration_response)?; - + + // 3. Parse and verify the attestation object (always required — both clients + // send it as `attestation_object`; the legacy `public_key` field is gone). + // The COSE-encoded public key is extracted from inside the attestation object. + let public_key = extract_public_key_from_attestation(®istration_response.attestation_object)?; + + // 4. Optionally verify authenticator provenance (Apple/Google hardware) + verify_attestation(®istration_response.attestation_object)?; + // 5. Store public key associated with vault owner store_passkey(vault_id, public_key.clone()).await?; - + Ok(public_key) } ``` diff --git a/ios/EthosProtocol/Sources/App/EthosProtocolApp.swift b/ios/EthosProtocol/Sources/App/EthosProtocolApp.swift index 4ed71d3..d1c0952 100644 --- a/ios/EthosProtocol/Sources/App/EthosProtocolApp.swift +++ b/ios/EthosProtocol/Sources/App/EthosProtocolApp.swift @@ -7,6 +7,7 @@ struct EthosProtocolApp: App { init() { BackgroundRefreshService.shared.registerBackgroundTask() + CheckInSyncTask.shared.registerBackgroundTask() ICloudSyncService.shared.restoreFromICloud() NotificationCenter.default.addObserver( diff --git a/ios/EthosProtocol/Sources/Services/APIClient.swift b/ios/EthosProtocol/Sources/Services/APIClient.swift index 5ef4e00..b7a9428 100644 --- a/ios/EthosProtocol/Sources/Services/APIClient.swift +++ b/ios/EthosProtocol/Sources/Services/APIClient.swift @@ -76,9 +76,12 @@ public final class APIClient { return try await post(path: "/auth/verify", body: body) } - func registerPasskey(credentialID: String, publicKey: String, clientDataJSON: String) async throws { + func registerPasskey(credentialID: String, attestationObject: String, clientDataJSON: String) async throws { + // Field name is `attestation_object` per shared/api-contract.md — the backend + // parses the COSE public key out of the attestation object itself. + // (Legacy field name `public_key` is not accepted by the server.) let body = ["credential_id": credentialID, - "public_key": publicKey, + "attestation_object": attestationObject, "client_data_json": clientDataJSON] let _: EmptyBody = try await post(path: "/auth/register", body: body) } diff --git a/ios/EthosProtocol/Sources/Services/CheckInSyncTask.swift b/ios/EthosProtocol/Sources/Services/CheckInSyncTask.swift new file mode 100644 index 0000000..0274421 --- /dev/null +++ b/ios/EthosProtocol/Sources/Services/CheckInSyncTask.swift @@ -0,0 +1,149 @@ +import BackgroundTasks +import Foundation + +// MARK: - CheckInSyncTask + +/// Drains the `PendingCheckInStore` queue whenever the device has connectivity. +/// +/// Mirrors Android's `CheckInSyncWorker` (CheckInSyncWorker.kt) exactly: +/// - Iterates pending items oldest-first. +/// - On `ApiResult.Success` → delete from queue. +/// - On network unavailable → mark `hasRetryableFailure`, leave item, schedule again. +/// - On server error with a NON-RETRYABLE code (400/404/410) → delete (vault is gone +/// or request is permanently invalid — retrying would never help). +/// - On any other server error (5xx, 401, etc.) → leave item for retry. +/// - Posts a local notification when all items are cleared (mirrors +/// `notificationHelper.cancelQueuedCheckIn()` on Android). +/// +/// Requires `"app.ethos-protocol.checkin-sync"` in BGTaskSchedulerPermittedIdentifiers +/// (Info.plist) and `BGProcessingTaskRequest` so the OS picks it up on the next +/// background opportunity with network access. +final class CheckInSyncTask { + static let shared = CheckInSyncTask() + static let taskIdentifier = "app.ethos-protocol.checkin-sync" + + // Error codes where the server has definitively rejected the check-in. Matches + // CheckInSyncWorker.NON_RETRYABLE_ERROR_CODES on Android exactly. + static let nonRetryableErrorCodes: Set = [400, 404, 410] + + // Injected for testing + var apiClient: APIClientProtocol = APIClient.shared + var store: PendingCheckInStore = .shared + + private init() {} + + // MARK: - Registration + + /// Call once from `AppDelegate.application(_:didFinishLaunchingWithOptions:)`. + func registerBackgroundTask() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.taskIdentifier, using: nil) { [weak self] task in + guard let processingTask = task as? BGProcessingTask else { + task.setTaskCompleted(success: false) + return + } + self?.handleSync(task: processingTask) + } + } + + /// Schedule a one-shot BGProcessingTask to run when connectivity is available. + /// Safe to call repeatedly — the OS de-duplicates pending requests for the same identifier. + func scheduleSync() { + let request = BGProcessingTaskRequest(identifier: Self.taskIdentifier) + request.requiresNetworkConnectivity = true + request.requiresExternalPower = false + // Submit best-effort; ignore if background tasks are disabled (simulator, low power mode). + try? BGTaskScheduler.shared.submit(request) + } + + // MARK: - Sync logic + + /// Drain the queue. Called both from the BGProcessingTask handler and directly + /// (foreground re-try when `NetworkMonitor` reports connectivity restored). + @discardableResult + func performSync() async -> SyncResult { + let pending = store.getAll() + guard !pending.isEmpty else { return .success } + + var hasRetryableFailure = false + + for item in pending { + let result = await apiClient.checkIn(vaultID: item.vaultId) + switch result { + case .success: + store.delete(item) + case .networkUnavailable: + hasRetryableFailure = true + case .serverError(let code, _): + if Self.nonRetryableErrorCodes.contains(code) { + // Server has permanently rejected this check-in — drop it. + store.delete(item) + } else { + hasRetryableFailure = true + } + } + } + + if store.count == 0 { + NotificationService.shared.cancelQueuedCheckIn() + } + + return hasRetryableFailure ? .retry : .success + } + + // MARK: - BGProcessingTask handler + + private func handleSync(task: BGProcessingTask) { + // Re-schedule before doing the work so a gap never opens up. + scheduleSync() + + var syncTask: Task? + task.expirationHandler = { syncTask?.cancel() } + + syncTask = Task { + let result = await performSync() + task.setTaskCompleted(success: result == .success) + } + } + + // MARK: - Result + + enum SyncResult: Equatable { + case success + case retry + } +} + +// MARK: - APIClientProtocol + +/// Subset of APIClient used by CheckInSyncTask, extracted so tests can inject a stub +/// without subclassing APIClient. Mirrors how Android tests mock ApiClient via Hilt. +protocol APIClientProtocol: AnyObject { + func checkIn(vaultID: String) async -> CheckInResult +} + +enum CheckInResult { + case success + case networkUnavailable + case serverError(code: Int, message: String) +} + +// MARK: - APIClient conformance + +extension APIClient: APIClientProtocol { + func checkIn(vaultID: String) async -> CheckInResult { + do { + try await checkIn(vaultID: vaultID) + return .success + } catch APIError.networkUnavailable { + return .networkUnavailable + } catch APIError.notFound { + return .serverError(code: 404, message: "Not found") + } catch APIError.serverError(let msg) { + // Parse the numeric code out of messages like "Server error 410" + let code = msg.components(separatedBy: " ").last.flatMap(Int.init) ?? 500 + return .serverError(code: code, message: msg) + } catch { + return .serverError(code: 500, message: error.localizedDescription) + } + } +} diff --git a/ios/EthosProtocol/Sources/Services/NotificationService.swift b/ios/EthosProtocol/Sources/Services/NotificationService.swift index 66bebbc..e1186a1 100644 --- a/ios/EthosProtocol/Sources/Services/NotificationService.swift +++ b/ios/EthosProtocol/Sources/Services/NotificationService.swift @@ -99,6 +99,33 @@ final class NotificationService: NSObject, UNUserNotificationCenterDelegate { center.add(request) } + /// Dismiss the "check-in queued" persistent notification once all pending + /// check-ins have been delivered. Mirrors Android's + /// `NotificationHelper.cancelQueuedCheckIn()` called from `CheckInSyncWorker`. + func cancelQueuedCheckIn() { + UNUserNotificationCenter.current() + .removeDeliveredNotifications(withIdentifiers: ["checkin-queued-badge"]) + UNUserNotificationCenter.current() + .removePendingNotificationRequests(withIdentifiers: ["checkin-queued-badge"]) + } + + /// Show a persistent notification while offline check-ins are queued. + /// Mirrors Android's `NotificationHelper.showQueuedCheckIn(count)`. + func showQueuedCheckIn(count: Int) { + let content = UNMutableNotificationContent() + content.title = "Check-in Queued" + content.body = count == 1 + ? "1 check-in is queued and will be sent when you're back online." + : "\(count) check-ins are queued and will be sent when you're back online." + content.sound = nil // silent — informational only + let request = UNNotificationRequest( + identifier: "checkin-queued-badge", + content: content, + trigger: nil // deliver immediately + ) + UNUserNotificationCenter.current().add(request) + } + func registerNotificationCategories() { // .authenticationRequired ensures iOS forces the device to be unlocked before this // action fires — otherwise anyone with the phone in hand could trigger a check-in diff --git a/ios/EthosProtocol/Sources/Services/PasskeyService.swift b/ios/EthosProtocol/Sources/Services/PasskeyService.swift index d39fd91..c7c914d 100644 --- a/ios/EthosProtocol/Sources/Services/PasskeyService.swift +++ b/ios/EthosProtocol/Sources/Services/PasskeyService.swift @@ -21,9 +21,12 @@ final class PasskeyService: NSObject { throw PasskeyError.registrationFailed } let credID = reg.credentialID.base64URLEncodedString() - let pubKey = reg.rawAttestationObject?.base64URLEncodedString() ?? "" + // Send the raw WebAuthn attestation object — not the public key itself. + // The backend extracts the COSE-encoded public key from this object during + // verification. Field name is `attestation_object` per shared/api-contract.md. + let attestationObject = reg.rawAttestationObject?.base64URLEncodedString() ?? "" let clientData = reg.rawClientDataJSON.base64URLEncodedString() - try await APIClient.shared.registerPasskey(credentialID: credID, publicKey: pubKey, clientDataJSON: clientData) + try await APIClient.shared.registerPasskey(credentialID: credID, attestationObject: attestationObject, clientDataJSON: clientData) return credID } diff --git a/ios/EthosProtocol/Sources/Services/PendingCheckInStore.swift b/ios/EthosProtocol/Sources/Services/PendingCheckInStore.swift new file mode 100644 index 0000000..4b9a3ff --- /dev/null +++ b/ios/EthosProtocol/Sources/Services/PendingCheckInStore.swift @@ -0,0 +1,92 @@ +import Foundation + +// MARK: - PendingCheckIn + +/// A single queued check-in that could not be delivered while the device was offline. +/// Mirrors Android's `PendingCheckIn` Room entity in CheckInQueue.kt. +struct PendingCheckIn: Codable, Equatable { + let vaultId: String + let queuedAt: Date +} + +// MARK: - PendingCheckInStore + +/// Durable, disk-backed queue for offline check-ins. +/// +/// Mirrors Android's `PendingCheckInDao` (CheckInQueue.kt) — insert on offline check-in, +/// delete on successful delivery, read by `CheckInSyncTask` when connectivity returns. +/// +/// Storage: a JSON file in the app's Application Support directory so it survives +/// app restarts (unlike UserDefaults, which can be evicted under storage pressure). +/// All mutations are synchronised on a serial queue to avoid data races. +final class PendingCheckInStore { + static let shared = PendingCheckInStore() + + private let fileURL: URL + private let queue = DispatchQueue(label: "com.ethosprotocol.PendingCheckInStore") + + // Injected for unit tests so we don't touch the real filesystem. + init(fileURL: URL? = nil) { + if let url = fileURL { + self.fileURL = url + } else { + let support = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("EthosProtocol", isDirectory: true) + try? FileManager.default.createDirectory(at: support, withIntermediateDirectories: true) + self.fileURL = support.appendingPathComponent("pending_checkins.json") + } + } + + // MARK: - Public API (matches PendingCheckInDao) + + /// Returns all queued check-ins, oldest first. + func getAll() -> [PendingCheckIn] { + queue.sync { load() } + } + + /// Returns the current queue count (used by the notification badge). + var count: Int { getAll().count } + + /// Enqueue a check-in for `vaultId`. Idempotent — a vault already in the queue + /// is not duplicated (mirrors Android's `OnConflictStrategy.REPLACE`). + func insert(_ item: PendingCheckIn) { + queue.sync { + var items = load() + items.removeAll { $0.vaultId == item.vaultId } + items.append(item) + save(items) + } + } + + /// Remove a successfully-delivered check-in from the queue. + func delete(_ item: PendingCheckIn) { + queue.sync { + var items = load() + items.removeAll { $0.vaultId == item.vaultId } + save(items) + } + } + + /// Wipe the entire queue (used in tests and on sign-out). + func deleteAll() { + queue.sync { save([]) } + } + + // MARK: - Private helpers + + private func load() -> [PendingCheckIn] { + guard let data = try? Data(contentsOf: fileURL) else { return [] } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return (try? decoder.decode([PendingCheckIn].self, from: data)) ?? [] + } + + private func save(_ items: [PendingCheckIn]) { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .prettyPrinted + guard let data = try? encoder.encode(items) else { return } + try? data.write(to: fileURL, options: .atomic) + } +} diff --git a/ios/EthosProtocol/Sources/ViewModels/Stores.swift b/ios/EthosProtocol/Sources/ViewModels/Stores.swift index d71feda..d75d466 100644 --- a/ios/EthosProtocol/Sources/ViewModels/Stores.swift +++ b/ios/EthosProtocol/Sources/ViewModels/Stores.swift @@ -106,6 +106,16 @@ final class VaultStore: ObservableObject { do { try await APIClient.shared.checkIn(vaultID: vault.id) if !Task.isCancelled { await load() } + } catch APIError.networkUnavailable { + // Offline: queue the check-in durably so it is retried when connectivity + // returns, mirroring Android's VaultViewModel.checkIn → PendingCheckInDao + // + CheckInSyncWorker pattern. + let item = PendingCheckIn(vaultId: vault.id, queuedAt: Date()) + PendingCheckInStore.shared.insert(item) + let count = PendingCheckInStore.shared.count + NotificationService.shared.showQueuedCheckIn(count: count) + CheckInSyncTask.shared.scheduleSync() + ifNotCancelled { self.error = "Offline — check-in queued and will retry automatically" } } catch { ifNotCancelled { self.error = error.localizedDescription } } @@ -147,13 +157,12 @@ final class VaultStore: ObservableObject { } private func scheduleReminders() { - for vault in vaults { scheduleReminder(for: vault) } - } - - private func scheduleReminder(for vault: Vault) { - guard vault.status == .active, let ttl = vault.ttlRemaining else { return } - NotificationService.shared.scheduleCheckInReminder( - vaultID: vault.id, vaultName: vault.id, ttlRemaining: ttl) + for vault in vaults { + guard vault.status == .active, let ttl = vault.ttlRemaining else { continue } + NotificationService.shared.scheduleCheckInReminder( + vaultID: vault.id, vaultName: vault.id, ttlRemaining: ttl, + checkInInterval: vault.checkInInterval) + } } } diff --git a/ios/EthosProtocol/Sources/Views/Views.swift b/ios/EthosProtocol/Sources/Views/Views.swift index 3ee9555..d8cff67 100644 --- a/ios/EthosProtocol/Sources/Views/Views.swift +++ b/ios/EthosProtocol/Sources/Views/Views.swift @@ -1036,7 +1036,8 @@ struct VaultActionDeepLinkView: View { systemImage: "person.2.fill", description: "Update the beneficiary for vault \(vaultID.prefix(16))…" ) { - error = "Beneficiary management is not yet available in the mobile app." + guard vault != nil else { error = "Vault not found"; return } + showManageBeneficiary = true } } }