DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542) #156
+1
-1
@@ -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.
|
||||
|
||||
+6
-6
@@ -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
|
||||
|
||||
+11
-10
@@ -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()
|
||||
}
|
||||
-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