fix(android): detekt — no four-way destructuring, single-track loader within the function cap (#6451)

The library-duration fallback rides on the poll outcome instead of being its
own member, and the adopt path reads the target's fields by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 07:46:01 -04:00
co-authored by Claude Opus 5.5
parent b1e55adc14
commit de41169ccb
7 changed files with 18 additions and 473 deletions
@@ -747,7 +747,7 @@ class MinstrelForwardingPlayer(
* the library's duration, so the scrubber has an end to run to.
*/
private fun durationFor(info: PositionInfo, single: SingleTrackLoader.PollOutcome?): Long =
if (single != null && info.trackDurationMs <= 0L) singleTrack.knownDurationMs() else info.trackDurationMs
if (single != null && info.trackDurationMs <= 0L) single.knownDurationMs else info.trackDurationMs
/**
* Ask the watchdog what to make of this poll, and act on its answer.
@@ -679,11 +679,11 @@ class OutputPickerController @Inject constructor(
localQueue: List<TrackRef>,
localIndex: Int,
): Boolean {
val (transport, rendering, route, queueMode) = target
val route = target.route
val plan = probeAdoptable(target, localQueue, localIndex) ?: return false
if (queueMode == QueueMode.SINGLE_TRACK) {
if (target.queueMode == QueueMode.SINGLE_TRACK) {
// The renderer holds only this track; arm the one after it.
singleTrack.adopt(transport, localQueue, plan.localIndex, plan.trackUri)
singleTrack.adopt(target.transport, localQueue, plan.localIndex, plan.trackUri)
}
Timber.w(
"UPnP adopt: attaching to live session on %s at track %d (%dms)",
@@ -704,9 +704,9 @@ class OutputPickerController @Inject constructor(
ActiveUpnp(
routeId = route.id,
routeName = route.name,
avTransport = transport,
rendering = rendering,
queueMode = queueMode,
avTransport = target.transport,
rendering = target.rendering,
queueMode = target.queueMode,
),
)
return true
@@ -70,8 +70,10 @@ class SingleTrackLoader @Inject constructor(
* renderer is playing (0 when nothing is loaded). [advanced] is true when
* this poll started the next track itself, so the reading it was given is
* already stale and the caller should not judge a stall from it.
* [knownDurationMs] is the library's duration for that track, for a
* renderer that reports none.
*/
data class PollOutcome(val trackNumber: Int, val advanced: Boolean)
data class PollOutcome(val trackNumber: Int, val advanced: Boolean, val knownDurationMs: Long = 0L)
/** Take [queue] as the one to play, and start [index] on the renderer. */
suspend fun load(transport: AVTransportClient, queue: List<TrackRef>, index: Int) =
@@ -137,9 +139,8 @@ class SingleTrackLoader @Inject constructor(
if (currentIndex !in queue.indices) return@withLock PollOutcome(0, advanced = false)
val moved = followRenderer(trackUri)
armNext(transport)
if (moved) return@withLock PollOutcome(currentIndex + 1, advanced = false)
val known = queue[currentIndex].durationSec * MILLIS_PER_SECOND
endedOnLastPoll = endDetector.onPoll(state, positionMs, durationMs, known)
if (moved) return@withLock PollOutcome(currentIndex + 1, advanced = false, knownDurationMs(queue, currentIndex))
endedOnLastPoll = endDetector.onPoll(state, positionMs, durationMs, knownDurationMs(queue, currentIndex))
val next = currentIndex + 1
val advance = endedOnLastPoll && playIntent && next in queue.indices
if (advance) {
@@ -147,7 +148,7 @@ class SingleTrackLoader @Inject constructor(
runCatching { start(transport, next) }
.onFailure { Timber.w(it, "UPnP single-track: could not start track %d", next + 1) }
}
PollOutcome(currentIndex + 1, advanced = advance)
PollOutcome(currentIndex + 1, advanced = advance, knownDurationMs(queue, currentIndex))
}
/**
@@ -162,10 +163,6 @@ class SingleTrackLoader @Inject constructor(
RemoteStallWatchdog.QueueState.UNKNOWN
}
/** The library's duration for the current track, for a renderer that reports none. */
fun knownDurationMs(): Long =
queue.getOrNull(currentIndex)?.durationSec?.times(MILLIS_PER_SECOND) ?: 0L
private suspend fun start(transport: AVTransportClient, index: Int): Boolean {
if (index !in queue.indices) return false
val token = mint(index)
@@ -240,11 +237,14 @@ class SingleTrackLoader @Inject constructor(
)
private companion object {
const val MILLIS_PER_SECOND = 1_000L
const val ARM_RETRY_POLLS = 10
}
}
/** The library's duration of [queue]'s track at [index], or 0 when there is none. */
private fun knownDurationMs(queue: List<TrackRef>, index: Int): Long =
queue.getOrNull(index)?.durationSec?.times(MILLIS_PER_SECOND) ?: 0L
/**
* Index in [queue] of the track with [id], taking the occurrence nearest
* [preferNear] so a track queued twice does not send the cursor across the
@@ -263,3 +263,4 @@ internal fun streamUriTrackId(uri: String): String? {
}
private const val TRACKS_PATH_MARKER = "/api/tracks/"
private const val MILLIS_PER_SECOND = 1_000L
@@ -1,137 +0,0 @@
package com.fabledsword.minstrel.settings.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.update.data.ApkInstaller
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.UpdateRepository
import com.fabledsword.minstrel.update.data.isBusy
import com.fabledsword.minstrel.update.data.isUpdateAvailable
import com.fabledsword.minstrel.update.data.message
import com.fabledsword.minstrel.update.data.stage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
/**
* One of three terminal states the Check-for-updates button surfaces.
* `Idle` is the pre-check state; `Latest` means the installed build
* matches or exceeds the server's bundled APK; `UpdateAvailable`
* surfaces an "Install vX.Y.Z" button that downloads the APK and
* installs it via [ApkInstaller].
*/
sealed interface UpdateCheckResult {
data object Idle : UpdateCheckResult
data object Latest : UpdateCheckResult
data class UpdateAvailable(val info: UpdateInfo) : UpdateCheckResult
data class Error(val message: String) : UpdateCheckResult
}
data class AboutUiState(
val installedVersion: String = BuildConfig.VERSION_NAME,
// The value the platform installs by, and therefore the one the update
// check must decide on. Held in state rather than read inline so a test
// can drive the comparison without a BuildConfig.
val installedCode: Long = BuildConfig.VERSION_CODE.toLong(),
// A debug build is signed with this machine's debug key, so the server's
// release-signed APK can never install over it. It updates from Android
// Studio instead.
val selfUpdates: Boolean = !BuildConfig.DEBUG,
val isChecking: Boolean = false,
val installStage: InstallStage = InstallStage.IDLE,
val installMessage: String? = null,
val result: UpdateCheckResult = UpdateCheckResult.Idle,
)
/**
* Backs the About card's update controls. "Check for updates" calls
* [UpdateRepository.getLatest], compares versus this build via
* [isUpdateAvailable] — on the ordering key where the server reports one,
* on the name otherwise — and reports the terminal state.
* When an update is available, [install] downloads the APK via
* [ApkInstaller] and installs it — routing the user to the "install
* unknown apps" settings page first when that permission hasn't been
* granted.
*/
@HiltViewModel
class AboutCardViewModel @Inject constructor(
private val repository: UpdateRepository,
private val installer: ApkInstaller,
) : ViewModel() {
private val internal = MutableStateFlow(AboutUiState())
val state: StateFlow<AboutUiState> = internal.asStateFlow()
fun checkForUpdates() {
if (internal.value.isChecking || !internal.value.selfUpdates) return
viewModelScope.launch {
internal.update { it.copy(isChecking = true, installMessage = null) }
val installed = internal.value.installedVersion
val installedCode = internal.value.installedCode
val result = runCatching { repository.getLatest() }
.map { latest ->
if (
isUpdateAvailable(
serverCode = latest.code,
serverName = latest.version,
installedCode = installedCode,
installedName = installed,
)
) {
UpdateCheckResult.UpdateAvailable(latest)
} else {
UpdateCheckResult.Latest
}
}
.getOrElse { e -> UpdateCheckResult.Error(ErrorCopy.fromThrowable(e)) }
internal.update { it.copy(isChecking = false, result = result) }
}
}
fun install(info: UpdateInfo) {
if (internal.value.installStage.isBusy()) return
if (!installer.canInstall()) {
installer.requestInstallPermission()
internal.update {
it.copy(installMessage = "Allow installs for Minstrel, then tap Install again.")
}
return
}
viewModelScope.launch {
internal.update {
it.copy(installStage = InstallStage.DOWNLOADING, installMessage = null)
}
val apk = download(info.apkUrl)
if (apk != null) {
// The install half now suspends on the platform's verdict, so it
// gets its own stage — reporting "Downloading…" through it would
// be a lie once a confirm dialog is on screen.
internal.update { it.copy(installStage = InstallStage.INSTALLING) }
val outcome = installer.install(apk)
internal.update {
it.copy(installStage = outcome.stage(), installMessage = outcome.message())
}
}
}
}
private suspend fun download(apkUrl: String): File? =
runCatching { installer.downloadApk(apkUrl) }
.onFailure { e ->
internal.update {
it.copy(
installStage = InstallStage.ERROR,
installMessage = "Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
)
}
}
.getOrNull()
}
@@ -1,78 +0,0 @@
package com.fabledsword.minstrel.update.data
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.UpdateInfo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
private const val POLL_INTERVAL_MS = 24 * 60 * 60 * 1000L
/**
* Drives the shell's soft "update available" banner. Polls
* `/api/client/version` at launch + every 24h and, when the bundled
* APK outranks this build — by ordering key where the server reports one,
* by name otherwise — exposes its [UpdateInfo] so
* [com.fabledsword.minstrel.update.ui.UpdateBanner] can nudge an
* install.
*
* Dismissals are kept in memory and keyed by version, so dismissing
* one release's banner still lets a later release re-surface — and a
* restart re-shows it, which is acceptable nudging. Server 404 /
* network errors stay silent. Constructed at
* launch via the construct-the-singleton trick in `MinstrelApplication`.
*
* A debug build never polls: it is signed with a local debug key, so the
* server's release-signed APK could never install over it.
*/
@Singleton
class UpdateBannerController @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
private val repository: UpdateRepository,
) {
private val latest = MutableStateFlow<UpdateInfo?>(null)
private val dismissed = MutableStateFlow<Set<String>>(emptySet())
/** The available update, or null when none / dismissed / unreachable. */
val available: StateFlow<UpdateInfo?> =
combine(latest, dismissed) { info, seen ->
info?.takeIf { it.version !in seen }
}.stateIn(scope, SharingStarted.Eagerly, null)
init {
if (!BuildConfig.DEBUG) startPolling()
}
private fun startPolling() {
scope.launch {
while (true) {
runOnce()
delay(POLL_INTERVAL_MS)
}
}
}
fun dismiss(version: String) {
dismissed.value = dismissed.value + version
}
private suspend fun runOnce() {
val info = runCatching { repository.getLatest() }.getOrNull() ?: return
latest.value = info.takeIf {
isUpdateAvailable(
serverCode = it.code,
serverName = it.version,
installedCode = BuildConfig.VERSION_CODE.toLong(),
installedName = BuildConfig.VERSION_NAME,
)
}
}
}
@@ -1,149 +0,0 @@
package com.fabledsword.minstrel.update.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.Download
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.X
import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.isBusy
/**
* Shell-level soft banner that nudges an available update. Renders
* nothing until [UpdateBannerController] surfaces a newer bundled APK
* (and the user hasn't dismissed that version): a download glyph,
* "Update Minstrel · {version} available", an Install button
* (download → system installer), and a dismiss X. Uses an understated
* surface tone, not the alarming error color reserved for
* `VersionTooOldBanner`.
*
* [ApkInstaller][com.fabledsword.minstrel.update.data.ApkInstaller]
* exposes no byte progress, so the downloading state is an
* indeterminate bar.
*/
@Composable
fun UpdateBanner(viewModel: UpdateBannerViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val info = state.info
AnimatedVisibility(
visible = info != null,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
// info can flip to null during the exit animation; hold the last
// non-null so the collapsing banner keeps its content.
val shown = info ?: return@AnimatedVisibility
BannerBody(
info = shown,
stage = state.stage,
message = state.message,
onInstall = { viewModel.install(shown) },
onDismiss = { viewModel.dismiss(shown.version) },
)
}
}
@Composable
private fun BannerBody(
info: UpdateInfo,
stage: InstallStage,
message: String?,
onInstall: () -> Unit,
onDismiss: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp),
) {
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
if (stage.isBusy()) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
color = MaterialTheme.colorScheme.primary,
)
}
if (message != null) {
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
@Composable
private fun BannerRow(
info: UpdateInfo,
stage: InstallStage,
onInstall: () -> Unit,
onDismiss: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Lucide.Download,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = if (stage == InstallStage.ERROR) {
"Update failed"
} else {
"Update Minstrel · ${info.version} available"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onInstall, enabled = !stage.isBusy()) {
// Downloading and installing are separate words because they're now
// separate waits — the install half suspends on the platform, which
// may be sitting on a confirm dialog.
Text(
when (stage) {
InstallStage.DOWNLOADING -> "Downloading…"
InstallStage.INSTALLING -> "Installing…"
else -> "Install"
},
)
}
IconButton(onClick = onDismiss) {
Icon(
imageVector = Lucide.X,
contentDescription = "Dismiss",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -1,92 +0,0 @@
package com.fabledsword.minstrel.update.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.update.data.ApkInstaller
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.UpdateBannerController
import com.fabledsword.minstrel.update.data.isBusy
import com.fabledsword.minstrel.update.data.message
import com.fabledsword.minstrel.update.data.stage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
data class UpdateBannerUiState(
val info: UpdateInfo? = null,
val stage: InstallStage = InstallStage.IDLE,
val message: String? = null,
)
/**
* Thin VM over [UpdateBannerController]. Surfaces the available update
* and runs the download → install handoff via [ApkInstaller], mirroring
* the About card's flow (route to "install unknown apps" settings first
* when the permission is missing).
*/
@HiltViewModel
class UpdateBannerViewModel @Inject constructor(
private val controller: UpdateBannerController,
private val installer: ApkInstaller,
) : ViewModel() {
private val installState = MutableStateFlow(InstallSnapshot(InstallStage.IDLE, null))
val uiState: StateFlow<UpdateBannerUiState> =
combine(controller.available, installState) { info, install ->
UpdateBannerUiState(info = info, stage = install.stage, message = install.message)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = UpdateBannerUiState(),
)
fun dismiss(version: String) = controller.dismiss(version)
fun install(info: UpdateInfo) {
if (installState.value.stage.isBusy()) return
if (!installer.canInstall()) {
installer.requestInstallPermission()
installState.value = InstallSnapshot(
InstallStage.IDLE,
"Allow installs for Minstrel, then tap Install again.",
)
return
}
viewModelScope.launch {
installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null)
val apk = download(info.apkUrl)
if (apk != null) {
// Await the platform's verdict rather than firing an intent and
// assuming it worked. On a silent install this suspends until
// the process is replaced, so the line below is only reached
// when the install did NOT simply succeed.
installState.value = InstallSnapshot(InstallStage.INSTALLING, null)
val outcome = installer.install(apk)
installState.value = InstallSnapshot(outcome.stage(), outcome.message())
}
}
}
private suspend fun download(apkUrl: String): File? =
runCatching { installer.downloadApk(apkUrl) }
.onFailure { e ->
installState.value = InstallSnapshot(
InstallStage.ERROR,
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
)
}
.getOrNull()
}
private data class InstallSnapshot(val stage: InstallStage, val message: String?)