From 57359ffae665116aed34f8e965dfe4b213903189 Mon Sep 17 00:00:00 2001 From: gabriel20xx Date: Mon, 27 Oct 2025 19:14:13 +0100 Subject: [PATCH] feat: enhance password validation and add auth backoff mechanism in sync process --- .../com/collabtable/app/data/api/ApiClient.kt | 6 +-- .../app/data/repository/SyncRepository.kt | 32 ++++++++++--- .../app/ui/screens/ServerSetupViewModel.kt | 9 +++- .../app/ui/screens/SettingsScreen.kt | 9 +++- .../java/com/collabtable/app/utils/Logger.kt | 45 ++++++++++--------- 5 files changed, 69 insertions(+), 32 deletions(-) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt index 2f27538..c48edb8 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt @@ -27,13 +27,13 @@ object ApiClient { val request = chain.request() val password = context?.let { - PreferencesManager.getInstance(it).getServerPassword() + PreferencesManager.getInstance(it).getServerPassword()?.trim() } val newRequest = - if (!password.isNullOrBlank()) { + if (!password.isNullOrBlank() && password != "\$password") { request.newBuilder() - .header("Authorization", "Bearer ${'$'}password") + .header("Authorization", "Bearer $password") .build() } else { request diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt index 239cc7c..e8d0af1 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt @@ -5,6 +5,7 @@ import androidx.room.withTransaction import com.collabtable.app.data.api.ApiClient import com.collabtable.app.data.api.SyncRequest import com.collabtable.app.data.database.CollabTableDatabase +import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.utils.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex @@ -17,6 +18,13 @@ class SyncRepository(context: Context) { private val api = ApiClient.api private val prefs = appContext.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE) + @Volatile private var authBackoffUntil: Long = 0L + @Volatile private var authBackoffMs: Long = 0L + + fun resetAuthBackoff() { + authBackoffMs = 0L + authBackoffUntil = 0L + } // We keep separate watermarks to avoid clock-skew issues: // - server watermark: server-assigned timestamp for fetching server changes // - local watermark: device local time for selecting local changes to upload @@ -53,6 +61,18 @@ class SyncRepository(context: Context) { // Prevent overlapping syncs across screens/viewmodels syncMutex.withLock { try { + // If not authenticated, skip making network calls (e.g., after leaving server) + val prefMgr = PreferencesManager.getInstance(appContext) + val currentPassword = prefMgr.getServerPassword()?.trim() + if (currentPassword.isNullOrBlank() || currentPassword == "\$password") { + // Quietly skip to avoid spamming server with missing Authorization + return@withContext Result.success(Unit) + } + // Respect auth backoff window to avoid spamming the server on repeated 401s + val now = System.currentTimeMillis() + if (authBackoffUntil > now) { + return@withContext Result.success(Unit) + } val lastServerTs = getLastServerSyncTs() val lastLocalTs = getLastLocalSyncTs() // Capture a snapshot timestamp BEFORE reading local changes to avoid missing @@ -100,12 +120,13 @@ class SyncRepository(context: Context) { if (!response.isSuccessful) { if (response.code() == 401) { // Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error. - Logger.e("Sync", "[ERROR] Unauthorized (401). Check server password in Settings.") setLastServerSyncTs(0) setLastLocalSyncTs(0) + // Apply exponential backoff on repeated auth failures + authBackoffMs = if (authBackoffMs == 0L) 2_000L else (authBackoffMs * 2).coerceAtMost(300_000L) + authBackoffUntil = System.currentTimeMillis() + authBackoffMs return@withContext Result.failure(Exception("Unauthorized (401). Please verify server password.")) } - Logger.e("Sync", "[ERROR] Sync failed: HTTP ${response.code()}") return@withContext Result.failure(Exception("Sync failed: ${response.code()}")) } val syncResponse = response.body()!! @@ -195,17 +216,16 @@ class SyncRepository(context: Context) { Logger.i("Sync", "[SYNC] Initial sync completed") } + // Successful sync clears any previous auth backoff + resetAuthBackoff() + return@withContext Result.success(Unit) } catch (e: Exception) { - if (e is java.net.UnknownHostException) { - Logger.e("Sync", "[ERROR] Unknown host. If you're running the server on the host machine, use 10.0.2.2 on the Android emulator or a LAN IP on a physical device.") - } // If WS indicated unauthorized, align behavior with HTTP path if (e.message?.contains("Unauthorized") == true) { setLastServerSyncTs(0) setLastLocalSyncTs(0) } - Logger.e("Sync", "[ERROR] Sync error: ${e.message}") return@withContext Result.failure(e) } } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt index e2a6767..47bb04d 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt @@ -85,13 +85,18 @@ class ServerSetupViewModel( // Ensure it ends with / normalizedUrl = if (normalizedUrl.endsWith("/")) normalizedUrl else "$normalizedUrl/" - // Validate password is not empty (trim leading/trailing spaces) + // Validate password is not empty (trim leading/trailing spaces) and not a placeholder val trimmedPassword = password.trim() if (trimmedPassword.isBlank()) { _validationError.value = "Password cannot be empty" _isValidating.value = false return@launch } + if (trimmedPassword == "\$password") { + _validationError.value = "Enter the actual server password, not the placeholder $password" + _isValidating.value = false + return@launch + } // On physical devices, block local-only hosts that won't resolve val isEmulator = run { @@ -179,7 +184,7 @@ class ServerSetupViewModel( val authRequest = Request.Builder() .url(testUrl) - .header("Authorization", "Bearer ${'$'}trimmedPassword") + .header("Authorization", "Bearer $trimmedPassword") .get() .build() diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt index 2a45d51..582ba45 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt @@ -46,6 +46,8 @@ import com.collabtable.app.ui.components.ConnectionStatusAction import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -115,7 +117,8 @@ fun SettingsScreen( Modifier .fillMaxSize() .padding(padding) - .padding(16.dp), + .padding(16.dp) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( @@ -188,11 +191,15 @@ fun SettingsScreen( val trimmed = passwordInput.trim() if (trimmed.isEmpty()) { passwordError = "Password cannot be empty" + } else if (trimmed == "\$password") { + passwordError = "Enter the actual server password, not the placeholder \$password" } else { preferencesManager.setServerPassword(trimmed) // Trigger a quick sync in background to validate coroutineScope.launch(Dispatchers.IO) { try { + // Clear any auth backoff immediately when updating password + syncRepository.resetAuthBackoff() syncRepository.performSync() } catch (_: Exception) { } } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/utils/Logger.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/utils/Logger.kt index fca4d57..b757ec4 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/utils/Logger.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/utils/Logger.kt @@ -33,29 +33,33 @@ object Logger { val logs: StateFlow> = _logs.asStateFlow() private const val MAX_LOGS = 500 + private const val DEDUPE_WINDOW_MS = 1_000L fun d( tag: String, message: String, ) { - log(LogLevel.DEBUG, tag, message) - Log.d(tag, message) + if (log(LogLevel.DEBUG, tag, message)) { + Log.d(tag, message) + } } fun i( tag: String, message: String, ) { - log(LogLevel.INFO, tag, message) - Log.i(tag, message) + if (log(LogLevel.INFO, tag, message)) { + Log.i(tag, message) + } } fun w( tag: String, message: String, ) { - log(LogLevel.WARN, tag, message) - Log.w(tag, message) + if (log(LogLevel.WARN, tag, message)) { + Log.w(tag, message) + } } fun e( @@ -64,11 +68,12 @@ object Logger { throwable: Throwable? = null, ) { val msg = if (throwable != null) "$message: ${throwable.message}" else message - log(LogLevel.ERROR, tag, msg) - if (throwable != null) { - Log.e(tag, message, throwable) - } else { - Log.e(tag, message) + if (log(LogLevel.ERROR, tag, msg)) { + if (throwable != null) { + Log.e(tag, message, throwable) + } else { + Log.e(tag, message) + } } } @@ -76,16 +81,16 @@ object Logger { level: LogLevel, tag: String, message: String, - ) { - val entry = - LogEntry( - timestamp = System.currentTimeMillis(), - level = level, - tag = tag, - message = message, - ) - + ): Boolean { + val now = System.currentTimeMillis() + val last = _logs.value.lastOrNull() + // Suppress exact duplicate consecutive logs within a short window + if (last != null && last.level == level && last.tag == tag && last.message == message && (now - last.timestamp) <= DEDUPE_WINDOW_MS) { + return false + } + val entry = LogEntry(timestamp = now, level = level, tag = tag, message = message) _logs.value = (_logs.value + entry).takeLast(MAX_LOGS) + return true } fun clear() {