feat: enhance password validation and add auth backoff mechanism in sync process

This commit is contained in:
2025-10-27 19:14:13 +01:00
parent f756be0ab7
commit 57359ffae6
5 changed files with 69 additions and 32 deletions
@@ -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
@@ -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)
}
}
@@ -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()
@@ -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) { }
}
@@ -33,29 +33,33 @@ object Logger {
val logs: StateFlow<List<LogEntry>> = _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() {