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.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -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
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-10
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user