feat: enhance password validation and add auth backoff mechanism in sync process
This commit is contained in:
@@ -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
|
||||
|
||||
+26
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-2
@@ -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()
|
||||
|
||||
|
||||
+8
-1
@@ -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,30 +33,34 @@ 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)
|
||||
if (log(LogLevel.DEBUG, tag, message)) {
|
||||
Log.d(tag, message)
|
||||
}
|
||||
}
|
||||
|
||||
fun i(
|
||||
tag: String,
|
||||
message: String,
|
||||
) {
|
||||
log(LogLevel.INFO, tag, message)
|
||||
if (log(LogLevel.INFO, tag, message)) {
|
||||
Log.i(tag, message)
|
||||
}
|
||||
}
|
||||
|
||||
fun w(
|
||||
tag: String,
|
||||
message: String,
|
||||
) {
|
||||
log(LogLevel.WARN, tag, message)
|
||||
if (log(LogLevel.WARN, tag, message)) {
|
||||
Log.w(tag, message)
|
||||
}
|
||||
}
|
||||
|
||||
fun e(
|
||||
tag: String,
|
||||
@@ -64,28 +68,29 @@ object Logger {
|
||||
throwable: Throwable? = null,
|
||||
) {
|
||||
val msg = if (throwable != null) "$message: ${throwable.message}" else message
|
||||
log(LogLevel.ERROR, tag, msg)
|
||||
if (log(LogLevel.ERROR, tag, msg)) {
|
||||
if (throwable != null) {
|
||||
Log.e(tag, message, throwable)
|
||||
} else {
|
||||
Log.e(tag, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun log(
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user