feat: enhance sync functionality with network backoff handling and improve item sorting in ListDetailScreen

This commit is contained in:
2025-11-08 20:34:19 +01:00
parent d7a2befc8b
commit 0bc3b3b06f
3 changed files with 114 additions and 46 deletions
@@ -8,6 +8,7 @@ import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.utils.Logger import com.collabtable.app.utils.Logger
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import java.net.UnknownHostException
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -20,11 +21,17 @@ class SyncRepository(context: Context) {
@Volatile private var authBackoffUntil: Long = 0L @Volatile private var authBackoffUntil: Long = 0L
@Volatile private var authBackoffMs: Long = 0L @Volatile private var authBackoffMs: Long = 0L
@Volatile private var networkBackoffUntil: Long = 0L
@Volatile private var networkBackoffMs: Long = 0L
fun resetAuthBackoff() { fun resetAuthBackoff() {
authBackoffMs = 0L authBackoffMs = 0L
authBackoffUntil = 0L authBackoffUntil = 0L
} }
private fun resetNetworkBackoff() {
networkBackoffMs = 0L
networkBackoffUntil = 0L
}
// We keep separate watermarks to avoid clock-skew issues: // We keep separate watermarks to avoid clock-skew issues:
// - server watermark: server-assigned timestamp for fetching server changes // - server watermark: server-assigned timestamp for fetching server changes
// - local watermark: device local time for selecting local changes to upload // - local watermark: device local time for selecting local changes to upload
@@ -73,6 +80,10 @@ class SyncRepository(context: Context) {
if (authBackoffUntil > now) { if (authBackoffUntil > now) {
return@withContext Result.success(Unit) return@withContext Result.success(Unit)
} }
// Respect network backoff for transient DNS failures / unreachable host
if (networkBackoffUntil > now) {
return@withContext Result.success(Unit)
}
val lastServerTs = getLastServerSyncTs() val lastServerTs = getLastServerSyncTs()
val lastLocalTs = getLastLocalSyncTs() val lastLocalTs = getLastLocalSyncTs()
// Capture a snapshot timestamp BEFORE reading local changes to avoid missing // Capture a snapshot timestamp BEFORE reading local changes to avoid missing
@@ -84,6 +95,16 @@ class SyncRepository(context: Context) {
Logger.i("Sync", "[SYNC] Starting initial sync with server") Logger.i("Sync", "[SYNC] Starting initial sync with server")
} }
// If our local DB appears empty but we have a nonzero server watermark (e.g., after reinstall
// or clearing local data), request a one-time full down-sync by resetting the effective
// lastSyncTimestamp to 0 for this request. This avoids permanently missing historical rows.
val hasAnyLists = database.listDao().getAllListIds().isNotEmpty()
val hasAnyItems = database.itemDao().getAllItemIds().isNotEmpty()
val effectiveLastServerTs = if (lastServerTs > 0 && (!hasAnyLists || !hasAnyItems)) 0L else lastServerTs
if (effectiveLastServerTs == 0L && lastServerTs > 0L) {
Logger.w("Sync", "Local store empty but watermark set; performing one-time full down-sync")
}
// Gather local changes since last sync // Gather local changes since last sync
val localLists = database.listDao().getListsUpdatedSince(lastLocalTs) val localLists = database.listDao().getListsUpdatedSince(lastLocalTs)
.filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs } .filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs }
@@ -108,7 +129,7 @@ class SyncRepository(context: Context) {
// Send to server and get updates // Send to server and get updates
val syncRequest = val syncRequest =
SyncRequest( SyncRequest(
lastSyncTimestamp = lastServerTs, lastSyncTimestamp = effectiveLastServerTs,
lists = localLists, lists = localLists,
fields = localFields, fields = localFields,
items = localItems, items = localItems,
@@ -116,7 +137,23 @@ class SyncRepository(context: Context) {
) )
// HTTP-only sync // HTTP-only sync
val response = api.sync(syncRequest) val response = try {
api.sync(syncRequest)
} catch (e: UnknownHostException) {
// Host could not be resolved; apply exponential backoff and surface a warning once.
networkBackoffMs = if (networkBackoffMs == 0L) 5_000L else (networkBackoffMs * 2).coerceAtMost(180_000L)
networkBackoffUntil = System.currentTimeMillis() + networkBackoffMs
Logger.w("Sync", "Network host unresolved (DNS). Backing off for ${networkBackoffMs/1000}s: ${e.message}")
return@withContext Result.failure(Exception("Network unreachable: unable to resolve host"))
} catch (e: Exception) {
if (e.message?.contains("Unable to resolve host", ignoreCase = true) == true) {
networkBackoffMs = if (networkBackoffMs == 0L) 5_000L else (networkBackoffMs * 2).coerceAtMost(180_000L)
networkBackoffUntil = System.currentTimeMillis() + networkBackoffMs
Logger.w("Sync", "DNS failure. Backing off for ${networkBackoffMs/1000}s: ${e.message}")
return@withContext Result.failure(Exception("Network unreachable: unable to resolve host"))
}
throw e
}
if (!response.isSuccessful) { if (!response.isSuccessful) {
if (response.code() == 401) { if (response.code() == 401) {
// Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error. // Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error.
@@ -218,6 +255,7 @@ class SyncRepository(context: Context) {
// Successful sync clears any previous auth backoff // Successful sync clears any previous auth backoff
resetAuthBackoff() resetAuthBackoff()
resetNetworkBackoff()
return@withContext Result.success(Unit) return@withContext Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
@@ -226,6 +264,7 @@ class SyncRepository(context: Context) {
setLastServerSyncTs(0) setLastServerSyncTs(0)
setLastLocalSyncTs(0) setLastLocalSyncTs(0)
} }
// Do not reset network backoff here; it's managed where thrown.
return@withContext Result.failure(e) return@withContext Result.failure(e)
} }
} }
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -126,6 +127,7 @@ fun ListDetailScreen(
var showManageColumnsDialog by remember { mutableStateOf(false) } var showManageColumnsDialog by remember { mutableStateOf(false) }
var showAddItemDialog by remember { mutableStateOf(false) } var showAddItemDialog by remember { mutableStateOf(false) }
var pendingScrollToBottom by remember { mutableStateOf(false) }
var itemToEdit by remember { mutableStateOf<ItemWithValues?>(null) } var itemToEdit by remember { mutableStateOf<ItemWithValues?>(null) }
var showSortDialog by remember { mutableStateOf(false) } var showSortDialog by remember { mutableStateOf(false) }
var showGroupDialog by remember { mutableStateOf(false) } var showGroupDialog by remember { mutableStateOf(false) }
@@ -218,12 +220,15 @@ fun ListDetailScreen(
stableItems stableItems
} }
// Default ordering: append new items at bottom by createdAt ascending.
// If an explicit sortField is chosen, use that instead.
val sortedItems: List<ItemWithValues> = val sortedItems: List<ItemWithValues> =
if (sortField != null) { if (sortField != null) {
val base = filteredItems.sortedWith(compareBy { item -> valueFor(item, sortField) }) val base = filteredItems.sortedWith(compareBy { item -> valueFor(item, sortField) })
if (sortAscending) base else base.asReversed() if (sortAscending) base else base.asReversed()
} else { } else {
filteredItems // Items from DB may not be ordered; enforce createdAt ascending
filteredItems.sortedBy { it.item.createdAt }
} }
val processedItems: List<ItemWithValues> = sortedItems val processedItems: List<ItemWithValues> = sortedItems
@@ -270,6 +275,7 @@ fun ListDetailScreen(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.horizontalScroll(rememberScrollState())
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -508,8 +514,23 @@ fun ListDetailScreen(
} }
} }
} else { } else {
val listState = rememberLazyListState()
// If a new item was just added, scroll to bottom after composition
LaunchedEffect(processedItems.size, pendingScrollToBottom) {
if (pendingScrollToBottom) {
// totalItemsCount includes the trailing filler; scroll to the last real item index
val lastIndex = (listState.layoutInfo.totalItemsCount - 2).coerceAtLeast(0)
try {
listState.animateScrollToItem(lastIndex)
} catch (_: Exception) {
listState.scrollToItem(lastIndex)
}
pendingScrollToBottom = false
}
}
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = listState,
) { ) {
groupedItems.entries.forEach { (groupName, groupItems) -> groupedItems.entries.forEach { (groupName, groupItems) ->
// Show group header if grouping is enabled // Show group header if grouping is enabled
@@ -597,6 +618,7 @@ fun ListDetailScreen(
onAdd = { fieldValues -> onAdd = { fieldValues ->
viewModel.addItemWithValues(fieldValues) viewModel.addItemWithValues(fieldValues)
showAddItemDialog = false showAddItemDialog = false
pendingScrollToBottom = true
}, },
) )
} }
@@ -318,11 +318,20 @@ fun ListItem(
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit,
dragHandle: (@Composable () -> Unit)? = null, dragHandle: (@Composable () -> Unit)? = null,
) { ) {
Row( // Expand the touch target to include the vertical spacing between rows without visually changing layout.
modifier = // We wrap the original row in a full-width Box with the same outer padding, then add a clickable
Modifier // modifier that uses additional vertical padding but negative padding inside to keep visual spacing.
Box(
modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp) // keep original horizontal spacing
.clickable(onClick = onListClick) // entire row (including margins) is tappable
.padding(vertical = 4.dp) // expand touch area (original visual was 8.dp inside Row)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp), // actual visual spacing remains ~8dp total (4 + 4)
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -332,12 +341,9 @@ fun ListItem(
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
} }
// Table name centered/left and clickable // Table name and subtitle
Column( Column(
modifier = modifier = Modifier.weight(1f),
Modifier
.weight(1f)
.clickable(onClick = onListClick),
) { ) {
Text(text = list.name, style = MaterialTheme.typography.titleMedium) Text(text = list.name, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
@@ -349,7 +355,7 @@ fun ListItem(
) )
} }
// Actions on the right // Actions on the right (retain individual click targets)
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onEditClick) { IconButton(onClick = onEditClick) {
Icon( Icon(
@@ -368,6 +374,7 @@ fun ListItem(
} }
} }
} }
}
@Composable @Composable
fun CreateListDialog( fun CreateListDialog(