diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt index 18db4513..65a34df6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -10,6 +10,8 @@ import com.fabledsword.minstrel.cache.CacheIndexer import com.fabledsword.minstrel.cache.mutations.MutationReplayer import com.fabledsword.minstrel.cache.sync.SyncController import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.diagnostics.DiagnosticsReporter +import com.fabledsword.minstrel.diagnostics.DiagnosticsUploader import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.LiveEventsDispatcher import com.fabledsword.minstrel.metadata.FreshnessSweeper @@ -155,6 +157,11 @@ class MinstrelApplication : */ @Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher + // Device diagnostics (M9). The reporter gates itself on the account's + // debug flag; the uploader drains its buffer on a tick / recovery. + @Suppress("unused") @Inject lateinit var diagnosticsReporter: DiagnosticsReporter + @Suppress("unused") @Inject lateinit var diagnosticsUploader: DiagnosticsUploader + @Inject @ApplicationScope lateinit var appScope: CoroutineScope override fun onCreate() { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/DiagnosticsApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/DiagnosticsApi.kt new file mode 100644 index 00000000..b6edd751 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/DiagnosticsApi.kt @@ -0,0 +1,36 @@ +package com.fabledsword.minstrel.api.endpoints + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import retrofit2.http.Body +import retrofit2.http.POST + +/** + * Retrofit interface for device diagnostics ingest (M9). One call + * uploads a batch of buffered events. The server stores them only when + * the account's debug_mode_enabled flag is on (it returns 204 otherwise, + * which the uploader treats as success so it can drop the batch). + */ +interface DiagnosticsApi { + @POST("api/diagnostics") + suspend fun report(@Body body: DiagnosticsReportRequest) +} + +@Serializable +data class DiagnosticsReportRequest( + @SerialName("client_id") val clientId: String, + @SerialName("app_version") val appVersion: String? = null, + @SerialName("os_version") val osVersion: String? = null, + val events: List, +) + +@Serializable +data class DiagnosticEventWire( + val kind: String, + // Device-clock epoch milliseconds. The server stamps its own + // received_at; both are stored so a skewed device clock is visible. + @SerialName("occurred_at") val occurredAt: Long, + // Opaque structured payload carrying the event sub-type + fields. + val payload: JsonElement, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt index 419a815c..54cb281b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt @@ -1,6 +1,7 @@ package com.fabledsword.minstrel.auth import com.fabledsword.minstrel.api.endpoints.AuthApi +import com.fabledsword.minstrel.api.endpoints.MeApi import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.models.UserRef import com.fabledsword.minstrel.models.wire.LoginRequestBody @@ -35,6 +36,7 @@ class AuthController @Inject constructor( retrofit: Retrofit, ) { private val api: AuthApi = retrofit.create() + private val meApi: MeApi = retrofit.create() private val currentUserState = MutableStateFlow(null) val currentUser: StateFlow = currentUserState.asStateFlow() @@ -55,6 +57,10 @@ class AuthController @Inject constructor( currentUserState.value = raw?.let { decodeUser(it) } } } + // Refresh from /api/me on startup so a remotely-changed account + // flag (e.g. admin enabling debug mode, M9) reaches the device + // without a re-login. Best-effort; failures keep the cached value. + scope.launch { refreshProfile() } } /** @@ -72,9 +78,32 @@ class AuthController @Inject constructor( ) currentUserState.value = user authStore.setUserJson(json.encodeToString(UserRef.serializer(), user)) + // Pull the fuller /me shape (carries debug_mode_enabled) right + // after login so the diagnostics gate is correct without waiting + // for the next startup refresh. + scope.launch { refreshProfile() } return user } + /** + * Re-fetch the caller's profile from /api/me and update currentUser + * + persisted userJson. Carries account-level flags (debug mode) + * that aren't in the login response. Best-effort: a network failure + * leaves the cached identity untouched. No-op when signed out. + */ + suspend fun refreshProfile() { + if (!isSignedIn) return + val p = runCatching { meApi.getProfile() }.getOrNull() ?: return + val user = UserRef( + id = p.id, + username = p.username, + isAdmin = p.isAdmin, + debugModeEnabled = p.debugModeEnabled, + ) + currentUserState.value = user + authStore.setUserJson(json.encodeToString(UserRef.serializer(), user)) + } + /** * Clears the local session immediately and best-effort hits * `/api/auth/logout` so the server can drop its session row. diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt index c4f493f8..f4aeabc1 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt @@ -59,6 +59,9 @@ class AuthStore @Inject constructor( private val cacheSettingsState = MutableStateFlow(CacheSettings.DEFAULT) val cacheSettings: StateFlow = cacheSettingsState.asStateFlow() + private val diagnosticsOptOutState = MutableStateFlow(false) + val diagnosticsOptOut: StateFlow = diagnosticsOptOutState.asStateFlow() + private val json = Json { ignoreUnknownKeys = true } init { @@ -70,6 +73,7 @@ class AuthStore @Inject constructor( themeModeState.value = row?.themeMode clientIdState.value = row?.clientId cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson) + diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false } } } @@ -112,6 +116,11 @@ class AuthStore @Inject constructor( scope.launch { persistCacheSettings(encoded) } } + fun setDiagnosticsOptOut(value: Boolean) { + diagnosticsOptOutState.value = value + scope.launch { persistDiagnosticsOptOut(value) } + } + private suspend fun persistCookie(value: String?) { if (dao.get() == null) { dao.upsert(currentEntity().copy(sessionCookie = value)) @@ -160,6 +169,14 @@ class AuthStore @Inject constructor( } } + private suspend fun persistDiagnosticsOptOut(value: Boolean) { + if (dao.get() == null) { + dao.upsert(currentEntity().copy(diagnosticsOptOut = value)) + } else { + dao.setDiagnosticsOptOut(value) + } + } + private fun currentEntity(): AuthSessionEntity = AuthSessionEntity( id = ROW_ID, sessionCookie = sessionCookieState.value, @@ -171,6 +188,7 @@ class AuthStore @Inject constructor( CacheSettings.serializer(), cacheSettingsState.value, ), + diagnosticsOptOut = diagnosticsOptOutState.value, ) companion object { 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 8e2d4d83..5e5c9306 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 @@ -16,6 +16,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao +import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity @@ -30,6 +31,7 @@ import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity +import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity /** @@ -61,8 +63,11 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity CachedHomeIndexEntity::class, CachedHistorySnapshotEntity::class, AuthSessionEntity::class, + DiagnosticEventEntity::class, ], - version = 6, + // v7: + diagnostic_events table (M9) and the diagnosticsOptOut column + // on auth_session. Pre-v1 destructive fallback rebuilds on mismatch. + version = 7, exportSchema = true, ) @TypeConverters(MinstrelTypeConverters::class) @@ -81,4 +86,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun cachedHomeIndexDao(): CachedHomeIndexDao abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao abstract fun authSessionDao(): AuthSessionDao + abstract fun diagnosticEventDao(): DiagnosticEventDao } 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 ece05791..c5c58df4 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.DiagnosticEventDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao @@ -105,5 +106,10 @@ object DatabaseModule { fun provideAudioCacheIndexDao(db: AppDatabase): AudioCacheIndexDao = db.audioCacheIndexDao() + @Provides + @Singleton + fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao = + db.diagnosticEventDao() + private const val DATABASE_NAME = "minstrel.db" } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt index 5bb2ccc9..3df6b01c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt @@ -42,4 +42,8 @@ interface AuthSessionDao { /** Partial update: change only the serialized cache settings. */ @Query("UPDATE auth_session SET cacheSettingsJson = :json WHERE id = 0") suspend fun setCacheSettingsJson(json: String?) + + /** Partial update: change only the per-device diagnostics opt-out. */ + @Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0") + suspend fun setDiagnosticsOptOut(optOut: Boolean) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/DiagnosticEventDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/DiagnosticEventDao.kt new file mode 100644 index 00000000..2361fc48 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/DiagnosticEventDao.kt @@ -0,0 +1,35 @@ +package com.fabledsword.minstrel.cache.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity + +@Dao +interface DiagnosticEventDao { + @Insert + suspend fun insert(row: DiagnosticEventEntity): Long + + /** FIFO drain order so the uploader sends oldest-first. */ + @Query("SELECT * FROM diagnostic_events ORDER BY id ASC LIMIT :limit") + suspend fun takeBatch(limit: Int): List + + @Query("SELECT COUNT(*) FROM diagnostic_events") + suspend fun count(): Int + + @Query("DELETE FROM diagnostic_events WHERE id IN (:ids)") + suspend fun deleteByIds(ids: List) + + /** + * Ring-buffer trim: drop the oldest rows beyond [keep]. Called after + * insert so a long offline stretch can't grow the buffer unbounded. + */ + @Query( + "DELETE FROM diagnostic_events WHERE id NOT IN " + + "(SELECT id FROM diagnostic_events ORDER BY id DESC LIMIT :keep)", + ) + suspend fun trimToNewest(keep: Int) + + @Query("DELETE FROM diagnostic_events") + suspend fun clear() +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt index c36cfa34..6b8456c9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt @@ -36,4 +36,11 @@ data class AuthSessionEntity( * CacheSettings shape evolves. */ val cacheSettingsJson: String? = null, + /** + * Per-device opt-out of diagnostics reporting (M9). When the + * account's debug mode is enabled by an admin, the user can still + * turn reporting OFF on this device (battery/privacy) — that local + * choice lives here. Default false = honor the account flag. + */ + val diagnosticsOptOut: Boolean = false, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/DiagnosticEventEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/DiagnosticEventEntity.kt new file mode 100644 index 00000000..7e5f2868 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/DiagnosticEventEntity.kt @@ -0,0 +1,28 @@ +package com.fabledsword.minstrel.cache.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * One buffered device-diagnostics event (M9). The DiagnosticsReporter + * writes rows here when the account's debug mode is on; the + * DiagnosticsUploader drains them to POST /api/diagnostics and deletes + * on success. + * + * This is a deliberate ring buffer that is NOT routed through the offline + * MutationQueue: diagnostics are high-volume, best-effort telemetry, and + * the bug we're chasing (roaming / dead-zone recovery) happens WHILE + * offline — so events must persist locally through the dead zone and + * upload on recovery, without clogging the user-data mutation replay. + * + * `occurredAtMillis` is the device-clock epoch-ms when the event happened + * (the server also stamps its own received_at). `payloadJson` is an + * opaque JSON object carrying the event sub-type + fields. + */ +@Entity(tableName = "diagnostic_events") +data class DiagnosticEventEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val kind: String, + val occurredAtMillis: Long, + val payloadJson: String, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/diagnostics/DiagnosticsReporter.kt b/android/app/src/main/java/com/fabledsword/minstrel/diagnostics/DiagnosticsReporter.kt new file mode 100644 index 00000000..bb743ff7 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/diagnostics/DiagnosticsReporter.kt @@ -0,0 +1,284 @@ +package com.fabledsword.minstrel.diagnostics + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.PowerManager +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.core.content.ContextCompat +import androidx.lifecycle.ProcessLifecycleOwner +import com.fabledsword.minstrel.auth.AuthController +import com.fabledsword.minstrel.auth.AuthStore +import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao +import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity +import com.fabledsword.minstrel.connectivity.ConnectivityObserver +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.player.RemotePlayerState +import com.fabledsword.minstrel.player.output.OutputPickerController +import com.fabledsword.minstrel.player.output.OutputRoute +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import javax.inject.Inject +import javax.inject.Singleton + +/** + * On-device diagnostics collector (M9). Active only while the account's + * debug mode is enabled (server flag, read via AuthController.currentUser) + * AND the user hasn't opted out locally. When active it taps existing + * connectivity / player / UPnP / power seams READ-ONLY and writes a + * timeseries of events into the [DiagnosticEventDao] ring buffer; the + * [DiagnosticsUploader] drains that buffer to the server. + * + * The aim is to explain two failure classes after the fact: roaming / + * poor-data recovery (connectivity + server-health transitions) and + * locked-phone UPnP desync (power/Doze transitions correlated with the + * Sonos-vs-local position deltas captured in the heartbeat). + */ +@Suppress("TooManyFunctions") // one collector + one (un)register pair per signal; cohesive +@Singleton +class DiagnosticsReporter @Inject constructor( + @ApplicationContext private val context: Context, + @ApplicationScope private val scope: CoroutineScope, + private val dao: DiagnosticEventDao, + private val authController: AuthController, + private val authStore: AuthStore, + private val connectivity: ConnectivityObserver, + private val networkStatus: NetworkStatusController, + private val playerController: PlayerController, + private val outputPicker: OutputPickerController, + private val remoteState: RemotePlayerState, + private val json: Json, +) { + @Volatile private var enabled = false + private var collectorJob: Job? = null + private var powerReceiver: BroadcastReceiver? = null + private var lifecycleObserver: DefaultLifecycleObserver? = null + + init { + // Gate = account flag (currentUser.debugModeEnabled) AND not the + // per-device local opt-out. + scope.launch { + combine(authController.currentUser, authStore.diagnosticsOptOut) { user, optOut -> + (user?.debugModeEnabled == true) && !optOut + }.distinctUntilChanged().collect { setEnabled(it) } + } + // Periodically re-pull /me so a remote admin flip propagates + // without a re-login or app restart. + scope.launch { + while (true) { + delay(GATE_REFRESH_MS) + authController.refreshProfile() + } + } + } + + private fun setEnabled(value: Boolean) { + if (value == enabled) return + enabled = value + if (value) start() else stop() + } + + private fun start() { + registerPowerReceiver() + registerLifecycleObserver() + collectorJob = scope.launch { + record("lifecycle", buildJsonObject { put("event", "reporting_started") }) + launch { collectConnectivity() } + launch { collectServerHealth() } + launch { collectUpnpDrops() } + launch { collectPlayerState() } + launch { collectRoutes() } + launch { heartbeatLoop() } + } + } + + private fun stop() { + collectorJob?.cancel() + collectorJob = null + unregisterPowerReceiver() + unregisterLifecycleObserver() + // Off means off: drop any unsent buffer (honors the user OFF + // intent and avoids leaking data captured before disable). + scope.launch { runCatching { dao.clear() } } + } + + private suspend fun collectConnectivity() { + connectivity.online.distinctUntilChanged().collect { online -> + record("connectivity", buildJsonObject { + put("event", "online_changed") + put("online", online) + }) + } + } + + private suspend fun collectServerHealth() { + networkStatus.state.distinctUntilChanged().collect { s -> + record("connectivity", buildJsonObject { + put("event", "server_health") + put("state", s.name) + }) + } + } + + private suspend fun collectUpnpDrops() { + playerController.dropEvents.collect { msg -> + record("upnp_sync", buildJsonObject { + put("event", "drop") + put("message", msg) + }) + } + } + + private suspend fun collectPlayerState() { + playerController.uiState + .map { Triple(it.currentSource, it.isUpnpLoading, it.playbackError) } + .distinctUntilChanged() + .collect { (source, upnpLoading, err) -> + record("upnp_sync", buildJsonObject { + put("event", "player_state") + put("source", source ?: "") + put("upnp_loading", upnpLoading) + if (err != null) put("error", err) + }) + } + } + + private suspend fun collectRoutes() { + outputPicker.routesState.map { it.current }.distinctUntilChanged().collect { r -> + record("upnp_sync", buildJsonObject { + put("event", "route") + put("id", r.id) + put("name", r.name) + put("kind", r.kind.name) + put("protocol", r.protocol.name) + put("connected", r.isConnected) + }) + } + } + + private suspend fun heartbeatLoop() { + while (true) { + delay(HEARTBEAT_MS) + val ui = playerController.uiState.value + val route = outputPicker.routesState.value.current + val routeActive = route.protocol != OutputRoute.Protocol.SYSTEM + // Idle = nothing worth sampling. Skip to keep the buffer lean. + if (!ui.isPlaying && !routeActive) continue + record("heartbeat", buildJsonObject { + put("server_health", networkStatus.state.value.name) + put("is_playing", ui.isPlaying) + put("source", ui.currentSource ?: "") + put("local_index", ui.queueIndex) + put("local_pos_ms", ui.positionMs) + put("route", route.name) + put("route_protocol", route.protocol.name) + // UPnP/Sonos remote-vs-local — the desync signal. + put("sonos_track", remoteState.trackNumber) + put("sonos_pos_ms", remoteState.positionMs) + put("sonos_playing", remoteState.isPlaying) + addPowerFields(this) + }) + } + } + + // --- power + lifecycle taps ------------------------------------------- + + private fun registerPowerReceiver() { + if (powerReceiver != null) return + val receiver = object : BroadcastReceiver() { + override fun onReceive(c: Context?, intent: Intent?) { + val action = intent?.action ?: return + if (!enabled) return + scope.launch { record("power", powerEvent(action)) } + } + } + val filter = IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED) + } + // System-protected broadcasts, but pass NOT_EXPORTED explicitly so + // the API 34+ registerReceiver flag requirement is satisfied. + ContextCompat.registerReceiver( + context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED, + ) + powerReceiver = receiver + } + + private fun unregisterPowerReceiver() { + powerReceiver?.let { runCatching { context.unregisterReceiver(it) } } + powerReceiver = null + } + + private fun registerLifecycleObserver() { + if (lifecycleObserver != null) return + val obs = object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) = logLifecycle("app_foreground") + override fun onStop(owner: LifecycleOwner) = logLifecycle("app_background") + } + lifecycleObserver = obs + scope.launch(Dispatchers.Main) { + ProcessLifecycleOwner.get().lifecycle.addObserver(obs) + } + } + + private fun unregisterLifecycleObserver() { + val obs = lifecycleObserver ?: return + lifecycleObserver = null + scope.launch(Dispatchers.Main) { + ProcessLifecycleOwner.get().lifecycle.removeObserver(obs) + } + } + + private fun logLifecycle(event: String) { + if (!enabled) return + scope.launch { record("lifecycle", buildJsonObject { put("event", event) }) } + } + + private fun powerEvent(action: String): JsonObject = buildJsonObject { + put("event", action.substringAfterLast('.')) + addPowerFields(this) + } + + private fun addPowerFields(builder: kotlinx.serialization.json.JsonObjectBuilder) { + val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager + builder.put("doze", pm.isDeviceIdleMode) + builder.put("screen_on", pm.isInteractive) + builder.put("battery_opt_ignored", pm.isIgnoringBatteryOptimizations(context.packageName)) + } + + private suspend fun record(kind: String, payload: JsonObject) { + runCatching { + val id = dao.insert( + DiagnosticEventEntity( + kind = kind, + occurredAtMillis = System.currentTimeMillis(), + payloadJson = json.encodeToString(JsonObject.serializer(), payload), + ), + ) + if (id % TRIM_INTERVAL == 0L) dao.trimToNewest(MAX_BUFFER) + } + } + + private companion object { + const val HEARTBEAT_MS = 45_000L + const val GATE_REFRESH_MS = 300_000L // re-pull /me every 5 min + const val MAX_BUFFER = 5_000 // ring-buffer ceiling, oldest dropped + const val TRIM_INTERVAL = 50L // trim every N inserts, not every one + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/diagnostics/DiagnosticsUploader.kt b/android/app/src/main/java/com/fabledsword/minstrel/diagnostics/DiagnosticsUploader.kt new file mode 100644 index 00000000..6ed816b2 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/diagnostics/DiagnosticsUploader.kt @@ -0,0 +1,115 @@ +package com.fabledsword.minstrel.diagnostics + +import android.os.Build +import com.fabledsword.minstrel.BuildConfig +import com.fabledsword.minstrel.api.endpoints.DiagnosticEventWire +import com.fabledsword.minstrel.api.endpoints.DiagnosticsApi +import com.fabledsword.minstrel.api.endpoints.DiagnosticsReportRequest +import com.fabledsword.minstrel.auth.AuthStore +import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao +import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.connectivity.ServerHealth +import com.fabledsword.minstrel.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.serialization.json.Json +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Drains the [DiagnosticEventDao] ring buffer to POST /api/diagnostics + * (M9). Triggers mirror [com.fabledsword.minstrel.cache.mutations.MutationReplayer]: + * a periodic tick, a sign-in (sessionCookie non-null), and a server-health + * recovery to [ServerHealth.Healthy] — so a dead-zone's buffered events + * upload the moment the link comes back. + * + * Deliberately NOT the MutationQueue: diagnostics are high-volume, + * best-effort telemetry. A failed upload just retries on the next tick; + * the server no-ops (204) when the account's debug flag is off, which the + * uploader treats as success so the buffer drains either way. + */ +@Singleton +class DiagnosticsUploader @Inject constructor( + private val dao: DiagnosticEventDao, + private val authStore: AuthStore, + private val json: Json, + @ApplicationScope private val scope: CoroutineScope, + networkStatus: NetworkStatusController, + retrofit: Retrofit, +) { + private val api: DiagnosticsApi = retrofit.create() + private val mutex = Mutex() + + init { + scope.launch { + while (true) { + delay(UPLOAD_INTERVAL_MS) + drainSafe() + } + } + scope.launch { + authStore.sessionCookie.filterNotNull().distinctUntilChanged().collect { drainSafe() } + } + scope.launch { + networkStatus.state + .map { it == ServerHealth.Healthy } + .distinctUntilChanged() + .filter { it } + .collect { drainSafe() } + } + } + + /** Coalesces concurrent triggers via tryLock — a drain in flight wins. */ + private suspend fun drainSafe() { + if (authStore.sessionCookie.value.isNullOrEmpty()) return + val clientId = authStore.clientId.value ?: return + if (!mutex.tryLock()) return + try { + drain(clientId) + } finally { + mutex.unlock() + } + } + + private suspend fun drain(clientId: String) { + while (true) { + val batch = runCatching { dao.takeBatch(BATCH_SIZE) }.getOrNull().orEmpty() + if (batch.isEmpty()) return + val sent = runCatching { upload(clientId, batch) }.isSuccess + if (!sent) return // leave rows for the next trigger + runCatching { dao.deleteByIds(batch.map { it.id }) } + if (batch.size < BATCH_SIZE) return + } + } + + private suspend fun upload(clientId: String, batch: List) { + api.report( + DiagnosticsReportRequest( + clientId = clientId, + appVersion = BuildConfig.VERSION_NAME, + osVersion = "Android ${Build.VERSION.RELEASE} (${Build.MODEL})", + events = batch.map { row -> + DiagnosticEventWire( + kind = row.kind, + occurredAt = row.occurredAtMillis, + payload = json.parseToJsonElement(row.payloadJson), + ) + }, + ), + ) + } + + private companion object { + const val UPLOAD_INTERVAL_MS = 60_000L + const val BATCH_SIZE = 100 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/UserRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/UserRef.kt index 35572695..e527451c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/UserRef.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/UserRef.kt @@ -16,4 +16,11 @@ data class UserRef( val id: String, val username: String, val isAdmin: Boolean = false, + /** + * Account-level diagnostics opt-in (M9). NOT present in the login + * response (that's the narrow UserView) — populated by the /api/me + * refresh in AuthController so a remote admin flip reaches the + * device. Default false until the first refresh. + */ + val debugModeEnabled: Boolean = false, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/MyProfileWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/MyProfileWire.kt index 7c811efc..0086bb3e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/MyProfileWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/MyProfileWire.kt @@ -18,4 +18,5 @@ data class MyProfileWire( @SerialName("display_name") val displayName: String? = null, val email: String? = null, @SerialName("is_admin") val isAdmin: Boolean = false, + @SerialName("debug_mode_enabled") val debugModeEnabled: Boolean = false, ) 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 943b723e..72e9fb91 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 @@ -28,6 +28,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -94,6 +95,7 @@ fun SettingsScreen( onPickTheme = themeVm::setThemeMode, onNavToRequests = { navController.navigate(Requests) }, onNavToAdmin = { navController.navigate(Admin) }, + onToggleDiagnostics = viewModel::setDiagnosticsOptOut, onSignOutClick = { showSignOutConfirm = true }, ) } @@ -116,6 +118,7 @@ private fun SettingsList( onPickTheme: (ThemeMode) -> Unit, onNavToRequests: () -> Unit, onNavToAdmin: () -> Unit, + onToggleDiagnostics: (Boolean) -> Unit, onSignOutClick: () -> Unit, ) { Column( @@ -148,6 +151,12 @@ private fun SettingsList( ProfileCard() PasswordCard() ListenBrainzCard() + if (state.diagnosticsEnabledByAdmin) { + DiagnosticsCard( + optOut = state.diagnosticsOptOut, + onToggle = onToggleDiagnostics, + ) + } AppearanceCard(themeMode = themeMode, onPick = onPickTheme) StorageCard() AboutCard() @@ -262,6 +271,46 @@ private fun AccountCard(username: String, isAdmin: Boolean, serverUrl: String) { } } +// Shown only while an admin has enabled debug mode on this account. Lets +// the user turn reporting OFF on this device (battery/privacy) without +// touching the account flag. Checked = reporting on = NOT opted out. +@Composable +private fun DiagnosticsCard(optOut: Boolean, onToggle: (Boolean) -> Unit) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Diagnostics", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Send diagnostic reports", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Your admin enabled debug mode to investigate an " + + "issue. This device streams connectivity and playback " + + "diagnostics. Turn off any time.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.size(12.dp)) + Switch( + checked = !optOut, + onCheckedChange = { checked -> onToggle(!checked) }, + ) + } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun AppearanceCard(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) { 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 ebe2f563..256ad581 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 @@ -19,12 +19,15 @@ data class SettingsState( val isAdmin: Boolean = false, val isSigningOut: Boolean = false, val signedOut: Boolean = false, + // M9: account debug flag (admin-set) + this device's local opt-out. + val diagnosticsEnabledByAdmin: Boolean = false, + val diagnosticsOptOut: Boolean = false, ) @HiltViewModel class SettingsViewModel @Inject constructor( private val authController: AuthController, - authStore: AuthStore, + private val authStore: AuthStore, ) : ViewModel() { private val transient = MutableStateFlow(TransientState()) @@ -41,19 +44,27 @@ class SettingsViewModel @Inject constructor( combine( authStore.baseUrl, authController.currentUser, + authStore.diagnosticsOptOut, transient, - ) { url, user, t -> + ) { url, user, optOut, t -> SettingsState( serverUrl = url, username = user?.username.orEmpty(), isAdmin = user?.isAdmin == true, isSigningOut = t.isSigningOut, signedOut = t.signedOut, + diagnosticsEnabledByAdmin = user?.debugModeEnabled == true, + diagnosticsOptOut = optOut, ) }.collect { internal.value = it } } } + /** Per-device opt-out of diagnostics while the account flag is on. */ + fun setDiagnosticsOptOut(optOut: Boolean) { + authStore.setDiagnosticsOptOut(optOut) + } + fun signOut() { if (transient.value.isSigningOut) return viewModelScope.launch { diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 9533a9e9..8e4d775a 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -391,6 +391,7 @@ export type AdminUser = { display_name: string | null; is_admin: boolean; auto_approve_requests: boolean; + debug_mode_enabled: boolean; created_at: string; }; @@ -451,6 +452,12 @@ export async function updateUserAutoApprove(id: string, autoApprove: boolean): P return api.put(`/api/admin/users/${id}/auto-approve`, { auto_approve: autoApprove }); } +// Flip an account's diagnostics/debug-reporting opt-in (M9). Admin-set; +// the client obeys it (with a local per-device OFF switch). +export async function updateUserDebugMode(id: string, enabled: boolean): Promise { + return api.put(`/api/admin/users/${id}/debug-mode`, { enabled }); +} + export function createAdminUsersQuery() { return createQuery({ queryKey: qk.adminUsers(), @@ -497,3 +504,82 @@ export function createSMTPConfigQuery() { staleTime: 60_000 }); } + +// Device diagnostics (M9) --------------------------------------------------- + +// Coarse event category. The finer event sub-type lives inside `payload`. +export type DiagnosticKind = + | 'connectivity' + | 'upnp_sync' + | 'power' + | 'lifecycle' + | 'heartbeat' + | 'http'; + +export type AdminDiagnostic = { + id: string; + user_id: string; + username: string; + client_id: string; + app_version?: string; + os_version?: string; + kind: DiagnosticKind | string; + payload: Record; + occurred_at: string; + received_at: string; +}; + +export type AdminDiagnosticDevice = { + client_id: string; + user_id: string; + username: string; + app_version: string; + os_version: string; + last_seen: string; + event_count: number; +}; + +export type DiagnosticsFilter = { + userId?: string; + clientId?: string; + kind?: string; + from?: string; // RFC3339 + to?: string; // RFC3339 + limit?: number; +}; + +export async function listAdminDiagnostics(f: DiagnosticsFilter): Promise { + const params = new URLSearchParams(); + if (f.userId) params.set('user_id', f.userId); + if (f.clientId) params.set('client_id', f.clientId); + if (f.kind) params.set('kind', f.kind); + if (f.from) params.set('from', f.from); + if (f.to) params.set('to', f.to); + if (f.limit !== undefined) params.set('limit', String(f.limit)); + const qs = params.toString(); + return api.get(qs ? `/api/admin/diagnostics?${qs}` : '/api/admin/diagnostics'); +} + +export async function listDiagnosticDevices(userId?: string): Promise { + const qs = userId ? `?user_id=${userId}` : ''; + return api.get(`/api/admin/diagnostics/devices${qs}`); +} + +export function createAdminDiagnosticsQuery(f: DiagnosticsFilter) { + return createQuery({ + queryKey: qk.adminDiagnostics(f as Record), + queryFn: () => listAdminDiagnostics(f), + // The operator enables debug then watches events stream in; a short + // poll keeps the timeline live without manual refresh. + refetchInterval: 10_000, + staleTime: 5_000 + }); +} + +export function createDiagnosticDevicesQuery(userId?: string) { + return createQuery({ + queryKey: qk.adminDiagnosticDevices(userId), + queryFn: () => listDiagnosticDevices(userId), + staleTime: 15_000 + }); +} diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 71f444f4..d12397e1 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -50,6 +50,10 @@ export const qk = { coverProviders: () => ['coverProviders'] as const, adminUsers: () => ['adminUsers'] as const, adminInvites: () => ['adminInvites'] as const, + adminDiagnostics: (f: Record) => + ['adminDiagnostics', f] as const, + adminDiagnosticDevices: (userId?: string) => + ['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const, smtpConfig: () => ['smtpConfig'] as const, suggestions: (limit?: number) => ['suggestions', { limit: limit ?? 12 }] as const, diff --git a/web/src/lib/components/AdminTabs.svelte b/web/src/lib/components/AdminTabs.svelte index 78ad4cc6..f7acbabd 100644 --- a/web/src/lib/components/AdminTabs.svelte +++ b/web/src/lib/components/AdminTabs.svelte @@ -9,6 +9,7 @@ { href: '/admin/requests', label: 'Requests' }, { href: '/admin/quarantine', label: 'Quarantine' }, { href: '/admin/playback-errors', label: 'Playback errors' }, + { href: '/admin/diagnostics', label: 'Diagnostics' }, { href: '/admin/users', label: 'Users' } ]; diff --git a/web/src/routes/admin/diagnostics/+page.svelte b/web/src/routes/admin/diagnostics/+page.svelte new file mode 100644 index 00000000..f6fb0e34 --- /dev/null +++ b/web/src/routes/admin/diagnostics/+page.svelte @@ -0,0 +1,302 @@ + + +{pageTitle('Admin · Diagnostics')} + +
+
+

Device diagnostics

+

+ Enable debug mode on an account to have its device(s) stream a timeseries + of connectivity, UPnP-sync, and power/Doze events here. Filter to the + window you care about, then copy or download the slice for analysis. +

+
+ + +
+
+ + + {#if selectedUser} + + + + {selectedUser.debug_mode_enabled ? 'Reporting active' : 'Reporting off'} + + {/if} +
+
+ + +
+ + + + + + +
+ + +
+
+ + + {#if diagQuery.isError} +

Couldn't load: {errMessage(diagQuery.error)}

+ {:else if diagQuery.isPending} +

Loading…

+ {:else if rows.length === 0} +

+ No events for this filter. Enable debug mode on an account and have the + device reproduce the issue — events appear here within a minute. +

+ {:else} +
+ {rows.length} events (oldest first) +
+
    + {#each rows as r (r.id)} +
  • + + {fmtTime(r.occurred_at)} + + + {kindLabel(r.kind)} + +
    + + {payloadPreview(r.payload)} + +
    {JSON.stringify(r.payload, null, 2)}
    +
    +
  • + {/each} +
+ {/if} +