M0: repo scaffolding, CI pipelines, and Go skeleton #1
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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<AboutUiState> = 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -235,11 +235,12 @@ private fun displayName(mode: ThemeMode): String = when (mode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AboutCard() {
|
private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "About",
|
text = "About",
|
||||||
@@ -247,14 +248,47 @@ private fun AboutCard() {
|
|||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Minstrel ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
|
text = "Installed: Minstrel ${state.installedVersion} " +
|
||||||
|
"(${BuildConfig.VERSION_CODE})",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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
|
@Composable
|
||||||
private fun SignOutButton(isSigningOut: Boolean, onSignOut: () -> Unit) {
|
private fun SignOutButton(isSigningOut: Boolean, onSignOut: () -> Unit) {
|
||||||
Button(
|
Button(
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<Int> =
|
||||||
|
v.removePrefix("v").split('.').map { it.toIntOrNull() ?: 0 }
|
||||||
|
|
||||||
|
private fun compareComponentWise(svr: List<Int>, ins: List<Int>, 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user