Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
a9c3cd0691
commit
f931565c0d
12 files changed
+894
-4
No files matched your search
@@ -7,6 +7,7 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".HermesControlApp"
|
android:name=".HermesControlApp"
|
||||||
|
|||||||
@@ -128,7 +128,6 @@ private fun appEntryProvider(
|
|||||||
entry<SettingsAbout> {
|
entry<SettingsAbout> {
|
||||||
SettingsAboutPage(
|
SettingsAboutPage(
|
||||||
onBack = { NavigationController.goBack() },
|
onBack = { NavigationController.goBack() },
|
||||||
viewModel = viewModel { SettingsViewModel() },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,4 +24,8 @@ data class ServerStoreState(
|
|||||||
// App display language. "system" = follow device locale; otherwise a BCP-47
|
// App display language. "system" = follow device locale; otherwise a BCP-47
|
||||||
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
||||||
val appLanguage: String = "system",
|
val appLanguage: String = "system",
|
||||||
|
// App version the silent update check (issue #867) last completed for.
|
||||||
|
// Null / mismatched with BuildConfig.VERSION_NAME → the About tab runs
|
||||||
|
// its one-time check again (re-check on app version bump).
|
||||||
|
val updateCheckDoneForVersion: String? = null,
|
||||||
)
|
)
|
||||||
@@ -623,4 +623,13 @@ object AuthManager {
|
|||||||
fun setTypingEffectDelayMs(delayMs: Int) {
|
fun setTypingEffectDelayMs(delayMs: Int) {
|
||||||
serverStore.update { it.copy(typingEffectDelayMs = delayMs) }
|
serverStore.update { it.copy(typingEffectDelayMs = delayMs) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── In-app update check (issue #867) ─────────────────────────────────
|
||||||
|
|
||||||
|
/** App version the silent update check last completed for (null = never). */
|
||||||
|
fun getUpdateCheckDoneForVersion(): String? = serverStore.getLatestState().updateCheckDoneForVersion
|
||||||
|
|
||||||
|
fun setUpdateCheckDoneForVersion(version: String) {
|
||||||
|
serverStore.update { it.copy(updateCheckDoneForVersion = version) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package com.m57.hermescontrol.data.update
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Latest release metadata from the GitHub releases API (issue #867) — the
|
||||||
|
* in-app self-update source. Fields map to the JSON via the app's shared
|
||||||
|
* snake_case [OkHttpProvider.json].
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class UpdateInfo(
|
||||||
|
val tagName: String = "",
|
||||||
|
val assets: List<Asset> = emptyList(),
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
data class Asset(
|
||||||
|
val name: String = "",
|
||||||
|
val size: Long = 0L,
|
||||||
|
val browserDownloadUrl: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The release APK asset — release.yml ships `hermes-mobile-<tag>.apk`. */
|
||||||
|
val apkAsset: Asset?
|
||||||
|
get() = assets.firstOrNull { it.name.endsWith(".apk", ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip a leading 'v' from a release tag ("v1.21.0" → "1.21.0"). */
|
||||||
|
fun normalizedVersion(tag: String): String = tag.trim().removePrefix("v")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [latest] is strictly newer than [current]. Numeric dot-segment
|
||||||
|
* comparison ("1.21.0" > "1.2"). Non-numeric suffixes (e.g. "1.0-dev")
|
||||||
|
* compare as 0; an unparseable version never claims an update exists.
|
||||||
|
*/
|
||||||
|
fun isNewerVersion(
|
||||||
|
latest: String,
|
||||||
|
current: String,
|
||||||
|
): Boolean {
|
||||||
|
val a = versionSegments(latest) ?: return false
|
||||||
|
val b = versionSegments(current) ?: return false
|
||||||
|
for (i in 0 until maxOf(a.size, b.size)) {
|
||||||
|
val x = a.getOrElse(i) { 0 }
|
||||||
|
val y = b.getOrElse(i) { 0 }
|
||||||
|
if (x != y) return x > y
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun versionSegments(version: String): List<Int>? {
|
||||||
|
val cleaned = normalizedVersion(version)
|
||||||
|
if (cleaned.isBlank()) return null
|
||||||
|
return cleaned.split('.').map { segment ->
|
||||||
|
// Leading numeric portion of each segment: "0-dev" → 0, "21" → 21.
|
||||||
|
segment.takeWhile { it.isDigit() }.toIntOrNull() ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks to the GitHub releases API and downloads the release APK (issue
|
||||||
|
* #867). Network methods are `open` so tests can fake them; the pure logic
|
||||||
|
* ([isNewerVersion], [parseUpdateInfo]) lives top-level for direct unit
|
||||||
|
* tests.
|
||||||
|
*/
|
||||||
|
open class AppUpdateChecker(
|
||||||
|
private val client: OkHttpClient = OkHttpProvider.base,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Fetch the latest release metadata. Returns null when there is no
|
||||||
|
* release yet (404) or the body can't be parsed. Throws [IOException]
|
||||||
|
* on network failure so the caller can surface a friendly error.
|
||||||
|
*/
|
||||||
|
open suspend fun fetchLatestRelease(): UpdateInfo? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url("https://api.github.com/repos/Hy4ri/hermes-mobile/releases/latest")
|
||||||
|
.header("Accept", "application/vnd.github+json")
|
||||||
|
.build()
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@use null
|
||||||
|
val body = response.body?.string().orEmpty()
|
||||||
|
parseUpdateInfo(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a release APK asset to [dest], reporting progress 0..1 via
|
||||||
|
* [onProgress]. True on success; false (with the partial file deleted)
|
||||||
|
* on any HTTP or I/O failure.
|
||||||
|
*/
|
||||||
|
open suspend fun downloadApk(
|
||||||
|
url: String,
|
||||||
|
dest: File,
|
||||||
|
onProgress: (Float) -> Unit,
|
||||||
|
): Boolean =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val request = Request.Builder().url(url).build()
|
||||||
|
try {
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@withContext false
|
||||||
|
val body = response.body ?: return@withContext false
|
||||||
|
val total = body.contentLength()
|
||||||
|
dest.outputStream().use { out ->
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
var read = 0L
|
||||||
|
body.byteStream().use { input ->
|
||||||
|
while (true) {
|
||||||
|
val n = input.read(buffer)
|
||||||
|
if (n == -1) break
|
||||||
|
out.write(buffer, 0, n)
|
||||||
|
read += n
|
||||||
|
if (total > 0) {
|
||||||
|
onProgress((read.toFloat() / total.toFloat()).coerceIn(0f, 1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
dest.delete()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val DEFAULT_BUFFER_SIZE = 8192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a GitHub `releases/latest` JSON body into [UpdateInfo], or null. */
|
||||||
|
fun parseUpdateInfo(json: String): UpdateInfo? =
|
||||||
|
runCatching {
|
||||||
|
OkHttpProvider.json.decodeFromString<UpdateInfo>(json)
|
||||||
|
}.getOrNull()
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.m57.hermescontrol.ui.settings
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.m57.hermescontrol.BuildConfig
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.update.AppUpdateChecker
|
||||||
|
import com.m57.hermescontrol.data.update.isNewerVersion
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/** UI state of the About-tab in-app updater (issue #867). */
|
||||||
|
sealed interface AppUpdateState {
|
||||||
|
/** Nothing shown yet (no check run in this session). */
|
||||||
|
data object Idle : AppUpdateState
|
||||||
|
|
||||||
|
/** A check is in flight. */
|
||||||
|
data object Checking : AppUpdateState
|
||||||
|
|
||||||
|
/** Latest release equals the installed version. */
|
||||||
|
data class UpToDate(val latestTag: String) : AppUpdateState
|
||||||
|
|
||||||
|
/** A newer release with an APK asset exists. */
|
||||||
|
data class UpdateAvailable(
|
||||||
|
val latestTag: String,
|
||||||
|
val apkUrl: String,
|
||||||
|
val sizeBytes: Long,
|
||||||
|
) : AppUpdateState
|
||||||
|
|
||||||
|
/** APK download in progress; [progress] is 0..1. */
|
||||||
|
data class Downloading(val progress: Float) : AppUpdateState
|
||||||
|
|
||||||
|
/** Download finished and the system package installer was launched. */
|
||||||
|
data class Installing(val latestTag: String) : AppUpdateState
|
||||||
|
|
||||||
|
/** Install-from-unknown-sources not granted for this app yet. */
|
||||||
|
data object NeedsUnknownSourcesPermission : AppUpdateState
|
||||||
|
|
||||||
|
data class Error(val message: String) : AppUpdateState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the in-app self-update flow (issue #867): check the GitHub
|
||||||
|
* releases API, download the release APK, and hand it to the system package
|
||||||
|
* installer. The installer launch uses the application context
|
||||||
|
* (FLAG_ACTIVITY_NEW_TASK), so no Activity is required.
|
||||||
|
*
|
||||||
|
* A silent check runs once per installed version (guarded by the
|
||||||
|
* `updateCheckDoneForVersion` app pref) — that's what puts the "Update
|
||||||
|
* available" badge on the About row without the user tapping anything.
|
||||||
|
*/
|
||||||
|
class AppUpdateViewModel(
|
||||||
|
application: Application,
|
||||||
|
private val checker: AppUpdateChecker = AppUpdateChecker(),
|
||||||
|
private val currentVersion: String = BuildConfig.VERSION_NAME,
|
||||||
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
private val installIntentFactory: (Uri) -> Intent = ::buildInstallIntent,
|
||||||
|
) : AndroidViewModel(application) {
|
||||||
|
private val _state = MutableStateFlow<AppUpdateState>(AppUpdateState.Idle)
|
||||||
|
val state: StateFlow<AppUpdateState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Silent first-launch check: once per installed version. Marked done
|
||||||
|
// after the attempt completes (success or failure) so a failed check
|
||||||
|
// can retry on the next open, but a dead network can't spam the API
|
||||||
|
// on every visit.
|
||||||
|
if (AuthManager.getUpdateCheckDoneForVersion() != currentVersion) {
|
||||||
|
checkForUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manual check from the About row. */
|
||||||
|
fun checkForUpdate() {
|
||||||
|
if (_state.value is AppUpdateState.Checking) return
|
||||||
|
_state.value = AppUpdateState.Checking
|
||||||
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
||||||
|
val result =
|
||||||
|
try {
|
||||||
|
checker.fetchLatestRelease()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
_state.value = AppUpdateState.Error(NETWORK_ERROR)
|
||||||
|
return@launch
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = AppUpdateState.Error(GENERIC_CHECK_ERROR)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val info =
|
||||||
|
result ?: run {
|
||||||
|
_state.value = AppUpdateState.Error(NO_RELEASE_ERROR)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val apk =
|
||||||
|
info.apkAsset ?: run {
|
||||||
|
_state.value = AppUpdateState.Error(NO_APK_ERROR)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
_state.value =
|
||||||
|
if (isNewerVersion(info.tagName, currentVersion)) {
|
||||||
|
AppUpdateState.UpdateAvailable(
|
||||||
|
latestTag = info.tagName,
|
||||||
|
apkUrl = apk.browserDownloadUrl,
|
||||||
|
sizeBytes = apk.size,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AppUpdateState.UpToDate(latestTag = info.tagName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download the release APK and launch the system installer. */
|
||||||
|
fun startUpdate() {
|
||||||
|
val available = _state.value as? AppUpdateState.UpdateAvailable ?: return
|
||||||
|
if (!canRequestInstalls()) {
|
||||||
|
_state.value = AppUpdateState.NeedsUnknownSourcesPermission
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val dest = File(getApplication<Application>().cacheDir, APK_FILE_NAME)
|
||||||
|
_state.value = AppUpdateState.Downloading(0f)
|
||||||
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
val downloaded =
|
||||||
|
checker.downloadApk(available.apkUrl, dest) { progress ->
|
||||||
|
_state.value = AppUpdateState.Downloading(progress)
|
||||||
|
}
|
||||||
|
if (!downloaded) {
|
||||||
|
_state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
_state.value = AppUpdateState.Installing(available.latestTag)
|
||||||
|
launchInstaller(dest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canRequestInstalls(): Boolean =
|
||||||
|
try {
|
||||||
|
getApplication<Application>().packageManager.canRequestPackageInstalls()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchInstaller(apkFile: File) {
|
||||||
|
try {
|
||||||
|
val context = getApplication<Application>()
|
||||||
|
val uri =
|
||||||
|
FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.fileprovider",
|
||||||
|
apkFile,
|
||||||
|
)
|
||||||
|
context.startActivity(installIntentFactory(uri))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = AppUpdateState.Error(INSTALLER_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val APK_FILE_NAME = "hermes-update.apk"
|
||||||
|
|
||||||
|
val NETWORK_ERROR = "Network error — check your connection"
|
||||||
|
val GENERIC_CHECK_ERROR = "Couldn't check for updates"
|
||||||
|
val NO_RELEASE_ERROR = "No release found yet"
|
||||||
|
val NO_APK_ERROR = "Release has no APK asset"
|
||||||
|
val DOWNLOAD_ERROR = "Download failed — tap to retry"
|
||||||
|
val INSTALLER_ERROR = "Couldn't open the installer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MIME type of an installable APK (issue #867). */
|
||||||
|
internal const val APK_MIME = "application/vnd.android.package-archive"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The system-installer intent for a downloaded APK: ACTION_VIEW with the
|
||||||
|
* package-archive mime, a read grant for the FileProvider URI, and
|
||||||
|
* NEW_TASK (launched from the application context).
|
||||||
|
*/
|
||||||
|
internal fun buildInstallIntent(uri: Uri): Intent =
|
||||||
|
Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(uri, APK_MIME)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.m57.hermescontrol.ui.settings
|
package com.m57.hermescontrol.ui.settings
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -19,6 +22,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -195,8 +199,10 @@ internal fun SettingsBehaviorPage(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun SettingsAboutPage(
|
internal fun SettingsAboutPage(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
viewModel: SettingsViewModel = viewModel { SettingsViewModel() },
|
viewModel: AppUpdateViewModel = viewModel(),
|
||||||
) {
|
) {
|
||||||
|
val updateState by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
HermesScaffold(
|
HermesScaffold(
|
||||||
title = { Text(stringResource(R.string.settings_sec_about)) },
|
title = { Text(stringResource(R.string.settings_sec_about)) },
|
||||||
navigationIcon = NavIcon.Back(onBack),
|
navigationIcon = NavIcon.Back(onBack),
|
||||||
@@ -210,7 +216,19 @@ internal fun SettingsAboutPage(
|
|||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
AboutSection()
|
AboutSection(
|
||||||
|
updateState = updateState,
|
||||||
|
onCheckUpdate = viewModel::checkForUpdate,
|
||||||
|
onStartUpdate = viewModel::startUpdate,
|
||||||
|
onOpenInstallSettings = {
|
||||||
|
val intent =
|
||||||
|
Intent(
|
||||||
|
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||||
|
Uri.parse("package:${context.packageName}"),
|
||||||
|
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
package com.m57.hermescontrol.ui.settings.components
|
package com.m57.hermescontrol.ui.settings.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -14,11 +20,17 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.BuildConfig
|
import com.m57.hermescontrol.BuildConfig
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.ui.settings.AppUpdateState
|
||||||
import com.m57.hermescontrol.ui.settings.InfoRow
|
import com.m57.hermescontrol.ui.settings.InfoRow
|
||||||
import com.m57.hermescontrol.ui.settings.SectionCard
|
import com.m57.hermescontrol.ui.settings.SectionCard
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AboutSection() {
|
internal fun AboutSection(
|
||||||
|
updateState: AppUpdateState = AppUpdateState.Idle,
|
||||||
|
onCheckUpdate: () -> Unit = {},
|
||||||
|
onStartUpdate: () -> Unit = {},
|
||||||
|
onOpenInstallSettings: () -> Unit = {},
|
||||||
|
) {
|
||||||
SectionCard {
|
SectionCard {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
@@ -38,6 +50,12 @@ internal fun AboutSection() {
|
|||||||
label = stringResource(R.string.settings_about_version),
|
label = stringResource(R.string.settings_about_version),
|
||||||
value = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
|
value = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
|
||||||
)
|
)
|
||||||
|
UpdateRow(
|
||||||
|
state = updateState,
|
||||||
|
onCheckUpdate = onCheckUpdate,
|
||||||
|
onStartUpdate = onStartUpdate,
|
||||||
|
onOpenInstallSettings = onOpenInstallSettings,
|
||||||
|
)
|
||||||
InfoRow(
|
InfoRow(
|
||||||
label = stringResource(R.string.settings_about_build),
|
label = stringResource(R.string.settings_about_build),
|
||||||
value =
|
value =
|
||||||
@@ -64,3 +82,100 @@ internal fun AboutSection() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-app update row (issue #867): tap to check GitHub releases; when a newer
|
||||||
|
* release exists, download + install from here. Idle/up-to-date/error states
|
||||||
|
* are tappable to re-check; actionable states show a labeled button.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun UpdateRow(
|
||||||
|
state: AppUpdateState,
|
||||||
|
onCheckUpdate: () -> Unit,
|
||||||
|
onStartUpdate: () -> Unit,
|
||||||
|
onOpenInstallSettings: () -> Unit,
|
||||||
|
) {
|
||||||
|
val tappable =
|
||||||
|
state is AppUpdateState.Idle ||
|
||||||
|
state is AppUpdateState.UpToDate ||
|
||||||
|
state is AppUpdateState.Error
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
.clickable(enabled = tappable) { onCheckUpdate() },
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_about_update),
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
when (state) {
|
||||||
|
is AppUpdateState.Idle -> {
|
||||||
|
ValueText(stringResource(R.string.settings_about_update_check))
|
||||||
|
}
|
||||||
|
|
||||||
|
is AppUpdateState.Checking -> {
|
||||||
|
ValueText(stringResource(R.string.settings_about_update_checking))
|
||||||
|
}
|
||||||
|
|
||||||
|
is AppUpdateState.UpToDate -> {
|
||||||
|
ValueText(stringResource(R.string.settings_about_update_uptodate, state.latestTag))
|
||||||
|
}
|
||||||
|
|
||||||
|
is AppUpdateState.UpdateAvailable -> {
|
||||||
|
ValueText(stringResource(R.string.settings_about_update_available, state.latestTag))
|
||||||
|
TextButton(onClick = onStartUpdate) {
|
||||||
|
Text(stringResource(R.string.settings_about_update_action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is AppUpdateState.Downloading -> {
|
||||||
|
ValueText(
|
||||||
|
stringResource(
|
||||||
|
R.string.settings_about_update_downloading,
|
||||||
|
(state.progress * 100).toInt(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { state.progress },
|
||||||
|
modifier = Modifier.width(80.dp).padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is AppUpdateState.Installing -> {
|
||||||
|
ValueText(stringResource(R.string.settings_about_update_installing, state.latestTag))
|
||||||
|
}
|
||||||
|
|
||||||
|
is AppUpdateState.NeedsUnknownSourcesPermission -> {
|
||||||
|
ValueText(stringResource(R.string.settings_about_update_allow_sources))
|
||||||
|
TextButton(onClick = onOpenInstallSettings) {
|
||||||
|
Text(stringResource(R.string.settings_about_update_open_settings))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is AppUpdateState.Error -> {
|
||||||
|
ValueText(state.message)
|
||||||
|
TextButton(onClick = onCheckUpdate) {
|
||||||
|
Text(stringResource(R.string.settings_about_update_retry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ValueText(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -154,6 +154,17 @@
|
|||||||
<string name="settings_about_debug">디버그</string>
|
<string name="settings_about_debug">디버그</string>
|
||||||
<string name="settings_about_release">릴리즈</string>
|
<string name="settings_about_release">릴리즈</string>
|
||||||
<string name="settings_about_commit">커밋</string>
|
<string name="settings_about_commit">커밋</string>
|
||||||
|
<string name="settings_about_update">업데이트</string>
|
||||||
|
<string name="settings_about_update_check">업데이트 확인</string>
|
||||||
|
<string name="settings_about_update_checking">확인 중…</string>
|
||||||
|
<string name="settings_about_update_uptodate">최신 버전 (v%1$s)</string>
|
||||||
|
<string name="settings_about_update_available">업데이트 가능: v%1$s</string>
|
||||||
|
<string name="settings_about_update_action">업데이트</string>
|
||||||
|
<string name="settings_about_update_downloading">다운로드 %1$d%%</string>
|
||||||
|
<string name="settings_about_update_installing">설치 중 v%1$s…</string>
|
||||||
|
<string name="settings_about_update_allow_sources">이 소스의 설치 허용</string>
|
||||||
|
<string name="settings_about_update_open_settings">설정 열기</string>
|
||||||
|
<string name="settings_about_update_retry">다시 시도</string>
|
||||||
<string name="action_save">저장</string>
|
<string name="action_save">저장</string>
|
||||||
<string name="settings_action_add_profile">프로필 추가</string>
|
<string name="settings_action_add_profile">프로필 추가</string>
|
||||||
<string name="settings_action_edit_profile">프로필 편집</string>
|
<string name="settings_action_edit_profile">프로필 편집</string>
|
||||||
|
|||||||
@@ -174,6 +174,17 @@
|
|||||||
<string name="settings_about_debug">Debug</string>
|
<string name="settings_about_debug">Debug</string>
|
||||||
<string name="settings_about_release">Release</string>
|
<string name="settings_about_release">Release</string>
|
||||||
<string name="settings_about_commit">Commit</string>
|
<string name="settings_about_commit">Commit</string>
|
||||||
|
<string name="settings_about_update">Update</string>
|
||||||
|
<string name="settings_about_update_check">Check for updates</string>
|
||||||
|
<string name="settings_about_update_checking">Checking…</string>
|
||||||
|
<string name="settings_about_update_uptodate">Up to date (v%1$s)</string>
|
||||||
|
<string name="settings_about_update_available">Update available: v%1$s</string>
|
||||||
|
<string name="settings_about_update_action">Update</string>
|
||||||
|
<string name="settings_about_update_downloading">Downloading %1$d%%</string>
|
||||||
|
<string name="settings_about_update_installing">Installing v%1$s…</string>
|
||||||
|
<string name="settings_about_update_allow_sources">Allow installs from this source</string>
|
||||||
|
<string name="settings_about_update_open_settings">Open settings</string>
|
||||||
|
<string name="settings_about_update_retry">Retry</string>
|
||||||
|
|
||||||
<!-- Settings list summaries (drill-down) -->
|
<!-- Settings list summaries (drill-down) -->
|
||||||
<string name="settings_summary_profiles">%1$d saved</string>
|
<string name="settings_summary_profiles">%1$d saved</string>
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.m57.hermescontrol.data.update
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure logic of the in-app updater (issue #867): version comparison and
|
||||||
|
* GitHub release JSON parsing. No Android/network dependencies.
|
||||||
|
*/
|
||||||
|
class AppUpdateCheckerTest {
|
||||||
|
// ── Version comparison ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNewerVersion_stripsLeadingV() {
|
||||||
|
assertTrue(
|
||||||
|
"release tags carry a leading v; versionName does not",
|
||||||
|
isNewerVersion("v1.22.0", "1.21.0"),
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
"same version must not count as newer",
|
||||||
|
isNewerVersion("v1.21.0", "1.21.0"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNewerVersion_comparesNumericSegments() {
|
||||||
|
assertTrue(isNewerVersion("1.21.0", "1.2"))
|
||||||
|
assertTrue(isNewerVersion("2.0.0", "1.99.99"))
|
||||||
|
assertFalse(isNewerVersion("1.2", "1.21.0"))
|
||||||
|
assertFalse(isNewerVersion("1.21.0", "1.21.0"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNewerVersion_nonNumericSuffixCountsAsZero() {
|
||||||
|
// Local dev default "1.0-dev" must still see a real release as newer.
|
||||||
|
assertTrue(isNewerVersion("1.21.0", "1.0-dev"))
|
||||||
|
assertTrue(isNewerVersion("v1.21.0", "1.0-dev"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNewerVersion_unparseableNeverClaimsUpdate() {
|
||||||
|
assertFalse(isNewerVersion("not-a-version", "1.21.0"))
|
||||||
|
assertFalse(isNewerVersion("1.21.0", ""))
|
||||||
|
assertFalse(isNewerVersion("", "1.21.0"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun normalizedVersion_stripsLeadingV() {
|
||||||
|
assertEquals("1.21.0", normalizedVersion("v1.21.0"))
|
||||||
|
assertEquals("1.21.0", normalizedVersion("1.21.0"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Release JSON parsing ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseUpdateInfo_picksApkAsset() {
|
||||||
|
val json =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"tag_name": "v1.22.0",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"name": "version.txt",
|
||||||
|
"size": 12,
|
||||||
|
"browser_download_url": "https://github.com/Hy4ri/hermes-mobile/releases/download/v1.22.0/version.txt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hermes-mobile-v1.22.0.apk",
|
||||||
|
"size": 12345678,
|
||||||
|
"browser_download_url": "https://github.com/Hy4ri/hermes-mobile/releases/download/v1.22.0/hermes-mobile-v1.22.0.apk"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val info = parseUpdateInfo(json)
|
||||||
|
assertNotNull(info)
|
||||||
|
assertEquals("v1.22.0", info!!.tagName)
|
||||||
|
val apk = info.apkAsset
|
||||||
|
assertNotNull("the .apk asset must be selected", apk)
|
||||||
|
assertEquals("hermes-mobile-v1.22.0.apk", apk!!.name)
|
||||||
|
assertEquals(12345678L, apk.size)
|
||||||
|
assertTrue(apk.browserDownloadUrl.endsWith(".apk"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseUpdateInfo_noApkAsset_yieldsNullApk() {
|
||||||
|
val json =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"tag_name": "v1.22.0",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"name": "version.txt",
|
||||||
|
"size": 12,
|
||||||
|
"browser_download_url": "https://github.com/Hy4ri/hermes-mobile/releases/download/v1.22.0/version.txt"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val info = parseUpdateInfo(json)
|
||||||
|
assertNotNull(info)
|
||||||
|
assertNull(info!!.apkAsset)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseUpdateInfo_malformedJson_yieldsNull() {
|
||||||
|
assertNull(parseUpdateInfo("not json at all"))
|
||||||
|
assertNull(parseUpdateInfo(""))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package com.m57.hermescontrol.ui.settings
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.update.AppUpdateChecker
|
||||||
|
import com.m57.hermescontrol.data.update.UpdateInfo
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkObject
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppUpdateViewModel state machine (issue #867): silent first-launch check,
|
||||||
|
* check/update flows, the unknown-sources gate, and the installer launch.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class AppUpdateViewModelTest {
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private lateinit var app: Application
|
||||||
|
private lateinit var packageManager: PackageManager
|
||||||
|
private lateinit var checker: AppUpdateChecker
|
||||||
|
|
||||||
|
private val currentVersion = "1.21.0"
|
||||||
|
|
||||||
|
private fun updateInfo(
|
||||||
|
tag: String = "v1.22.0",
|
||||||
|
apkName: String = "hermes-mobile-v1.22.0.apk",
|
||||||
|
): UpdateInfo =
|
||||||
|
UpdateInfo(
|
||||||
|
tagName = tag,
|
||||||
|
assets =
|
||||||
|
listOf(
|
||||||
|
UpdateInfo.Asset(
|
||||||
|
name = apkName,
|
||||||
|
size = 12345678L,
|
||||||
|
browserDownloadUrl = "https://example.com/$apkName",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
mockkObject(AuthManager)
|
||||||
|
every { AuthManager.getUpdateCheckDoneForVersion() } returns null
|
||||||
|
every { AuthManager.setUpdateCheckDoneForVersion(any()) } returns Unit
|
||||||
|
|
||||||
|
app = mockk(relaxed = true)
|
||||||
|
every { app.cacheDir } returns File(System.getProperty("java.io.tmpdir"))
|
||||||
|
every { app.packageName } returns "com.m57.hermescontrol"
|
||||||
|
packageManager = mockk(relaxed = true)
|
||||||
|
every { app.packageManager } returns packageManager
|
||||||
|
every { packageManager.canRequestPackageInstalls() } returns true
|
||||||
|
|
||||||
|
checker = mockk()
|
||||||
|
coEvery { checker.fetchLatestRelease() } returns updateInfo()
|
||||||
|
coEvery { checker.downloadApk(any(), any(), any()) } returns true
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
unmockkAll()
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createViewModel(): AppUpdateViewModel = AppUpdateViewModel(app, checker, currentVersion, testDispatcher)
|
||||||
|
|
||||||
|
// ── Silent first-launch check ───────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun init_runsSilentCheckWhenNeverChecked() =
|
||||||
|
runTest {
|
||||||
|
val vm = createViewModel()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { checker.fetchLatestRelease() }
|
||||||
|
assertEquals(
|
||||||
|
AppUpdateState.UpdateAvailable("v1.22.0", "https://example.com/hermes-mobile-v1.22.0.apk", 12345678L),
|
||||||
|
vm.state.value,
|
||||||
|
)
|
||||||
|
coVerify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun init_skipsCheckWhenAlreadyCheckedForThisVersion() =
|
||||||
|
runTest {
|
||||||
|
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { checker.fetchLatestRelease() }
|
||||||
|
assertEquals(AppUpdateState.Idle, vm.state.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun init_rechecksOnVersionBump() =
|
||||||
|
runTest {
|
||||||
|
every { AuthManager.getUpdateCheckDoneForVersion() } returns "1.20.0"
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { checker.fetchLatestRelease() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Manual check ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkForUpdate_upToDateWhenSameVersion() =
|
||||||
|
runTest {
|
||||||
|
coEvery { checker.fetchLatestRelease() } returns updateInfo(tag = "v1.21.0")
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(AppUpdateState.UpToDate("v1.21.0"), vm.state.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkForUpdate_networkFailure_surfacesError() =
|
||||||
|
runTest {
|
||||||
|
coEvery { checker.fetchLatestRelease() } throws IOException("boom")
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = vm.state.value
|
||||||
|
assertTrue(state is AppUpdateState.Error)
|
||||||
|
assertEquals("Network error — check your connection", (state as AppUpdateState.Error).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkForUpdate_noReleaseYet_surfacesError() =
|
||||||
|
runTest {
|
||||||
|
coEvery { checker.fetchLatestRelease() } returns null
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = vm.state.value
|
||||||
|
assertTrue(state is AppUpdateState.Error)
|
||||||
|
assertEquals("No release found yet", (state as AppUpdateState.Error).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkForUpdate_releaseWithoutApk_surfacesError() =
|
||||||
|
runTest {
|
||||||
|
coEvery { checker.fetchLatestRelease() } returns UpdateInfo(tagName = "v1.22.0")
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = vm.state.value
|
||||||
|
assertTrue(state is AppUpdateState.Error)
|
||||||
|
assertEquals("Release has no APK asset", (state as AppUpdateState.Error).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkForUpdate_marksDoneEvenWhenCheckFails() =
|
||||||
|
runTest {
|
||||||
|
coEvery { checker.fetchLatestRelease() } throws IOException("boom")
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update flow ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun startUpdate_downloadsAndLaunchesInstaller() =
|
||||||
|
runTest {
|
||||||
|
val uri = mockk<Uri>()
|
||||||
|
mockkStatic(FileProvider::class)
|
||||||
|
every { FileProvider.getUriForFile(any(), any(), any()) } returns uri
|
||||||
|
|
||||||
|
// android.content.Intent can't be constructed in a plain JVM test,
|
||||||
|
// so the intent factory is injected; assert the VM launches
|
||||||
|
// exactly the intent the factory built for the APK URI.
|
||||||
|
val intentMock = mockk<Intent>()
|
||||||
|
val capturedIntents = mutableListOf<Intent>()
|
||||||
|
every { app.startActivity(capture(capturedIntents)) } returns Unit
|
||||||
|
|
||||||
|
val vm =
|
||||||
|
AppUpdateViewModel(app, checker, currentVersion, testDispatcher) { intentMock }
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(vm.state.value is AppUpdateState.UpdateAvailable)
|
||||||
|
|
||||||
|
vm.startUpdate()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
checker.downloadApk("https://example.com/hermes-mobile-v1.22.0.apk", any(), any())
|
||||||
|
}
|
||||||
|
assertEquals(AppUpdateState.Installing("v1.22.0"), vm.state.value)
|
||||||
|
assertTrue("installer must be launched", capturedIntents.isNotEmpty())
|
||||||
|
assertTrue("launched intent must be the factory's", capturedIntents[0] === intentMock)
|
||||||
|
verify { FileProvider.getUriForFile(any(), "com.m57.hermescontrol.fileprovider", any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun startUpdate_withoutUnknownSourcesPermission_opensGate() =
|
||||||
|
runTest {
|
||||||
|
every { packageManager.canRequestPackageInstalls() } returns false
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.startUpdate()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(AppUpdateState.NeedsUnknownSourcesPermission, vm.state.value)
|
||||||
|
coVerify(exactly = 0) { checker.downloadApk(any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun startUpdate_downloadFailure_surfacesErrorAndRetries() =
|
||||||
|
runTest {
|
||||||
|
coEvery { checker.downloadApk(any(), any(), any()) } returns false
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.startUpdate()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = vm.state.value
|
||||||
|
assertTrue(state is AppUpdateState.Error)
|
||||||
|
assertEquals("Download failed — tap to retry", (state as AppUpdateState.Error).message)
|
||||||
|
// Retry path: tapping the row re-runs the check, which recovers.
|
||||||
|
vm.checkForUpdate()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(vm.state.value is AppUpdateState.UpdateAvailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun startUpdate_ignoredWhenStateIsNotUpdateAvailable() =
|
||||||
|
runTest {
|
||||||
|
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(AppUpdateState.Idle, vm.state.value)
|
||||||
|
|
||||||
|
vm.startUpdate()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { checker.downloadApk(any(), any(), any()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user