feat(android): notifications bell, inbox screen and settings, offline-first (#5343, #5345)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m11s
release / go (push) Successful in 1m29s
release / android (push) Failing after 1m41s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 2m49s
release / Build signed APK (releases and dev) (push) Canceled after 2m50s

- The top bar carries a bell with a badge. The badge is inverse, not
  error red, and counts to 9, then shows 9+. It opens the Notifications
  screen: one line per notice, how long ago, "Mark all read", pull to
  refresh. A tap marks the notice read and opens its screen (albums,
  artists, requests, admin requests and quarantine; other admin pages
  land on Admin).
- The newest 50 notices live in Room (cached_notifications, v11 with
  MIGRATION_10_11), so the badge and the list work offline. A refresh
  keeps a read made here that the server hasn't seen yet.
- Reads, read-all and setting toggles go through the MutationQueue
  (rule 100): NOTIFICATION_READ, NOTIFICATIONS_READ_ALL and
  NOTIFICATION_SETTING_SET.
  - Read-all sends the newest notice shown, rounded up a millisecond
    (Room keeps ms, the server µs), so a late replay leaves newer notices
    unread.
  - Settings collapse per kind and channel.
- The `notification.created` live event, a return to the foreground and
  reconnecting all refresh the inbox.
- Settings → Notifications: a row per kind with Inbox, Phone and Email.
  Admin kinds sit under "Library health". Phone and email ride on the
  inbox. One line says why email is off. If the system blocks
  notifications, a row opens Minstrel's notification settings; it is
  re-checked on resume.
- Signing out clears the cached inbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 07:35:56 -04:00
co-authored by Claude Opus 5.5
parent f11dba2336
commit c416157a1b
26 changed files with 1521 additions and 2 deletions
@@ -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<NotificationWire>,
@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<NotificationKindSettingWire>,
@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<NotificationSettingChangeWire>)
@@ -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`))",
)
}
}
@@ -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"
}
@@ -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<List<CachedNotificationEntity>>
@Query("SELECT COUNT(*) FROM cached_notifications WHERE readAt IS NULL")
fun observeUnreadCount(): Flow<Int>
@Query("SELECT * FROM cached_notifications")
suspend fun getAll(): List<CachedNotificationEntity>
@Query("DELETE FROM cached_notifications")
suspend fun clear()
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(rows: List<CachedNotificationEntity>)
/** The newest page replaces the cache whole: a notice gone server-side goes here too. */
@Transaction
suspend fun replaceAll(rows: List<CachedNotificationEntity>) {
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<CachedNotificationSettingsEntity?>
@Query("SELECT * FROM cached_notification_settings WHERE id = 1")
suspend fun getSettings(): CachedNotificationSettingsEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertSettings(row: CachedNotificationSettingsEntity)
}
@@ -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?,
)
@@ -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
}
}
@@ -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
}
@@ -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)
@@ -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() {
@@ -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<Notifications> {
WithAnimatedScope {
ShellScaffold(onExpandPlayer = expandPlayer) {
NotificationsScreen(navController = navController)
}
}
}
composable<NotificationSettings> {
WithAnimatedScope {
ShellScaffold(onExpandPlayer = expandPlayer) {
NotificationSettingsScreen(navController = navController)
}
}
}
}
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
composable<NowPlaying>(
// Slide up from the bottom on enter; back down on dismiss.
@@ -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 ────────────────────────
@@ -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<NotificationSettingsWire?> = 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)
}
}
},
)
@@ -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<List<CachedNotificationEntity>> = dao.observeAll().distinctUntilChanged()
/** Unread notices in the cached page, which is what the badge shows. */
val unreadCount: Flow<Int> = 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>): 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,
)
}
@@ -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
}
}
@@ -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"
}
@@ -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<NotificationSettingsWire?> = repository.settings
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), null)
val isAdmin: StateFlow<Boolean> = 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) }
}
}
@@ -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"
}
}
@@ -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<List<CachedNotificationEntity>?> = repository.notifications
.map<List<CachedNotificationEntity>, List<CachedNotificationEntity>?> { 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() }
}
}
@@ -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,
@@ -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) }
}
}
@@ -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<Boolean> = 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<Int> = notifications.unreadCount
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = 0,
)
}
@@ -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) }
@@ -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)))
}
}
@@ -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<CachedNotificationSettingsEntity>()
coEvery { dao.upsertSettings(capture(saved)) } returns Unit
val body = slot<PutNotificationSettingsBody>()
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()) }
}
}
@@ -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<Unit>(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<ReadAllBody>()
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<List<CachedNotificationEntity>>()
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()))
}
}
@@ -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))
}
}