refactor: remove WebSocket implementation and switch to HTTP-only sync with configurable polling interval
This commit is contained in:
-166
@@ -1,166 +0,0 @@
|
|||||||
package com.collabtable.app.data.api
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.collabtable.app.data.preferences.PreferencesManager
|
|
||||||
import com.collabtable.app.utils.Logger
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import com.google.gson.JsonObject
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.coroutines.withTimeout
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
import okhttp3.WebSocket
|
|
||||||
import okhttp3.WebSocketListener
|
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
|
||||||
import java.util.UUID
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
private data class MessageEnvelope(
|
|
||||||
val id: String? = null,
|
|
||||||
val type: String,
|
|
||||||
val payload: Any? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
object WebSocketSyncClient {
|
|
||||||
private val gson = Gson()
|
|
||||||
|
|
||||||
private val logging =
|
|
||||||
HttpLoggingInterceptor().apply {
|
|
||||||
level = HttpLoggingInterceptor.Level.BASIC
|
|
||||||
}
|
|
||||||
|
|
||||||
private val client: OkHttpClient =
|
|
||||||
OkHttpClient.Builder()
|
|
||||||
.addInterceptor(logging)
|
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
|
||||||
.writeTimeout(30, TimeUnit.SECONDS)
|
|
||||||
.pingInterval(15, TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private fun buildWebSocketUrl(httpApiBaseUrl: String): String {
|
|
||||||
// Expecting something like: http://host:port/api/
|
|
||||||
var url = httpApiBaseUrl
|
|
||||||
// Ensure trailing slash removed for manipulation
|
|
||||||
if (url.endsWith("/")) url = url.dropLast(1)
|
|
||||||
|
|
||||||
// Replace scheme
|
|
||||||
url =
|
|
||||||
when {
|
|
||||||
url.startsWith("https://") -> "wss://" + url.removePrefix("https://")
|
|
||||||
url.startsWith("http://") -> "ws://" + url.removePrefix("http://")
|
|
||||||
else -> "ws://$url" // best effort
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace trailing /api with /api/ws
|
|
||||||
return if (url.endsWith("/api")) "$url/ws" else "$url/api/ws"
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun sync(
|
|
||||||
context: Context,
|
|
||||||
request: SyncRequest,
|
|
||||||
): Result<SyncResponse> =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val prefs = PreferencesManager.getInstance(context)
|
|
||||||
val baseUrl = prefs.getServerUrl()
|
|
||||||
val password = prefs.getServerPassword()
|
|
||||||
val wsUrl = buildWebSocketUrl(baseUrl)
|
|
||||||
val messageId = UUID.randomUUID().toString()
|
|
||||||
|
|
||||||
val deferred = CompletableDeferred<Result<SyncResponse>>()
|
|
||||||
var socket: WebSocket? = null
|
|
||||||
|
|
||||||
val httpRequestBuilder = Request.Builder().url(wsUrl)
|
|
||||||
if (!password.isNullOrBlank()) {
|
|
||||||
httpRequestBuilder.addHeader("Authorization", "Bearer $password")
|
|
||||||
}
|
|
||||||
val httpRequest = httpRequestBuilder.build()
|
|
||||||
|
|
||||||
val listener =
|
|
||||||
object : WebSocketListener() {
|
|
||||||
override fun onOpen(
|
|
||||||
webSocket: WebSocket,
|
|
||||||
response: Response,
|
|
||||||
) {
|
|
||||||
// Send sync message when opened
|
|
||||||
val envelope =
|
|
||||||
MessageEnvelope(
|
|
||||||
id = messageId,
|
|
||||||
type = "sync",
|
|
||||||
payload = request,
|
|
||||||
)
|
|
||||||
val json = gson.toJson(envelope)
|
|
||||||
webSocket.send(json)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMessage(
|
|
||||||
webSocket: WebSocket,
|
|
||||||
text: String,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
val root = gson.fromJson(text, JsonObject::class.java)
|
|
||||||
val type = root.get("type")?.asString
|
|
||||||
val id = root.get("id")?.asString
|
|
||||||
if (type == "syncResponse" && id == messageId) {
|
|
||||||
val payload = root.getAsJsonObject("payload")
|
|
||||||
val resp = gson.fromJson(payload, SyncResponse::class.java)
|
|
||||||
if (!deferred.isCompleted) {
|
|
||||||
deferred.complete(Result.success(resp))
|
|
||||||
webSocket.close(1000, "done")
|
|
||||||
}
|
|
||||||
} else if (type == "error" && !deferred.isCompleted) {
|
|
||||||
val msg = root.getAsJsonObject("payload")?.get("message")?.asString ?: "WebSocket error"
|
|
||||||
deferred.complete(Result.failure(Exception(msg)))
|
|
||||||
webSocket.close(1002, "error")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (!deferred.isCompleted) {
|
|
||||||
deferred.complete(Result.failure(e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClosing(
|
|
||||||
webSocket: WebSocket,
|
|
||||||
code: Int,
|
|
||||||
reason: String,
|
|
||||||
) {
|
|
||||||
// 1008 indicates policy violation, used here for Unauthorized
|
|
||||||
if (!deferred.isCompleted) {
|
|
||||||
if (code == 1008) {
|
|
||||||
deferred.complete(Result.failure(Exception("Unauthorized (WS 1008)")))
|
|
||||||
} else {
|
|
||||||
deferred.complete(Result.failure(Exception("WebSocket closing ($code): $reason")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFailure(
|
|
||||||
webSocket: WebSocket,
|
|
||||||
t: Throwable,
|
|
||||||
response: Response?,
|
|
||||||
) {
|
|
||||||
if (!deferred.isCompleted) {
|
|
||||||
deferred.complete(Result.failure(t))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
socket = client.newWebSocket(httpRequest, listener)
|
|
||||||
// Wait up to 30s for response (WS roundtrip)
|
|
||||||
val result = withTimeout(30_000) { deferred.await() }
|
|
||||||
return@withContext result
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Logger.w("WS", "Falling back to HTTP sync: ${e.message}")
|
|
||||||
return@withContext Result.failure(e)
|
|
||||||
} finally {
|
|
||||||
// OkHttp cleans up sockets automatically when no references remain
|
|
||||||
// but we try to close if still open
|
|
||||||
socket?.close(1000, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+20
@@ -25,6 +25,10 @@ class PreferencesManager(context: Context) {
|
|||||||
private val _sortOrder = MutableStateFlow(getSortOrder())
|
private val _sortOrder = MutableStateFlow(getSortOrder())
|
||||||
val sortOrder: StateFlow<String> = _sortOrder.asStateFlow()
|
val sortOrder: StateFlow<String> = _sortOrder.asStateFlow()
|
||||||
|
|
||||||
|
// Sync polling interval in milliseconds (configurable)
|
||||||
|
private val _syncPollIntervalMs = MutableStateFlow(getSyncPollIntervalMs())
|
||||||
|
val syncPollIntervalMs: StateFlow<Long> = _syncPollIntervalMs.asStateFlow()
|
||||||
|
|
||||||
fun getServerUrl(): String {
|
fun getServerUrl(): String {
|
||||||
return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL
|
return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL
|
||||||
}
|
}
|
||||||
@@ -124,6 +128,18 @@ class PreferencesManager(context: Context) {
|
|||||||
_sortOrder.value = normalized
|
_sortOrder.value = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync interval: read/write with sensible bounds to avoid too-aggressive polling
|
||||||
|
fun getSyncPollIntervalMs(): Long {
|
||||||
|
val raw = prefs.getLong(KEY_SYNC_POLL_INTERVAL_MS, DEFAULT_SYNC_POLL_INTERVAL_MS)
|
||||||
|
return raw.coerceIn(MIN_SYNC_POLL_INTERVAL_MS, MAX_SYNC_POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSyncPollIntervalMs(ms: Long) {
|
||||||
|
val clamped = ms.coerceIn(MIN_SYNC_POLL_INTERVAL_MS, MAX_SYNC_POLL_INTERVAL_MS)
|
||||||
|
prefs.edit().putLong(KEY_SYNC_POLL_INTERVAL_MS, clamped).apply()
|
||||||
|
_syncPollIntervalMs.value = clamped
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val KEY_SERVER_URL = "server_url"
|
private const val KEY_SERVER_URL = "server_url"
|
||||||
private const val KEY_FIRST_RUN = "first_run"
|
private const val KEY_FIRST_RUN = "first_run"
|
||||||
@@ -133,7 +149,11 @@ class PreferencesManager(context: Context) {
|
|||||||
private const val KEY_DYNAMIC_COLOR = "dynamic_color"
|
private const val KEY_DYNAMIC_COLOR = "dynamic_color"
|
||||||
private const val KEY_AMOLED_DARK = "amoled_dark"
|
private const val KEY_AMOLED_DARK = "amoled_dark"
|
||||||
private const val KEY_SORT_ORDER = "sort_order"
|
private const val KEY_SORT_ORDER = "sort_order"
|
||||||
|
private const val KEY_SYNC_POLL_INTERVAL_MS = "sync_poll_interval_ms"
|
||||||
private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/"
|
private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/"
|
||||||
|
private const val DEFAULT_SYNC_POLL_INTERVAL_MS = 5000L
|
||||||
|
private const val MIN_SYNC_POLL_INTERVAL_MS = 250L
|
||||||
|
private const val MAX_SYNC_POLL_INTERVAL_MS = 600_000L // 10 minutes
|
||||||
const val THEME_MODE_SYSTEM = "system"
|
const val THEME_MODE_SYSTEM = "system"
|
||||||
const val THEME_MODE_LIGHT = "light"
|
const val THEME_MODE_LIGHT = "light"
|
||||||
const val THEME_MODE_DARK = "dark"
|
const val THEME_MODE_DARK = "dark"
|
||||||
|
|||||||
+12
-19
@@ -4,7 +4,6 @@ import android.content.Context
|
|||||||
import androidx.room.withTransaction
|
import androidx.room.withTransaction
|
||||||
import com.collabtable.app.data.api.ApiClient
|
import com.collabtable.app.data.api.ApiClient
|
||||||
import com.collabtable.app.data.api.SyncRequest
|
import com.collabtable.app.data.api.SyncRequest
|
||||||
import com.collabtable.app.data.api.WebSocketSyncClient
|
|
||||||
import com.collabtable.app.data.database.CollabTableDatabase
|
import com.collabtable.app.data.database.CollabTableDatabase
|
||||||
import com.collabtable.app.utils.Logger
|
import com.collabtable.app.utils.Logger
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -60,25 +59,19 @@ class SyncRepository(context: Context) {
|
|||||||
itemValues = localValues,
|
itemValues = localValues,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Try WebSocket first; on failure, fall back to HTTP
|
// HTTP-only sync
|
||||||
val wsResult = WebSocketSyncClient.sync(appContext, syncRequest)
|
val response = api.sync(syncRequest)
|
||||||
val syncResponse =
|
if (!response.isSuccessful) {
|
||||||
if (wsResult.isSuccess) {
|
if (response.code() == 401) {
|
||||||
wsResult.getOrThrow()
|
// Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error.
|
||||||
} else {
|
Logger.e("Sync", "[ERROR] Unauthorized (401). Check server password in Settings.")
|
||||||
val response = api.sync(syncRequest)
|
setLastSyncTimestamp(0)
|
||||||
if (!response.isSuccessful) {
|
return@withContext Result.failure(Exception("Unauthorized (401). Please verify server password."))
|
||||||
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.")
|
|
||||||
setLastSyncTimestamp(0)
|
|
||||||
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()}"))
|
|
||||||
}
|
|
||||||
response.body()!!
|
|
||||||
}
|
}
|
||||||
|
Logger.e("Sync", "[ERROR] Sync failed: HTTP ${response.code()}")
|
||||||
|
return@withContext Result.failure(Exception("Sync failed: ${response.code()}"))
|
||||||
|
}
|
||||||
|
val syncResponse = response.body()!!
|
||||||
// Only log receive when there are actual server changes
|
// Only log receive when there are actual server changes
|
||||||
val inTotal =
|
val inTotal =
|
||||||
syncResponse.lists.size +
|
syncResponse.lists.size +
|
||||||
|
|||||||
+38
-52
@@ -24,6 +24,7 @@ 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.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.ClickableText
|
import androidx.compose.foundation.text.ClickableText
|
||||||
@@ -2522,11 +2523,8 @@ fun ManageColumnsDialog(
|
|||||||
val reorderState =
|
val reorderState =
|
||||||
rememberReorderableLazyListState(
|
rememberReorderableLazyListState(
|
||||||
onMove = { from, to ->
|
onMove = { from, to ->
|
||||||
// Map from Lazy positions (with dividers) to data indices.
|
// Indices from reorderable refer to draggable items only
|
||||||
// Insert AFTER the upper item when hovering between rows.
|
reorderedFields.move(from.index, to.index)
|
||||||
val fromData = from.index / 2
|
|
||||||
val toData = (to.index + 1) / 2
|
|
||||||
reorderedFields.move(fromData, toData)
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2540,53 +2538,41 @@ fun ManageColumnsDialog(
|
|||||||
contentPadding = PaddingValues(vertical = 16.dp),
|
contentPadding = PaddingValues(vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
) {
|
) {
|
||||||
// Interleave dividers as separate Lazy items so dividers don't move with dragged rows
|
itemsIndexed(reorderedFields, key = { _, field -> field.id }) { index, field ->
|
||||||
items(
|
ReorderableItem(
|
||||||
count = if (reorderedFields.isEmpty()) 0 else reorderedFields.size * 2 - 1,
|
reorderState,
|
||||||
key = { pos ->
|
key = field.id,
|
||||||
if (pos % 2 == 0) reorderedFields[pos / 2].id else "divider-$pos"
|
) { _ ->
|
||||||
},
|
Box(
|
||||||
) { pos ->
|
modifier = Modifier.animateItemPlacement(),
|
||||||
if (pos % 2 == 1) {
|
) {
|
||||||
Divider()
|
ColumnItem(
|
||||||
} else {
|
field = field,
|
||||||
val index = pos / 2
|
onEdit = { fieldToEdit = field },
|
||||||
val field = reorderedFields[index]
|
onDelete = { fieldToDelete = field },
|
||||||
ReorderableItem(
|
onMoveUp = {
|
||||||
reorderState,
|
if (index > 0) {
|
||||||
key = field.id,
|
val item = reorderedFields.removeAt(index)
|
||||||
) { _ ->
|
reorderedFields.add(index - 1, item)
|
||||||
Box(
|
}
|
||||||
modifier = Modifier.animateItemPlacement(),
|
},
|
||||||
) {
|
onMoveDown = {
|
||||||
ColumnItem(
|
if (index < reorderedFields.size - 1) {
|
||||||
field = field,
|
val item = reorderedFields.removeAt(index)
|
||||||
onEdit = { fieldToEdit = field },
|
reorderedFields.add(index + 1, item)
|
||||||
onDelete = { fieldToDelete = field },
|
}
|
||||||
onMoveUp = {
|
},
|
||||||
if (index > 0) {
|
canMoveUp = index > 0,
|
||||||
val item = reorderedFields.removeAt(index)
|
canMoveDown = index < reorderedFields.size - 1,
|
||||||
reorderedFields.add(index - 1, item)
|
dragHandle = {
|
||||||
}
|
Icon(
|
||||||
},
|
imageVector = Icons.Default.DragHandle,
|
||||||
onMoveDown = {
|
contentDescription = "Reorder",
|
||||||
if (index < reorderedFields.size - 1) {
|
modifier = Modifier.detectReorder(reorderState),
|
||||||
val item = reorderedFields.removeAt(index)
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
reorderedFields.add(index + 1, item)
|
)
|
||||||
}
|
},
|
||||||
},
|
)
|
||||||
canMoveUp = index > 0,
|
|
||||||
canMoveDown = index < reorderedFields.size - 1,
|
|
||||||
dragHandle = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.DragHandle,
|
|
||||||
contentDescription = "Reorder",
|
|
||||||
modifier = Modifier.detectReorder(reorderState),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -65,9 +65,11 @@ class ListDetailViewModel(
|
|||||||
|
|
||||||
private fun startPeriodicSync() {
|
private fun startPeriodicSync() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val prefs = com.collabtable.app.data.preferences.PreferencesManager.getInstance(context)
|
||||||
while (true) {
|
while (true) {
|
||||||
performSync()
|
performSync()
|
||||||
kotlinx.coroutines.delay(5000) // Sync every 5 seconds
|
val delayMs = prefs.getSyncPollIntervalMs()
|
||||||
|
kotlinx.coroutines.delay(delayMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-36
@@ -15,7 +15,7 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
@@ -27,7 +27,6 @@ import androidx.compose.material.icons.filled.Settings
|
|||||||
import androidx.compose.material.icons.filled.Sort
|
import androidx.compose.material.icons.filled.Sort
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Divider
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -173,11 +172,8 @@ fun ListsScreen(
|
|||||||
onMove = { from, to ->
|
onMove = { from, to ->
|
||||||
dragging = true
|
dragging = true
|
||||||
val newList = working.toMutableList()
|
val newList = working.toMutableList()
|
||||||
// Map from Lazy positions (with dividers) to data indices.
|
// Indices provided by reorderable correspond to draggable items only (dividers ignored)
|
||||||
// to.index can be odd (between items). Insert AFTER the upper item for better feel.
|
newList.move(from.index, to.index)
|
||||||
val fromData = from.index / 2
|
|
||||||
val toData = (to.index + 1) / 2
|
|
||||||
newList.move(fromData, toData)
|
|
||||||
working = newList
|
working = newList
|
||||||
},
|
},
|
||||||
onDragEnd = { _, _ ->
|
onDragEnd = { _, _ ->
|
||||||
@@ -195,35 +191,23 @@ fun ListsScreen(
|
|||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
) {
|
) {
|
||||||
// Interleave dividers as separate list items so they don't move with dragged rows
|
itemsIndexed(working, key = { _, it -> it.id }) { _, list ->
|
||||||
items(
|
ReorderableItem(reorderState, key = list.id) { _ ->
|
||||||
count = if (working.isEmpty()) 0 else working.size * 2 - 1,
|
Box(modifier = Modifier.animateItemPlacement()) {
|
||||||
key = { pos ->
|
ListItem(
|
||||||
if (pos % 2 == 0) working[pos / 2].id else "divider-$pos"
|
list = list,
|
||||||
},
|
onListClick = { onNavigateToList(list.id) },
|
||||||
) { pos ->
|
onEditClick = { listToEdit = list },
|
||||||
if (pos % 2 == 1) {
|
onDeleteClick = { listToDelete = list },
|
||||||
Divider()
|
dragHandle = {
|
||||||
} else {
|
Icon(
|
||||||
val index = pos / 2
|
imageVector = Icons.Default.DragHandle,
|
||||||
val list = working[index]
|
contentDescription = "Reorder",
|
||||||
ReorderableItem(reorderState, key = list.id) { _ ->
|
modifier = Modifier.detectReorder(reorderState),
|
||||||
Box(modifier = Modifier.animateItemPlacement()) {
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
ListItem(
|
)
|
||||||
list = list,
|
},
|
||||||
onListClick = { onNavigateToList(list.id) },
|
)
|
||||||
onEditClick = { listToEdit = list },
|
|
||||||
onDeleteClick = { listToDelete = list },
|
|
||||||
dragHandle = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.DragHandle,
|
|
||||||
contentDescription = "Reorder",
|
|
||||||
modifier = Modifier.detectReorder(reorderState),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -99,8 +99,10 @@ class ListsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun startPeriodicSync() {
|
private suspend fun startPeriodicSync() {
|
||||||
|
val prefs = com.collabtable.app.data.preferences.PreferencesManager.getInstance(context)
|
||||||
while (true) {
|
while (true) {
|
||||||
kotlinx.coroutines.delay(5000) // Wait 5 seconds before next sync
|
val delayMs = prefs.getSyncPollIntervalMs()
|
||||||
|
kotlinx.coroutines.delay(delayMs)
|
||||||
performSync()
|
performSync()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@@ -72,6 +73,9 @@ fun SettingsScreen(
|
|||||||
val displayUrl = remember(serverUrl) { formatServerUrlForDisplay(serverUrl) }
|
val displayUrl = remember(serverUrl) { formatServerUrlForDisplay(serverUrl) }
|
||||||
var showLeaveDialog by remember { mutableStateOf(false) }
|
var showLeaveDialog by remember { mutableStateOf(false) }
|
||||||
var isLeaving by remember { mutableStateOf(false) }
|
var isLeaving by remember { mutableStateOf(false) }
|
||||||
|
val syncIntervalMs by preferencesManager.syncPollIntervalMs.collectAsState()
|
||||||
|
var syncIntervalInput by remember(syncIntervalMs) { mutableStateOf(syncIntervalMs.toString()) }
|
||||||
|
var syncIntervalError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -216,6 +220,72 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync settings
|
||||||
|
Text(
|
||||||
|
text = "Sync",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "HTTP polling interval (ms)",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = syncIntervalInput,
|
||||||
|
onValueChange = {
|
||||||
|
syncIntervalInput = it.filter { ch -> ch.isDigit() }
|
||||||
|
syncIntervalError = null
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
singleLine = true,
|
||||||
|
placeholder = { Text("5000") },
|
||||||
|
)
|
||||||
|
Button(onClick = {
|
||||||
|
val parsed = syncIntervalInput.toLongOrNull()
|
||||||
|
if (parsed == null) {
|
||||||
|
syncIntervalError = "Enter a valid number"
|
||||||
|
} else {
|
||||||
|
// Bounds are enforced in setter
|
||||||
|
preferencesManager.setSyncPollIntervalMs(parsed)
|
||||||
|
syncIntervalError = null
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text("Apply")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (syncIntervalError != null) {
|
||||||
|
Text(
|
||||||
|
text = syncIntervalError!!,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "Current: ${'$'}syncIntervalMs ms (min 250, max 600000)",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors =
|
colors =
|
||||||
|
|||||||
@@ -19,10 +19,9 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"better-sqlite3": "^11.0.0",
|
"better-sqlite3": "^11.0.0",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"ws": "^8.16.0",
|
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
"@typescript-eslint/parser": "^7.0.2",
|
||||||
"@typescript-eslint/parser": "^7.0.2",
|
"eslint": "^8.57.0",
|
||||||
"eslint": "^8.57.0",
|
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"pg": "^8.11.3"
|
"pg": "^8.11.3"
|
||||||
@@ -32,7 +31,6 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/node": "^20.10.6",
|
"@types/node": "^20.10.6",
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
"@types/ws": "^8.5.10",
|
|
||||||
"@types/pg": "^8.10.2",
|
"@types/pg": "^8.10.2",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"ts-node-dev": "^2.0.0"
|
"ts-node-dev": "^2.0.0"
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import fieldRoutes from './routes/fieldRoutes';
|
|||||||
import itemRoutes from './routes/itemRoutes';
|
import itemRoutes from './routes/itemRoutes';
|
||||||
import syncRoutes from './routes/syncRoutes';
|
import syncRoutes from './routes/syncRoutes';
|
||||||
import webRoutes from './routes/webRoutes';
|
import webRoutes from './routes/webRoutes';
|
||||||
import { createServer } from 'http';
|
|
||||||
import { initWebSocket } from './ws';
|
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -48,9 +46,8 @@ app.use('/', webRoutes);
|
|||||||
console.log('- Items');
|
console.log('- Items');
|
||||||
console.log('- ItemValues');
|
console.log('- ItemValues');
|
||||||
|
|
||||||
const server = createServer(app);
|
// Start HTTP server (WebSocket removed)
|
||||||
initWebSocket(server);
|
app.listen(PORT, () => {
|
||||||
server.listen(PORT, () => {
|
|
||||||
console.log(`Server is running on port ${PORT}`);
|
console.log(`Server is running on port ${PORT}`);
|
||||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||||
const dbClient = (process.env.DB_CLIENT || process.env.DB_TYPE || 'sqlite').toLowerCase();
|
const dbClient = (process.env.DB_CLIENT || process.env.DB_TYPE || 'sqlite').toLowerCase();
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
import { Server as HttpServer, IncomingMessage } from 'http';
|
|
||||||
import { WebSocketServer, WebSocket } from 'ws';
|
|
||||||
import { dbAdapter } from './db';
|
|
||||||
|
|
||||||
interface SyncRequest {
|
|
||||||
lastSyncTimestamp: number;
|
|
||||||
lists: any[];
|
|
||||||
fields: any[];
|
|
||||||
items: any[];
|
|
||||||
itemValues: any[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MessageEnvelope<T = any> {
|
|
||||||
id?: string;
|
|
||||||
type: string;
|
|
||||||
payload?: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkAuth(req: any): boolean {
|
|
||||||
const serverPassword = process.env.SERVER_PASSWORD;
|
|
||||||
if (!serverPassword) return true; // auth disabled
|
|
||||||
const auth = req.headers?.authorization as string | undefined;
|
|
||||||
if (!auth) return false;
|
|
||||||
const parts = auth.split(' ');
|
|
||||||
return parts.length === 2 && parts[0] === 'Bearer' && parts[1] === serverPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UPSERT_LIST = 'INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted';
|
|
||||||
const UPSERT_FIELD = 'INSERT INTO fields (id, name, fieldType, fieldOptions, listId, "order", createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, fieldType = EXCLUDED.fieldType, fieldOptions = EXCLUDED.fieldOptions, "order" = EXCLUDED."order", updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted';
|
|
||||||
const UPSERT_ITEM = 'INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted';
|
|
||||||
const UPSERT_ITEM_VALUE = 'INSERT INTO item_values (id, itemId, fieldId, value, updatedAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET value = EXCLUDED.value, updatedAt = EXCLUDED.updatedAt';
|
|
||||||
|
|
||||||
async function processSync(data: SyncRequest) {
|
|
||||||
await dbAdapter.transaction(async (tx) => {
|
|
||||||
for (const list of data.lists || []) {
|
|
||||||
await tx.execute(UPSERT_LIST, [list.id, list.name, list.createdAt, list.updatedAt, list.isDeleted ? 1 : 0]);
|
|
||||||
}
|
|
||||||
for (const field of data.fields || []) {
|
|
||||||
await tx.execute(UPSERT_FIELD, [field.id, field.name, field.fieldType, field.fieldOptions, field.listId, field.order, field.createdAt, field.updatedAt, field.isDeleted ? 1 : 0]);
|
|
||||||
}
|
|
||||||
for (const item of data.items || []) {
|
|
||||||
await tx.execute(UPSERT_ITEM, [item.id, item.listId, item.createdAt, item.updatedAt, item.isDeleted ? 1 : 0]);
|
|
||||||
}
|
|
||||||
for (const val of data.itemValues || []) {
|
|
||||||
await tx.execute(UPSERT_ITEM_VALUE, [val.id, val.itemId, val.fieldId, val.value, val.updatedAt]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let serverLists: any[], serverFields: any[], serverItems: any[], serverItemValues: any[];
|
|
||||||
const since = data.lastSyncTimestamp;
|
|
||||||
if (since === 0) {
|
|
||||||
serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE isDeleted = 0');
|
|
||||||
serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE isDeleted = 0');
|
|
||||||
serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE isDeleted = 0');
|
|
||||||
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values');
|
|
||||||
} else {
|
|
||||||
serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE updatedAt >= ?', [since]);
|
|
||||||
serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE updatedAt >= ?', [since]);
|
|
||||||
serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE updatedAt >= ?', [since]);
|
|
||||||
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values WHERE updatedAt >= ?', [since]);
|
|
||||||
}
|
|
||||||
|
|
||||||
serverLists = serverLists.map(l => ({ ...l, isDeleted: !!l.isDeleted }));
|
|
||||||
serverFields = serverFields.map(f => ({ ...f, isDeleted: !!f.isDeleted }));
|
|
||||||
serverItems = serverItems.map(i => ({ ...i, isDeleted: !!i.isDeleted }));
|
|
||||||
|
|
||||||
return {
|
|
||||||
lists: serverLists,
|
|
||||||
fields: serverFields,
|
|
||||||
items: serverItems,
|
|
||||||
itemValues: serverItemValues,
|
|
||||||
serverTimestamp: Date.now(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initWebSocket(server: HttpServer) {
|
|
||||||
const wss = new WebSocketServer({ server, path: '/api/ws' });
|
|
||||||
console.log('WebSocket server initialized at /api/ws');
|
|
||||||
|
|
||||||
wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
|
|
||||||
if (!checkAuth(req)) {
|
|
||||||
ws.close(1008, 'Unauthorized');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.on('message', async (raw: Buffer) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(raw.toString()) as MessageEnvelope;
|
|
||||||
if (msg.type === 'ping') {
|
|
||||||
const resp: MessageEnvelope = { type: 'pong', id: msg.id };
|
|
||||||
ws.send(JSON.stringify(resp));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg.type === 'sync') {
|
|
||||||
const respData = await processSync(msg.payload as SyncRequest);
|
|
||||||
const resp: MessageEnvelope = { type: 'syncResponse', id: msg.id, payload: respData };
|
|
||||||
ws.send(JSON.stringify(resp));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const err: MessageEnvelope = { type: 'error', payload: { message: 'Invalid message' } };
|
|
||||||
ws.send(JSON.stringify(err));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user