feat: enhance sync functionality with network backoff handling and improve item sorting in ListDetailScreen
This commit is contained in:
+41
-2
@@ -8,6 +8,7 @@ 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 java.net.UnknownHostException
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -20,11 +21,17 @@ class SyncRepository(context: Context) {
|
||||
|
||||
@Volatile private var authBackoffUntil: Long = 0L
|
||||
@Volatile private var authBackoffMs: Long = 0L
|
||||
@Volatile private var networkBackoffUntil: Long = 0L
|
||||
@Volatile private var networkBackoffMs: Long = 0L
|
||||
|
||||
fun resetAuthBackoff() {
|
||||
authBackoffMs = 0L
|
||||
authBackoffUntil = 0L
|
||||
}
|
||||
private fun resetNetworkBackoff() {
|
||||
networkBackoffMs = 0L
|
||||
networkBackoffUntil = 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
|
||||
@@ -73,6 +80,10 @@ class SyncRepository(context: Context) {
|
||||
if (authBackoffUntil > now) {
|
||||
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 lastLocalTs = getLastLocalSyncTs()
|
||||
// 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")
|
||||
}
|
||||
|
||||
// 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
|
||||
val localLists = database.listDao().getListsUpdatedSince(lastLocalTs)
|
||||
.filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs }
|
||||
@@ -108,7 +129,7 @@ class SyncRepository(context: Context) {
|
||||
// Send to server and get updates
|
||||
val syncRequest =
|
||||
SyncRequest(
|
||||
lastSyncTimestamp = lastServerTs,
|
||||
lastSyncTimestamp = effectiveLastServerTs,
|
||||
lists = localLists,
|
||||
fields = localFields,
|
||||
items = localItems,
|
||||
@@ -116,7 +137,23 @@ class SyncRepository(context: Context) {
|
||||
)
|
||||
|
||||
// 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.code() == 401) {
|
||||
// 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
|
||||
resetAuthBackoff()
|
||||
resetNetworkBackoff()
|
||||
|
||||
return@withContext Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
@@ -226,6 +264,7 @@ class SyncRepository(context: Context) {
|
||||
setLastServerSyncTs(0)
|
||||
setLastLocalSyncTs(0)
|
||||
}
|
||||
// Do not reset network backoff here; it's managed where thrown.
|
||||
return@withContext Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
+23
-1
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -126,6 +127,7 @@ fun ListDetailScreen(
|
||||
|
||||
var showManageColumnsDialog by remember { mutableStateOf(false) }
|
||||
var showAddItemDialog by remember { mutableStateOf(false) }
|
||||
var pendingScrollToBottom by remember { mutableStateOf(false) }
|
||||
var itemToEdit by remember { mutableStateOf<ItemWithValues?>(null) }
|
||||
var showSortDialog by remember { mutableStateOf(false) }
|
||||
var showGroupDialog by remember { mutableStateOf(false) }
|
||||
@@ -218,12 +220,15 @@ fun ListDetailScreen(
|
||||
stableItems
|
||||
}
|
||||
|
||||
// Default ordering: append new items at bottom by createdAt ascending.
|
||||
// If an explicit sortField is chosen, use that instead.
|
||||
val sortedItems: List<ItemWithValues> =
|
||||
if (sortField != null) {
|
||||
val base = filteredItems.sortedWith(compareBy { item -> valueFor(item, sortField) })
|
||||
if (sortAscending) base else base.asReversed()
|
||||
} else {
|
||||
filteredItems
|
||||
// Items from DB may not be ordered; enforce createdAt ascending
|
||||
filteredItems.sortedBy { it.item.createdAt }
|
||||
}
|
||||
|
||||
val processedItems: List<ItemWithValues> = sortedItems
|
||||
@@ -270,6 +275,7 @@ fun ListDetailScreen(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -508,8 +514,23 @@ fun ListDetailScreen(
|
||||
}
|
||||
}
|
||||
} 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(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
) {
|
||||
groupedItems.entries.forEach { (groupName, groupItems) ->
|
||||
// Show group header if grouping is enabled
|
||||
@@ -597,6 +618,7 @@ fun ListDetailScreen(
|
||||
onAdd = { fieldValues ->
|
||||
viewModel.addItemWithValues(fieldValues)
|
||||
showAddItemDialog = false
|
||||
pendingScrollToBottom = true
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+17
-10
@@ -318,11 +318,20 @@ fun ListItem(
|
||||
onDeleteClick: () -> Unit,
|
||||
dragHandle: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
// Expand the touch target to include the vertical spacing between rows without visually changing layout.
|
||||
// We wrap the original row in a full-width Box with the same outer padding, then add a clickable
|
||||
// modifier that uses additional vertical padding but negative padding inside to keep visual spacing.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.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,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -332,12 +341,9 @@ fun ListItem(
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
// Table name centered/left and clickable
|
||||
// Table name and subtitle
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.clickable(onClick = onListClick),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(text = list.name, style = MaterialTheme.typography.titleMedium)
|
||||
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) {
|
||||
IconButton(onClick = onEditClick) {
|
||||
Icon(
|
||||
@@ -367,6 +373,7 @@ fun ListItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
Reference in New Issue
Block a user