From 2f8b66271efb71f62e7c8a375ca1ebab1b6b72ec Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Wed, 18 Feb 2026 22:44:35 +0100 Subject: [PATCH] Tweak mobile app vault sync indicator to only show syncing when downloading new vault (#1737) --- .../nativevaultmanager/NativeVaultManager.kt | 72 +++++++++++++++++++ .../net/aliasvault/app/vaultstore/AppError.kt | 33 +++++++++ apps/mobile-app/app/initialize.tsx | 2 - apps/mobile-app/app/reinitialize.tsx | 2 - .../components/ServerSyncIndicator.tsx | 23 ++++-- apps/mobile-app/context/DbContext.tsx | 20 +++++- apps/mobile-app/hooks/useVaultMutate.ts | 5 +- apps/mobile-app/hooks/useVaultSync.ts | 24 ++++++- .../RCTNativeVaultManager.mm | 4 ++ .../ios/NativeVaultManager/VaultManager.swift | 64 +++++++++++++++++ .../ios/VaultStoreKit/Enums/AppError.swift | 35 +++++++++ apps/mobile-app/specs/NativeVaultManager.ts | 4 ++ 12 files changed, 274 insertions(+), 14 deletions(-) diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt index fc26c7023..e28c5e8a3 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt @@ -1208,6 +1208,78 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : // MARK: - Vault Sync and Mutate + /** + * Quick check if sync is needed without doing the actual sync. + * Used to show appropriate UI indicator before starting sync. + * @param promise The promise to resolve with sync status. + */ + @ReactMethod + override fun checkSyncStatus(promise: Promise) { + CoroutineScope(Dispatchers.IO).launch { + try { + val versionCheck = vaultStore.checkVaultVersion(webApiService) + val resultMap = Arguments.createMap().apply { + putBoolean("success", true) + putBoolean("hasNewerVault", versionCheck.isNewVersionAvailable) + putBoolean("hasDirtyChanges", versionCheck.syncState.isDirty) + putBoolean("isOffline", false) + putBoolean("requiresLogout", false) + putNull("errorKey") + } + withContext(Dispatchers.Main) { + promise.resolve(resultMap) + } + } catch (e: AppError) { + withContext(Dispatchers.Main) { + // Check for specific error types that require logout + val requiresLogout = e.isAuthenticationError || e.isVersionError + val errorKey = e.translationKey + val isOffline = e.isNetworkError + + if (isOffline) { + val syncState = vaultStore.getSyncState() + val resultMap = Arguments.createMap().apply { + putBoolean("success", true) + putBoolean("hasNewerVault", false) + putBoolean("hasDirtyChanges", syncState.isDirty) + putBoolean("isOffline", true) + putBoolean("requiresLogout", false) + putNull("errorKey") + } + promise.resolve(resultMap) + } else { + val resultMap = Arguments.createMap().apply { + putBoolean("success", !requiresLogout) + putBoolean("hasNewerVault", false) + putBoolean("hasDirtyChanges", false) + putBoolean("isOffline", false) + putBoolean("requiresLogout", requiresLogout) + if (errorKey != null) { + putString("errorKey", errorKey) + } else { + putNull("errorKey") + } + } + promise.resolve(resultMap) + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Log.e(TAG, "Error checking sync status", e) + val resultMap = Arguments.createMap().apply { + putBoolean("success", false) + putBoolean("hasNewerVault", false) + putBoolean("hasDirtyChanges", false) + putBoolean("isOffline", false) + putBoolean("requiresLogout", false) + putNull("errorKey") + } + promise.resolve(resultMap) + } + } + } + } + /** * Unified vault sync method that handles all sync scenarios. * @param promise The promise to resolve with VaultSyncResult. diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/AppError.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/AppError.kt index 5e6eb203e..00314c5eb 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/AppError.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/AppError.kt @@ -346,4 +346,37 @@ sealed class AppError(message: String, cause: Throwable? = null) : Exception(mes is UnknownError -> "E-001" is ParseError -> "E-002" } + + /** + * Check if this is an authentication error that requires logout. + */ + val isAuthenticationError: Boolean + get() = when (this) { + is AuthenticationFailed, is SessionExpired, is PasswordChanged -> true + else -> false + } + + /** + * Check if this is a version/compatibility error that requires logout. + */ + val isVersionError: Boolean + get() = when (this) { + is ClientVersionNotSupported, is ServerVersionNotSupported, is VaultVersionIncompatible -> true + else -> false + } + + /** + * Check if this is a network error (offline mode). + */ + val isNetworkError: Boolean + get() = when (this) { + is ServerUnavailable, is NetworkError, is Timeout -> true + else -> false + } + + /** + * Get the translation key for this error (error code format for lookup in translation files). + */ + val translationKey: String? + get() = code } diff --git a/apps/mobile-app/app/initialize.tsx b/apps/mobile-app/app/initialize.tsx index 147ba56a5..a85f2ed97 100644 --- a/apps/mobile-app/app/initialize.tsx +++ b/apps/mobile-app/app/initialize.tsx @@ -112,7 +112,6 @@ export default function Initialize() : React.ReactNode { * The ServerSyncIndicator will show sync progress/offline status. * This also handles uploading pending local changes (isDirty) from previous sessions. */ - dbContext.setIsSyncing(true); void (async (): Promise => { try { await syncVault({ @@ -146,7 +145,6 @@ export default function Initialize() : React.ReactNode { }, }); } finally { - dbContext.setIsSyncing(false); await dbContext.refreshSyncState(); } })(); diff --git a/apps/mobile-app/app/reinitialize.tsx b/apps/mobile-app/app/reinitialize.tsx index 771811193..cd9f12c72 100644 --- a/apps/mobile-app/app/reinitialize.tsx +++ b/apps/mobile-app/app/reinitialize.tsx @@ -115,7 +115,6 @@ export default function ReinitializeScreen() : React.ReactNode { * The ServerSyncIndicator will show sync progress/offline status. * This also handles uploading pending local changes (isDirty) from previous sessions. */ - dbContext.setIsSyncing(true); void (async (): Promise => { try { await syncVault({ @@ -150,7 +149,6 @@ export default function ReinitializeScreen() : React.ReactNode { }, }); } finally { - dbContext.setIsSyncing(false); await dbContext.refreshSyncState(); } })(); diff --git a/apps/mobile-app/components/ServerSyncIndicator.tsx b/apps/mobile-app/components/ServerSyncIndicator.tsx index 974295b44..a4a79725e 100644 --- a/apps/mobile-app/components/ServerSyncIndicator.tsx +++ b/apps/mobile-app/components/ServerSyncIndicator.tsx @@ -21,13 +21,14 @@ const MIN_SYNC_DISPLAY_TIME = 1500; /** * Floating sync status indicator component. - * Displays sync state badges for offline mode, syncing, and pending sync. + * Displays sync state badges for offline mode, syncing, uploading, and pending sync. * * Priority order (highest to lowest): * 1. Offline (amber) - network unavailable - * 2. Syncing (green spinner) - sync in progress (minimum 1.5s display) - * 3. Pending (blue spinner) - local changes waiting to be uploaded - * 4. Hidden - when synced + * 2. Syncing (green spinner) - downloading new vault from server (minimum 1.5s display) + * 3. Uploading (blue spinner) - uploading local changes to server + * 4. Pending (blue icon) - local changes waiting to be uploaded, tappable to retry + * 5. Hidden - when synced */ export function ServerSyncIndicator(): React.ReactNode { const { t } = useTranslation(); @@ -219,7 +220,19 @@ export function ServerSyncIndicator(): React.ReactNode { ); } - // Priority 3: Pending indicator (tappable to force sync) + // Priority 3: Uploading indicator (not tappable, shows progress) + if (dbContext.isUploading) { + return ( + + + + {t('sync.syncing')} + + + ); + } + + // Priority 4: Pending indicator (tappable to force sync) if (dbContext.isDirty) { return ( void; + setIsUploading: (uploading: boolean) => void; setIsOffline: (offline: boolean) => Promise; /** * Check if email errors should be suppressed. @@ -61,10 +63,15 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children } const [isDirty, setIsDirty] = useState(false); /** - * Sync state tracking - isSyncing indicates a sync operation is in progress. + * Sync state tracking - isSyncing indicates a download sync operation is in progress. */ const [isSyncing, setIsSyncingState] = useState(false); + /** + * Sync state tracking - isUploading indicates an upload operation is in progress. + */ + const [isUploading, setIsUploadingState] = useState(false); + /** * Offline mode state - indicates network is unavailable. */ @@ -207,6 +214,13 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children } setIsSyncingState(syncing); }, []); + /** + * Set uploading state - exposed for use by sync hooks. + */ + const setIsUploading = useCallback((uploading: boolean): void => { + setIsUploadingState(uploading); + }, []); + /** * Set offline mode and persist to native layer. */ @@ -271,8 +285,10 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children } // Sync state isDirty, isSyncing, + isUploading, isOffline, setIsSyncing, + setIsUploading, setIsOffline, shouldSuppressEmailErrors, refreshSyncState, @@ -286,7 +302,7 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children } storeEncryptionKeyDerivationParams, checkStoredVault, setDatabaseAvailable, - }), [sqliteClient, dbInitialized, dbAvailable, isDirty, isSyncing, isOffline, setIsSyncing, setIsOffline, shouldSuppressEmailErrors, refreshSyncState, hasPendingMigrations, clearDatabase, getVaultMetadata, testDatabaseConnection, verifyEncryptionKey, unlockVault, storeEncryptionKey, storeEncryptionKeyDerivationParams, checkStoredVault, setDatabaseAvailable]); + }), [sqliteClient, dbInitialized, dbAvailable, isDirty, isSyncing, isUploading, isOffline, setIsSyncing, setIsUploading, setIsOffline, shouldSuppressEmailErrors, refreshSyncState, hasPendingMigrations, clearDatabase, getVaultMetadata, testDatabaseConnection, verifyEncryptionKey, unlockVault, storeEncryptionKey, storeEncryptionKeyDerivationParams, checkStoredVault, setDatabaseAvailable]); return ( diff --git a/apps/mobile-app/hooks/useVaultMutate.ts b/apps/mobile-app/hooks/useVaultMutate.ts index bacfe7835..3e646d6d7 100644 --- a/apps/mobile-app/hooks/useVaultMutate.ts +++ b/apps/mobile-app/hooks/useVaultMutate.ts @@ -91,7 +91,8 @@ export function useVaultMutate() : { * This is fire-and-forget - the ServerSyncIndicator shows progress. */ const triggerBackgroundSync = useCallback(async (options: VaultMutationOptions): Promise => { - dbContext.setIsSyncing(true); + // Show uploading indicator since we're uploading local changes + dbContext.setIsUploading(true); try { await syncVault({ @@ -118,7 +119,7 @@ export function useVaultMutate() : { } }); } finally { - dbContext.setIsSyncing(false); + dbContext.setIsUploading(false); await dbContext.refreshSyncState(); } }, [syncVault, dbContext]); diff --git a/apps/mobile-app/hooks/useVaultSync.ts b/apps/mobile-app/hooks/useVaultSync.ts index ae443095d..be84b7c6a 100644 --- a/apps/mobile-app/hooks/useVaultSync.ts +++ b/apps/mobile-app/hooks/useVaultSync.ts @@ -89,8 +89,27 @@ export const useVaultSync = (): { onStatus?.(t('vault.checkingVaultUpdates')); + /* + * Quick check if sync is needed - this tells us if server has newer vault + * or if we have local changes to upload, so we can show the appropriate indicator. + */ + const statusCheck = await NativeVaultManager.checkSyncStatus(); + + // Handle logout requirement from status check + if (statusCheck.requiresLogout) { + const errorMessage = statusCheck.errorKey ? t(getErrorTranslationKey(extractErrorCode(statusCheck.errorKey) ?? AppErrorCode.UNKNOWN_ERROR)) : undefined; + await app.logout(errorMessage); + return false; + } + + // Show appropriate indicator based on what sync will do + if (statusCheck.hasNewerVault) { + dbContext.setIsSyncing(true); + } else if (statusCheck.hasDirtyChanges && !statusCheck.isOffline) { + dbContext.setIsUploading(true); + } + // Call the unified native sync method - // This handles all sync scenarios: download, upload, merge, race detection const result = await NativeVaultManager.syncVaultWithServer(); if (abortSignal?.aborted) { @@ -219,6 +238,9 @@ export const useVaultSync = (): { return false; } finally { syncInProgressRef.current = false; + // Always clear syncing/uploading states when done + dbContext.setIsSyncing(false); + dbContext.setIsUploading(false); } }, [app, dbContext, t]); diff --git a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm index 1304f4574..37912e5a6 100644 --- a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm +++ b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm @@ -241,6 +241,10 @@ // MARK: - Vault Sync +- (void)checkSyncStatus:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [vaultManager checkSyncStatus:resolve rejecter:reject]; +} + - (void)syncVaultWithServer:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [vaultManager syncVaultWithServer:resolve rejecter:reject]; } diff --git a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift index 995c4bc31..28e9cdb33 100644 --- a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift +++ b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift @@ -636,6 +636,70 @@ public class VaultManager: NSObject { // MARK: - Vault Sync + @objc + func checkSyncStatus(_ resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + Task { + do { + let versionCheck = try await vaultStore.checkVaultVersion(using: webApiService) + await MainActor.run { + let response: [String: Any] = [ + "success": true, + "hasNewerVault": versionCheck.isNewVersionAvailable, + "hasDirtyChanges": versionCheck.syncState.isDirty, + "isOffline": false, + "requiresLogout": false, + "errorKey": NSNull() + ] + resolve(response) + } + } catch let error as AppError { + await MainActor.run { + // Check for specific error types that require logout + let requiresLogout = error.isAuthenticationError || error.isVersionError + let errorKey = error.translationKey + + // Check if offline + let isOffline = error.isNetworkError + if isOffline { + let syncState = vaultStore.getSyncState() + let response: [String: Any] = [ + "success": true, + "hasNewerVault": false, + "hasDirtyChanges": syncState.isDirty, + "isOffline": true, + "requiresLogout": false, + "errorKey": NSNull() + ] + resolve(response) + } else { + let response: [String: Any] = [ + "success": !requiresLogout, + "hasNewerVault": false, + "hasDirtyChanges": false, + "isOffline": false, + "requiresLogout": requiresLogout, + "errorKey": errorKey as Any + ] + resolve(response) + } + } + } catch { + await MainActor.run { + let response: [String: Any] = [ + "success": false, + "hasNewerVault": false, + "hasDirtyChanges": false, + "isOffline": false, + "requiresLogout": false, + "errorKey": NSNull() + ] + resolve(response) + } + } + } + } + @objc func syncVaultWithServer(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { diff --git a/apps/mobile-app/ios/VaultStoreKit/Enums/AppError.swift b/apps/mobile-app/ios/VaultStoreKit/Enums/AppError.swift index d82189a8b..a8a23675a 100644 --- a/apps/mobile-app/ios/VaultStoreKit/Enums/AppError.swift +++ b/apps/mobile-app/ios/VaultStoreKit/Enums/AppError.swift @@ -216,4 +216,39 @@ public enum AppError: Error { return "Parse error: \(message)" } } + + /// Check if this is an authentication error that requires logout. + public var isAuthenticationError: Bool { + switch self { + case .authenticationFailed, .sessionExpired, .passwordChanged: + return true + default: + return false + } + } + + /// Check if this is a version/compatibility error that requires logout. + public var isVersionError: Bool { + switch self { + case .clientVersionNotSupported, .serverVersionNotSupported, .vaultVersionIncompatible: + return true + default: + return false + } + } + + /// Check if this is a network error (offline mode). + public var isNetworkError: Bool { + switch self { + case .serverUnavailable, .networkError, .timeout: + return true + default: + return false + } + } + + /// Get the translation key for this error (error code format for lookup in translation files). + public var translationKey: String? { + return code + } } diff --git a/apps/mobile-app/specs/NativeVaultManager.ts b/apps/mobile-app/specs/NativeVaultManager.ts index 293433c3c..8d806369f 100644 --- a/apps/mobile-app/specs/NativeVaultManager.ts +++ b/apps/mobile-app/specs/NativeVaultManager.ts @@ -26,6 +26,10 @@ export interface Spec extends TurboModule { // Returns detailed result about what action was taken syncVaultWithServer(): Promise<{ success: boolean; action: 'uploaded' | 'downloaded' | 'merged' | 'already_in_sync' | 'error'; newRevision: number; wasOffline: boolean; error: string | null }>; + // Quick check if sync is needed without doing the actual sync + // Used to show appropriate UI indicator before starting sync + checkSyncStatus(): Promise<{ success: boolean; hasNewerVault: boolean; hasDirtyChanges: boolean; isOffline: boolean; requiresLogout: boolean; errorKey: string | null }>; + // Sync state management (kept for local mutation tracking) getSyncState(): Promise<{isDirty: boolean; mutationSequence: number; serverRevision: number; isSyncing: boolean}>; markVaultClean(mutationSeqAtStart: number, newServerRevision: number): Promise;