feat: implement notification system with polling and event handling; add device ID middleware and cleanup logic
This commit is contained in:
@@ -77,6 +77,7 @@ dependencies {
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
|
||||
kapt 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||
|
||||
// Coroutines
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
+10
@@ -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"
|
||||
|
||||
+95
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+7
-20
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user