diff --git a/CollabTableAndroid/app/build.gradle b/CollabTableAndroid/app/build.gradle index d57dab8..1cd2a5d 100644 --- a/CollabTableAndroid/app/build.gradle +++ b/CollabTableAndroid/app/build.gradle @@ -2,7 +2,8 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' id 'org.jetbrains.kotlin.plugin.compose' - id 'com.google.devtools.ksp' + // KSP not available for Kotlin 2.2.21 yet; switch to kapt for Room annotation processing + id 'org.jetbrains.kotlin.kapt' id 'org.jlleitschuh.gradle.ktlint' id 'io.gitlab.arturbosch.detekt' } @@ -40,9 +41,6 @@ android { buildFeatures { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = '1.6.8' - } packaging { resources { excludes += '/META-INF/{AL2.0,LGPL2.1}' @@ -69,10 +67,10 @@ dependencies { // ViewModel implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2' - // Room - implementation 'androidx.room:room-runtime:2.6.1' - implementation 'androidx.room:room-ktx:2.6.1' - ksp 'androidx.room:room-compiler:2.6.1' + // Room (use kapt for annotation processing). Bump to version compatible with Kotlin 2.2 metadata + implementation 'androidx.room:room-runtime:2.7.0' + implementation 'androidx.room:room-ktx:2.7.0' + kapt 'androidx.room:room-compiler:2.7.0' // Retrofit implementation 'com.squareup.retrofit2:retrofit:2.9.0' diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/CollabTableApplication.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/CollabTableApplication.kt index 5016667..767fd86 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/CollabTableApplication.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/CollabTableApplication.kt @@ -1,74 +1,78 @@ -package com.collabtable.app - -import android.app.Application -import android.app.NotificationChannel -import android.app.NotificationManager -import android.content.Context -import com.collabtable.app.data.api.ApiClient -import com.collabtable.app.data.repository.SyncRepository -import com.collabtable.app.notifications.NotificationHelper -import com.collabtable.app.work.ListChangeNotificationWorker -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ProcessLifecycleOwner -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager -import java.util.concurrent.TimeUnit -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch - -class CollabTableApplication : Application() { - override fun onCreate() { - super.onCreate() - // Initialize API client with saved server URL - ApiClient.initialize(this) - // Kick off a one-time full down-sync on cold app start if local DB is empty but server has data. - // This complements in-screen sync logic and ensures earliest possible hydration. - CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { - try { - // Force a full down-sync on app start when server credentials are present. - SyncRepository(this@CollabTableApplication).performSync(forceFullDown = true) - } catch (_: Exception) { - // Silent; UI layers will surface subsequent sync issues. - } - } - - // Create notification channel(s) for list events - createNotificationChannels() - - // Schedule periodic background check for list changes to surface notifications - scheduleListChangeWorker() - - // Auto-clear notifications when app comes to foreground - ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { - override fun onStart(owner: LifecycleOwner) { - NotificationHelper.clearListEventNotifications(this@CollabTableApplication) - } - }) - } - - private fun createNotificationChannels() { - val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val listEventsChannel = NotificationChannel( - NotificationHelper.CHANNEL_LIST_EVENTS, - "List Events", - NotificationManager.IMPORTANCE_DEFAULT, - ).apply { - description = "Notifications for list add, edit, and remove events" - } - manager.createNotificationChannel(listEventsChannel) - } - - private fun scheduleListChangeWorker() { - val work = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) - .build() - WorkManager.getInstance(this).enqueueUniquePeriodicWork( - "list_change_notifier", - ExistingPeriodicWorkPolicy.UPDATE, - work, - ) - } -} +package com.collabtable.app + +import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.collabtable.app.data.api.ApiClient +import com.collabtable.app.data.repository.SyncRepository +import com.collabtable.app.notifications.NotificationHelper +import com.collabtable.app.work.ListChangeNotificationWorker +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.util.concurrent.TimeUnit + +class CollabTableApplication : Application() { + override fun onCreate() { + super.onCreate() + // Initialize API client with saved server URL + ApiClient.initialize(this) + // Kick off a one-time full down-sync on cold app start if local DB is empty but server has data. + // This complements in-screen sync logic and ensures earliest possible hydration. + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + // Force a full down-sync on app start when server credentials are present. + SyncRepository(this@CollabTableApplication).performSync(forceFullDown = true) + } catch (_: Exception) { + // Silent; UI layers will surface subsequent sync issues. + } + } + + // Create notification channel(s) for list events + createNotificationChannels() + + // Schedule periodic background check for list changes to surface notifications + scheduleListChangeWorker() + + // Auto-clear notifications when app comes to foreground + ProcessLifecycleOwner.get().lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + NotificationHelper.clearListEventNotifications(this@CollabTableApplication) + } + }, + ) + } + + private fun createNotificationChannels() { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val listEventsChannel = + NotificationChannel( + NotificationHelper.CHANNEL_LIST_EVENTS, + "List Events", + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = "Notifications for list add, edit, and remove events" + } + manager.createNotificationChannel(listEventsChannel) + } + + private fun scheduleListChangeWorker() { + val work = + PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .build() + WorkManager.getInstance(this).enqueueUniquePeriodicWork( + "list_change_notifier", + ExistingPeriodicWorkPolicy.UPDATE, + work, + ) + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt index c48edb8..5416566 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt @@ -1,9 +1,9 @@ package com.collabtable.app.data.api import android.content.Context -import com.collabtable.app.data.preferences.PreferencesManager -import android.os.Build import android.net.Uri +import android.os.Build +import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.utils.Logger import okhttp3.Interceptor import okhttp3.OkHttpClient @@ -32,7 +32,8 @@ object ApiClient { val newRequest = if (!password.isNullOrBlank() && password != "\$password") { - request.newBuilder() + request + .newBuilder() .header("Authorization", "Bearer $password") .build() } else { @@ -43,7 +44,8 @@ object ApiClient { } private val okHttpClient = - OkHttpClient.Builder() + OkHttpClient + .Builder() .addInterceptor(authInterceptor) .addInterceptor(loggingInterceptor) .connectTimeout(30, TimeUnit.SECONDS) @@ -51,13 +53,13 @@ object ApiClient { .writeTimeout(30, TimeUnit.SECONDS) .build() - private fun buildRetrofit(): Retrofit { - return Retrofit.Builder() + private fun buildRetrofit(): Retrofit = + Retrofit + .Builder() .baseUrl(baseUrl) .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .build() - } private fun isEmulator(): Boolean { val fingerprint = Build.FINGERPRINT.lowercase() @@ -65,9 +67,14 @@ object ApiClient { val brand = Build.BRAND.lowercase() val device = Build.DEVICE.lowercase() val product = Build.PRODUCT.lowercase() - return fingerprint.contains("generic") || fingerprint.contains("emulator") || - model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for") || - brand.startsWith("generic") || device.startsWith("generic") || product.contains("sdk") + return fingerprint.contains("generic") || + fingerprint.contains("emulator") || + model.contains("google_sdk") || + model.contains("emulator") || + model.contains("android sdk built for") || + brand.startsWith("generic") || + device.startsWith("generic") || + product.contains("sdk") } private fun ensureTrailingSlash(url: String): String = if (url.endsWith("/")) url else "$url/" @@ -104,7 +111,8 @@ object ApiClient { baseUrl = normalizeForAndroidEmulator(prefs.getServerUrl()) try { Logger.i("HTTP", "API base URL initialized to $baseUrl") - } catch (_: Exception) { } + } catch (_: Exception) { + } retrofit = buildRetrofit() } @@ -112,7 +120,8 @@ object ApiClient { baseUrl = normalizeForAndroidEmulator(url) try { Logger.i("HTTP", "API base URL set to $baseUrl") - } catch (_: Exception) { } + } catch (_: Exception) { + } retrofit = buildRetrofit() } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt index a9aa61c..c791837 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt @@ -54,20 +54,19 @@ abstract class CollabTableDatabase : RoomDatabase() { @Volatile private var dbInstance: CollabTableDatabase? = null - fun getDatabase(context: Context): CollabTableDatabase { - return dbInstance ?: synchronized(this) { + fun getDatabase(context: Context): CollabTableDatabase = + dbInstance ?: synchronized(this) { val instance = - Room.databaseBuilder( - context.applicationContext, - CollabTableDatabase::class.java, - "collab_table_database", - ) - .addMigrations(migration1To2, migration2To3) + Room + .databaseBuilder( + context.applicationContext, + CollabTableDatabase::class.java, + "collab_table_database", + ).addMigrations(migration1To2, migration2To3) .build() dbInstance = instance instance } - } fun clearDatabase(context: Context) { synchronized(this) { diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Field.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Field.kt index 15a692a..c227c5d 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Field.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Field.kt @@ -84,35 +84,31 @@ data class Field( } } - fun getDropdownOptions(): List { - return if (getType() == FieldType.DROPDOWN && fieldOptions.isNotBlank()) { + fun getDropdownOptions(): List = + if (getType() == FieldType.DROPDOWN && fieldOptions.isNotBlank()) { fieldOptions.split("|") } else { emptyList() } - } - fun getCurrency(): String { - return if ((getType() == FieldType.CURRENCY) && fieldOptions.isNotBlank()) { + fun getCurrency(): String = + if ((getType() == FieldType.CURRENCY) && fieldOptions.isNotBlank()) { fieldOptions } else { "CHF" } - } - fun getMaxRating(): Int { - return if (getType() == FieldType.RATING && fieldOptions.isNotBlank()) { + fun getMaxRating(): Int = + if (getType() == FieldType.RATING && fieldOptions.isNotBlank()) { fieldOptions.toIntOrNull() ?: 5 } else { 5 } - } - fun getAutocompleteOptions(): List { - return if (getType() == FieldType.AUTOCOMPLETE && fieldOptions.isNotBlank()) { + fun getAutocompleteOptions(): List = + if (getType() == FieldType.AUTOCOMPLETE && fieldOptions.isNotBlank()) { fieldOptions.split("|") } else { emptyList() } - } } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt index ac52ce9..62d571c 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt @@ -2,10 +2,10 @@ package com.collabtable.app.data.preferences import android.content.Context import android.content.SharedPreferences -import org.json.JSONObject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONObject class PreferencesManager(context: Context) { private val prefs: SharedPreferences = @@ -210,7 +210,10 @@ class PreferencesManager(context: Context) { } } - fun setColumnWidths(listId: String, widthsDp: Map) { + fun setColumnWidths( + listId: String, + widthsDp: Map, + ) { val key = COLUMN_WIDTHS_PREFIX + listId val json = JSONObject() widthsDp.forEach { (fieldId, width) -> @@ -229,11 +232,11 @@ class PreferencesManager(context: Context) { private const val KEY_DYNAMIC_COLOR = "dynamic_color" private const val KEY_AMOLED_DARK = "amoled_dark" private const val KEY_SORT_ORDER = "sort_order" - private const val KEY_SYNC_POLL_INTERVAL_MS = "sync_poll_interval_ms" - private const val KEY_NOTIFY_LIST_ADDED = "notify_list_added" - private const val KEY_NOTIFY_LIST_EDITED = "notify_list_edited" - private const val KEY_NOTIFY_LIST_REMOVED = "notify_list_removed" - private const val KEY_LAST_LIST_NOTIFY_CHECK_TS = "last_list_notify_check_ts" + private const val KEY_SYNC_POLL_INTERVAL_MS = "sync_poll_interval_ms" + private const val KEY_NOTIFY_LIST_ADDED = "notify_list_added" + private const val KEY_NOTIFY_LIST_EDITED = "notify_list_edited" + private const val KEY_NOTIFY_LIST_REMOVED = "notify_list_removed" + private const val KEY_LAST_LIST_NOTIFY_CHECK_TS = "last_list_notify_check_ts" private const val COLUMN_WIDTHS_PREFIX = "column_widths_" // + listId private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/" private const val DEFAULT_SYNC_POLL_INTERVAL_MS = 250L diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt index 0a090d5..58899b0 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt @@ -10,10 +10,10 @@ 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 +import java.net.UnknownHostException class SyncRepository(context: Context) { private val appContext = context.applicationContext @@ -22,18 +22,23 @@ class SyncRepository(context: Context) { private val prefs = appContext.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE) @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 - } + 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 @@ -69,219 +74,225 @@ class SyncRepository(context: Context) { withContext(Dispatchers.IO) { // Prevent overlapping syncs across screens/viewmodels syncMutex.withLock { - try { - // Only attempt network sync when app is in foreground to avoid background DNS issues - // Foreground check removed (lifecycle owner not available in this module); rely solely on network availability. - // Skip sync when there's no validated network available - if (!isNetworkAvailable()) { - return@withContext Result.success(Unit) - } - // 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) - } - // 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 - // updates that happen during an in-flight sync. We'll advance the local watermark - // to this snapshot once the sync completes successfully. - val localSnapshotTs = System.currentTimeMillis() - val isInitialSync = lastServerTs == 0L - if (isInitialSync) { - 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 = when { - // App start can request a full down-sync regardless of local contents - forceFullDown && lastServerTs > 0L -> 0L - // Fallback: if local looks empty but we have a watermark, do a one-time full down-sync - lastServerTs > 0L && (!hasAnyLists || !hasAnyItems) -> 0L - else -> lastServerTs - } - if (effectiveLastServerTs == 0L && lastServerTs > 0L) { - val reason = if (forceFullDown) "startup forced" else "local empty" - Logger.w("Sync", "$reason full down-sync requested") - } - - // Gather local changes since last sync - val localLists = database.listDao().getListsUpdatedSince(lastLocalTs) - .filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs } - val localFields = database.fieldDao().getFieldsUpdatedSince(lastLocalTs) - .filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs } - val localItems = database.itemDao().getItemsUpdatedSince(lastLocalTs) - .filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs } - val localValues = database.itemValueDao().getValuesUpdatedSince(lastLocalTs) - .filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs } - - // Only log send when there are actual local changes - val localTotal = localLists.size + localFields.size + localItems.size + localValues.size - if (localTotal > 0) { - Logger.i( - "Sync", - "[OUT] Sending changes " + - "(lists=${localLists.size}, fields=${localFields.size}, " + - "items=${localItems.size}, values=${localValues.size})", - ) - } - - // Send to server and get updates - val syncRequest = - SyncRequest( - lastSyncTimestamp = effectiveLastServerTs, - lists = localLists, - fields = localFields, - items = localItems, - itemValues = localValues, - ) - - // HTTP-only sync - 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")) + try { + // Only attempt network sync when app is in foreground to avoid background DNS issues + // Foreground check removed (lifecycle owner not available in this module); rely solely on network availability. + // Skip sync when there's no validated network available + if (!isNetworkAvailable()) { + return@withContext Result.success(Unit) } - throw e - } - if (!response.isSuccessful) { - if (response.code() == 401) { - // Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error. + // 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) + } + // 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 + // updates that happen during an in-flight sync. We'll advance the local watermark + // to this snapshot once the sync completes successfully. + val localSnapshotTs = System.currentTimeMillis() + val isInitialSync = lastServerTs == 0L + if (isInitialSync) { + 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 = + when { + // App start can request a full down-sync regardless of local contents + forceFullDown && lastServerTs > 0L -> 0L + // Fallback: if local looks empty but we have a watermark, do a one-time full down-sync + lastServerTs > 0L && (!hasAnyLists || !hasAnyItems) -> 0L + else -> lastServerTs + } + if (effectiveLastServerTs == 0L && lastServerTs > 0L) { + val reason = if (forceFullDown) "startup forced" else "local empty" + Logger.w("Sync", "$reason full down-sync requested") + } + + // Gather local changes since last sync + val localLists = + database.listDao().getListsUpdatedSince(lastLocalTs) + .filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs } + val localFields = + database.fieldDao().getFieldsUpdatedSince(lastLocalTs) + .filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs } + val localItems = + database.itemDao().getItemsUpdatedSince(lastLocalTs) + .filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs } + val localValues = + database.itemValueDao().getValuesUpdatedSince(lastLocalTs) + .filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs } + + // Only log send when there are actual local changes + val localTotal = localLists.size + localFields.size + localItems.size + localValues.size + if (localTotal > 0) { + Logger.i( + "Sync", + "[OUT] Sending changes " + + "(lists=${localLists.size}, fields=${localFields.size}, " + + "items=${localItems.size}, values=${localValues.size})", + ) + } + + // Send to server and get updates + val syncRequest = + SyncRequest( + lastSyncTimestamp = effectiveLastServerTs, + lists = localLists, + fields = localFields, + items = localItems, + itemValues = localValues, + ) + + // HTTP-only sync + 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. + 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.")) + } + return@withContext Result.failure(Exception("Sync failed: ${response.code()}")) + } + val syncResponse = response.body()!! + // Only log receive when there are actual server changes + val inTotal = + syncResponse.lists.size + + syncResponse.fields.size + + syncResponse.items.size + + syncResponse.itemValues.size + if (inTotal > 0) { + Logger.i( + "Sync", + "[IN] Received changes " + + "(lists=${syncResponse.lists.size}, fields=${syncResponse.fields.size}, " + + "items=${syncResponse.items.size}, values=${syncResponse.itemValues.size})", + ) + } + + // Apply server changes to local database atomically in correct order + database.withTransaction { + // 1) Upsert lists first, preserving local orderIndex when present + val localIdToOrder = database.listDao().getListIdsAndOrder().associate { it.id to it.orderIndex } + // Build a map of local updatedAt to avoid overwriting newer local changes with older server data + val localUpdatedMap = database.listDao().getListsUpdatedSince(0).associateBy({ it.id }, { it.updatedAt }) + + val listsPreservingOrder = + syncResponse.lists + .filter { incoming -> + val localUpdated = localUpdatedMap[incoming.id] + localUpdated == null || incoming.updatedAt >= localUpdated + } + .map { incoming -> + val localOrder = localIdToOrder[incoming.id] + if (localOrder != null) incoming.copy(orderIndex = localOrder) else incoming + } + + if (listsPreservingOrder.isNotEmpty()) { + database.listDao().insertLists(listsPreservingOrder) + } + + // Build set of existing list ids to guard child inserts + val existingListIds = database.listDao().getAllListIds().toSet() + + // 2) Filter and upsert fields whose parent list exists + val filteredFields = syncResponse.fields.filter { f -> f.listId in existingListIds } + if (filteredFields.size != syncResponse.fields.size) { + val dropped = syncResponse.fields.size - filteredFields.size + Logger.w("Sync", "Dropping $dropped field(s) with missing parent list to avoid FK errors") + } + if (filteredFields.isNotEmpty()) { + database.fieldDao().insertFields(filteredFields) + } + + // 3) Filter and upsert items whose parent list exists + val filteredItems = syncResponse.items.filter { i -> i.listId in existingListIds } + if (filteredItems.size != syncResponse.items.size) { + val dropped = syncResponse.items.size - filteredItems.size + Logger.w("Sync", "Dropping $dropped item(s) with missing parent list to avoid FK errors") + } + if (filteredItems.isNotEmpty()) { + database.itemDao().insertItems(filteredItems) + } + + // 4) Filter item values to only those whose parents (item and field) exist locally + val existingItemIds = database.itemDao().getAllItemIds().toSet() + val existingFieldIds = database.fieldDao().getAllFieldIds().toSet() + val filteredValues = + syncResponse.itemValues.filter { v -> + v.itemId in existingItemIds && v.fieldId in existingFieldIds + } + if (filteredValues.size != syncResponse.itemValues.size) { + val dropped = syncResponse.itemValues.size - filteredValues.size + Logger.w("Sync", "Dropping $dropped item value(s) with missing parents to avoid FK errors") + } + if (filteredValues.isNotEmpty()) { + database.itemValueDao().insertValues(filteredValues) + } + } + + // Update watermarks: server ts from response, local ts from device clock now + setLastServerSyncTs(syncResponse.serverTimestamp) + // Advance local watermark to the snapshot taken before reading local changes. + // This avoids missing updates that occurred while the sync was in-flight. + setLastLocalSyncTs(localSnapshotTs) + + if (isInitialSync) { + Logger.i("Sync", "[SYNC] Initial sync completed") + } + + // Successful sync clears any previous auth backoff + resetAuthBackoff() + resetNetworkBackoff() + + return@withContext Result.success(Unit) + } catch (e: Exception) { + // If WS indicated unauthorized, align behavior with HTTP path + if (e.message?.contains("Unauthorized") == true) { 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.")) } - return@withContext Result.failure(Exception("Sync failed: ${response.code()}")) + // Do not reset network backoff here; it's managed where thrown. + return@withContext Result.failure(e) } - val syncResponse = response.body()!! - // Only log receive when there are actual server changes - val inTotal = - syncResponse.lists.size + - syncResponse.fields.size + - syncResponse.items.size + - syncResponse.itemValues.size - if (inTotal > 0) { - Logger.i( - "Sync", - "[IN] Received changes " + - "(lists=${syncResponse.lists.size}, fields=${syncResponse.fields.size}, " + - "items=${syncResponse.items.size}, values=${syncResponse.itemValues.size})", - ) - } - - // Apply server changes to local database atomically in correct order - database.withTransaction { - // 1) Upsert lists first, preserving local orderIndex when present - val localIdToOrder = database.listDao().getListIdsAndOrder().associate { it.id to it.orderIndex } - // Build a map of local updatedAt to avoid overwriting newer local changes with older server data - val localUpdatedMap = database.listDao().getListsUpdatedSince(0).associateBy({ it.id }, { it.updatedAt }) - - val listsPreservingOrder = - syncResponse.lists - .filter { incoming -> - val localUpdated = localUpdatedMap[incoming.id] - localUpdated == null || incoming.updatedAt >= localUpdated - } - .map { incoming -> - val localOrder = localIdToOrder[incoming.id] - if (localOrder != null) incoming.copy(orderIndex = localOrder) else incoming - } - - if (listsPreservingOrder.isNotEmpty()) { - database.listDao().insertLists(listsPreservingOrder) - } - - // Build set of existing list ids to guard child inserts - val existingListIds = database.listDao().getAllListIds().toSet() - - // 2) Filter and upsert fields whose parent list exists - val filteredFields = syncResponse.fields.filter { f -> f.listId in existingListIds } - if (filteredFields.size != syncResponse.fields.size) { - val dropped = syncResponse.fields.size - filteredFields.size - Logger.w("Sync", "Dropping $dropped field(s) with missing parent list to avoid FK errors") - } - if (filteredFields.isNotEmpty()) { - database.fieldDao().insertFields(filteredFields) - } - - // 3) Filter and upsert items whose parent list exists - val filteredItems = syncResponse.items.filter { i -> i.listId in existingListIds } - if (filteredItems.size != syncResponse.items.size) { - val dropped = syncResponse.items.size - filteredItems.size - Logger.w("Sync", "Dropping $dropped item(s) with missing parent list to avoid FK errors") - } - if (filteredItems.isNotEmpty()) { - database.itemDao().insertItems(filteredItems) - } - - // 4) Filter item values to only those whose parents (item and field) exist locally - val existingItemIds = database.itemDao().getAllItemIds().toSet() - val existingFieldIds = database.fieldDao().getAllFieldIds().toSet() - val filteredValues = - syncResponse.itemValues.filter { v -> - v.itemId in existingItemIds && v.fieldId in existingFieldIds - } - if (filteredValues.size != syncResponse.itemValues.size) { - val dropped = syncResponse.itemValues.size - filteredValues.size - Logger.w("Sync", "Dropping $dropped item value(s) with missing parents to avoid FK errors") - } - if (filteredValues.isNotEmpty()) { - database.itemValueDao().insertValues(filteredValues) - } - } - - // Update watermarks: server ts from response, local ts from device clock now - setLastServerSyncTs(syncResponse.serverTimestamp) - // Advance local watermark to the snapshot taken before reading local changes. - // This avoids missing updates that occurred while the sync was in-flight. - setLastLocalSyncTs(localSnapshotTs) - - if (isInitialSync) { - Logger.i("Sync", "[SYNC] Initial sync completed") - } - - // Successful sync clears any previous auth backoff - resetAuthBackoff() - resetNetworkBackoff() - - return@withContext Result.success(Unit) - } catch (e: Exception) { - // If WS indicated unauthorized, align behavior with HTTP path - if (e.message?.contains("Unauthorized") == true) { - setLastServerSyncTs(0) - setLastLocalSyncTs(0) - } - // Do not reset network backoff here; it's managed where thrown. - return@withContext Result.failure(e) - } } } @@ -295,9 +306,10 @@ class SyncRepository(context: Context) { val caps = cm.getNetworkCapabilities(network) ?: return false val hasInternet = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) val isValidated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) - val hasTransport = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || - caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || - caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + val hasTransport = + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) return hasInternet && isValidated && hasTransport } } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/notifications/NotificationHelper.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/notifications/NotificationHelper.kt index 0c5ff2d..1edecef 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/notifications/NotificationHelper.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/notifications/NotificationHelper.kt @@ -1,93 +1,126 @@ -package com.collabtable.app.notifications - -import android.app.NotificationManager -import android.content.Context -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import com.collabtable.app.R - -object NotificationHelper { - const val CHANNEL_LIST_EVENTS = "list_events" - private const val GROUP_LIST_EVENTS = "group_list_events" - private const val SUMMARY_ID = 1000 - - // Keep a small rolling buffer of recent event lines for the summary - private val recentEventLines = ArrayDeque() - @Volatile private var totalEventCount: Int = 0 - private val postedIds = mutableSetOf() - - private fun builder(context: Context, title: String, text: String): NotificationCompat.Builder { - return NotificationCompat.Builder(context, CHANNEL_LIST_EVENTS) - .setSmallIcon(R.mipmap.ic_launcher) - .setContentTitle(title) - .setContentText(text) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setAutoCancel(true) - } - - private fun notify(context: Context, id: Int, builder: NotificationCompat.Builder) { - with(NotificationManagerCompat.from(context)) { - notify(id, builder.build()) - } - } - - fun showListAdded(context: Context, listId: String, name: String) = showEvent(context, listId, "add", "List added", "\"$name\" was created") - fun showListEdited(context: Context, listId: String, name: String) = showEvent(context, listId, "edit", "List edited", "\"$name\" was renamed/updated") - fun showListRemoved(context: Context, listId: String, name: String) = showEvent(context, listId, "remove", "List removed", "\"$name\" was deleted") - - private fun showEvent(context: Context, listId: String, type: String, title: String, text: String) { - val b = builder(context, title, text).setGroup(GROUP_LIST_EVENTS) - val id = (type + listId).hashCode() - synchronized(postedIds) { postedIds.add(id) } - notify(context, id, b) - recordEventLine(type, text) - postSummary(context) - } - - private fun postSummary(context: Context) { - val inbox = NotificationCompat.InboxStyle() - synchronized(recentEventLines) { - recentEventLines.take(5).forEach { line -> inbox.addLine(line) } - inbox.setSummaryText("${totalEventCount} total event" + if (totalEventCount == 1) "" else "s") - } - val summary = NotificationCompat.Builder(context, CHANNEL_LIST_EVENTS) - .setSmallIcon(R.mipmap.ic_launcher) - .setContentTitle("List activity") - .setContentText("Recent list changes") - .setStyle(inbox) - .setGroup(GROUP_LIST_EVENTS) - .setGroupSummary(true) - .setAutoCancel(true) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setNumber(totalEventCount) - notify(context, SUMMARY_ID, summary) - } - - private fun recordEventLine(type: String, text: String) { - val line = when (type) { - "add" -> "Added • $text" - "edit" -> "Edited • $text" - "remove" -> "Removed • $text" - else -> text - } - synchronized(recentEventLines) { - recentEventLines.addFirst(line) - // Keep only the last 8 lines to bound memory and UI clutter - while (recentEventLines.size > 8) recentEventLines.removeLast() - } - totalEventCount += 1 - } - - fun clearListEventNotifications(context: Context) { - val mgr = NotificationManagerCompat.from(context) - synchronized(postedIds) { - postedIds.forEach { id -> mgr.cancel(id) } - postedIds.clear() - } - mgr.cancel(SUMMARY_ID) - synchronized(recentEventLines) { - recentEventLines.clear() - totalEventCount = 0 - } - } -} +package com.collabtable.app.notifications + +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.collabtable.app.R + +object NotificationHelper { + const val CHANNEL_LIST_EVENTS = "list_events" + private const val GROUP_LIST_EVENTS = "group_list_events" + private const val SUMMARY_ID = 1000 + + // Keep a small rolling buffer of recent event lines for the summary + private val recentEventLines = ArrayDeque() + + @Volatile private var totalEventCount: Int = 0 + private val postedIds = mutableSetOf() + + private fun builder( + context: Context, + title: String, + text: String, + ): NotificationCompat.Builder { + return NotificationCompat.Builder(context, CHANNEL_LIST_EVENTS) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(text) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + } + + private fun notify( + context: Context, + id: Int, + builder: NotificationCompat.Builder, + ) { + with(NotificationManagerCompat.from(context)) { + notify(id, builder.build()) + } + } + + fun showListAdded( + context: Context, + listId: String, + name: String, + ) = showEvent(context, listId, "add", "List added", "\"$name\" was created") + + fun showListEdited( + context: Context, + listId: String, + name: String, + ) = showEvent(context, listId, "edit", "List edited", "\"$name\" was renamed/updated") + + fun showListRemoved( + context: Context, + listId: String, + name: String, + ) = showEvent(context, listId, "remove", "List removed", "\"$name\" was deleted") + + private fun showEvent( + context: Context, + listId: String, + type: String, + title: String, + text: String, + ) { + val b = builder(context, title, text).setGroup(GROUP_LIST_EVENTS) + val id = (type + listId).hashCode() + synchronized(postedIds) { postedIds.add(id) } + notify(context, id, b) + recordEventLine(type, text) + postSummary(context) + } + + private fun postSummary(context: Context) { + val inbox = NotificationCompat.InboxStyle() + synchronized(recentEventLines) { + recentEventLines.take(5).forEach { line -> inbox.addLine(line) } + inbox.setSummaryText("$totalEventCount total event" + if (totalEventCount == 1) "" else "s") + } + val summary = + NotificationCompat.Builder(context, CHANNEL_LIST_EVENTS) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle("List activity") + .setContentText("Recent list changes") + .setStyle(inbox) + .setGroup(GROUP_LIST_EVENTS) + .setGroupSummary(true) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setNumber(totalEventCount) + notify(context, SUMMARY_ID, summary) + } + + private fun recordEventLine( + type: String, + text: String, + ) { + val line = + when (type) { + "add" -> "Added • $text" + "edit" -> "Edited • $text" + "remove" -> "Removed • $text" + else -> text + } + synchronized(recentEventLines) { + recentEventLines.addFirst(line) + // Keep only the last 8 lines to bound memory and UI clutter + while (recentEventLines.size > 8) recentEventLines.removeLast() + } + totalEventCount += 1 + } + + fun clearListEventNotifications(context: Context) { + val mgr = NotificationManagerCompat.from(context) + synchronized(postedIds) { + postedIds.forEach { id -> mgr.cancel(id) } + postedIds.clear() + } + mgr.cancel(SUMMARY_ID) + synchronized(recentEventLines) { + recentEventLines.clear() + totalEventCount = 0 + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/components/ConnectionStatus.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/components/ConnectionStatus.kt index d67cfc1..807362c 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/components/ConnectionStatus.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/components/ConnectionStatus.kt @@ -1,124 +1,136 @@ -package com.collabtable.app.ui.components - -import android.net.Uri -import android.os.Build -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.size -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import com.collabtable.app.data.preferences.PreferencesManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request -import java.util.concurrent.TimeUnit - -private val statusClient: OkHttpClient = - OkHttpClient.Builder() - .connectTimeout(5, TimeUnit.SECONDS) - .readTimeout(5, TimeUnit.SECONDS) - .build() - -private fun isEmulator(): Boolean { - val fp = Build.FINGERPRINT.lowercase() - val model = Build.MODEL.lowercase() - val brand = Build.BRAND.lowercase() - val device = Build.DEVICE.lowercase() - val product = Build.PRODUCT.lowercase() - return fp.contains("generic") || fp.contains("emulator") || - model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for") || - brand.startsWith("generic") || device.startsWith("generic") || product.contains("sdk") -} - -private fun toHealthUrl(rawApiUrl: String): String { - // Ensure we hit the unauthenticated /health endpoint and remap localhost on emulator - return try { - val uri = Uri.parse(rawApiUrl) - val host = uri.host?.lowercase() - val needsRemap = isEmulator() && (host == "localhost" || host == "127.0.0.1" || host == "host.docker.internal") - val scheme = uri.scheme ?: "http" - val portPart = if (uri.port != -1) ":${uri.port}" else "" - val base = if (needsRemap) "$scheme://10.0.2.2$portPart" else "$scheme://${uri.host ?: ""}$portPart" - val path = (uri.encodedPath ?: "/").trimEnd('/') - val healthPath = if (path.endsWith("/api") || path.endsWith("/api/")) "/health" else "/health" - base + healthPath - } catch (e: Exception) { - rawApiUrl.replace("/api/", "/health").replace("/api", "/health") - } -} - -@Composable -fun ConnectionStatusAction( - prefs: PreferencesManager, - modifier: Modifier = Modifier, - showLatency: Boolean = true, -) { - var ok by remember { mutableStateOf(null) } - var latencyMs by remember { mutableStateOf(null) } - - val intervalMs by prefs.syncPollIntervalMs.collectAsState() - - // Periodic health check aligned to polling interval - LaunchedEffect(intervalMs) { - while (true) { - try { - val healthUrl = toHealthUrl(prefs.getServerUrl()) - val req = Request.Builder().url(healthUrl).get().build() - val start = System.nanoTime() - val resp = withContext(Dispatchers.IO) { statusClient.newCall(req).execute() } - val tookMs = (System.nanoTime() - start) / 1_000_000 - if (resp.isSuccessful) { - ok = true - latencyMs = tookMs - } else { - ok = false - } - resp.close() - } catch (e: Exception) { - ok = false - } - delay(intervalMs) - } - } - - val dotColor: Color = when (ok) { - true -> Color(0xFF2E7D32) // green - false -> MaterialTheme.colorScheme.error - null -> MaterialTheme.colorScheme.onSurfaceVariant - } - - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - if (showLatency && ok == true && latencyMs != null) { - Text( - text = "${latencyMs} ms", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } - Box( - modifier = - Modifier - .size(12.dp) - .background(dotColor, shape = androidx.compose.foundation.shape.CircleShape), - ) - } -} +package com.collabtable.app.ui.components + +import android.net.Uri +import android.os.Build +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.collabtable.app.data.preferences.PreferencesManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.TimeUnit + +private val statusClient: OkHttpClient = + OkHttpClient + .Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build() + +private fun isEmulator(): Boolean { + val fp = Build.FINGERPRINT.lowercase() + val model = Build.MODEL.lowercase() + val brand = Build.BRAND.lowercase() + val device = Build.DEVICE.lowercase() + val product = Build.PRODUCT.lowercase() + return fp.contains("generic") || + fp.contains("emulator") || + model.contains("google_sdk") || + model.contains("emulator") || + model.contains("android sdk built for") || + brand.startsWith("generic") || + device.startsWith("generic") || + product.contains("sdk") +} + +private fun toHealthUrl(rawApiUrl: String): String { + // Ensure we hit the unauthenticated /health endpoint and remap localhost on emulator + return try { + val uri = Uri.parse(rawApiUrl) + val host = uri.host?.lowercase() + val needsRemap = isEmulator() && (host == "localhost" || host == "127.0.0.1" || host == "host.docker.internal") + val scheme = uri.scheme ?: "http" + val portPart = if (uri.port != -1) ":${uri.port}" else "" + val base = if (needsRemap) "$scheme://10.0.2.2$portPart" else "$scheme://${uri.host ?: ""}$portPart" + val path = (uri.encodedPath ?: "/").trimEnd('/') + val healthPath = if (path.endsWith("/api") || path.endsWith("/api/")) "/health" else "/health" + base + healthPath + } catch (e: Exception) { + rawApiUrl.replace("/api/", "/health").replace("/api", "/health") + } +} + +@Composable +fun ConnectionStatusAction( + prefs: PreferencesManager, + modifier: Modifier = Modifier, + showLatency: Boolean = true, +) { + var ok by remember { mutableStateOf(null) } + var latencyMs by remember { mutableStateOf(null) } + + val intervalMs by prefs.syncPollIntervalMs.collectAsState() + + // Periodic health check aligned to polling interval + LaunchedEffect(intervalMs) { + while (true) { + try { + val healthUrl = toHealthUrl(prefs.getServerUrl()) + val req = + Request + .Builder() + .url(healthUrl) + .get() + .build() + val start = System.nanoTime() + val resp = withContext(Dispatchers.IO) { statusClient.newCall(req).execute() } + val tookMs = (System.nanoTime() - start) / 1_000_000 + if (resp.isSuccessful) { + ok = true + latencyMs = tookMs + } else { + ok = false + } + resp.close() + } catch (e: Exception) { + ok = false + } + delay(intervalMs) + } + } + + val dotColor: Color = + when (ok) { + true -> Color(0xFF2E7D32) // green + false -> MaterialTheme.colorScheme.error + null -> MaterialTheme.colorScheme.onSurfaceVariant + } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (showLatency && ok == true && latencyMs != null) { + Text( + text = "$latencyMs ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + Box( + modifier = + Modifier + .size(12.dp) + .background(dotColor, shape = androidx.compose.foundation.shape.CircleShape), + ) + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/navigation/MainApp.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/navigation/MainApp.kt index 015ce41..20611ae 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/navigation/MainApp.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/navigation/MainApp.kt @@ -1,138 +1,139 @@ -package com.collabtable.app.ui.navigation - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.navigation.NavGraph.Companion.findStartDestination -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument -import androidx.navigation.NavType -import com.collabtable.app.data.preferences.PreferencesManager -import com.collabtable.app.ui.screens.ListDetailScreen -import com.collabtable.app.ui.screens.ListsScreen -import com.collabtable.app.ui.screens.LogsScreen -import com.collabtable.app.ui.screens.ServerSetupScreen -import com.collabtable.app.ui.screens.SettingsScreen - -private object Routes { - const val Setup = "server_setup" - const val MainRoot = "main_root" - const val Tables = "tables" - const val Settings = "settings" - const val ListDetail = "list/{listId}" - const val Logs = "logs" -} - -@Composable -fun MainApp() { - val context = LocalContext.current - val prefs = remember { PreferencesManager.getInstance(context) } - - // Show setup flow until completed; then show main app with bottom nav - if (prefs.isFirstRun()) { - val navController = rememberNavController() - NavHost(navController, startDestination = Routes.Setup) { - composable(Routes.Setup) { - ServerSetupScreen( - onSetupComplete = { - // Mark not first run and trigger recomposition to show main UI - prefs.setIsFirstRun(false) - }, - ) - } - } - return - } - - val navController = rememberNavController() - val backStackEntry by navController.currentBackStackEntryAsState() - val currentRoute = backStackEntry?.destination?.route - - Scaffold( - bottomBar = { - NavigationBar { - NavigationBarItem( - selected = currentRoute == Routes.Tables || currentRoute?.startsWith("list/") == true, - onClick = { - navController.navigate(Routes.Tables) { - popUpTo(navController.graph.findStartDestination().id) { saveState = true } - launchSingleTop = true - restoreState = true - } - }, - icon = { androidx.compose.material3.Icon(Icons.Default.Home, contentDescription = null) }, - label = { Text("Tables") }, - ) - NavigationBarItem( - selected = currentRoute == Routes.Settings, - onClick = { - navController.navigate(Routes.Settings) { - popUpTo(navController.graph.findStartDestination().id) { saveState = true } - launchSingleTop = true - restoreState = true - } - }, - icon = { androidx.compose.material3.Icon(Icons.Default.Settings, contentDescription = null) }, - label = { Text("Settings") }, - ) - } - }, - ) { innerPadding -> - // Apply the innerPadding from the Scaffold so content is not obscured by the bottom navigation bar. - Surface( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - color = MaterialTheme.colorScheme.background, - ) { - NavHost( - navController = navController, - startDestination = Routes.Tables, - ) { - composable(Routes.Tables) { - ListsScreen( - onNavigateToList = { listId -> navController.navigate("list/$listId") }, - onNavigateToSettings = { navController.navigate(Routes.Settings) }, - onNavigateToLogs = { navController.navigate(Routes.Logs) }, - ) - } - composable(Routes.Settings) { - SettingsScreen( - onNavigateBack = { navController.popBackStack() }, - onNavigateToLogs = { navController.navigate(Routes.Logs) }, - onLeaveServer = { - // Reset to setup flow by toggling the flag and rebuilding graph via recomposition - prefs.setIsFirstRun(true) - }, - ) - } - composable(Routes.Logs) { - LogsScreen(onNavigateBack = { navController.popBackStack() }) - } - composable( - route = Routes.ListDetail, - arguments = listOf(navArgument("listId") { type = NavType.StringType }), - ) { backStackEntry -> - val listId = backStackEntry.arguments?.getString("listId") ?: return@composable - ListDetailScreen(listId = listId, onNavigateBack = { navController.popBackStack() }) - } - } - } - } -} +package com.collabtable.app.ui.navigation + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.collabtable.app.data.preferences.PreferencesManager +import com.collabtable.app.ui.screens.ListDetailScreen +import com.collabtable.app.ui.screens.ListsScreen +import com.collabtable.app.ui.screens.LogsScreen +import com.collabtable.app.ui.screens.ServerSetupScreen +import com.collabtable.app.ui.screens.SettingsScreen + +private object Routes { + const val SETUP = "server_setup" + const val MAIN_ROOT = "main_root" + const val TABLES = "tables" + const val SETTINGS = "settings" + const val LIST_DETAIL = "list/{listId}" + const val LOGS = "logs" +} + +@Composable +fun MainApp() { + val context = LocalContext.current + val prefs = remember { PreferencesManager.getInstance(context) } + + // Show setup flow until completed; then show main app with bottom nav + if (prefs.isFirstRun()) { + val navController = rememberNavController() + NavHost(navController, startDestination = Routes.SETUP) { + composable(Routes.SETUP) { + ServerSetupScreen( + onSetupComplete = { + // Mark not first run and trigger recomposition to show main UI + prefs.setIsFirstRun(false) + }, + ) + } + } + return + } + + val navController = rememberNavController() + val backStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = backStackEntry?.destination?.route + + Scaffold( + bottomBar = { + NavigationBar { + NavigationBarItem( + selected = currentRoute == Routes.TABLES || currentRoute?.startsWith("list/") == true, + onClick = { + navController.navigate(Routes.TABLES) { + popUpTo(navController.graph.findStartDestination().id) { saveState = true } + launchSingleTop = true + restoreState = true + } + }, + icon = { androidx.compose.material3.Icon(Icons.Default.Home, contentDescription = null) }, + label = { Text("Tables") }, + ) + NavigationBarItem( + selected = currentRoute == Routes.SETTINGS, + onClick = { + navController.navigate(Routes.SETTINGS) { + popUpTo(navController.graph.findStartDestination().id) { saveState = true } + launchSingleTop = true + restoreState = true + } + }, + icon = { androidx.compose.material3.Icon(Icons.Default.Settings, contentDescription = null) }, + label = { Text("Settings") }, + ) + } + }, + ) { innerPadding -> + // Apply the innerPadding from the Scaffold so content is not obscured by the bottom navigation bar. + Surface( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding), + color = MaterialTheme.colorScheme.background, + ) { + NavHost( + navController = navController, + startDestination = Routes.TABLES, + ) { + composable(Routes.TABLES) { + ListsScreen( + onNavigateToList = { listId -> navController.navigate("list/$listId") }, + onNavigateToSettings = { navController.navigate(Routes.SETTINGS) }, + onNavigateToLogs = { navController.navigate(Routes.LOGS) }, + ) + } + composable(Routes.SETTINGS) { + SettingsScreen( + onNavigateBack = { navController.popBackStack() }, + onNavigateToLogs = { navController.navigate(Routes.LOGS) }, + onLeaveServer = { + // Reset to setup flow by toggling the flag and rebuilding graph via recomposition + prefs.setIsFirstRun(true) + }, + ) + } + composable(Routes.LOGS) { + LogsScreen(onNavigateBack = { navController.popBackStack() }) + } + composable( + route = Routes.LIST_DETAIL, + arguments = listOf(navArgument("listId") { type = NavType.StringType }), + ) { backStackEntry -> + val listId = backStackEntry.arguments?.getString("listId") ?: return@composable + ListDetailScreen(listId = listId, onNavigateBack = { navController.popBackStack() }) + } + } + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt index 6c44bb1..9ad17c9 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt @@ -8,14 +8,11 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.gestures.scrollable -import androidx.compose.foundation.gestures.rememberScrollableState -import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -27,29 +24,28 @@ 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.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountBox -import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.AccountBox +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.FitScreen import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.FitScreen import androidx.compose.material.icons.filled.Star import androidx.compose.material3.* import androidx.compose.runtime.Composable @@ -66,20 +62,17 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.zIndex import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Dp @@ -87,11 +80,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.collabtable.app.R -import com.collabtable.app.data.preferences.PreferencesManager -import com.collabtable.app.ui.components.ConnectionStatusAction import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.model.Field import com.collabtable.app.data.model.ItemWithValues +import com.collabtable.app.data.preferences.PreferencesManager +import com.collabtable.app.ui.components.ConnectionStatusAction +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import org.burnoutcrew.reorderable.ReorderableItem import org.burnoutcrew.reorderable.detectReorder import org.burnoutcrew.reorderable.rememberReorderableLazyListState @@ -99,10 +96,6 @@ import org.burnoutcrew.reorderable.reorderable import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale -import kotlinx.coroutines.launch -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -192,16 +185,20 @@ fun ListDetailScreen( Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_item)) } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), ) }, floatingActionButton = {}, ) { padding -> // Apply filtering, sorting, and grouping to items - fun valueFor(item: ItemWithValues, field: Field?): String { + fun valueFor( + item: ItemWithValues, + field: Field?, + ): String { if (field == null) return "" return item.values.find { it.fieldId == field.id }?.value ?: "" } @@ -406,30 +403,33 @@ fun ListDetailScreen( onClick = { val newWidths = mutableMapOf() stableFields.forEach { field -> - val headerPx = textMeasurer.measure( - AnnotatedString(field.name), - style = headerTextStyle, - ).size.width.toFloat() + val headerPx = + textMeasurer.measure( + AnnotatedString(field.name), + style = headerTextStyle, + ).size.width.toFloat() var maxContentPx = 0f stableItems.forEach { itemWithValues -> val v = itemWithValues.values.find { it.fieldId == field.id }?.value val display = getDisplayTextForMeasure(field, v) if (display.isNotEmpty()) { - val w = textMeasurer.measure( - AnnotatedString(display), - style = bodyTextStyle, - ).size.width.toFloat() + val w = + textMeasurer.measure( + AnnotatedString(display), + style = bodyTextStyle, + ).size.width.toFloat() if (w > maxContentPx) maxContentPx = w } } - val widthDp = with(density) { - val headerDp = headerPx.toDp() + 12.dp + 12.dp + 24.dp + 2.dp - val contentDp = maxContentPx.toDp() + 8.dp + 8.dp + 2.dp - val base = maxOf(headerDp, contentDp, 100.dp) - (base + 6.dp).value - } + val widthDp = + with(density) { + val headerDp = headerPx.toDp() + 12.dp + 12.dp + 24.dp + 2.dp + val contentDp = maxContentPx.toDp() + 8.dp + 8.dp + 2.dp + val base = maxOf(headerDp, contentDp, 100.dp) + (base + 6.dp).value + } newWidths[field.id] = widthDp } newWidths.forEach { (id, w) -> fieldWidths[id] = w.dp } @@ -665,7 +665,10 @@ fun ListDetailScreen( } // Produce the display text used in cells for measurement purposes (single-line content baseline) -private fun getDisplayTextForMeasure(field: Field, raw: String?): String { +private fun getDisplayTextForMeasure( + field: Field, + raw: String?, +): String { val value = raw ?: "" return when (field.getType()) { com.collabtable.app.data.model.FieldType.TEXT, @@ -677,7 +680,8 @@ private fun getDisplayTextForMeasure(field: Field, raw: String?): String { com.collabtable.app.data.model.FieldType.LOCATION, com.collabtable.app.data.model.FieldType.DATE, com.collabtable.app.data.model.FieldType.TIME, - com.collabtable.app.data.model.FieldType.DATETIME -> value + com.collabtable.app.data.model.FieldType.DATETIME, + -> value com.collabtable.app.data.model.FieldType.CURRENCY -> if (value.isBlank()) "" else field.getCurrency() + value com.collabtable.app.data.model.FieldType.PERCENTAGE -> if (value.isBlank()) "" else "$value%" @@ -763,9 +767,10 @@ fun FieldHeader( Text( text = field.name, style = MaterialTheme.typography.labelLarge, - modifier = Modifier - .weight(1f) - .clickable { onHeaderClick() }, + modifier = + Modifier + .weight(1f) + .clickable { onHeaderClick() }, ) // Resize handle @@ -797,14 +802,15 @@ fun FieldHeader( // Start or maintain continuous expand+scroll when at/right of the handle if (dragAmount.x >= 0f || autoScrollJob != null) { if (autoScrollJob == null) { - autoScrollJob = scope.launch { - while (isActive) { - // Keep expanding a bit and reveal the end as space grows - onWidthChange(autoStepDp) - scrollState.scrollTo(scrollState.maxValue) - delay(16) + autoScrollJob = + scope.launch { + while (isActive) { + // Keep expanding a bit and reveal the end as space grows + onWidthChange(autoStepDp) + scrollState.scrollTo(scrollState.maxValue) + delay(16) + } } - } } } // Cancel only on a clear leftward move to avoid jitter stopping expansion diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt index 7f0aa48..911a6f3 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt @@ -22,17 +22,14 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.List -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Sort import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -45,7 +42,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -58,7 +54,6 @@ import com.collabtable.app.data.model.CollabList import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.ui.components.ConnectionStatusAction import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import org.burnoutcrew.reorderable.ReorderableItem import org.burnoutcrew.reorderable.detectReorder import org.burnoutcrew.reorderable.rememberReorderableLazyListState @@ -215,9 +210,10 @@ fun ListsScreen( Column(modifier = Modifier.fillMaxSize()) { // Controls above the list (align with table view UX) Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 6.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, ) { @@ -318,16 +314,22 @@ fun ListItem( // 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) // 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) + modifier = + Modifier + .fillMaxWidth() + // keep original horizontal spacing + .padding(horizontal = 12.dp) + // entire row (including margins) is tappable + .clickable(onClick = onListClick) + // expand touch area (original visual was 8.dp inside Row) + .padding(vertical = 4.dp), ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), // actual visual spacing remains ~8dp total (4 + 4) + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + // actual visual spacing remains ~8dp total (4 + 4) horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -446,13 +448,14 @@ private fun SortMenu(prefs: PreferencesManager) { val currentOrder by prefs.sortOrder.collectAsState(initial = prefs.getSortOrder()) var expanded by remember { mutableStateOf(false) } - val label = when (currentOrder) { - PreferencesManager.SORT_UPDATED_DESC -> "Updated ↓" - PreferencesManager.SORT_UPDATED_ASC -> "Updated ↑" - PreferencesManager.SORT_NAME_ASC -> "Name A–Z" - PreferencesManager.SORT_NAME_DESC -> "Name Z–A" - else -> "Sort" - } + val label = + when (currentOrder) { + PreferencesManager.SORT_UPDATED_DESC -> "Updated ↓" + PreferencesManager.SORT_UPDATED_ASC -> "Updated ↑" + PreferencesManager.SORT_NAME_ASC -> "Name A–Z" + PreferencesManager.SORT_NAME_DESC -> "Name Z–A" + else -> "Sort" + } Box { TextButton(onClick = { expanded = true }) { @@ -461,26 +464,41 @@ private fun SortMenu(prefs: PreferencesManager) { DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DropdownMenuItem( text = { Text("Updated (newest first)") }, - onClick = { prefs.setSortOrder(PreferencesManager.SORT_UPDATED_DESC); expanded = false }, + onClick = { + prefs.setSortOrder(PreferencesManager.SORT_UPDATED_DESC) + expanded = false + }, ) DropdownMenuItem( text = { Text("Updated (oldest first)") }, - onClick = { prefs.setSortOrder(PreferencesManager.SORT_UPDATED_ASC); expanded = false }, + onClick = { + prefs.setSortOrder(PreferencesManager.SORT_UPDATED_ASC) + expanded = false + }, ) DropdownMenuItem( text = { Text("Name (A–Z)") }, - onClick = { prefs.setSortOrder(PreferencesManager.SORT_NAME_ASC); expanded = false }, + onClick = { + prefs.setSortOrder(PreferencesManager.SORT_NAME_ASC) + expanded = false + }, ) DropdownMenuItem( text = { Text("Name (Z–A)") }, - onClick = { prefs.setSortOrder(PreferencesManager.SORT_NAME_DESC); expanded = false }, + onClick = { + prefs.setSortOrder(PreferencesManager.SORT_NAME_DESC) + expanded = false + }, ) } } } // Format updatedAt relative to now: "Updated 5 sec/min/hour(s) ago" (days included when applicable) -private fun formatUpdatedAgo(updatedAt: Long, nowMs: Long): String { +private fun formatUpdatedAgo( + updatedAt: Long, + nowMs: Long, +): String { val delta = (nowMs - updatedAt).coerceAtLeast(0L) val seconds = delta / 1000 return when { @@ -492,12 +510,12 @@ private fun formatUpdatedAgo(updatedAt: Long, nowMs: Long): String { seconds < 86_400 -> { val hours = seconds / 3600 val unit = if (hours == 1L) "hour" else "hours" - "Updated ${hours} $unit ago" + "Updated $hours $unit ago" } else -> { val days = seconds / 86_400 val unit = if (days == 1L) "day" else "days" - "Updated ${days} $unit ago" + "Updated $days $unit ago" } } } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt index abb0679..63bb45c 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt @@ -1,14 +1,14 @@ package com.collabtable.app.ui.screens import android.content.Context +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.model.CollabList import com.collabtable.app.data.repository.SyncRepository import com.collabtable.app.notifications.NotificationHelper -import androidx.lifecycle.ProcessLifecycleOwner -import androidx.lifecycle.Lifecycle import com.collabtable.app.utils.Logger import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/LogsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/LogsScreen.kt index 5793e73..eb5c612 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/LogsScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/LogsScreen.kt @@ -18,7 +18,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.HorizontalDivider import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.FilterList import androidx.compose.material3.Badge @@ -26,6 +25,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt index a979023..8224ae3 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt @@ -1,5 +1,8 @@ package com.collabtable.app.ui.screens +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -41,10 +44,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import com.collabtable.app.data.preferences.PreferencesManager -import android.os.Build @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt index 47bb04d..b71b2f5 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt @@ -1,5 +1,7 @@ package com.collabtable.app.ui.screens +import android.net.Uri +import android.os.Build import android.os.NetworkOnMainThreadException import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -18,8 +20,6 @@ import java.net.ConnectException import java.net.SocketTimeoutException import java.net.UnknownHostException import java.util.concurrent.TimeUnit -import android.os.Build -import android.net.Uri class ServerSetupViewModel( private val preferencesManager: PreferencesManager, @@ -34,14 +34,15 @@ class ServerSetupViewModel( val validationError: StateFlow = _validationError.asStateFlow() private val okHttpClient = - OkHttpClient.Builder() + OkHttpClient + .Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .build() fun validateAndSaveServerUrl( url: String, - password: String, + password: String, ) { viewModelScope.launch { _isValidating.value = true @@ -99,17 +100,28 @@ class ServerSetupViewModel( } // On physical devices, block local-only hosts that won't resolve - val isEmulator = run { - val fp = Build.FINGERPRINT.lowercase() - val model = Build.MODEL.lowercase() - val brand = Build.BRAND.lowercase() - val device = Build.DEVICE.lowercase() - val product = Build.PRODUCT.lowercase() - fp.contains("generic") || fp.contains("emulator") || - model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for") || - brand.startsWith("generic") || device.startsWith("generic") || product.contains("sdk") - } - val host = try { Uri.parse(normalizedUrl).host?.lowercase() } catch (_: Exception) { null } + val isEmulator = + run { + val fp = Build.FINGERPRINT.lowercase() + val model = Build.MODEL.lowercase() + val brand = Build.BRAND.lowercase() + val device = Build.DEVICE.lowercase() + val product = Build.PRODUCT.lowercase() + fp.contains("generic") || + fp.contains("emulator") || + model.contains("google_sdk") || + model.contains("emulator") || + model.contains("android sdk built for") || + brand.startsWith("generic") || + device.startsWith("generic") || + product.contains("sdk") + } + val host = + try { + Uri.parse(normalizedUrl).host?.lowercase() + } catch (_: Exception) { + null + } val localOnlyHosts = setOf("localhost", "127.0.0.1", "host.docker.internal", "10.0.2.2") if (!isEmulator && host != null && host in localOnlyHosts) { _validationError.value = "On a physical device, use your computer's LAN IP (e.g., 192.168.x.x:3000) instead of '$host'." @@ -120,7 +132,8 @@ class ServerSetupViewModel( // Try to reach the health endpoint (no auth required) val healthUrl = normalizedUrl.replace("/api/", "/health") val healthRequest = - Request.Builder() + Request + .Builder() .url(healthUrl) .get() .build() @@ -158,7 +171,8 @@ class ServerSetupViewModel( val httpsHealthUrl = httpsUrl.replace("/api/", "/health") val httpsHealthRequest = - Request.Builder() + Request + .Builder() .url(httpsHealthUrl) .get() .build() @@ -182,7 +196,8 @@ class ServerSetupViewModel( // Now validate the password by making an authenticated request val testUrl = "${finalUrl}lists" val authRequest = - Request.Builder() + Request + .Builder() .url(testUrl) .header("Authorization", "Bearer $trimmedPassword") .get() diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt index 32c2add..93e0e32 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt @@ -1,5 +1,10 @@ package com.collabtable.app.ui.screens +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,7 +17,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.Brightness4 import androidx.compose.material.icons.filled.Brightness7 @@ -26,7 +30,6 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -38,8 +41,6 @@ import androidx.compose.material3.TextField import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -51,9 +52,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import android.Manifest -import android.content.pm.PackageManager -import android.os.Build import androidx.core.content.ContextCompat import com.collabtable.app.R import com.collabtable.app.data.api.ApiClient @@ -65,7 +63,6 @@ import com.collabtable.app.utils.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext - @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -507,11 +504,8 @@ fun SettingsScreen( } } } - - } - if (showLeaveDialog) { AlertDialog( onDismissRequest = { showLeaveDialog = false }, @@ -582,6 +576,7 @@ fun SettingsScreen( // Test connectivity removed; connection status is shown in the top app bar indicator } + private fun formatServerUrlForDisplay(raw: String): String { var s = raw.trim() if (s.isEmpty()) return "" diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/work/ListChangeNotificationWorker.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/work/ListChangeNotificationWorker.kt index b04db8f..28df5b4 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/work/ListChangeNotificationWorker.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/work/ListChangeNotificationWorker.kt @@ -1,56 +1,59 @@ -package com.collabtable.app.work - -import android.content.Context -import androidx.work.CoroutineWorker -import androidx.work.WorkerParameters -import com.collabtable.app.data.database.CollabTableDatabase -import com.collabtable.app.data.model.CollabList -import com.collabtable.app.data.preferences.PreferencesManager -import com.collabtable.app.notifications.NotificationHelper - -class ListChangeNotificationWorker( - appContext: Context, - workerParams: WorkerParameters, -) : CoroutineWorker(appContext, workerParams) { - override suspend fun doWork(): Result { - val prefs = PreferencesManager.getInstance(applicationContext) - val db = CollabTableDatabase.getDatabase(applicationContext) - - val since = prefs.getLastListNotifyCheckTimestamp() - val now = System.currentTimeMillis() - return try { - val changed = db.listDao().getListsUpdatedSince(since) - if (changed.isNotEmpty()) { - changed.forEach { list -> - emitNotificationsFor(list, prefs) - } - } - // Update checkpoint regardless to avoid duplicate spam - prefs.setLastListNotifyCheckTimestamp(now) - Result.success() - } catch (e: Exception) { - Result.retry() - } - } - - private fun emitNotificationsFor(list: CollabList, prefs: PreferencesManager) { - // Determine event type - when { - list.isDeleted -> { - if (prefs.notifyListRemoved.value) { - NotificationHelper.showListRemoved(applicationContext, list.id, list.name) - } - } - list.createdAt == list.updatedAt -> { - if (prefs.notifyListAdded.value) { - NotificationHelper.showListAdded(applicationContext, list.id, list.name) - } - } - else -> { - if (prefs.notifyListEdited.value) { - NotificationHelper.showListEdited(applicationContext, list.id, list.name) - } - } - } - } -} +package com.collabtable.app.work + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.collabtable.app.data.database.CollabTableDatabase +import com.collabtable.app.data.model.CollabList +import com.collabtable.app.data.preferences.PreferencesManager +import com.collabtable.app.notifications.NotificationHelper + +class ListChangeNotificationWorker( + appContext: Context, + workerParams: WorkerParameters, +) : CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { + val prefs = PreferencesManager.getInstance(applicationContext) + val db = CollabTableDatabase.getDatabase(applicationContext) + + val since = prefs.getLastListNotifyCheckTimestamp() + val now = System.currentTimeMillis() + return try { + val changed = db.listDao().getListsUpdatedSince(since) + if (changed.isNotEmpty()) { + changed.forEach { list -> + emitNotificationsFor(list, prefs) + } + } + // Update checkpoint regardless to avoid duplicate spam + prefs.setLastListNotifyCheckTimestamp(now) + Result.success() + } catch (e: Exception) { + Result.retry() + } + } + + private fun emitNotificationsFor( + list: CollabList, + prefs: PreferencesManager, + ) { + // Determine event type + when { + list.isDeleted -> { + if (prefs.notifyListRemoved.value) { + NotificationHelper.showListRemoved(applicationContext, list.id, list.name) + } + } + list.createdAt == list.updatedAt -> { + if (prefs.notifyListAdded.value) { + NotificationHelper.showListAdded(applicationContext, list.id, list.name) + } + } + else -> { + if (prefs.notifyListEdited.value) { + NotificationHelper.showListEdited(applicationContext, list.id, list.name) + } + } + } + } +} diff --git a/CollabTableAndroid/build.gradle b/CollabTableAndroid/build.gradle index 463282b..f2e1625 100644 --- a/CollabTableAndroid/build.gradle +++ b/CollabTableAndroid/build.gradle @@ -2,9 +2,8 @@ plugins { id 'com.android.application' version '8.8.0' apply false id 'com.android.library' version '8.8.0' apply false - id 'org.jetbrains.kotlin.android' version '2.0.21' apply false - id 'org.jetbrains.kotlin.plugin.compose' version '2.0.21' apply false - id 'com.google.devtools.ksp' version '2.0.21-1.0.27' apply false - id 'org.jlleitschuh.gradle.ktlint' version '12.1.0' apply false - id 'io.gitlab.arturbosch.detekt' version '1.23.6' apply false + id 'org.jetbrains.kotlin.android' version '2.2.21' apply false + id 'org.jetbrains.kotlin.plugin.compose' version '2.2.21' apply false + id 'org.jlleitschuh.gradle.ktlint' version '14.0.1' apply false + id 'io.gitlab.arturbosch.detekt' version '1.23.8' apply false } diff --git a/CollabTableAndroid/detekt.yml b/CollabTableAndroid/detekt.yml new file mode 100644 index 0000000..e69de29