mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-02-19 07:28:47 -05:00
Tweak mobile app vault sync indicator to only show syncing when downloading new vault (#1737)
This commit is contained in:
committed by
Leendert de Borst
parent
02a5a25a27
commit
2f8b66271e
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user