diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/UpdateInfo.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/UpdateInfo.kt new file mode 100644 index 00000000..745de7c4 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/UpdateInfo.kt @@ -0,0 +1,15 @@ +package com.fabledsword.minstrel.models + +/** + * Wire shape returned by `GET /api/client/version`. Mirrors + * `flutter_client/lib/update/update_info.dart UpdateInfo`. + * + * `version` is the server-bundled APK version (may have a leading + * "v" from the git tag); `apkUrl` is server-relative (e.g. + * `/api/client/apk`); `sizeBytes` is the download size. + */ +data class UpdateInfo( + val version: String, + val apkUrl: String, + val sizeBytes: Long, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/UpdateInfoWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/UpdateInfoWire.kt new file mode 100644 index 00000000..fe772cd6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/UpdateInfoWire.kt @@ -0,0 +1,15 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Wire shape for `GET /api/client/version`. Defaults match Flutter: + * apk_url falls back to `/api/client/apk` if the server omits it. + */ +@Serializable +data class UpdateInfoWire( + val version: String = "", + @SerialName("apk_url") val apkUrl: String = "/api/client/apk", + @SerialName("size_bytes") val sizeBytes: Long = 0, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt new file mode 100644 index 00000000..98a38aff --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt @@ -0,0 +1,69 @@ +package com.fabledsword.minstrel.settings.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.BuildConfig +import com.fabledsword.minstrel.models.UpdateInfo +import com.fabledsword.minstrel.update.data.UpdateRepository +import com.fabledsword.minstrel.update.data.isVersionNewer +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 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` + * means the server has a newer build (rendered as "Install vX.Y.Z" + * in slice 2 of #25 — for now it just shows the version label). + */ +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, + val isChecking: Boolean = false, + val result: UpdateCheckResult = UpdateCheckResult.Idle, +) + +/** + * Backs the About card's "Check for updates" button. Calls + * [UpdateRepository.getLatest] on tap, compares versus the build's + * VERSION_NAME via [isVersionNewer], and reports the terminal state. + */ +@HiltViewModel +class AboutCardViewModel @Inject constructor( + private val repository: UpdateRepository, +) : ViewModel() { + + private val internal = MutableStateFlow(AboutUiState()) + val state: StateFlow = internal.asStateFlow() + + fun checkForUpdates() { + if (internal.value.isChecking) return + viewModelScope.launch { + internal.update { it.copy(isChecking = true) } + val installed = internal.value.installedVersion + val result = runCatching { repository.getLatest() } + .map { latest -> + if (isVersionNewer(latest.version, installed)) { + UpdateCheckResult.UpdateAvailable(latest) + } else { + UpdateCheckResult.Latest + } + } + .getOrElse { e -> + UpdateCheckResult.Error(e.message ?: "Couldn't reach server") + } + internal.update { it.copy(isChecking = false, result = result) } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt index d1168d67..4b1da639 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt @@ -235,11 +235,12 @@ private fun displayName(mode: ThemeMode): String = when (mode) { } @Composable -private fun AboutCard() { +private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() ElevatedCard(modifier = Modifier.fillMaxWidth()) { Column( modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = "About", @@ -247,14 +248,47 @@ private fun AboutCard() { color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "Minstrel ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", + text = "Installed: Minstrel ${state.installedVersion} " + + "(${BuildConfig.VERSION_CODE})", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + UpdateCheckLine(result = state.result) + Button( + onClick = viewModel::checkForUpdates, + enabled = !state.isChecking, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.isChecking) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.size(8.dp)) + } + Text(if (state.isChecking) "Checking…" else "Check for updates") + } } } } +@Composable +private fun UpdateCheckLine(result: UpdateCheckResult) { + val text = when (result) { + UpdateCheckResult.Idle -> return + UpdateCheckResult.Latest -> "You're on the latest version." + is UpdateCheckResult.UpdateAvailable -> + "Update available: ${result.info.version}" + is UpdateCheckResult.Error -> "Couldn't check: ${result.message}" + } + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + @Composable private fun SignOutButton(isSigningOut: Boolean, onSignOut: () -> Unit) { Button( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/api/ClientVersionApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/api/ClientVersionApi.kt new file mode 100644 index 00000000..b05c79f4 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/api/ClientVersionApi.kt @@ -0,0 +1,14 @@ +package com.fabledsword.minstrel.update.api + +import com.fabledsword.minstrel.models.wire.UpdateInfoWire +import retrofit2.http.GET + +/** + * Retrofit interface for `GET /api/client/version`. Returns the + * server-bundled APK version + download URL + size. Mirrors the + * Flutter `ClientUpdateController` shape. + */ +interface ClientVersionApi { + @GET("api/client/version") + suspend fun getVersion(): UpdateInfoWire +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateRepository.kt new file mode 100644 index 00000000..e046d162 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateRepository.kt @@ -0,0 +1,61 @@ +package com.fabledsword.minstrel.update.data + +import com.fabledsword.minstrel.models.UpdateInfo +import com.fabledsword.minstrel.models.wire.UpdateInfoWire +import com.fabledsword.minstrel.update.api.ClientVersionApi +import retrofit2.Retrofit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Thin facade over `/api/client/version`. Reused by the About card + * (Check for updates button) and — in a follow-up — a startup + * version banner. + */ +@Singleton +class UpdateRepository @Inject constructor(retrofit: Retrofit) { + private val api: ClientVersionApi = retrofit.create(ClientVersionApi::class.java) + + suspend fun getLatest(): UpdateInfo = api.getVersion().toDomain() +} + +private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo( + version = version, + apkUrl = apkUrl, + sizeBytes = sizeBytes, +) + +/** + * True when [server] is strictly newer than [installed]. Mirrors + * Flutter's `isVersionNewer` — splits both strings on `.`, parses + * each component as an int (non-numeric = 0), pads the shorter list + * with zeros, then compares component-wise. Handles our date-style + * versions (2026.05.10.1) which exceed semver's three-part shape, + * and treats "2026.05.10" as equal to "2026.05.10.0". + * + * Falls back to string inequality when neither side parsed any + * non-zero component (branch-name builds like "main" vs "dev") so + * a dev build still gets the banner. + */ +fun isVersionNewer(server: String, installed: String): Boolean { + val svr = parseVersion(server) + val ins = parseVersion(installed) + if (svr.all { it == 0 } && ins.all { it == 0 }) { + return server.removePrefix("v") != installed.removePrefix("v") + } + val n = maxOf(svr.size, ins.size) + val svrPadded = svr + List(n - svr.size) { 0 } + val insPadded = ins + List(n - ins.size) { 0 } + return compareComponentWise(svrPadded, insPadded, n) +} + +private fun parseVersion(v: String): List = + v.removePrefix("v").split('.').map { it.toIntOrNull() ?: 0 } + +private fun compareComponentWise(svr: List, ins: List, n: Int): Boolean { + for (i in 0 until n) { + if (svr[i] > ins[i]) return true + if (svr[i] < ins[i]) return false + } + return false +}