feat(android+web/diagnostics): on-device debug reporter + admin timeline (M9)
Android: a gated DiagnosticsReporter taps connectivity, server-health, UPnP drops/player-state/route, power (Doze/battery-opt/screen), and app fg/bg, plus a heartbeat snapshotting Sonos-vs-local position — the locked-phone desync signal. Events buffer in a Room ring buffer (deliberately NOT the MutationQueue: high-volume best-effort telemetry that must survive the dead zone being debugged) and DiagnosticsUploader drains them on a tick / health-recovery / sign-in. Gating: the account flag (users.debug_mode_enabled) reaches the device via a new /api/me refresh in AuthController; a per-device local OFF switch lives in Settings. Reporter runs only when enabled && !optOut; disabling drops the unsent buffer. Web admin: /admin/diagnostics — pick account+device+kind+time-window, see a chronological timeline, flip an account's debug mode remotely, and Copy-JSON / Download-NDJSON the slice for analysis. Room schema 6→7 (new diagnostic_events table + auth_session.diagnosticsOptOut; pre-v1 destructive fallback). Refs Scribe M9 (#119), tasks #1174 #1175 #1176 #1177. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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<DiagnosticEventWire>,
|
||||
)
|
||||
|
||||
@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,
|
||||
)
|
||||
@@ -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<UserRef?>(null)
|
||||
val currentUser: StateFlow<UserRef?> = 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.
|
||||
|
||||
@@ -59,6 +59,9 @@ class AuthStore @Inject constructor(
|
||||
private val cacheSettingsState = MutableStateFlow(CacheSettings.DEFAULT)
|
||||
val cacheSettings: StateFlow<CacheSettings> = cacheSettingsState.asStateFlow()
|
||||
|
||||
private val diagnosticsOptOutState = MutableStateFlow(false)
|
||||
val diagnosticsOptOut: StateFlow<Boolean> = 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+4
@@ -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)
|
||||
}
|
||||
|
||||
+35
@@ -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<DiagnosticEventEntity>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM diagnostic_events")
|
||||
suspend fun count(): Int
|
||||
|
||||
@Query("DELETE FROM diagnostic_events WHERE id IN (:ids)")
|
||||
suspend fun deleteByIds(ids: List<Long>)
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
+7
@@ -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,
|
||||
)
|
||||
|
||||
Vendored
+28
@@ -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,
|
||||
)
|
||||
+284
@@ -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
|
||||
}
|
||||
}
|
||||
+115
@@ -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<DiagnosticEventEntity>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+13
-2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user