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:
+1
-1
@@ -747,7 +747,7 @@ class MinstrelForwardingPlayer(
|
|||||||
* the library's duration, so the scrubber has an end to run to.
|
* the library's duration, so the scrubber has an end to run to.
|
||||||
*/
|
*/
|
||||||
private fun durationFor(info: PositionInfo, single: SingleTrackLoader.PollOutcome?): Long =
|
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.
|
* Ask the watchdog what to make of this poll, and act on its answer.
|
||||||
|
|||||||
+6
-6
@@ -679,11 +679,11 @@ class OutputPickerController @Inject constructor(
|
|||||||
localQueue: List<TrackRef>,
|
localQueue: List<TrackRef>,
|
||||||
localIndex: Int,
|
localIndex: Int,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val (transport, rendering, route, queueMode) = target
|
val route = target.route
|
||||||
val plan = probeAdoptable(target, localQueue, localIndex) ?: return false
|
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.
|
// 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(
|
Timber.w(
|
||||||
"UPnP adopt: attaching to live session on %s at track %d (%dms)",
|
"UPnP adopt: attaching to live session on %s at track %d (%dms)",
|
||||||
@@ -704,9 +704,9 @@ class OutputPickerController @Inject constructor(
|
|||||||
ActiveUpnp(
|
ActiveUpnp(
|
||||||
routeId = route.id,
|
routeId = route.id,
|
||||||
routeName = route.name,
|
routeName = route.name,
|
||||||
avTransport = transport,
|
avTransport = target.transport,
|
||||||
rendering = rendering,
|
rendering = target.rendering,
|
||||||
queueMode = queueMode,
|
queueMode = target.queueMode,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
|
|||||||
+11
-10
@@ -70,8 +70,10 @@ class SingleTrackLoader @Inject constructor(
|
|||||||
* renderer is playing (0 when nothing is loaded). [advanced] is true when
|
* 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
|
* 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.
|
* 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. */
|
/** Take [queue] as the one to play, and start [index] on the renderer. */
|
||||||
suspend fun load(transport: AVTransportClient, queue: List<TrackRef>, index: Int) =
|
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)
|
if (currentIndex !in queue.indices) return@withLock PollOutcome(0, advanced = false)
|
||||||
val moved = followRenderer(trackUri)
|
val moved = followRenderer(trackUri)
|
||||||
armNext(transport)
|
armNext(transport)
|
||||||
if (moved) return@withLock PollOutcome(currentIndex + 1, advanced = false)
|
if (moved) return@withLock PollOutcome(currentIndex + 1, advanced = false, knownDurationMs(queue, currentIndex))
|
||||||
val known = queue[currentIndex].durationSec * MILLIS_PER_SECOND
|
endedOnLastPoll = endDetector.onPoll(state, positionMs, durationMs, knownDurationMs(queue, currentIndex))
|
||||||
endedOnLastPoll = endDetector.onPoll(state, positionMs, durationMs, known)
|
|
||||||
val next = currentIndex + 1
|
val next = currentIndex + 1
|
||||||
val advance = endedOnLastPoll && playIntent && next in queue.indices
|
val advance = endedOnLastPoll && playIntent && next in queue.indices
|
||||||
if (advance) {
|
if (advance) {
|
||||||
@@ -147,7 +148,7 @@ class SingleTrackLoader @Inject constructor(
|
|||||||
runCatching { start(transport, next) }
|
runCatching { start(transport, next) }
|
||||||
.onFailure { Timber.w(it, "UPnP single-track: could not start track %d", next + 1) }
|
.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
|
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 {
|
private suspend fun start(transport: AVTransportClient, index: Int): Boolean {
|
||||||
if (index !in queue.indices) return false
|
if (index !in queue.indices) return false
|
||||||
val token = mint(index)
|
val token = mint(index)
|
||||||
@@ -240,11 +237,14 @@ class SingleTrackLoader @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val MILLIS_PER_SECOND = 1_000L
|
|
||||||
const val ARM_RETRY_POLLS = 10
|
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
|
* 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
|
* [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 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()
|
|
||||||
}
|
|
||||||
-78
@@ -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?)
|
|
||||||
Reference in New Issue
Block a user