feat: implement notification system with polling and event handling; add device ID middleware and cleanup logic

This commit is contained in:
2025-11-13 18:10:50 +01:00
parent 6b5ae7f578
commit a8c7b859a8
19 changed files with 354 additions and 25 deletions
@@ -13,6 +13,7 @@ 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.notifications.NotificationPoller
import com.collabtable.app.work.ListChangeNotificationWorker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -42,11 +43,18 @@ class CollabTableApplication : Application() {
// Schedule periodic background check for list changes to surface notifications
scheduleListChangeWorker()
// Start foreground notification polling loop (respects user interval)
NotificationPoller.start(this)
// Auto-clear notifications when app comes to foreground
ProcessLifecycleOwner.get().lifecycle.addObserver(
object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
NotificationHelper.clearListEventNotifications(this@CollabTableApplication)
NotificationPoller.start(this@CollabTableApplication)
}
override fun onStop(owner: LifecycleOwner) {
NotificationPoller.stop()
}
},
)
@@ -43,10 +43,20 @@ object ApiClient {
chain.proceed(newRequest)
}
private val deviceIdInterceptor =
Interceptor { chain ->
val request = chain.request()
val devId = context?.let { PreferencesManager.getInstance(it).getDeviceId() }
val newReq =
if (!devId.isNullOrBlank()) request.newBuilder().header("x-device-id", devId).build() else request
chain.proceed(newReq)
}
private val okHttpClient =
OkHttpClient
.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(deviceIdInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
@@ -0,0 +1,16 @@
package com.collabtable.app.data.api
data class NotificationEvent(
val id: String,
val deviceIdOrigin: String?,
val eventType: String,
val entityType: String,
val entityId: String?,
val listId: String?,
val createdAt: Long,
)
data class PollResponse(
val notifications: List<NotificationEvent>,
val serverTimestamp: Long,
)
@@ -7,6 +7,8 @@ import com.collabtable.app.data.model.ItemValue
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.GET
import retrofit2.http.Query
data class SyncRequest(
val lastSyncTimestamp: Long,
@@ -29,4 +31,9 @@ interface CollabTableApi {
suspend fun sync(
@Body request: SyncRequest,
): Response<SyncResponse>
@GET("notifications/poll")
suspend fun pollNotifications(
@Query("since") since: Long,
): Response<PollResponse>
}
@@ -258,8 +258,18 @@ class PreferencesManager(
prefs.edit().putString(key, json.toString()).apply()
}
// Generate and cache a stable device id (UUID) for this install
fun getDeviceId(): String {
val existing = prefs.getString(KEY_DEVICE_ID, null)
if (!existing.isNullOrBlank()) return existing
val id = java.util.UUID.randomUUID().toString()
prefs.edit().putString(KEY_DEVICE_ID, id).apply()
return id
}
companion object {
private const val KEY_SERVER_URL = "server_url"
private const val KEY_DEVICE_ID = "device_id"
private const val KEY_FIRST_RUN = "first_run"
private const val KEY_SERVER_PASSWORD = "server_password"
private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp"
@@ -0,0 +1,95 @@
package com.collabtable.app.notifications
import android.content.Context
import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.api.CollabTableApi
import com.collabtable.app.data.api.NotificationEvent
import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.utils.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
object NotificationPoller {
@Volatile private var job: Job? = null
fun start(context: Context) {
if (job?.isActive == true) return
val appCtx = context.applicationContext
val prefs = PreferencesManager.getInstance(appCtx)
val api = ApiClient.api
val db = CollabTableDatabase.getDatabase(appCtx)
job = CoroutineScope(Dispatchers.IO).launch {
while (isActive) {
try {
// Skip if password missing (auth disabled)
val pwd = prefs.getServerPassword()?.trim()
if (pwd.isNullOrBlank() || pwd == "\$password") {
delay(2_000)
continue
}
val since = prefs.getLastListNotifyCheckTimestamp()
val resp = api.pollNotifications(since)
if (resp.isSuccessful) {
val body = resp.body()
val events = body?.notifications.orEmpty()
if (events.isNotEmpty()) {
// Map and emit based on preferences
events.forEach { ev ->
val listId = ev.listId ?: return@forEach
val list = db.listDao().getListById(listId) ?: return@forEach
handleEvent(appCtx, ev, list.name, prefs)
}
}
// Advance checkpoint to server timestamp to avoid re-processing
val stamp = body?.serverTimestamp ?: System.currentTimeMillis()
prefs.setLastListNotifyCheckTimestamp(stamp)
} else if (resp.code() == 401) {
// Unauthorized; back off by advancing timestamp minimally to avoid tight loop
delay(5_000)
}
} catch (e: Exception) {
try { Logger.w("NotifPoll", "Polling failed: ${e.message}") } catch (_: Exception) {}
// brief backoff
delay(1_500)
}
val interval = prefs.syncPollIntervalMs.value
delay(interval)
}
}
}
fun stop() {
job?.cancel()
job = null
}
private fun handleEvent(
context: Context,
ev: NotificationEvent,
listName: String,
prefs: PreferencesManager,
) {
// Respect notification switches by grouping semantics
when (ev.entityType.lowercase()) {
"list" -> when (ev.eventType.lowercase()) {
"created" -> if (prefs.notifyListAdded.value) NotificationHelper.showListAdded(context, ev.listId ?: "", listName)
"updated" -> if (prefs.notifyListEdited.value) NotificationHelper.showListEdited(context, ev.listId ?: "", listName)
"deleted" -> if (prefs.notifyListRemoved.value) NotificationHelper.showListRemoved(context, ev.listId ?: "", listName)
}
// Treat fields/items/value changes as content updates
"field", "item", "value" -> if (prefs.notifyListContentUpdated.value) {
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName)
}
else -> when (ev.eventType.lowercase()) {
"listcontentupdated" -> if (prefs.notifyListContentUpdated.value) {
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName)
}
}
}
}
}
@@ -537,6 +537,7 @@ fun ListDetailScreen(
}
}
// Render content states outside the horizontalScroll so empty state centers relative to viewport
when {
isLoading && stableItems.isEmpty() -> {
Box(
@@ -545,6 +546,7 @@ fun ListDetailScreen(
) { androidx.compose.material3.CircularProgressIndicator() }
}
stableItems.isEmpty() -> {
// Use full-width Box + centered text (header remains above inside scroll container)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
@@ -553,7 +555,9 @@ fun ListDetailScreen(
text = stringResource(R.string.no_items),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
textAlign = TextAlign.Center,
)
}
@@ -128,16 +128,8 @@ class ListDetailViewModel(
return state.isAtLeast(Lifecycle.State.STARTED)
}
private fun maybeNotifyListContentUpdated() {
val prefs =
com.collabtable.app.data.preferences.PreferencesManager
.getInstance(context)
if (prefs.notifyListContentUpdated.value && !isInForeground()) {
val name = _list.value?.name ?: "Table"
com.collabtable.app.notifications.NotificationHelper
.showListContentUpdated(context, listId, name)
}
}
// Local changes should not notify this same device; remote-change notification (future) will be triggered elsewhere.
private fun maybeNotifyListContentUpdated() { /* intentionally disabled for local-origin changes */ }
fun renameList(newName: String) {
viewModelScope.launch {
@@ -202,8 +194,7 @@ class ListDetailViewModel(
}
}
performSync()
maybeNotifyListContentUpdated()
performSync() // no local notification
}
}
@@ -224,8 +215,7 @@ class ListDetailViewModel(
database.listDao().updateList(l.copy(updatedAt = ts))
}
}
performSync()
maybeNotifyListContentUpdated()
performSync() // no local notification
}
}
@@ -252,8 +242,7 @@ class ListDetailViewModel(
database.listDao().updateList(l.copy(updatedAt = ts))
}
}
performSync()
maybeNotifyListContentUpdated()
performSync() // no local notification
}
}
}
@@ -319,8 +308,7 @@ class ListDetailViewModel(
database.listDao().updateList(l.copy(updatedAt = timestamp))
}
}
performSync()
maybeNotifyListContentUpdated()
performSync() // no local notification
}
}
@@ -355,8 +343,7 @@ class ListDetailViewModel(
database.listDao().updateList(l.copy(updatedAt = timestamp))
}
}
performSync()
maybeNotifyListContentUpdated()
performSync() // no local notification
}
}