diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/NotificationsApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/NotificationsApi.kt new file mode 100644 index 00000000..74e48aa7 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/NotificationsApi.kt @@ -0,0 +1,90 @@ +package com.fabledsword.minstrel.api.endpoints + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * The notifications inbox and its per-user settings (M489). The server + * renders each notice's title, body and link, so the app shows them as given. + */ +interface NotificationsApi { + @GET("api/me/notifications") + suspend fun list(@Query("limit") limit: Int): NotificationsPageWire + + /** 204; 404 when the notice is gone, which a replay treats as done. */ + @POST("api/me/notifications/{id}/read") + suspend fun markRead(@Path("id") id: String) + + @POST("api/me/notifications/read-all") + suspend fun readAll(@Body body: ReadAllBody) + + @GET("api/me/notification-settings") + suspend fun getSettings(): NotificationSettingsWire + + @PUT("api/me/notification-settings") + suspend fun putSettings(@Body body: PutNotificationSettingsBody): NotificationSettingsWire +} + +@Serializable +data class NotificationWire( + val id: String, + val kind: String, + val title: String, + val body: String, + val link: String, + @SerialName("created_at") val createdAt: String, + @SerialName("read_at") val readAt: String? = null, +) + +@Serializable +data class NotificationsPageWire( + val items: List, + @SerialName("unread_count") val unreadCount: Long, + @SerialName("next_before") val nextBefore: String? = null, +) + +/** + * `upTo` limits "mark all read" to what existed when the user asked, so a + * replay landing later leaves newer notices unread. No default on purpose: + * the app's Json drops default-valued fields. + */ +@Serializable +data class ReadAllBody(@SerialName("up_to") val upTo: String?) + +@Serializable +data class NotificationKindSettingWire( + val kind: String, + @SerialName("admin_only") val adminOnly: Boolean, + val inbox: Boolean, + val phone: Boolean, + val email: Boolean, +) + +@Serializable +data class NotificationSettingsWire( + val kinds: List, + @SerialName("email_available") val emailAvailable: Boolean, + @SerialName("email_unavailable_reason") val emailUnavailableReason: String? = null, +) + +/** + * One kind's change. Untouched channels stay null and, being equal to their + * default, are left out of the JSON, so the server changes only the channel + * the user touched. + */ +@Serializable +data class NotificationSettingChangeWire( + val kind: String, + val inbox: Boolean? = null, + val phone: Boolean? = null, + val email: Boolean? = null, +) + +@Serializable +data class PutNotificationSettingsBody(val kinds: List) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt index 49dc21db..6e8f1b80 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt @@ -13,6 +13,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao +import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao @@ -28,6 +29,8 @@ import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity +import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity +import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity @@ -66,7 +69,12 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity CachedHistorySnapshotEntity::class, AuthSessionEntity::class, DiagnosticEventEntity::class, + CachedNotificationEntity::class, + CachedNotificationSettingsEntity::class, ], + // v11: + cached_notifications and cached_notification_settings, the + // notifications inbox and its settings (M489). MIGRATION_10_11 creates + // both; nothing to backfill, the first refresh fills them. // v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/ // albumPeak, the ReplayGain values the player levels by (M464 #5000). // MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends @@ -82,7 +90,7 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity // on auth_session. Pre-v1 destructive fallback rebuilds on mismatch — // which is exactly right here: the next sync refills every row with the // new column populated, so there is nothing to migrate by hand. - version = 10, + version = 11, exportSchema = true, ) @TypeConverters(MinstrelTypeConverters::class) @@ -102,6 +110,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao abstract fun authSessionDao(): AuthSessionDao abstract fun diagnosticEventDao(): DiagnosticEventDao + abstract fun cachedNotificationDao(): CachedNotificationDao } /** v8 → v9: add the nullable normalization preference column (#4998). */ @@ -125,3 +134,22 @@ val MIGRATION_9_10: Migration = object : Migration(9, 10) { db.execSQL("UPDATE sync_metadata SET cursor = 0") } } + +/** + * v10 → v11: the notifications inbox cache and its settings (M489). The SQL + * matches what Room generates for the two entities; Room checks it on open. + */ +val MIGRATION_10_11: Migration = object : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `cached_notifications` (" + + "`id` TEXT NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, " + + "`body` TEXT NOT NULL, `link` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, " + + "`readAt` INTEGER, PRIMARY KEY(`id`))", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `cached_notification_settings` (" + + "`id` INTEGER NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`id`))", + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt index db7e4f06..17e8248a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt @@ -10,6 +10,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao +import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao @@ -37,7 +38,7 @@ object DatabaseModule { // launch, so users lose only the unsynced mutation queue // (acceptable while we're iterating). Replace with explicit // Migration entries before the first tagged release. - .addMigrations(MIGRATION_8_9, MIGRATION_9_10) + .addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11) .fallbackToDestructiveMigration(dropAllTables = true) .build() @@ -112,5 +113,10 @@ object DatabaseModule { fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao = db.diagnosticEventDao() + @Provides + @Singleton + fun provideCachedNotificationDao(db: AppDatabase): CachedNotificationDao = + db.cachedNotificationDao() + private const val DATABASE_NAME = "minstrel.db" } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedNotificationDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedNotificationDao.kt new file mode 100644 index 00000000..71f37b13 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedNotificationDao.kt @@ -0,0 +1,51 @@ +package com.fabledsword.minstrel.cache.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity +import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.datetime.Instant + +@Dao +interface CachedNotificationDao { + @Query("SELECT * FROM cached_notifications ORDER BY createdAt DESC, id DESC") + fun observeAll(): Flow> + + @Query("SELECT COUNT(*) FROM cached_notifications WHERE readAt IS NULL") + fun observeUnreadCount(): Flow + + @Query("SELECT * FROM cached_notifications") + suspend fun getAll(): List + + @Query("DELETE FROM cached_notifications") + suspend fun clear() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(rows: List) + + /** The newest page replaces the cache whole: a notice gone server-side goes here too. */ + @Transaction + suspend fun replaceAll(rows: List) { + clear() + insertAll(rows) + } + + @Query("UPDATE cached_notifications SET readAt = :at WHERE id = :id AND readAt IS NULL") + suspend fun markRead(id: String, at: Instant) + + @Query("UPDATE cached_notifications SET readAt = :at WHERE readAt IS NULL") + suspend fun markAllRead(at: Instant) + + @Query("SELECT * FROM cached_notification_settings WHERE id = 1") + fun observeSettings(): Flow + + @Query("SELECT * FROM cached_notification_settings WHERE id = 1") + suspend fun getSettings(): CachedNotificationSettingsEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertSettings(row: CachedNotificationSettingsEntity) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedNotificationEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedNotificationEntity.kt new file mode 100644 index 00000000..642e588e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedNotificationEntity.kt @@ -0,0 +1,21 @@ +package com.fabledsword.minstrel.cache.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.datetime.Instant + +/** + * One notice from the user's inbox (M489), kept so the Notifications screen + * and the bell's badge work offline, and so a read made offline shows at once. + * The newest page is cached; older notices are the server's to keep. + */ +@Entity(tableName = "cached_notifications") +data class CachedNotificationEntity( + @PrimaryKey val id: String, + val kind: String, + val title: String, + val body: String, + val link: String, + val createdAt: Instant, + val readAt: Instant?, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedNotificationSettingsEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedNotificationSettingsEntity.kt new file mode 100644 index 00000000..272abf30 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedNotificationSettingsEntity.kt @@ -0,0 +1,18 @@ +package com.fabledsword.minstrel.cache.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Single-row copy of the user's notification settings (M489), stored as the + * wire JSON, so the settings screen opens offline and a toggle shows at once. + */ +@Entity(tableName = "cached_notification_settings") +data class CachedNotificationSettingsEntity( + @PrimaryKey val id: Int = SINGLETON_ID, + val json: String, +) { + companion object { + const val SINGLETON_ID = 1 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt index 32ea636c..07898135 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt @@ -1,5 +1,6 @@ package com.fabledsword.minstrel.cache.mutations +import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity import com.fabledsword.minstrel.settings.data.NormalizationPrefs @@ -47,6 +48,17 @@ object MutationKind { // preference, a target state like the toggles above, so queued changes // collapse to the last one and an older one can never be replayed last. const val NORMALIZATION_SET: String = "normalization_set" + + // M489 notifications inbox. A read is a one-way action (read never goes + // back to unread), so neither read kind needs collapsing. Read-all + // carries the moment the user asked, so a late replay leaves newer + // notices unread. + const val NOTIFICATION_READ: String = "notification_read" + const val NOTIFICATIONS_READ_ALL: String = "notifications_read_all" + + // M489 per-kind channel setting. One row per (kind, channel) target + // state, collapsed on that pair, so the newest choice is the one sent. + const val NOTIFICATION_SETTING_SET: String = "notification_setting_set" } /** @@ -190,6 +202,25 @@ class MutationQueue @Inject constructor( json.encodeToString(NormalizationPrefs.serializer(), prefs), ) + suspend fun enqueueNotificationRead(id: String): Long = insertUserDriven( + MutationKind.NOTIFICATION_READ, + json.encodeToString(NotificationReadPayload.serializer(), NotificationReadPayload(id)), + ) + + suspend fun enqueueNotificationsReadAll(upToIso: String?): Long = insertUserDriven( + MutationKind.NOTIFICATIONS_READ_ALL, + json.encodeToString( + NotificationsReadAllPayload.serializer(), + NotificationsReadAllPayload(upToIso), + ), + ) + + suspend fun enqueueNotificationSettingSet(payload: NotificationSettingPayload): Long = + insertUserDriven( + MutationKind.NOTIFICATION_SETTING_SET, + json.encodeToString(NotificationSettingPayload.serializer(), payload), + ) + suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven( MutationKind.REQUEST_CANCEL, json.encodeToString( @@ -335,3 +366,36 @@ data class PlaybackErrorReportPayload( val detail: String? = null, val clientId: String, ) + +/** Persisted payload for `MutationKind.NOTIFICATION_READ` (M489). */ +@Serializable +data class NotificationReadPayload(val id: String) + +/** + * Persisted payload for `MutationKind.NOTIFICATIONS_READ_ALL` (M489). + * `upToIso` is the newest notice the user could see when they asked; null + * when the inbox was empty on the device, which marks everything. + */ +@Serializable +data class NotificationsReadAllPayload(val upToIso: String?) + +/** + * Persisted payload for `MutationKind.NOTIFICATION_SETTING_SET` (M489): one + * kind's one channel, as a target state. `channel` is "inbox" | "phone" | + * "email". No defaults, so every field is always written. + */ +@Serializable +data class NotificationSettingPayload( + val kind: String, + val channel: String, + val value: Boolean, +) + +/** The wire change for one queued channel setting, or null for an unknown channel. */ +internal fun notificationSettingChange(p: NotificationSettingPayload): NotificationSettingChangeWire? = + when (p.channel) { + "inbox" -> NotificationSettingChangeWire(kind = p.kind, inbox = p.value) + "phone" -> NotificationSettingChangeWire(kind = p.kind, phone = p.value) + "email" -> NotificationSettingChangeWire(kind = p.kind, email = p.value) + else -> null + } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationReplayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationReplayer.kt index 55dd4a24..73891906 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationReplayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationReplayer.kt @@ -8,6 +8,9 @@ import com.fabledsword.minstrel.api.endpoints.EventsApi import com.fabledsword.minstrel.api.endpoints.FlagRequest import com.fabledsword.minstrel.api.endpoints.LikesApi import com.fabledsword.minstrel.api.endpoints.MeApi +import com.fabledsword.minstrel.api.endpoints.NotificationsApi +import com.fabledsword.minstrel.api.endpoints.PutNotificationSettingsBody +import com.fabledsword.minstrel.api.endpoints.ReadAllBody import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi import com.fabledsword.minstrel.api.endpoints.PlaylistsApi @@ -82,6 +85,7 @@ class MutationReplayer @Inject constructor( private val requestsApi: RequestsApi = retrofit.create() private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create() private val meApi: MeApi = retrofit.create() + private val notificationsApi: NotificationsApi = retrofit.create() private val mutex = Mutex() @@ -170,6 +174,9 @@ class MutationReplayer @Inject constructor( MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload) MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload) MutationKind.NORMALIZATION_SET -> dispatchNormalizationSet(row.payload) + MutationKind.NOTIFICATION_READ -> dispatchNotificationRead(row.payload) + MutationKind.NOTIFICATIONS_READ_ALL -> dispatchNotificationsReadAll(row.payload) + MutationKind.NOTIFICATION_SETTING_SET -> dispatchNotificationSettingSet(row.payload) // Unknown kind — drop so a stale schema entry can't wedge the queue. else -> Outcome.DROP } @@ -293,6 +300,27 @@ class MutationReplayer @Inject constructor( return Outcome.SENT } + /** A 404 (the notice was trimmed or already gone) is a 4xx, so DROP: nothing left to do. */ + private suspend fun dispatchNotificationRead(payload: String): Outcome { + val decoded = json.decodeFromString(NotificationReadPayload.serializer(), payload) + notificationsApi.markRead(decoded.id) + return Outcome.SENT + } + + private suspend fun dispatchNotificationsReadAll(payload: String): Outcome { + val decoded = json.decodeFromString(NotificationsReadAllPayload.serializer(), payload) + notificationsApi.readAll(ReadAllBody(upTo = decoded.upToIso)) + return Outcome.SENT + } + + /** An unknown channel can only come from a corrupt row: DROP it. */ + private suspend fun dispatchNotificationSettingSet(payload: String): Outcome { + val decoded = json.decodeFromString(NotificationSettingPayload.serializer(), payload) + val change = notificationSettingChange(decoded) ?: return Outcome.DROP + notificationsApi.putSettings(PutNotificationSettingsBody(listOf(change))) + return Outcome.SENT + } + private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome { val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload) playbackErrorsApi.report( @@ -355,6 +383,10 @@ private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when ( json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload) }.getOrNull()?.let { "${row.kind}:${it.mbid}" } + MutationKind.NOTIFICATION_SETTING_SET -> runCatching { + json.decodeFromString(NotificationSettingPayload.serializer(), row.payload) + }.getOrNull()?.let { "${row.kind}:${it.kind}:${it.channel}" } + // One preference per user, so every normalization row shares one key. MutationKind.NORMALIZATION_SET -> runCatching { json.decodeFromString(NormalizationPrefs.serializer(), row.payload) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEventsDispatcher.kt b/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEventsDispatcher.kt index ed69d41b..7df93ef0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEventsDispatcher.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEventsDispatcher.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.likes.data.LikesRepository +import com.fabledsword.minstrel.notifications.data.NotificationsRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject @@ -30,6 +31,7 @@ import javax.inject.Singleton class LiveEventsDispatcher @Inject constructor( private val eventsStream: EventsStream, private val likes: LikesRepository, + private val notifications: NotificationsRepository, @ApplicationScope private val scope: CoroutineScope, ) : DefaultLifecycleObserver { @@ -49,6 +51,8 @@ class LiveEventsDispatcher @Inject constructor( "artist.liked", "artist.unliked", -> refreshLikes() + // M489: a contentless nudge; the inbox refetches its newest page. + "notification.created" -> refreshNotifications() } // Other kinds (playlist.*, quarantine.*, request.status_changed, // scan.*) reach screen-scoped subscribers via EventsStream @@ -61,6 +65,13 @@ class LiveEventsDispatcher @Inject constructor( // not have reconnected yet; flush the cross-screen refreshes // defensively. refreshLikes() + refreshNotifications() + } + + private fun refreshNotifications() { + scope.launch { + runCatching { notifications.refresh() } + } } private fun refreshLikes() { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt index 6f03705c..43cefbf5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt @@ -25,6 +25,8 @@ import com.fabledsword.minstrel.home.ui.HomeScreen import com.fabledsword.minstrel.library.ui.AlbumDetailScreen import com.fabledsword.minstrel.library.ui.ArtistDetailScreen import com.fabledsword.minstrel.library.ui.LibraryScreen +import com.fabledsword.minstrel.notifications.ui.NotificationSettingsScreen +import com.fabledsword.minstrel.notifications.ui.NotificationsScreen import com.fabledsword.minstrel.player.ui.NowPlayingScreen import com.fabledsword.minstrel.player.ui.QueueScreen import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen @@ -59,6 +61,7 @@ fun MinstrelNavGraph( ) { inShellTopLevel(navController, expandPlayer) inShellDetail(navController, expandPlayer) + inShellNotifications(navController, expandPlayer) outsideShell(navController) } } @@ -200,6 +203,27 @@ private fun NavGraphBuilder.inShellDetail( } } +/** The notifications inbox and its settings (M489). */ +private fun NavGraphBuilder.inShellNotifications( + navController: NavHostController, + expandPlayer: () -> Unit, +) { + composable { + WithAnimatedScope { + ShellScaffold(onExpandPlayer = expandPlayer) { + NotificationsScreen(navController = navController) + } + } + } + composable { + WithAnimatedScope { + ShellScaffold(onExpandPlayer = expandPlayer) { + NotificationSettingsScreen(navController = navController) + } + } + } +} + private fun NavGraphBuilder.outsideShell(navController: NavHostController) { composable( // Slide up from the bottom on enter; back down on dismiss. diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt index 6e470f85..830a09f3 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt @@ -13,6 +13,8 @@ import kotlinx.serialization.Serializable @Serializable data object Settings @Serializable data object Admin @Serializable data object Requests +@Serializable data object Notifications +@Serializable data object NotificationSettings // ── In-shell detail / push-on-top destinations ──────────────────────── diff --git a/android/app/src/main/java/com/fabledsword/minstrel/notifications/data/NotificationSettingsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/notifications/data/NotificationSettingsRepository.kt new file mode 100644 index 00000000..2b4c1f03 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/notifications/data/NotificationSettingsRepository.kt @@ -0,0 +1,104 @@ +package com.fabledsword.minstrel.notifications.data + +import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire +import com.fabledsword.minstrel.api.endpoints.NotificationsApi +import com.fabledsword.minstrel.api.endpoints.PutNotificationSettingsBody +import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao +import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao +import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity +import com.fabledsword.minstrel.cache.mutations.MutationKind +import com.fabledsword.minstrel.cache.mutations.MutationQueue +import com.fabledsword.minstrel.cache.mutations.NotificationSettingPayload +import com.fabledsword.minstrel.cache.mutations.notificationSettingChange +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json +import retrofit2.Retrofit +import retrofit2.create +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** A notification channel, as the server names it. */ +enum class NotificationChannel(val wire: String) { INBOX("inbox"), PHONE("phone"), EMAIL("email") } + +/** + * Per-user notification settings (M489), following snippet #5107: the device + * copy (a cached JSON row) shows a change at once, the PUT is best effort, + * and a failed or out-of-order one is queued for the MutationReplayer. + */ +@Singleton +class NotificationSettingsRepository @Inject constructor( + retrofit: Retrofit, + private val dao: CachedNotificationDao, + private val mutationDao: CachedMutationDao, + private val mutationQueue: MutationQueue, + private val json: Json, +) { + private val api: NotificationsApi = retrofit.create() + + val settings: Flow = dao.observeSettings().map { it?.let(::decode) } + + /** + * Takes the server's settings unless a change made here is still queued: + * the server has not seen it, and its older value would undo the change + * on screen until the replay lands. Throws on a network failure. + */ + suspend fun refresh() { + val server = api.getSettings() + if (mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET)) return + save(server) + } + + suspend fun set(kind: String, channel: NotificationChannel, value: Boolean) { + current()?.let { save(it.withChannel(kind, channel, value)) } + val payload = NotificationSettingPayload(kind = kind, channel = channel.wire, value = value) + // An earlier change still queued would replay after this PUT; queue + // behind it instead, and the replayer sends the newest per channel. + if (mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET)) { + mutationQueue.enqueueNotificationSettingSet(payload) + return + } + val change = notificationSettingChange(payload) ?: return + try { + save(api.putSettings(PutNotificationSettingsBody(listOf(change)))) + } catch (e: CancellationException) { + throw e + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + Timber.i(e, "notification settings: PUT failed; queued") + mutationQueue.enqueueNotificationSettingSet(payload) + } + } + + private suspend fun current(): NotificationSettingsWire? = dao.getSettings()?.let(::decode) + + /** Null for an unreadable row: the next refresh writes a good one. */ + private fun decode(row: CachedNotificationSettingsEntity): NotificationSettingsWire? = + runCatching { json.decodeFromString(NotificationSettingsWire.serializer(), row.json) }.getOrNull() + + private suspend fun save(s: NotificationSettingsWire) { + val encoded = json.encodeToString(NotificationSettingsWire.serializer(), s) + dao.upsertSettings(CachedNotificationSettingsEntity(json = encoded)) + } +} + +internal fun NotificationSettingsWire.withChannel( + kind: String, + channel: NotificationChannel, + value: Boolean, +): NotificationSettingsWire = copy( + kinds = kinds.map { k -> + if (k.kind != kind) { + k + } else { + when (channel) { + NotificationChannel.INBOX -> k.copy(inbox = value) + NotificationChannel.PHONE -> k.copy(phone = value) + NotificationChannel.EMAIL -> k.copy(email = value) + } + } + }, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/notifications/data/NotificationsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/notifications/data/NotificationsRepository.kt new file mode 100644 index 00000000..0fd75a9f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/notifications/data/NotificationsRepository.kt @@ -0,0 +1,129 @@ +package com.fabledsword.minstrel.notifications.data + +import com.fabledsword.minstrel.api.endpoints.NotificationWire +import com.fabledsword.minstrel.api.endpoints.NotificationsApi +import com.fabledsword.minstrel.api.endpoints.ReadAllBody +import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao +import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity +import com.fabledsword.minstrel.cache.mutations.MutationQueue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import retrofit2.HttpException +import retrofit2.Retrofit +import retrofit2.create +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +/** + * The notifications inbox (M489). The newest page lives in Room so the bell's + * badge and the Notifications screen work offline. Reads follow rule 100: the + * device marks the row at once, the call is best effort, and a failed call is + * queued for the MutationReplayer. + */ +@Singleton +class NotificationsRepository @Inject constructor( + retrofit: Retrofit, + private val dao: CachedNotificationDao, + private val mutationQueue: MutationQueue, +) { + private val api: NotificationsApi = retrofit.create() + + val notifications: Flow> = dao.observeAll().distinctUntilChanged() + + /** Unread notices in the cached page, which is what the badge shows. */ + val unreadCount: Flow = dao.observeUnreadCount().distinctUntilChanged() + + /** + * Replaces the cache with the server's newest page. A read made on this + * device and not yet replayed stays read: a read never goes back to + * unread, so the device's mark wins over the server's older view. + * Throws on a network failure; the cache then stands. + */ + suspend fun refresh() { + val page = api.list(limit = PAGE_SIZE) + val localReads = dao.getAll().associate { it.id to it.readAt } + dao.replaceAll(page.items.mapNotNull { it.toEntity(localReads[it.id]) }) + } + + suspend fun markRead(id: String) { + dao.markRead(id, Clock.System.now()) + try { + api.markRead(id) + } catch (e: CancellationException) { + throw e + } catch (e: HttpException) { + // A 4xx (404: trimmed or gone server-side) leaves nothing to mark; + // a 5xx is the server's trouble, so the read waits in the queue. + if (e.code() >= HTTP_SERVER_ERROR) { + mutationQueue.enqueueNotificationRead(id) + } else { + Timber.i(e, "notifications: mark read refused (%d)", e.code()) + } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + Timber.i(e, "notifications: mark read failed; queued") + mutationQueue.enqueueNotificationRead(id) + } + } + + /** + * Marks everything the device holds read. The server is asked to mark + * only what existed up to the newest notice shown here, so a replay that + * lands later leaves anything newer unread. + */ + suspend fun markAllRead() { + val upTo = readAllCutoff(dao.getAll().map { it.createdAt })?.toString() + dao.markAllRead(Clock.System.now()) + try { + api.readAll(ReadAllBody(upTo = upTo)) + } catch (e: CancellationException) { + throw e + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + Timber.i(e, "notifications: mark all read failed; queued") + mutationQueue.enqueueNotificationsReadAll(upTo) + } + } + + /** On sign-out: the next account on this device must not see these. */ + suspend fun clearLocal() { + dao.clear() + } + + companion object { + /** The newest page the device keeps; older notices stay on the server. */ + const val PAGE_SIZE = 50 + + private const val HTTP_SERVER_ERROR = 500 + } +} + +/** + * The newest notice shown, rounded up a millisecond. Room keeps milliseconds + * and the server microseconds, so the stored time can sit just before the + * server's; without rounding up, the newest notice would stay unread. + */ +internal fun readAllCutoff(createdAts: List): Instant? = + createdAts.maxOrNull()?.plus(1.milliseconds) + +/** Null for a row whose timestamp will not parse, rather than failing the page. */ +internal fun NotificationWire.toEntity(localReadAt: Instant?): CachedNotificationEntity? { + val created = runCatching { Instant.parse(createdAt) }.getOrNull() ?: return null + val serverRead = readAt?.let { runCatching { Instant.parse(it) }.getOrNull() } + return CachedNotificationEntity( + id = id, + kind = kind, + title = title, + body = body, + link = link, + createdAt = created, + readAt = serverRead ?: localReadAt, + ) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/notifications/ui/NotificationLinks.kt b/android/app/src/main/java/com/fabledsword/minstrel/notifications/ui/NotificationLinks.kt new file mode 100644 index 00000000..7c49897d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/notifications/ui/NotificationLinks.kt @@ -0,0 +1,27 @@ +package com.fabledsword.minstrel.notifications.ui + +import com.fabledsword.minstrel.nav.Admin +import com.fabledsword.minstrel.nav.AdminQuarantine +import com.fabledsword.minstrel.nav.AdminRequests +import com.fabledsword.minstrel.nav.AlbumDetail +import com.fabledsword.minstrel.nav.ArtistDetail +import com.fabledsword.minstrel.nav.Requests + +/** + * The app screen for a notice's link. The server writes web paths, the same + * for every client; this maps them onto the app's routes. Admin pages the app + * has no screen for (missing files, duplicates, playback errors) open the + * Admin landing; anything unknown opens nothing. + */ +internal fun routeForLink(link: String): Any? { + val parts = link.trim('/').split('/').filter { it.isNotEmpty() } + return when { + parts.size == 2 && parts[0] == "albums" -> AlbumDetail(parts[1]) + parts.size == 2 && parts[0] == "artists" -> ArtistDetail(parts[1]) + parts == listOf("requests") -> Requests + parts == listOf("admin", "requests") -> AdminRequests + parts == listOf("admin", "quarantine") -> AdminQuarantine + parts.firstOrNull() == "admin" -> Admin + else -> null + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/notifications/ui/NotificationSettingsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/notifications/ui/NotificationSettingsScreen.kt new file mode 100644 index 00000000..fb387acb --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/notifications/ui/NotificationSettingsScreen.kt @@ -0,0 +1,262 @@ +@file:Suppress("TooManyFunctions") // Compose screen + private row composables + +package com.fabledsword.minstrel.notifications.ui + +import android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.app.NotificationManagerCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.composables.icons.lucide.ChevronRight +import com.composables.icons.lucide.Lucide +import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire +import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire +import com.fabledsword.minstrel.nav.NotificationSettings +import com.fabledsword.minstrel.notifications.data.NotificationChannel +import com.fabledsword.minstrel.shared.widgets.LoadingCentered +import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar +import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets + +private val CELL_WIDTH = 56.dp + +/** + * Per-user notification settings (M489): a row per kind with a box for each + * channel. Rows read as a menu (rule 188); anything that needs explaining + * is one short line. + */ +@Composable +fun NotificationSettingsScreen( + navController: NavHostController, + viewModel: NotificationSettingsViewModel = hiltViewModel(), +) { + val settings by viewModel.settings.collectAsStateWithLifecycle() + val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle() + val context = LocalContext.current + // Re-read on every resume: the user may come back from system settings. + var phoneAllowed by remember { mutableStateOf(true) } + LifecycleResumeEffect(Unit) { + phoneAllowed = NotificationManagerCompat.from(context).areNotificationsEnabled() + onPauseOrDispose { } + } + Scaffold( + contentWindowInsets = ShellContentWindowInsets, + modifier = Modifier.fillMaxSize(), + topBar = { + MinstrelTopAppBar( + title = "Notifications", + navController = navController, + currentRouteName = NotificationSettings::class.qualifiedName, + onBack = { navController.popBackStack() }, + ) + }, + ) { inner -> + val s = settings + if (s == null) { + LoadingCentered(modifier = Modifier.padding(inner)) + } else { + SettingsBody( + settings = s, + isAdmin = isAdmin, + // Null while the system lets Minstrel post notifications. + onPhoneBlocked = if (phoneAllowed) null else ({ openAppNotificationSettings(context) }), + onSet = viewModel::set, + modifier = Modifier.padding(inner), + ) + } + } +} + +@Composable +private fun SettingsBody( + settings: NotificationSettingsWire, + isAdmin: Boolean, + onPhoneBlocked: (() -> Unit)?, + onSet: (String, NotificationChannel, Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + if (onPhoneBlocked != null) { + PhoneBlockedRow(onClick = onPhoneBlocked) + } + HeaderRow() + settings.kinds.filter { !it.adminOnly }.forEach { KindRow(it, settings.emailAvailable, onSet) } + val adminKinds = settings.kinds.filter { it.adminOnly } + if (adminKinds.isNotEmpty()) { + SectionLabel("Library health") + adminKinds.forEach { KindRow(it, settings.emailAvailable, onSet) } + } + if (!settings.emailAvailable) { + Muted(emailUnavailableLine(settings.emailUnavailableReason, isAdmin)) + } + } +} + +@Composable +private fun HeaderRow() { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.weight(1f)) + NotificationChannel.entries.forEach { c -> + Text( + text = channelLabel(c), + modifier = Modifier.width(CELL_WIDTH), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +private fun KindRow( + row: NotificationKindSettingWire, + emailAvailable: Boolean, + onSet: (String, NotificationChannel, Boolean) -> Unit, +) { + val label = kindLabel(row.kind) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge) + NotificationChannel.entries.forEach { c -> + val enabled = channelEnabled(row, c, emailAvailable) + Box(Modifier.width(CELL_WIDTH), contentAlignment = Alignment.Center) { + Checkbox( + checked = channelValue(row, c) && enabled, + onCheckedChange = { onSet(row.kind, c, it) }, + enabled = enabled, + modifier = Modifier.semantics { contentDescription = "$label: ${channelLabel(c)}" }, + ) + } + } + } +} + +@Composable +private fun PhoneBlockedRow(onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(Modifier.weight(1f)) { + Text("Phone alerts are off", style = MaterialTheme.typography.bodyLarge) + Muted("Allow notifications for Minstrel in system settings", padded = false) + } + Icon(Lucide.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun Muted(text: String, padded: Boolean = true) { + Text( + text = text, + modifier = if (padded) Modifier.padding(horizontal = 16.dp, vertical = 12.dp) else Modifier, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +private fun openAppNotificationSettings(context: Context) { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + runCatching { context.startActivity(intent) } +} + +/** Phone and email ride on the inbox; email also needs an address and SMTP. */ +internal fun channelEnabled( + row: NotificationKindSettingWire, + channel: NotificationChannel, + emailAvailable: Boolean, +): Boolean = when (channel) { + NotificationChannel.INBOX -> true + NotificationChannel.PHONE -> row.inbox + NotificationChannel.EMAIL -> row.inbox && emailAvailable +} + +internal fun channelValue(row: NotificationKindSettingWire, channel: NotificationChannel): Boolean = + when (channel) { + NotificationChannel.INBOX -> row.inbox + NotificationChannel.PHONE -> row.phone + NotificationChannel.EMAIL -> row.email + } + +internal fun channelLabel(c: NotificationChannel): String = when (c) { + NotificationChannel.INBOX -> "Inbox" + NotificationChannel.PHONE -> "Phone" + NotificationChannel.EMAIL -> "Email" +} + +/** Short row labels, the same as the web's. Unknown kinds show their key. */ +internal fun kindLabel(kind: String): String = when (kind) { + "request_approved" -> "Request approved" + "request_rejected" -> "Request declined" + "request_completed" -> "New music arrived" + "request_pending" -> "Requests to review" + "quarantine_flagged" -> "Tracks flagged" + "scan_failed" -> "Library scan failed" + "tracks_missing" -> "Tracks gone missing" + "duplicates_found" -> "Duplicates to review" + "playback_errors" -> "Playback errors" + else -> kind +} + +internal fun emailUnavailableLine(reason: String?, isAdmin: Boolean): String = when { + reason == "no_address" -> "Email is off: add an email address in your profile" + isAdmin -> "Email is off: SMTP isn't set up on the server" + else -> "Email is off: this server doesn't send email" +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/notifications/ui/NotificationSettingsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/notifications/ui/NotificationSettingsViewModel.kt new file mode 100644 index 00000000..218bdd49 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/notifications/ui/NotificationSettingsViewModel.kt @@ -0,0 +1,39 @@ +package com.fabledsword.minstrel.notifications.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire +import com.fabledsword.minstrel.auth.AuthController +import com.fabledsword.minstrel.notifications.data.NotificationChannel +import com.fabledsword.minstrel.notifications.data.NotificationSettingsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val SHARE_STOP_TIMEOUT_MS = 5_000L + +@HiltViewModel +class NotificationSettingsViewModel @Inject constructor( + private val repository: NotificationSettingsRepository, + authController: AuthController, +) : ViewModel() { + val settings: StateFlow = repository.settings + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), null) + + val isAdmin: StateFlow = authController.currentUser + .map { it?.isAdmin == true } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), false) + + init { + // Offline, the device's copy stands. + viewModelScope.launch { runCatching { repository.refresh() } } + } + + fun set(kind: String, channel: NotificationChannel, value: Boolean) { + viewModelScope.launch { repository.set(kind, channel, value) } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/notifications/ui/NotificationsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/notifications/ui/NotificationsScreen.kt new file mode 100644 index 00000000..dd933cd6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/notifications/ui/NotificationsScreen.kt @@ -0,0 +1,155 @@ +package com.fabledsword.minstrel.notifications.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.composables.icons.lucide.Bell +import com.composables.icons.lucide.Lucide +import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity +import com.fabledsword.minstrel.nav.Notifications +import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.LoadingCentered +import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold +import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +/** + * The notifications inbox (M489): one row per notice, its title on one line + * and how long ago. Tapping a row marks it read and opens what it is about. + */ +@Composable +fun NotificationsScreen( + navController: NavHostController, + viewModel: NotificationsViewModel = hiltViewModel(), +) { + val items by viewModel.items.collectAsStateWithLifecycle() + val anyUnread = items?.any { it.readAt == null } == true + Scaffold( + contentWindowInsets = ShellContentWindowInsets, + modifier = Modifier.fillMaxSize(), + topBar = { + MinstrelTopAppBar( + title = "Notifications", + navController = navController, + currentRouteName = Notifications::class.qualifiedName, + onBack = { navController.popBackStack() }, + actions = { + if (anyUnread) { + TextButton(onClick = viewModel::markAllRead) { Text("Mark all read") } + } + }, + ) + }, + ) { inner -> + PullToRefreshScaffold( + onRefresh = { viewModel.refresh().join() }, + modifier = Modifier.fillMaxSize().padding(inner), + ) { + val list = items + when { + list == null -> LoadingCentered() + list.isEmpty() -> EmptyState( + title = "Nothing waiting for you", + body = "Requests, new music and anything needing a look will gather here.", + icon = Lucide.Bell, + ) + else -> LazyColumn(modifier = Modifier.fillMaxSize()) { + items(list, key = { it.id }) { item -> + NotificationRow( + item = item, + onClick = { + viewModel.open(item) + routeForLink(item.link)?.let { navController.navigate(it) } + }, + ) + HorizontalDivider() + } + } + } + } + } +} + +@Composable +private fun NotificationRow(item: CachedNotificationEntity, onClick: () -> Unit) { + val unread = item.readAt == null + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp) + .semantics(mergeDescendants = true) { + if (unread) contentDescription = "Unread: ${item.title}" + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .size(8.dp) + .then( + if (unread) { + Modifier.background(MaterialTheme.colorScheme.onSurface, CircleShape) + } else { + Modifier + }, + ), + ) + Text( + text = item.title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (unread) FontWeight.Medium else FontWeight.Normal, + color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = ago(item.createdAt), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** "just now", "12m", "5h", "3d": the same coarse steps as the web inbox. */ +internal fun ago(at: Instant, now: Instant = Clock.System.now()): String { + val d = now - at + return when { + d >= 1.days -> "${d.inWholeDays}d" + d >= 1.hours -> "${d.inWholeHours}h" + d >= 1.minutes -> "${d.inWholeMinutes}m" + else -> "just now" + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/notifications/ui/NotificationsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/notifications/ui/NotificationsViewModel.kt new file mode 100644 index 00000000..d65e9c8f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/notifications/ui/NotificationsViewModel.kt @@ -0,0 +1,49 @@ +package com.fabledsword.minstrel.notifications.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.connectivity.recoveries +import com.fabledsword.minstrel.notifications.data.NotificationsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val SHARE_STOP_TIMEOUT_MS = 5_000L + +/** `items` is null until the cache has been read once, so the screen can tell "loading" from "empty". */ +@HiltViewModel +class NotificationsViewModel @Inject constructor( + private val repository: NotificationsRepository, + networkStatus: NetworkStatusController, +) : ViewModel() { + val items: StateFlow?> = repository.notifications + .map, List?> { it } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), null) + + init { + refresh() + // Back online: the cached page may be stale (#1245's recovery idiom). + viewModelScope.launch { networkStatus.recoveries().collect { refresh() } } + } + + /** Offline, the cached page stands. */ + fun refresh(): Job = viewModelScope.launch { + runCatching { repository.refresh() } + } + + fun open(item: CachedNotificationEntity) { + if (item.readAt != null) return + viewModelScope.launch { repository.markRead(item.id) } + } + + fun markAllRead() { + viewModelScope.launch { repository.markAllRead() } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt index 41b11fe8..ec4b13b0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController +import com.composables.icons.lucide.Bell import com.composables.icons.lucide.ChevronRight import com.composables.icons.lucide.ListMusic import com.composables.icons.lucide.LogOut @@ -52,6 +53,7 @@ import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Shield import com.fabledsword.minstrel.BuildConfig import com.fabledsword.minstrel.nav.Admin +import com.fabledsword.minstrel.nav.NotificationSettings import com.fabledsword.minstrel.nav.Requests import com.fabledsword.minstrel.nav.Settings as SettingsRoute import com.fabledsword.minstrel.nav.ServerUrl @@ -98,6 +100,7 @@ fun SettingsScreen( themeMode = themeMode, onPickTheme = themeVm::setThemeMode, onNavToRequests = { navController.navigate(Requests) }, + onNavToNotifications = { navController.navigate(NotificationSettings) }, onNavToAdmin = { navController.navigate(Admin) }, onToggleDiagnostics = viewModel::setDiagnosticsOptOut, onSignOutClick = { showSignOutConfirm = true }, @@ -121,6 +124,7 @@ private fun SettingsList( themeMode: ThemeMode, onPickTheme: (ThemeMode) -> Unit, onNavToRequests: () -> Unit, + onNavToNotifications: () -> Unit, onNavToAdmin: () -> Unit, onToggleDiagnostics: (Boolean) -> Unit, onSignOutClick: () -> Unit, @@ -144,6 +148,12 @@ private fun SettingsList( subtitle = "Track what you've asked Minstrel to add", onClick = onNavToRequests, ) + NavTile( + icon = Lucide.Bell, + title = "Notifications", + subtitle = "What reaches you, and where", + onClick = onNavToNotifications, + ) if (state.isAdmin) { NavTile( icon = Lucide.Shield, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsViewModel.kt index 256ad581..6a1e9562 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.fabledsword.minstrel.auth.AuthController import com.fabledsword.minstrel.auth.AuthStore +import com.fabledsword.minstrel.notifications.data.NotificationsRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -28,6 +29,7 @@ data class SettingsState( class SettingsViewModel @Inject constructor( private val authController: AuthController, private val authStore: AuthStore, + private val notifications: NotificationsRepository, ) : ViewModel() { private val transient = MutableStateFlow(TransientState()) @@ -70,6 +72,8 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { transient.update { it.copy(isSigningOut = true) } authController.signOut() + // The inbox is this account's; the next one on the device must not see it. + runCatching { notifications.clearLocal() } transient.update { it.copy(isSigningOut = false, signedOut = true) } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/AppBarActionsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/AppBarActionsViewModel.kt index e93242e6..7225af92 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/AppBarActionsViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/AppBarActionsViewModel.kt @@ -3,6 +3,7 @@ package com.fabledsword.minstrel.shared.widgets import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.fabledsword.minstrel.auth.AuthController +import com.fabledsword.minstrel.notifications.data.NotificationsRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -22,6 +23,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L @HiltViewModel class AppBarActionsViewModel @Inject constructor( authController: AuthController, + notifications: NotificationsRepository, ) : ViewModel() { val isAdmin: StateFlow = authController.currentUser .map { it?.isAdmin == true } @@ -30,4 +32,12 @@ class AppBarActionsViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), initialValue = false, ) + + /** Unread notices for the bell's badge (M489). */ + val unreadCount: StateFlow = notifications.unreadCount + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = 0, + ) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/MainAppBarActions.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/MainAppBarActions.kt index a1707c59..d8cffb5f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/MainAppBarActions.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/MainAppBarActions.kt @@ -1,10 +1,13 @@ package com.fabledsword.minstrel.shared.widgets import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -14,6 +17,7 @@ import androidx.compose.runtime.setValue import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController +import com.composables.icons.lucide.Bell import com.composables.icons.lucide.House import com.composables.icons.lucide.LibraryBig import com.composables.icons.lucide.Lucide @@ -23,6 +27,7 @@ import com.fabledsword.minstrel.nav.Admin import com.fabledsword.minstrel.nav.Discover import com.fabledsword.minstrel.nav.Home import com.fabledsword.minstrel.nav.Library +import com.fabledsword.minstrel.nav.Notifications import com.fabledsword.minstrel.nav.Playlists import com.fabledsword.minstrel.nav.Search as SearchRoute import com.fabledsword.minstrel.nav.Settings as SettingsRoute @@ -50,6 +55,7 @@ fun MainAppBarActions( viewModel: AppBarActionsViewModel = hiltViewModel(), ) { val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle() + val unread by viewModel.unreadCount.collectAsStateWithLifecycle() Row { if (currentRouteName != Home::class.qualifiedName) { IconButton(onClick = { navController.navigate(Home) }) { @@ -66,10 +72,47 @@ fun MainAppBarActions( Icon(Lucide.SearchIcon, contentDescription = "Search") } } + if (currentRouteName != Notifications::class.qualifiedName) { + NotificationsBell(unread = unread, onClick = { navController.navigate(Notifications) }) + } OverflowMenu(navController = navController, isAdmin = isAdmin) } } +/** The inbox bell with its unread badge (M489): the count to 9, then "9+". */ +@Composable +private fun NotificationsBell(unread: Int, onClick: () -> Unit) { + val label = badgeLabel(unread) + IconButton(onClick = onClick) { + // Inverse of the bar, not error red: an unread count is news, not a + // fault, and the house style keeps the accent off general chrome. + BadgedBox( + badge = { + if (label.isNotEmpty()) { + Badge( + containerColor = MaterialTheme.colorScheme.onSurface, + contentColor = MaterialTheme.colorScheme.surface, + ) { Text(label) } + } + }, + ) { + Icon( + Lucide.Bell, + contentDescription = if (unread > 0) "Notifications, $unread unread" else "Notifications", + ) + } + } +} + +/** Badge text: nothing at zero, the count up to nine, then "9+". */ +internal fun badgeLabel(count: Int): String = when { + count <= 0 -> "" + count > MAX_BADGE -> "$MAX_BADGE+" + else -> count.toString() +} + +private const val MAX_BADGE = 9 + @Composable private fun OverflowMenu(navController: NavHostController, isAdmin: Boolean) { var expanded by remember { mutableStateOf(false) } diff --git a/android/app/src/test/java/com/fabledsword/minstrel/cache/mutations/SupersededToggleIdsTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/cache/mutations/SupersededToggleIdsTest.kt index c54356c5..3a223b45 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/cache/mutations/SupersededToggleIdsTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/cache/mutations/SupersededToggleIdsTest.kt @@ -1,5 +1,6 @@ package com.fabledsword.minstrel.cache.mutations +import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity import com.fabledsword.minstrel.settings.data.NormalizationMode import com.fabledsword.minstrel.settings.data.NormalizationPrefs @@ -153,4 +154,34 @@ class SupersededToggleIdsTest { ) assertEquals(setOf(1L, 3L), supersededToggleIds(rows, json)) } + + private fun settingRow(id: Long, kind: String, channel: String, value: Boolean) = CachedMutationEntity( + id = id, + kind = MutationKind.NOTIFICATION_SETTING_SET, + payload = json.encodeToString( + NotificationSettingPayload.serializer(), + NotificationSettingPayload(kind, channel, value), + ), + ) + + @Test + fun `notification settings collapse per kind and channel, never across them`() { + val rows = listOf( + settingRow(1, "request_completed", "email", false), + settingRow(2, "request_completed", "phone", false), + settingRow(3, "request_completed", "email", true), + settingRow(4, "request_approved", "email", false), + ) + // Only the older email toggle for request_completed is superseded. + assertEquals(setOf(1L), supersededToggleIds(rows, json)) + } + + @Test + fun `a queued setting becomes a one-channel change, and an unknown channel none`() { + assertEquals( + NotificationSettingChangeWire(kind = "k", phone = true), + notificationSettingChange(NotificationSettingPayload("k", "phone", true)), + ) + assertEquals(null, notificationSettingChange(NotificationSettingPayload("k", "pager", true))) + } } diff --git a/android/app/src/test/java/com/fabledsword/minstrel/notifications/data/NotificationSettingsRepositoryTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/notifications/data/NotificationSettingsRepositoryTest.kt new file mode 100644 index 00000000..bd034ae1 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/notifications/data/NotificationSettingsRepositoryTest.kt @@ -0,0 +1,107 @@ +package com.fabledsword.minstrel.notifications.data + +import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire +import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire +import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire +import com.fabledsword.minstrel.api.endpoints.NotificationsApi +import com.fabledsword.minstrel.api.endpoints.PutNotificationSettingsBody +import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao +import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao +import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity +import com.fabledsword.minstrel.cache.mutations.MutationKind +import com.fabledsword.minstrel.cache.mutations.MutationQueue +import com.fabledsword.minstrel.cache.mutations.NotificationSettingPayload +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test +import retrofit2.Retrofit +import java.io.IOException +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** Settings follow snippet #5107: shown at once, never overwritten or reordered by an older change. */ +class NotificationSettingsRepositoryTest { + private val json = Json { ignoreUnknownKeys = true } + private val api: NotificationsApi = mockk(relaxed = true) + private val dao: CachedNotificationDao = mockk(relaxed = true) + private val mutationDao: CachedMutationDao = mockk() + private val queue: MutationQueue = mockk(relaxed = true) + private val retrofit: Retrofit = mockk { + every { create(NotificationsApi::class.java) } returns api + } + private val repo = NotificationSettingsRepository(retrofit, dao, mutationDao, queue, json) + + private val settings = NotificationSettingsWire( + kinds = listOf( + NotificationKindSettingWire("request_completed", false, inbox = true, phone = true, email = true), + ), + emailAvailable = true, + ) + + private fun cached() { + coEvery { dao.getSettings() } returns CachedNotificationSettingsEntity( + json = json.encodeToString(NotificationSettingsWire.serializer(), settings), + ) + } + + @Test + fun `a toggle is shown at once and sends only that channel`() = runTest { + cached() + coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns false + val saved = mutableListOf() + coEvery { dao.upsertSettings(capture(saved)) } returns Unit + val body = slot() + coEvery { api.putSettings(capture(body)) } returns settings + + repo.set("request_completed", NotificationChannel.EMAIL, false) + + val optimistic = json.decodeFromString(NotificationSettingsWire.serializer(), saved.first().json) + assertFalse(optimistic.kinds.single().email) + assertEquals( + listOf(NotificationSettingChangeWire(kind = "request_completed", email = false)), + body.captured.kinds, + ) + coVerify(exactly = 0) { queue.enqueueNotificationSettingSet(any()) } + } + + @Test + fun `a toggle that cannot reach the server is queued`() = runTest { + cached() + coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns false + coEvery { api.putSettings(any()) } throws IOException("offline") + + repo.set("request_completed", NotificationChannel.PHONE, false) + + coVerify { + queue.enqueueNotificationSettingSet(NotificationSettingPayload("request_completed", "phone", false)) + } + } + + @Test + fun `a toggle behind a queued one queues too`() = runTest { + cached() + coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns true + + repo.set("request_completed", NotificationChannel.INBOX, false) + + coVerify(exactly = 0) { api.putSettings(any()) } + coVerify { + queue.enqueueNotificationSettingSet(NotificationSettingPayload("request_completed", "inbox", false)) + } + } + + @Test + fun `refresh leaves a queued change on screen`() = runTest { + coEvery { api.getSettings() } returns settings + coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns true + + repo.refresh() + + coVerify(exactly = 0) { dao.upsertSettings(any()) } + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/notifications/data/NotificationsRepositoryTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/notifications/data/NotificationsRepositoryTest.kt new file mode 100644 index 00000000..cc3e7343 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/notifications/data/NotificationsRepositoryTest.kt @@ -0,0 +1,136 @@ +package com.fabledsword.minstrel.notifications.data + +import com.fabledsword.minstrel.api.endpoints.NotificationWire +import com.fabledsword.minstrel.api.endpoints.NotificationsApi +import com.fabledsword.minstrel.api.endpoints.NotificationsPageWire +import com.fabledsword.minstrel.api.endpoints.ReadAllBody +import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao +import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity +import com.fabledsword.minstrel.cache.mutations.MutationQueue +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.jupiter.api.Test +import retrofit2.HttpException +import retrofit2.Response +import retrofit2.Retrofit +import java.io.IOException +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Reads are offline-first (rule 100): the device marks the row at once, and a + * read the server has not taken is queued, except when the server says the + * notice is gone. + */ +class NotificationsRepositoryTest { + private val api: NotificationsApi = mockk(relaxed = true) + private val dao: CachedNotificationDao = mockk(relaxed = true) + private val queue: MutationQueue = mockk(relaxed = true) + private val retrofit: Retrofit = mockk { + every { create(NotificationsApi::class.java) } returns api + } + private val repo = NotificationsRepository(retrofit, dao, queue) + + private fun httpError(status: Int) = HttpException(Response.error(status, "".toResponseBody())) + + private fun row(id: String, created: String, readAt: Instant? = null) = CachedNotificationEntity( + id = id, + kind = "request_completed", + title = "t", + body = "b", + link = "/requests", + createdAt = Instant.parse(created), + readAt = readAt, + ) + + @Test + fun `a read is shown at once and sent`() = runTest { + repo.markRead("n1") + + coVerify { dao.markRead("n1", any()) } + coVerify { api.markRead("n1") } + coVerify(exactly = 0) { queue.enqueueNotificationRead(any()) } + } + + @Test + fun `a read that cannot reach the server is queued`() = runTest { + coEvery { api.markRead("n1") } throws IOException("offline") + + repo.markRead("n1") + + coVerify { dao.markRead("n1", any()) } + coVerify { queue.enqueueNotificationRead("n1") } + } + + @Test + fun `a notice the server no longer has is not queued`() = runTest { + coEvery { api.markRead("n1") } throws httpError(404) + + repo.markRead("n1") + + coVerify(exactly = 0) { queue.enqueueNotificationRead(any()) } + } + + @Test + fun `a server error queues the read for later`() = runTest { + coEvery { api.markRead("n1") } throws httpError(503) + + repo.markRead("n1") + + coVerify { queue.enqueueNotificationRead("n1") } + } + + @Test + fun `mark all read sends the newest notice shown, rounded up, and queues it offline`() = runTest { + coEvery { dao.getAll() } returns listOf( + row("a", "2026-10-08T10:00:00.123Z"), + row("b", "2026-10-08T11:00:00.456Z"), + ) + val body = slot() + coEvery { api.readAll(capture(body)) } throws IOException("offline") + + repo.markAllRead() + + coVerify { dao.markAllRead(any()) } + assertEquals("2026-10-08T11:00:00.457Z", body.captured.upTo) + coVerify { queue.enqueueNotificationsReadAll("2026-10-08T11:00:00.457Z") } + } + + @Test + fun `refresh keeps a read made here that the server has not seen yet`() = runTest { + val readHere = Instant.parse("2026-10-08T12:00:00Z") + coEvery { dao.getAll() } returns listOf(row("a", "2026-10-08T10:00:00Z", readAt = readHere)) + coEvery { api.list(any()) } returns NotificationsPageWire( + items = listOf( + NotificationWire("a", "request_completed", "t", "b", "/requests", "2026-10-08T10:00:00Z", null), + NotificationWire("b", "request_completed", "t", "b", "/requests", "2026-10-08T11:00:00Z", null), + ), + unreadCount = 2, + ) + val saved = slot>() + coEvery { dao.replaceAll(capture(saved)) } returns Unit + + repo.refresh() + + val byId = saved.captured.associateBy { it.id } + assertEquals(readHere, byId.getValue("a").readAt) + assertNull(byId.getValue("b").readAt) + } + + @Test + fun `a notice with an unreadable timestamp is skipped, not the whole page`() { + val bad = NotificationWire("x", "k", "t", "b", "/", "yesterday", null) + assertNull(bad.toEntity(null)) + } + + @Test + fun `no cutoff when nothing is shown`() { + assertNull(readAllCutoff(emptyList())) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/notifications/ui/NotificationUiTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/notifications/ui/NotificationUiTest.kt new file mode 100644 index 00000000..763e13ba --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/notifications/ui/NotificationUiTest.kt @@ -0,0 +1,66 @@ +package com.fabledsword.minstrel.notifications.ui + +import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire +import com.fabledsword.minstrel.nav.Admin +import com.fabledsword.minstrel.nav.AdminQuarantine +import com.fabledsword.minstrel.nav.AdminRequests +import com.fabledsword.minstrel.nav.AlbumDetail +import com.fabledsword.minstrel.nav.ArtistDetail +import com.fabledsword.minstrel.nav.Requests +import com.fabledsword.minstrel.notifications.data.NotificationChannel +import com.fabledsword.minstrel.shared.widgets.badgeLabel +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NotificationUiTest { + @Test + fun `server links open the matching screens`() { + assertEquals(AlbumDetail("al-1"), routeForLink("/albums/al-1")) + assertEquals(ArtistDetail("ar-1"), routeForLink("/artists/ar-1")) + assertEquals(Requests, routeForLink("/requests")) + assertEquals(AdminRequests, routeForLink("/admin/requests")) + assertEquals(AdminQuarantine, routeForLink("/admin/quarantine")) + // Admin pages the app has no screen for open the Admin landing. + assertEquals(Admin, routeForLink("/admin/duplicates")) + assertEquals(Admin, routeForLink("/admin")) + assertNull(routeForLink("/somewhere-new")) + } + + @Test + fun `the badge counts to nine, then 9+`() { + assertEquals("", badgeLabel(0)) + assertEquals("1", badgeLabel(1)) + assertEquals("9", badgeLabel(9)) + assertEquals("9+", badgeLabel(10)) + } + + @Test + fun `ages read in coarse steps`() { + val now = Instant.parse("2026-10-08T12:00:00Z") + assertEquals("just now", ago(Instant.parse("2026-10-08T11:59:30Z"), now)) + assertEquals("12m", ago(Instant.parse("2026-10-08T11:48:00Z"), now)) + assertEquals("5h", ago(Instant.parse("2026-10-08T07:00:00Z"), now)) + assertEquals("3d", ago(Instant.parse("2026-10-05T12:00:00Z"), now)) + } + + @Test + fun `phone and email ride on the inbox, and email needs to be available`() { + val on = NotificationKindSettingWire("request_completed", false, inbox = true, phone = true, email = true) + val off = on.copy(inbox = false) + assertTrue(channelEnabled(on, NotificationChannel.PHONE, emailAvailable = true)) + assertFalse(channelEnabled(off, NotificationChannel.PHONE, emailAvailable = true)) + assertFalse(channelEnabled(on, NotificationChannel.EMAIL, emailAvailable = false)) + assertTrue(channelEnabled(off, NotificationChannel.INBOX, emailAvailable = false)) + } + + @Test + fun `the email line says why it is off`() { + assertEquals("Email is off: add an email address in your profile", emailUnavailableLine("no_address", false)) + assertEquals("Email is off: SMTP isn't set up on the server", emailUnavailableLine("smtp_not_configured", true)) + assertEquals("Email is off: this server doesn't send email", emailUnavailableLine("smtp_not_configured", false)) + } +}