feat: implement notification system for list events, including added, edited, and removed notifications with user preferences and background checks

This commit is contained in:
2025-11-11 20:23:42 +01:00
parent 607a0c4fcb
commit 3e029b16ca
9 changed files with 459 additions and 1 deletions
+4
View File
@@ -54,6 +54,7 @@ dependencies {
// Core Android
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
implementation 'androidx.activity:activity-compose:1.8.1'
// Compose
@@ -81,6 +82,9 @@ dependencies {
// Coroutines
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
// WorkManager for background checks/notifications
implementation 'androidx.work:work-runtime-ktx:2.9.0'
// Testing
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
@@ -3,6 +3,8 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Android 13+ runtime notification permission -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".CollabTableApplication"
@@ -1,8 +1,20 @@
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
@@ -23,5 +35,40 @@ class CollabTableApplication : Application() {
// 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<ListChangeNotificationWorker>(15, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"list_change_notifier",
ExistingPeriodicWorkPolicy.UPDATE,
work,
)
}
}
@@ -30,6 +30,16 @@ class PreferencesManager(context: Context) {
private val _syncPollIntervalMs = MutableStateFlow(getSyncPollIntervalMs())
val syncPollIntervalMs: StateFlow<Long> = _syncPollIntervalMs.asStateFlow()
// Notification preferences (per list event type)
private val _notifyListAdded = MutableStateFlow(isNotifyListAddedEnabled())
val notifyListAdded: StateFlow<Boolean> = _notifyListAdded.asStateFlow()
private val _notifyListEdited = MutableStateFlow(isNotifyListEditedEnabled())
val notifyListEdited: StateFlow<Boolean> = _notifyListEdited.asStateFlow()
private val _notifyListRemoved = MutableStateFlow(isNotifyListRemovedEnabled())
val notifyListRemoved: StateFlow<Boolean> = _notifyListRemoved.asStateFlow()
fun getServerUrl(): String {
return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL
}
@@ -141,6 +151,43 @@ class PreferencesManager(context: Context) {
_syncPollIntervalMs.value = clamped
}
// Last time we checked lists for background notifications
fun getLastListNotifyCheckTimestamp(): Long {
return prefs.getLong(KEY_LAST_LIST_NOTIFY_CHECK_TS, 0L)
}
fun setLastListNotifyCheckTimestamp(ts: Long) {
prefs.edit().putLong(KEY_LAST_LIST_NOTIFY_CHECK_TS, ts).apply()
}
// Notification settings accessors
private fun isNotifyListAddedEnabled(): Boolean {
return prefs.getBoolean(KEY_NOTIFY_LIST_ADDED, true)
}
fun setNotifyListAddedEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_NOTIFY_LIST_ADDED, enabled).apply()
_notifyListAdded.value = enabled
}
private fun isNotifyListEditedEnabled(): Boolean {
return prefs.getBoolean(KEY_NOTIFY_LIST_EDITED, true)
}
fun setNotifyListEditedEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_NOTIFY_LIST_EDITED, enabled).apply()
_notifyListEdited.value = enabled
}
private fun isNotifyListRemovedEnabled(): Boolean {
return prefs.getBoolean(KEY_NOTIFY_LIST_REMOVED, true)
}
fun setNotifyListRemovedEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_NOTIFY_LIST_REMOVED, enabled).apply()
_notifyListRemoved.value = enabled
}
// Persist per-list column widths (fieldId -> widthDp)
// Stored as a JSON object string in SharedPreferences under key: COLUMN_WIDTHS_PREFIX + listId
fun getColumnWidths(listId: String): Map<String, Float> {
@@ -183,6 +230,10 @@ class PreferencesManager(context: Context) {
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 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
@@ -0,0 +1,93 @@
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<String>()
@Volatile private var totalEventCount: Int = 0
private val postedIds = mutableSetOf<Int>()
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
}
}
}
@@ -6,6 +6,9 @@ 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
@@ -128,6 +131,7 @@ class ListsViewModel(
database.listDao().insertList(newList)
// Sync immediately after creating
performSync()
maybeNotifyListAdded(newList)
}
}
@@ -147,6 +151,7 @@ class ListsViewModel(
)
// Sync immediately after renaming
performSync()
maybeNotifyListEdited(list.copy(name = newName.trim()))
}
}
}
@@ -164,6 +169,7 @@ class ListsViewModel(
database.itemDao().softDeleteItemsByList(listId, ts)
// Sync immediately after deleting
performSync()
maybeNotifyListRemoved(list)
}
}
}
@@ -176,4 +182,30 @@ class ListsViewModel(
_isLoading.value = false
}
}
private fun isInForeground(): Boolean {
val state = ProcessLifecycleOwner.get().lifecycle.currentState
return state.isAtLeast(Lifecycle.State.STARTED)
}
private fun maybeNotifyListAdded(list: CollabList) {
val prefs = com.collabtable.app.data.preferences.PreferencesManager.getInstance(context)
if (prefs.notifyListAdded.value && !isInForeground()) {
NotificationHelper.showListAdded(context, list.id, list.name)
}
}
private fun maybeNotifyListEdited(list: CollabList) {
val prefs = com.collabtable.app.data.preferences.PreferencesManager.getInstance(context)
if (prefs.notifyListEdited.value && !isInForeground()) {
NotificationHelper.showListEdited(context, list.id, list.name)
}
}
private fun maybeNotifyListRemoved(list: CollabList) {
val prefs = com.collabtable.app.data.preferences.PreferencesManager.getInstance(context)
if (prefs.notifyListRemoved.value && !isInForeground()) {
NotificationHelper.showListRemoved(context, list.id, list.name)
}
}
}
@@ -41,7 +41,10 @@ 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
@@ -57,9 +60,26 @@ fun ServerSetupScreen(onSetupComplete: () -> Unit) {
val validationResult by viewModel.validationResult.collectAsState()
val validationError by viewModel.validationError.collectAsState()
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
// If granted, enable all; else disable all
preferencesManager.setNotifyListAddedEnabled(granted)
preferencesManager.setNotifyListEditedEnabled(granted)
preferencesManager.setNotifyListRemovedEnabled(granted)
onSetupComplete()
}
LaunchedEffect(validationResult) {
if (validationResult == true) {
onSetupComplete()
// On Android 13+ prompt for notifications; otherwise default to enabled
if (Build.VERSION.SDK_INT >= 33) {
permissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
} else {
preferencesManager.setNotifyListAddedEnabled(true)
preferencesManager.setNotifyListEditedEnabled(true)
preferencesManager.setNotifyListRemovedEnabled(true)
onSetupComplete()
}
}
}
@@ -29,6 +29,8 @@ 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
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -36,6 +38,8 @@ 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
@@ -47,6 +51,10 @@ 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
import com.collabtable.app.data.database.CollabTableDatabase
@@ -70,11 +78,15 @@ fun SettingsScreen(
val preferencesManager = remember { PreferencesManager.getInstance(context) }
val syncRepository = remember { SyncRepository(context) }
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val serverUrl = preferencesManager.getServerUrl()
val themeMode by preferencesManager.themeMode.collectAsState()
val dynamicColor by preferencesManager.dynamicColor.collectAsState()
val amoledDark by preferencesManager.amoledDark.collectAsState()
val notifyListAdded by preferencesManager.notifyListAdded.collectAsState()
val notifyListEdited by preferencesManager.notifyListEdited.collectAsState()
val notifyListRemoved by preferencesManager.notifyListRemoved.collectAsState()
val displayUrl = remember(serverUrl) { formatServerUrlForDisplay(serverUrl) }
var showLeaveDialog by remember { mutableStateOf(false) }
var isLeaving by remember { mutableStateOf(false) }
@@ -83,6 +95,38 @@ fun SettingsScreen(
var syncIntervalError by remember { mutableStateOf<String?>(null) }
// Authentication UI removed
// Permission launcher for Android 13+ notification permission
var pendingPermissionAction by remember { mutableStateOf<(() -> Unit)?>(null) }
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
pendingPermissionAction?.invoke()
} else {
coroutineScope.launch {
snackbarHostState.showSnackbar(
message = "Notification permission denied. Enable it in system settings to turn this on.",
)
}
}
pendingPermissionAction = null
}
fun ensureNotificationPermission(onGranted: () -> Unit) {
if (Build.VERSION.SDK_INT < 33) {
onGranted()
return
}
val granted =
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (granted) {
onGranted()
} else {
pendingPermissionAction = onGranted
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
// Removed test connectivity logic; the connection indicator is shown via ConnectionStatusAction
Scaffold(
@@ -104,6 +148,7 @@ fun SettingsScreen(
),
)
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { padding ->
Column(
modifier =
@@ -372,6 +417,114 @@ fun SettingsScreen(
}
// Notifications section (placed after main content but before dialogs)
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Notifications",
style = MaterialTheme.typography.titleMedium,
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text("List added")
Text(
text = "Notify when a new list/table is created",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = notifyListAdded,
onCheckedChange = { checked ->
if (checked) {
ensureNotificationPermission {
preferencesManager.setNotifyListAddedEnabled(true)
}
} else {
preferencesManager.setNotifyListAddedEnabled(false)
}
},
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text("List edited")
Text(
text = "Notify when a list/table name changes",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = notifyListEdited,
onCheckedChange = { checked ->
if (checked) {
ensureNotificationPermission {
preferencesManager.setNotifyListEditedEnabled(true)
}
} else {
preferencesManager.setNotifyListEditedEnabled(false)
}
},
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text("List removed")
Text(
text = "Notify when a list/table is deleted",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = notifyListRemoved,
onCheckedChange = { checked ->
if (checked) {
ensureNotificationPermission {
preferencesManager.setNotifyListRemovedEnabled(true)
}
} else {
preferencesManager.setNotifyListRemovedEnabled(false)
}
},
)
}
Text(
text = "Notifications fire only when the app is in background or not visible.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (showLeaveDialog) {
AlertDialog(
onDismissRequest = { showLeaveDialog = false },
@@ -0,0 +1,56 @@
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)
}
}
}
}
}