Tweak mobile app vault sync indicator to only show syncing when downloading new vault (#1737)

This commit is contained in:
Leendert de Borst
2026-02-18 22:44:35 +01:00
committed by Leendert de Borst
parent 02a5a25a27
commit 2f8b66271e
12 changed files with 274 additions and 14 deletions

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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<void> => {
try {
await syncVault({
@@ -146,7 +145,6 @@ export default function Initialize() : React.ReactNode {
},
});
} finally {
dbContext.setIsSyncing(false);
await dbContext.refreshSyncState();
}
})();

View File

@@ -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<void> => {
try {
await syncVault({
@@ -150,7 +149,6 @@ export default function ReinitializeScreen() : React.ReactNode {
},
});
} finally {
dbContext.setIsSyncing(false);
await dbContext.refreshSyncState();
}
})();

View File

@@ -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 (
<View style={[styles.container, styles.pending]} testID="sync-indicator-uploading">
<ActivityIndicator size="small" color={colors.info} />
<ThemedText style={[styles.text, styles.pendingText]}>
{t('sync.syncing')}
</ThemedText>
</View>
);
}
// Priority 4: Pending indicator (tappable to force sync)
if (dbContext.isDirty) {
return (
<RobustPressable

View File

@@ -12,8 +12,10 @@ type DbContextType = {
// Sync state tracking
isDirty: boolean;
isSyncing: boolean;
isUploading: boolean;
isOffline: boolean;
setIsSyncing: (syncing: boolean) => void;
setIsUploading: (uploading: boolean) => void;
setIsOffline: (offline: boolean) => Promise<void>;
/**
* 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 (
<DbContext.Provider value={contextValue}>

View File

@@ -91,7 +91,8 @@ export function useVaultMutate() : {
* This is fire-and-forget - the ServerSyncIndicator shows progress.
*/
const triggerBackgroundSync = useCallback(async (options: VaultMutationOptions): Promise<void> => {
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]);

View File

@@ -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]);

View File

@@ -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];
}

View File

@@ -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) {

View File

@@ -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
}
}

View File

@@ -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<boolean>;