diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2464235..929b940 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,18 @@ All notable changes to SearchMob are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses Ubuntu-style date
versioning (`YY.MM.VV`).
+## [Unreleased]
+
+### Added
+- **You now actually get told when an update is out.** When SearchMob is open and a newer release is
+ available, it posts a system notification and shows a banner across the top of the app; the served
+ search pages show the same banner (only to you, on this device, never to other people on your
+ network). The check still runs about once a day through the privacy proxy and stays opt-out.
+- **One-tap update.** Tapping **Update** (in the banner or the notification) downloads the new APK,
+ verifies it against the release's published SHA-256 checksums, and hands it to the system package
+ installer (which shows its usual install confirmation). Falls back to the release page if there is
+ no usable asset or the download fails.
+
## [26.06.00] - 2026-06-02
### Added
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a13bd34..313e02c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -13,6 +13,9 @@
+
+
+
+
+
(null)
- // Set when the (opt-out, throttled) launch-time update check finds a strictly newer release; the
- // root composable observes it to show an in-app "Update available" prompt. Null means no prompt.
- private var availableUpdate by mutableStateOf(null)
+ // True while a one-click update download/verify is in flight, so the banner shows progress and the
+ // Update button is disabled. The banner itself is driven by the persisted pending-update prefs.
+ private var updateInProgress by mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -101,7 +116,9 @@ class MainActivity : ComponentActivity() {
currentVersionCode = currentVersionCode(),
).checkIfDue()
}
- if (update != null) availableUpdate = update
+ // A found update persists to prefs (driving the banner reactively); also post a system
+ // notification so the user is told while the app is open. The notification opens the app.
+ if (update != null) UpdateNotifier.notify(this@MainActivity, update.latestVersionName)
}
setContent {
@@ -122,12 +139,53 @@ class MainActivity : ComponentActivity() {
SearchMobApp(
deps = deps,
openSearchToken = openSearchToken,
- availableUpdate = availableUpdate,
- onDismissUpdate = { availableUpdate = null },
+ currentVersionCode = currentVersionCode(),
+ updateInProgress = updateInProgress,
+ onStartUpdate = ::startUpdate,
)
}
}
+ /**
+ * One-click update: re-fetch the latest release (for fresh asset URLs + checksum), download and
+ * verify the APK, then hand it to the system installer. Falls back to opening the release page
+ * when there is no usable asset (or on failure). Runs the network work off the main thread; the
+ * banner shows progress via [updateInProgress].
+ */
+ private fun startUpdate() {
+ if (updateInProgress) return
+ updateInProgress = true
+ lifecycleScope.launch {
+ val fallback = deps.preferencesRepository.pendingUpdateUrl().ifBlank { RELEASES_PAGE_URL }
+ val result =
+ withContext(Dispatchers.IO) {
+ val client = HttpClientFactory.create(connectTimeoutMs = 10_000, readTimeoutMs = 60_000)
+ UpdateFlow.prepare(client, cacheDir, fallback)
+ }
+ updateInProgress = false
+ when (result) {
+ is UpdateFlow.Result.Installable -> {
+ UpdateNotifier.cancel(this@MainActivity)
+ runCatching { UpdateInstaller.installApk(this@MainActivity, result.file) }
+ .onFailure { openUrl(fallback) }
+ }
+ is UpdateFlow.Result.OpenPage -> openUrl(result.url)
+ is UpdateFlow.Result.Failed -> {
+ Toast.makeText(
+ this@MainActivity,
+ getString(R.string.update_download_failed, result.message),
+ Toast.LENGTH_LONG,
+ ).show()
+ openUrl(result.url)
+ }
+ }
+ }
+ }
+
+ private fun openUrl(url: String) {
+ runCatching { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
+ }
+
/**
* The running build's version code, used to decide whether the latest release is newer. Reads
* [PackageInfo.longVersionCode] on API 28+ and the deprecated `versionCode` below it; returns 0 on
@@ -162,8 +220,9 @@ class MainActivity : ComponentActivity() {
fun SearchMobApp(
deps: AppDependencies,
openSearchToken: Long? = null,
- availableUpdate: UpdateInfo? = null,
- onDismissUpdate: () -> Unit = {},
+ currentVersionCode: Int = 0,
+ updateInProgress: Boolean = false,
+ onStartUpdate: () -> Unit = {},
) {
val prefs: UserPreferences by deps.preferencesRepository.preferences
.collectAsStateWithLifecycle(initialValue = UserPreferences())
@@ -179,6 +238,18 @@ fun SearchMobApp(
.collectAsStateWithLifecycle(initialValue = null)
val personalizationEnabled: Boolean by deps.preferencesRepository.personalizationEnabled
.collectAsStateWithLifecycle(initialValue = false)
+ // The "update available" banner is driven by the persisted pending-update record (written by the
+ // launch-time check), so it survives a restart and clears itself once the user is current.
+ val pendingVersion: String by deps.preferencesRepository.pendingUpdateVersion
+ .collectAsStateWithLifecycle(initialValue = "")
+ val pendingUrl: String by deps.preferencesRepository.pendingUpdateUrl
+ .collectAsStateWithLifecycle(initialValue = "")
+ var updateDismissed by remember { mutableStateOf(false) }
+ val showUpdateBanner =
+ !updateDismissed &&
+ pendingVersion.isNotBlank() &&
+ pendingUrl.isNotBlank() &&
+ (VersionTag.toVersionCode(pendingVersion) ?: 0) > currentVersionCode
val showOnboarding: Boolean? =
if (onboardingCompleted == null || onboardingVersion == null) {
null
@@ -210,66 +281,83 @@ fun SearchMobApp(
themeMode = prefs.themeMode,
dynamicColor = prefs.dynamicColor,
) {
- when (showOnboarding) {
- null -> Unit // loading; render nothing for the first frame
- true ->
- OnboardingWizard(
- port = port,
- onComplete = { completeOnboarding() },
- onOpenUrl = { url -> context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) },
- onStartService = { ServiceController.start(context) },
- // Finishing the wizard hands off to the main app, where the history /
- // zero-knowledge privacy controls live in Settings.
- onOpenPrivacySettings = { completeOnboarding() },
- personalizationEnabled = personalizationEnabled,
- onSetPersonalization = {
- scope.launch { deps.preferencesRepository.setPersonalizationEnabled(it) }
- },
+ // The update banner is pinned above the content (only past onboarding so it never competes
+ // with the wizard); the rest of the app renders below it.
+ Column(modifier = Modifier.fillMaxWidth()) {
+ if (showUpdateBanner && showOnboarding == false) {
+ UpdateBanner(
+ version = pendingVersion,
+ inProgress = updateInProgress,
+ onUpdate = onStartUpdate,
+ onDismiss = { updateDismissed = true },
)
- else -> SearchMobNavHost(factory = factory, navController = navController)
- }
-
- // In-app update prompt: shown only once the user is past onboarding so it never competes with
- // the wizard. Opening the releases page requires an explicit tap; "Not now" just dismisses.
- if (availableUpdate != null && showOnboarding == false) {
- UpdateAvailableDialog(
- update = availableUpdate,
- onOpenReleases = {
- val url = availableUpdate.releaseUrl.takeIf { it.isNotBlank() } ?: RELEASES_PAGE_URL
- context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
- onDismissUpdate()
- },
- onDismiss = onDismissUpdate,
- )
+ }
+ when (showOnboarding) {
+ null -> Unit // loading; render nothing for the first frame
+ true ->
+ OnboardingWizard(
+ port = port,
+ onComplete = { completeOnboarding() },
+ onOpenUrl = { url -> context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) },
+ onStartService = { ServiceController.start(context) },
+ // Finishing the wizard hands off to the main app, where the history /
+ // zero-knowledge privacy controls live in Settings.
+ onOpenPrivacySettings = { completeOnboarding() },
+ personalizationEnabled = personalizationEnabled,
+ onSetPersonalization = {
+ scope.launch { deps.preferencesRepository.setPersonalizationEnabled(it) }
+ },
+ )
+ else -> SearchMobNavHost(factory = factory, navController = navController)
+ }
}
}
}
/**
- * Material3 dialog announcing a newer release. It only surfaces the version and a link out; SearchMob
- * never auto-downloads or auto-installs, so the user stays in control of whether and when to update.
+ * The "update available" banner, pinned at the top of the app. Shows the available version with an
+ * Update action (the verified one-click download + system install) and a dismiss. While a download is
+ * in flight the action is disabled and the label shows progress.
*/
@Composable
-private fun UpdateAvailableDialog(
- update: UpdateInfo,
- onOpenReleases: () -> Unit,
+private fun UpdateBanner(
+ version: String,
+ inProgress: Boolean,
+ onUpdate: () -> Unit,
onDismiss: () -> Unit,
) {
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text(LocalContext.current.getString(R.string.update_available_title)) },
- text = {
- Text(LocalContext.current.getString(R.string.update_available_body, update.latestVersionName))
- },
- confirmButton = {
- TextButton(onClick = onOpenReleases) {
- Text(LocalContext.current.getString(R.string.update_open_releases))
+ Surface(
+ color = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ // Edge-to-edge is on, so pad for the status bar this banner sits beneath.
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .statusBarsPadding()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ val context = LocalContext.current
+ Text(
+ text =
+ if (inProgress) {
+ context.getString(R.string.update_banner_downloading, version)
+ } else {
+ context.getString(R.string.update_banner_available, version)
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.weight(1f),
+ )
+ Button(onClick = onUpdate, enabled = !inProgress) {
+ Text(context.getString(R.string.update_banner_action))
}
- },
- dismissButton = {
- TextButton(onClick = onDismiss) {
- Text(LocalContext.current.getString(R.string.update_not_now))
+ TextButton(onClick = onDismiss, enabled = !inProgress) {
+ Text(context.getString(R.string.update_banner_dismiss))
}
- },
- )
+ }
+ }
}
diff --git a/app/src/main/java/org/searchmob/server/SearchServer.kt b/app/src/main/java/org/searchmob/server/SearchServer.kt
index 7edfbc5..d6286b5 100644
--- a/app/src/main/java/org/searchmob/server/SearchServer.kt
+++ b/app/src/main/java/org/searchmob/server/SearchServer.kt
@@ -134,6 +134,10 @@ fun Application.searchModule(
// and the `/click` learning route records nothing.
personalizationPreferences: PersonalizationPreferences? = null,
personalizationEnabled: suspend () -> Boolean = { false },
+ // Owner-only "update available" banner. Returns (version, releaseUrl) when a newer release is
+ // pending, or null. Rendered only for the loopback owner (a network visitor can't install
+ // anything and the owner's version is not leaked). Default null = no banner.
+ updateBanner: suspend () -> Pair? = { null },
// Network-mode access control (mirrors the desktop `_SecurityHeadersMiddleware`). When the server
// is bound to all interfaces, a non-loopback client hitting a query route must present this token,
// and its Host header must be loopback / an IP literal / one of `allowedHosts` (DNS-rebind guard).
@@ -208,7 +212,8 @@ fun Application.searchModule(
val link = settingsAvailable(call)
val owner = rankingPreferences != null && isOwnerRequest(call)
val rules = if (owner) rankingPreferences.load() else null
- call.respondHtml { renderHomePage(link, rules, owner) }
+ val banner = if (isOwnerRequest(call)) updateBanner() else null
+ call.respondHtml { renderHomePage(link, rules, owner, banner) }
}
get("/healthz") {
call.respondText("ok")
@@ -244,6 +249,7 @@ fun Application.searchModule(
} else {
null
}
+ val banner = if (isOwnerRequest(call)) updateBanner() else null
call.respondHtml {
renderResultsPage(
query,
@@ -254,6 +260,7 @@ fun Application.searchModule(
vertical.value,
link,
linkBuilder,
+ banner,
)
}
}
@@ -558,12 +565,15 @@ private fun HTML.renderResultsPage(
// When set (owner + personalization on), maps a result's (position, url) to the `/click` link
// that trains the model; null means plain destination links (network visitors, disabled owner).
linkBuilder: ((Int, String) -> String)? = null,
+ // Owner-only "update available" banner (version, releaseUrl), or null.
+ updateBanner: Pair? = null,
) {
attributes["lang"] = "en"
val results = outcome.results
head { pageHead(if (query.isBlank()) "SearchMob" else "$query · SearchMob") }
body {
attributes["data-page"] = "results"
+ updateBanner(updateBanner)
div("topbar") {
a(href = "/", classes = "logo") { +"SearchMob" }
form(action = "/search", method = FormMethod.get, classes = "searchbox") {
@@ -794,11 +804,13 @@ private fun HTML.renderHomePage(
settingsLink: Boolean = false,
rules: RankingRules? = null,
editable: Boolean = false,
+ updateBanner: Pair? = null,
) {
attributes["lang"] = "en"
head { pageHead("SearchMob") }
body {
attributes["data-page"] = "home"
+ updateBanner(updateBanner)
div("topbar") {
span("logo") { +"SearchMob" }
settingsLink(settingsLink)
@@ -828,6 +840,24 @@ private fun FlowContent.settingsLink(show: Boolean) {
if (show) a(href = "/settings", classes = "settings-link") { +"Settings" }
}
+/**
+ * Owner-only "update available" banner pinned above the top bar. [banner] is (version, releaseUrl);
+ * null renders nothing. The link opens the release page (the in-app updater offers the one-click
+ * install). The kotlinx.html builder escapes the version and href, so no manual escaping is needed.
+ */
+private fun FlowContent.updateBanner(banner: Pair?) {
+ if (banner == null) return
+ val (version, url) = banner
+ div("updatebar") {
+ attributes["role"] = "status"
+ span("msg") { +"SearchMob $version is available." }
+ a(href = url, classes = "btn") {
+ attributes["rel"] = "noopener noreferrer"
+ +"Get the update"
+ }
+ }
+}
+
private fun FlowContent.selectField(
name: String,
options: List>,
@@ -1236,6 +1266,12 @@ private val PAGE_CSS =
.topbar{display:flex;align-items:center;gap:14px;padding:10px 18px;border-bottom:1px solid var(--border);
position:sticky;top:0;background:var(--topbar);backdrop-filter:saturate(1.4) blur(8px);z-index:10}
.topbar .logo{font-weight:800;font-size:20px;color:var(--accent);letter-spacing:-.5px;white-space:nowrap}
+ .updatebar{display:flex;align-items:center;gap:12px;padding:9px 18px;background:var(--accent);
+ color:#fff;font-size:13px}
+ .updatebar .msg{font-weight:600}
+ .updatebar .btn{margin-left:auto;background:#fff;color:var(--accent);border-radius:16px;padding:5px 14px;
+ font-weight:700;text-decoration:none;white-space:nowrap}
+ .updatebar .btn:hover{text-decoration:none;opacity:.92}
.theme-toggle{margin-left:auto;background:transparent;border:1px solid var(--border);color:var(--fg);
border-radius:20px;padding:6px 14px;cursor:pointer;font-size:13px;white-space:nowrap}
.theme-toggle:hover{border-color:var(--accent);color:var(--accent)}
@@ -1397,6 +1433,9 @@ class SearchServer(
// owner's personalization toggle, read fresh each request.
private val personalizationPreferences: PersonalizationPreferences? = null,
private val personalizationEnabled: suspend () -> Boolean = { false },
+ // Owner-only "update available" banner: returns (version, releaseUrl) when a newer release is
+ // pending, else null. Read fresh each request so it appears/clears without a server restart.
+ private val updateBanner: suspend () -> Pair? = { null },
// Lazily resolved each (re)start so a token minted after the server started still takes effect.
private val accessToken: () -> String? = { null },
) {
@@ -1439,6 +1478,7 @@ class SearchServer(
historyStore,
personalizationPreferences,
personalizationEnabled,
+ updateBanner,
token,
) { port }
}
diff --git a/app/src/main/java/org/searchmob/service/SearchMobService.kt b/app/src/main/java/org/searchmob/service/SearchMobService.kt
index 99664ed..2ca67ca 100644
--- a/app/src/main/java/org/searchmob/service/SearchMobService.kt
+++ b/app/src/main/java/org/searchmob/service/SearchMobService.kt
@@ -45,6 +45,7 @@ import org.searchmob.server.suggest.HistorySuggestionsProvider
import org.searchmob.server.suggest.UpstreamSuggestionsProvider
import org.searchmob.ui.prefs.DataStorePreferencesStore
import org.searchmob.ui.prefs.PreferencesRepository
+import org.searchmob.update.VersionTag
/**
* The always-on backbone: a `specialUse` foreground service.
@@ -147,6 +148,20 @@ class SearchMobService : Service() {
// the owner's personalization toggle.
personalizationPreferences = (application as SearchMobApplication).personalizationPreferences,
personalizationEnabled = { preferences.personalizationEnabled() },
+ // Owner-only served-page "update available" banner: surfaced only when a newer release is
+ // pending (recorded by the launch-time check). The server further gates it to loopback.
+ updateBanner = {
+ val version = preferences.pendingUpdateVersion()
+ val url = preferences.pendingUpdateUrl()
+ if (version.isNotBlank() &&
+ url.isNotBlank() &&
+ (VersionTag.toVersionCode(version) ?: 0) > currentVersionCode()
+ ) {
+ version to url
+ } else {
+ null
+ }
+ },
// Powers the served Settings page (preference toggles + history view/clear), loopback-only.
userPreferences = preferences,
historyStore = (application as SearchMobApplication).storage.history,
@@ -299,6 +314,18 @@ class SearchMobService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
+ /** The running build's version code, for gating the served-page update banner. 0 on any failure. */
+ private fun currentVersionCode(): Int =
+ runCatching {
+ val info = packageManager.getPackageInfo(packageName, 0)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ info.longVersionCode.toInt()
+ } else {
+ @Suppress("DEPRECATION")
+ info.versionCode
+ }
+ }.getOrDefault(0)
+
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
diff --git a/app/src/main/java/org/searchmob/ui/prefs/PreferencesRepository.kt b/app/src/main/java/org/searchmob/ui/prefs/PreferencesRepository.kt
index 83046a0..6eb9e7d 100644
--- a/app/src/main/java/org/searchmob/ui/prefs/PreferencesRepository.kt
+++ b/app/src/main/java/org/searchmob/ui/prefs/PreferencesRepository.kt
@@ -180,6 +180,34 @@ class PreferencesRepository(
suspend fun setLastUpdateCheckMs(value: Long) =
store.setString(PreferenceKeys.LAST_UPDATE_CHECK_MS, value.toString())
+ /**
+ * The newest release a prior check found (version name + release URL), or empty strings when none
+ * is pending. Observed by the in-app banner and read by the served-page banner; persisted so a
+ * found update survives a restart. Set via [setPendingUpdate] and cleared by [clearPendingUpdate].
+ */
+ val pendingUpdateVersion: Flow =
+ store.getString(PreferenceKeys.PENDING_UPDATE_VERSION, "")
+
+ val pendingUpdateUrl: Flow =
+ store.getString(PreferenceKeys.PENDING_UPDATE_URL, "")
+
+ suspend fun pendingUpdateVersion(): String = pendingUpdateVersion.first()
+
+ suspend fun pendingUpdateUrl(): String = pendingUpdateUrl.first()
+
+ suspend fun setPendingUpdate(
+ version: String,
+ url: String,
+ ) {
+ store.setString(PreferenceKeys.PENDING_UPDATE_VERSION, version)
+ store.setString(PreferenceKeys.PENDING_UPDATE_URL, url)
+ }
+
+ suspend fun clearPendingUpdate() {
+ store.setString(PreferenceKeys.PENDING_UPDATE_VERSION, "")
+ store.setString(PreferenceKeys.PENDING_UPDATE_URL, "")
+ }
+
suspend fun setThemeMode(mode: ThemeMode) = store.setString(PreferenceKeys.THEME_MODE, mode.name)
suspend fun setDynamicColor(enabled: Boolean) = store.setBoolean(PreferenceKeys.DYNAMIC_COLOR, enabled)
diff --git a/app/src/main/java/org/searchmob/ui/prefs/UserPreferences.kt b/app/src/main/java/org/searchmob/ui/prefs/UserPreferences.kt
index f8abf64..6038f0d 100644
--- a/app/src/main/java/org/searchmob/ui/prefs/UserPreferences.kt
+++ b/app/src/main/java/org/searchmob/ui/prefs/UserPreferences.kt
@@ -49,4 +49,10 @@ object PreferenceKeys {
// Stored as a string because the store has no Long type; parsed back to a Long timestamp.
const val LAST_UPDATE_CHECK_MS = "last_update_check_ms"
+
+ // The newest release the last update check found (empty when none / up to date). These drive the
+ // in-app and served-page "update available" banners and the notification, so a found update
+ // survives a restart until a later check supersedes or clears it.
+ const val PENDING_UPDATE_VERSION = "pending_update_version"
+ const val PENDING_UPDATE_URL = "pending_update_url"
}
diff --git a/app/src/main/java/org/searchmob/update/PackageInstallReceiver.kt b/app/src/main/java/org/searchmob/update/PackageInstallReceiver.kt
new file mode 100644
index 0000000..4c04d75
--- /dev/null
+++ b/app/src/main/java/org/searchmob/update/PackageInstallReceiver.kt
@@ -0,0 +1,28 @@
+package org.searchmob.update
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInstaller
+
+/**
+ * Receives [PackageInstaller] session status callbacks for an in-app update install. The only status
+ * that needs handling is STATUS_PENDING_USER_ACTION: the system hands back an Intent that launches its
+ * own install-confirmation UI (the app is not a device owner, so the user always confirms). Other
+ * statuses (success/failure) are terminal and need no action here; the user sees the system's result.
+ */
+class PackageInstallReceiver : BroadcastReceiver() {
+ override fun onReceive(
+ context: Context,
+ intent: Intent,
+ ) {
+ val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
+ if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) {
+ @Suppress("DEPRECATION")
+ val confirm = intent.getParcelableExtra(Intent.EXTRA_INTENT) ?: return
+ // The receiver runs outside an Activity context, so a new task is required to launch the UI.
+ confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(confirm)
+ }
+ }
+}
diff --git a/app/src/main/java/org/searchmob/update/UpdateCheckCoordinator.kt b/app/src/main/java/org/searchmob/update/UpdateCheckCoordinator.kt
index df2cefc..0246432 100644
--- a/app/src/main/java/org/searchmob/update/UpdateCheckCoordinator.kt
+++ b/app/src/main/java/org/searchmob/update/UpdateCheckCoordinator.kt
@@ -35,6 +35,11 @@ class UpdateCheckCoordinator(
/**
* Runs a check if enabled and due. Returns the [UpdateInfo] when a strictly newer release is found
* (the signal to prompt), or null when disabled, not due, on any failure, or already up to date.
+ *
+ * On a completed check it also persists the pending-update record: set when a newer release is
+ * found (so the in-app and served-page banners survive a restart), cleared when the check confirms
+ * the user is current (so the banner disappears after they update). A network failure leaves any
+ * existing record untouched.
*/
suspend fun checkIfDue(): UpdateInfo? =
runCatching {
@@ -46,6 +51,12 @@ class UpdateCheckCoordinator(
preferences.setLastUpdateCheckMs(now)
val latest = checker.fetchLatest() ?: return null
- if (latest.isNewerThan(currentVersionCode)) latest else null
+ if (latest.isNewerThan(currentVersionCode)) {
+ preferences.setPendingUpdate(latest.latestVersionName, latest.releaseUrl)
+ latest
+ } else {
+ preferences.clearPendingUpdate()
+ null
+ }
}.getOrNull()
}
diff --git a/app/src/main/java/org/searchmob/update/UpdateChecker.kt b/app/src/main/java/org/searchmob/update/UpdateChecker.kt
index 8bc1a81..cfb2c5e 100644
--- a/app/src/main/java/org/searchmob/update/UpdateChecker.kt
+++ b/app/src/main/java/org/searchmob/update/UpdateChecker.kt
@@ -3,6 +3,8 @@ package org.searchmob.update
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.int
+import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
@@ -15,17 +17,35 @@ const val LATEST_RELEASE_API_URL = "https://api.github.com/repos/FlintWave/Searc
/** Fallback releases page opened when a specific release `html_url` is unavailable. */
const val RELEASES_PAGE_URL = "https://github.com/FlintWave/SearchMob/releases/latest"
+/** The integrity-anchor asset published alongside the APK (see the release workflow). */
+const val SHA256SUMS_ASSET_NAME = "SHA256SUMS"
+
+/** One published asset attached to a GitHub release (the signed APK, or the SHA256SUMS file). */
+data class ReleaseAsset(
+ val name: String,
+ val downloadUrl: String,
+ val size: Long = 0,
+)
+
/**
* Result of a successful update check: the latest published release as a comparable version code, its
- * human-readable name, and the page to open. [isNewerThan] tells the caller whether to prompt.
+ * human-readable name, the page to open, and its published assets. [isNewerThan] tells the caller
+ * whether to prompt; [apkAsset]/[checksumsAsset] back the in-app download-and-install path.
*/
data class UpdateInfo(
val latestVersionCode: Int,
val latestVersionName: String,
val releaseUrl: String,
+ val assets: List = emptyList(),
) {
/** An update is available only when the latest version code is strictly greater than [current]. */
fun isNewerThan(current: Int): Boolean = latestVersionCode > current
+
+ /** The signed APK asset to download for an in-app install, or null when the release has none. */
+ fun apkAsset(): ReleaseAsset? = assets.firstOrNull { it.name.endsWith(".apk", ignoreCase = true) }
+
+ /** The SHA256SUMS asset used to verify a downloaded APK's integrity, or null when absent. */
+ fun checksumsAsset(): ReleaseAsset? = assets.firstOrNull { it.name == SHA256SUMS_ASSET_NAME }
}
/**
@@ -100,9 +120,27 @@ class UpdateChecker(
latestVersionCode = versionCode,
latestVersionName = tag!!.trim().removePrefix("v").removePrefix("V"),
releaseUrl = htmlUrl,
+ assets = parseAssets(obj["assets"]),
)
}.getOrNull()
+ /**
+ * Parses the release `assets` array into [ReleaseAsset]s, skipping malformed entries (missing a
+ * usable name or `browser_download_url`). Fail-soft like the rest of the parser.
+ */
+ private fun parseAssets(element: kotlinx.serialization.json.JsonElement?): List {
+ val array = runCatching { element?.jsonArray }.getOrNull() ?: return emptyList()
+ return array.mapNotNull { entry ->
+ val obj = runCatching { entry.jsonObject }.getOrNull() ?: return@mapNotNull null
+ val name = obj["name"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
+ val url =
+ obj["browser_download_url"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
+ ?: return@mapNotNull null
+ val size = runCatching { obj["size"]?.jsonPrimitive?.int?.toLong() }.getOrNull() ?: 0L
+ ReleaseAsset(name = name, downloadUrl = url, size = size)
+ }
+ }
+
/**
* Reads at most [MAX_RESPONSE_BYTES] from the response body, mirroring `HttpEngineAdapter`: an
* oversized body is rejected (returns null) rather than fully buffered.
diff --git a/app/src/main/java/org/searchmob/update/UpdateFlow.kt b/app/src/main/java/org/searchmob/update/UpdateFlow.kt
new file mode 100644
index 0000000..4cb5a8d
--- /dev/null
+++ b/app/src/main/java/org/searchmob/update/UpdateFlow.kt
@@ -0,0 +1,39 @@
+package org.searchmob.update
+
+import okhttp3.OkHttpClient
+import java.io.File
+
+/**
+ * Prepares the one-click update: re-fetch the latest release (so the asset URLs and checksum are
+ * fresh), pick the APK + SHA256SUMS, and download-and-verify. The outcome tells the caller whether to
+ * launch the system installer, fall back to the release page (Linux-style multi-format / no usable
+ * asset), or report a failure. Kept off the Android framework so it is unit-testable on the JVM.
+ */
+object UpdateFlow {
+ sealed interface Result {
+ /** A verified APK is ready to hand to the system package installer. */
+ data class Installable(val file: File) : Result
+
+ /** No in-app install path; open this release page in the browser instead. */
+ data class OpenPage(val url: String) : Result
+
+ /** The download or verification failed; show [message] and open [url]. */
+ data class Failed(val message: String, val url: String) : Result
+ }
+
+ suspend fun prepare(
+ client: OkHttpClient,
+ cacheDir: File,
+ fallbackUrl: String,
+ ): Result {
+ val info = UpdateChecker(client).fetchLatest() ?: return Result.OpenPage(fallbackUrl)
+ val apk = info.apkAsset()
+ val sums = info.checksumsAsset()
+ if (apk == null || sums == null) return Result.OpenPage(info.releaseUrl)
+ return try {
+ Result.Installable(UpdateInstaller.downloadAndVerify(client, apk, sums, File(cacheDir, "updates")))
+ } catch (e: UpdateDownloadException) {
+ Result.Failed(e.message ?: "Download failed.", info.releaseUrl)
+ }
+ }
+}
diff --git a/app/src/main/java/org/searchmob/update/UpdateInstaller.kt b/app/src/main/java/org/searchmob/update/UpdateInstaller.kt
new file mode 100644
index 0000000..b54a9ea
--- /dev/null
+++ b/app/src/main/java/org/searchmob/update/UpdateInstaller.kt
@@ -0,0 +1,177 @@
+package org.searchmob.update
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.File
+import java.security.MessageDigest
+
+/** Upper bound on a downloaded APK. SearchMob's APK is a few MiB; this bounds disk/memory use. */
+const val MAX_APK_BYTES = 256L * 1024 * 1024
+
+/** Upper bound on the SHA256SUMS body: one short line per asset. */
+private const val MAX_SUMS_BYTES = 64L * 1024L
+
+/** A download or integrity check failed. Carries a user-facing message. */
+class UpdateDownloadException(message: String) : Exception(message)
+
+/**
+ * Downloads the release APK and verifies it against the published `SHA256SUMS`, then hands it to the
+ * system package installer. This is a fetch-and-hand-off, not a silent install: the OS always shows
+ * its install confirmation (the app is not a device owner), which is the right place for the user to
+ * consent. The download streams through the shared privacy-proxy OkHttp client (no cookies, rotated
+ * User-Agent), is size-capped, and computes the SHA-256 while streaming so a tampered byte fails the
+ * check before the file is ever opened.
+ */
+object UpdateInstaller {
+ /**
+ * Parses `SHA256SUMS` content into `{assetName: lowercaseHexDigest}`. Accepts the standard
+ * `sha256sum` format `<64-hex>`; malformed lines are skipped and a
+ * leading `*` (binary-mode marker) on the name is stripped.
+ */
+ fun parseSha256Sums(text: String): Map {
+ val out = LinkedHashMap()
+ for (raw in text.lineSequence()) {
+ val line = raw.trim()
+ if (line.isEmpty()) continue
+ val parts = line.split(Regex("\\s+"), limit = 2)
+ if (parts.size != 2) continue
+ val digest = parts[0].lowercase()
+ val name = parts[1].trim().removePrefix("*")
+ if (digest.length == 64 && digest.all { it in "0123456789abcdef" }) {
+ out[name] = digest
+ }
+ }
+ return out
+ }
+
+ /** The expected SHA-256 for [assetName] from SHA256SUMS content, or null when absent. */
+ fun expectedDigest(
+ sumsText: String,
+ assetName: String,
+ ): String? = parseSha256Sums(sumsText)[assetName]
+
+ /**
+ * Streams [apk] into [destDir], verifying its SHA-256 against [sums]. Returns the saved file.
+ * Throws [UpdateDownloadException] on any transport failure, an oversized body, a missing checksum
+ * entry, or a digest mismatch. The file is written to a temp name and only moved into place once
+ * the checksum verifies, so a partial or tampered download never lands at the final path.
+ */
+ suspend fun downloadAndVerify(
+ client: OkHttpClient,
+ apk: ReleaseAsset,
+ sums: ReleaseAsset,
+ destDir: File,
+ maxBytes: Long = MAX_APK_BYTES,
+ ): File =
+ withContext(Dispatchers.IO) {
+ val expected =
+ expectedDigest(fetchText(client, sums.downloadUrl, MAX_SUMS_BYTES), apk.name)
+ ?: throw UpdateDownloadException(
+ "No published checksum for ${apk.name}; refusing to install an unverified download.",
+ )
+
+ destDir.mkdirs()
+ val finalFile = File(destDir, apk.name)
+ val tmp = File.createTempFile("download-", ".apk.part", destDir)
+ val digest = MessageDigest.getInstance("SHA-256")
+ try {
+ val request = Request.Builder().url(apk.downloadUrl).get().build()
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw UpdateDownloadException("Download failed: HTTP ${response.code}")
+ }
+ val source =
+ response.body?.byteStream()
+ ?: throw UpdateDownloadException("Download failed: empty response.")
+ tmp.outputStream().use { out ->
+ val buf = ByteArray(64 * 1024)
+ var total = 0L
+ while (true) {
+ val n = source.read(buf)
+ if (n < 0) break
+ total += n
+ if (total > maxBytes) {
+ throw UpdateDownloadException("The download exceeded the expected size; aborting.")
+ }
+ digest.update(buf, 0, n)
+ out.write(buf, 0, n)
+ }
+ }
+ }
+ val actual = digest.digest().joinToString("") { "%02x".format(it) }
+ if (actual != expected) {
+ throw UpdateDownloadException(
+ "The downloaded file's checksum did not match the published value; discarding it.",
+ )
+ }
+ if (finalFile.exists()) finalFile.delete()
+ if (!tmp.renameTo(finalFile)) {
+ throw UpdateDownloadException("Could not finalize the downloaded file.")
+ }
+ finalFile
+ } catch (e: UpdateDownloadException) {
+ tmp.delete()
+ throw e
+ } catch (e: Exception) {
+ tmp.delete()
+ throw UpdateDownloadException("Download failed: ${e.message}")
+ }
+ }
+
+ /**
+ * Hands [apk] to the system [PackageInstaller]. The OS shows its install confirmation UI (routed
+ * via [PackageInstallReceiver] when it reports STATUS_PENDING_USER_ACTION). Requires the
+ * `REQUEST_INSTALL_PACKAGES` permission; the user grants "install unknown apps" if not already.
+ */
+ fun installApk(
+ context: Context,
+ apk: File,
+ ) {
+ val installer = context.packageManager.packageInstaller
+ val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
+ params.setAppPackageName(context.packageName)
+ val sessionId = installer.createSession(params)
+ installer.openSession(sessionId).use { session ->
+ apk.inputStream().use { input ->
+ session.openWrite("searchmob.apk", 0, apk.length()).use { out ->
+ input.copyTo(out)
+ session.fsync(out)
+ }
+ }
+ val intent = Intent(context, PackageInstallReceiver::class.java)
+ val pending =
+ android.app.PendingIntent.getBroadcast(
+ context,
+ sessionId,
+ intent,
+ android.app.PendingIntent.FLAG_MUTABLE or android.app.PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+ session.commit(pending.intentSender)
+ }
+ }
+
+ /** Reads at most [maxBytes] of a URL's body as UTF-8 text, or throws [UpdateDownloadException]. */
+ private fun fetchText(
+ client: OkHttpClient,
+ url: String,
+ maxBytes: Long,
+ ): String {
+ val request = Request.Builder().url(url).get().build()
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw UpdateDownloadException("Could not fetch the checksum file: HTTP ${response.code}")
+ }
+ val body = response.body ?: throw UpdateDownloadException("Could not fetch the checksum file.")
+ val source = body.source()
+ if (source.request(maxBytes + 1)) {
+ throw UpdateDownloadException("Checksum file was unexpectedly large; aborting.")
+ }
+ return source.buffer.readString(body.contentType()?.charset() ?: Charsets.UTF_8)
+ }
+ }
+}
diff --git a/app/src/main/java/org/searchmob/update/UpdateNotifier.kt b/app/src/main/java/org/searchmob/update/UpdateNotifier.kt
new file mode 100644
index 0000000..c062899
--- /dev/null
+++ b/app/src/main/java/org/searchmob/update/UpdateNotifier.kt
@@ -0,0 +1,81 @@
+package org.searchmob.update
+
+import android.Manifest
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import org.searchmob.MainActivity
+import org.searchmob.R
+
+/**
+ * Posts (and clears) the "update available" system notification. Tapping it opens the app, where the
+ * banner offers the verified one-click install. Separate low-key channel from the foreground-service
+ * notification so the user can mute one without the other.
+ */
+object UpdateNotifier {
+ const val CHANNEL_ID = "searchmob_update"
+ const val NOTIFICATION_ID = 1002
+
+ fun ensureChannel(context: Context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel =
+ NotificationChannel(
+ CHANNEL_ID,
+ context.getString(R.string.update_channel_name),
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ).apply {
+ description = context.getString(R.string.update_channel_description)
+ }
+ context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
+ }
+ }
+
+ /**
+ * Shows the notification for [versionName]. No-op when POST_NOTIFICATIONS is not granted (Android
+ * 13+): the in-app banner still surfaces the update, so a denied permission is not fatal.
+ */
+ fun notify(
+ context: Context,
+ versionName: String,
+ ) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ return
+ }
+ ensureChannel(context)
+ val openApp =
+ Intent(context, MainActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ val pending =
+ PendingIntent.getActivity(
+ context,
+ 0,
+ openApp,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+ val notification =
+ NotificationCompat.Builder(context, CHANNEL_ID)
+ .setContentTitle(context.getString(R.string.update_available_title))
+ .setContentText(context.getString(R.string.update_notification_text, versionName))
+ .setSmallIcon(R.drawable.ic_stat_search)
+ .setContentIntent(pending)
+ .setAutoCancel(true)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setCategory(NotificationCompat.CATEGORY_RECOMMENDATION)
+ .build()
+ NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification)
+ }
+
+ fun cancel(context: Context) {
+ NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d7c6858..43508f9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -149,6 +149,15 @@
A newer version of SearchMob is available: %1$s. You can download it from the releases page.
Open releases page
Not now
+
+ App updates
+ Notifies you when a newer version of SearchMob is available.
+ SearchMob %1$s is available. Tap to update.
+ SearchMob %1$s is available.
+ Downloading SearchMob %1$s…
+ Update
+ Dismiss
+ Update download failed: %1$s. Opening the release page instead.
Keep the service alive
diff --git a/app/src/test/java/org/searchmob/server/UpdateBannerRouteTest.kt b/app/src/test/java/org/searchmob/server/UpdateBannerRouteTest.kt
new file mode 100644
index 0000000..3fe65b7
--- /dev/null
+++ b/app/src/test/java/org/searchmob/server/UpdateBannerRouteTest.kt
@@ -0,0 +1,85 @@
+package org.searchmob.server
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.net.HttpURLConnection
+import java.net.URL
+
+/**
+ * The owner-only "update available" banner on the served home + results pages, against a real
+ * loopback server (requests originate from 127.0.0.1 = the owner). The route gates the banner with
+ * `isOwnerRequest`; a network visitor never reaches it (covered by the isLoopbackHost helper tests).
+ */
+class UpdateBannerRouteTest {
+ private class OneResultProvider : SearchResultProvider {
+ override suspend fun search(query: String): List =
+ listOf(SearchResult(title = "A page", url = "https://news.example/x", snippet = "s", engine = "e"))
+ }
+
+ private fun waitForHealthz(
+ port: Int,
+ attempts: Int = 30,
+ ): Int {
+ repeat(attempts) {
+ try {
+ val c = URL("http://$LOOPBACK_HOST:$port/healthz").openConnection() as HttpURLConnection
+ c.connectTimeout = 500
+ c.readTimeout = 500
+ val code = c.responseCode
+ c.disconnect()
+ return code
+ } catch (_: Exception) {
+ Thread.sleep(100)
+ }
+ }
+ throw AssertionError("server did not respond on $port")
+ }
+
+ private fun body(
+ port: Int,
+ path: String,
+ ): String {
+ val c = URL("http://$LOOPBACK_HOST:$port$path").openConnection() as HttpURLConnection
+ c.instanceFollowRedirects = false
+ val text = c.inputStream.bufferedReader().readText()
+ c.disconnect()
+ return text
+ }
+
+ private fun server(updateBanner: suspend () -> Pair?) =
+ SearchServer(provider = OneResultProvider(), updateBanner = updateBanner)
+
+ @Test
+ fun ownerSeesBannerOnHomeAndResults() {
+ val server = server { "26.07.00" to "https://example.test/r/v26.07.00" }
+ val port = server.start(freeLoopbackPort())
+ try {
+ assertEquals(200, waitForHealthz(port))
+ val home = body(port, "/")
+ assertTrue(home.contains("class=\"updatebar\""))
+ assertTrue(home.contains("https://example.test/r/v26.07.00"))
+ assertTrue(home.contains("SearchMob 26.07.00 is available."))
+
+ val results = body(port, "/search?q=hi")
+ assertTrue(results.contains("class=\"updatebar\""))
+ assertTrue(results.contains("https://example.test/r/v26.07.00"))
+ } finally {
+ server.stop()
+ }
+ }
+
+ @Test
+ fun noBannerWhenProviderReturnsNull() {
+ val server = server { null }
+ val port = server.start(freeLoopbackPort())
+ try {
+ assertEquals(200, waitForHealthz(port))
+ assertFalse(body(port, "/").contains("class=\"updatebar\""))
+ assertFalse(body(port, "/search?q=hi").contains("class=\"updatebar\""))
+ } finally {
+ server.stop()
+ }
+ }
+}
diff --git a/app/src/test/java/org/searchmob/update/UpdateCheckCoordinatorTest.kt b/app/src/test/java/org/searchmob/update/UpdateCheckCoordinatorTest.kt
index 73dccf1..cbadbe7 100644
--- a/app/src/test/java/org/searchmob/update/UpdateCheckCoordinatorTest.kt
+++ b/app/src/test/java/org/searchmob/update/UpdateCheckCoordinatorTest.kt
@@ -126,6 +126,42 @@ class UpdateCheckCoordinatorTest {
}
}
+ @Test
+ fun checkIfDue_persistsPendingUpdateWhenNewer() =
+ runTest {
+ val server = MockWebServer()
+ server.enqueue(MockResponse().setBody(releaseJson("v26.06.00")))
+ server.start()
+ try {
+ val r = repo()
+ val checker = UpdateChecker(proxyClient(), baseUrl = server.url("/latest").toString())
+ UpdateCheckCoordinator(r, checker, currentVersionCode = 260501, nowMs = { 1_000_000 }).checkIfDue()
+ assertEquals("26.06.00", r.pendingUpdateVersion())
+ assertTrue(r.pendingUpdateUrl().endsWith("/v26.06.00"))
+ } finally {
+ server.shutdown()
+ }
+ }
+
+ @Test
+ fun checkIfDue_clearsPendingUpdateWhenUpToDate() =
+ runTest {
+ val server = MockWebServer()
+ server.enqueue(MockResponse().setBody(releaseJson("v26.05.01")))
+ server.start()
+ try {
+ val r = repo()
+ // A stale pending record from a prior check must be cleared once we're current.
+ r.setPendingUpdate("26.06.00", "https://example.test/old")
+ val checker = UpdateChecker(proxyClient(), baseUrl = server.url("/latest").toString())
+ UpdateCheckCoordinator(r, checker, currentVersionCode = 260501, nowMs = { 1_000_000 }).checkIfDue()
+ assertEquals("", r.pendingUpdateVersion())
+ assertEquals("", r.pendingUpdateUrl())
+ } finally {
+ server.shutdown()
+ }
+ }
+
@Test
fun checkIfDue_stampsThrottleEvenOnFailure() =
runTest {
diff --git a/app/src/test/java/org/searchmob/update/UpdateCheckerTest.kt b/app/src/test/java/org/searchmob/update/UpdateCheckerTest.kt
index 2365d54..5339076 100644
--- a/app/src/test/java/org/searchmob/update/UpdateCheckerTest.kt
+++ b/app/src/test/java/org/searchmob/update/UpdateCheckerTest.kt
@@ -97,6 +97,40 @@ class UpdateCheckerTest {
assertNull(checker.parse("this is not json"))
}
+ // ----- assets parsing + selection -----
+
+ private val releaseWithAssets =
+ """
+ {"tag_name":"v26.07.00","html_url":"https://example.test/r/v26.07.00","assets":[
+ {"name":"searchmob-26.07.00.apk","browser_download_url":"https://example.test/dl/app.apk","size":12345},
+ {"name":"SHA256SUMS","browser_download_url":"https://example.test/dl/SHA256SUMS"},
+ {"name":"broken-no-url"},
+ "not-a-dict"
+ ]}
+ """.trimIndent()
+
+ @Test
+ fun parse_readsAssetsAndSkipsMalformedEntries() {
+ val info = UpdateChecker(proxyClient()).parse(releaseWithAssets)!!
+ assertEquals(listOf("searchmob-26.07.00.apk", "SHA256SUMS"), info.assets.map { it.name })
+ assertEquals(12345L, info.assets[0].size)
+ assertEquals(0L, info.assets[1].size)
+ }
+
+ @Test
+ fun apkAndChecksumsAssetSelection() {
+ val info = UpdateChecker(proxyClient()).parse(releaseWithAssets)!!
+ assertEquals("searchmob-26.07.00.apk", info.apkAsset()!!.name)
+ assertEquals("SHA256SUMS", info.checksumsAsset()!!.name)
+ }
+
+ @Test
+ fun apkAsset_nullWhenAbsent() {
+ val info = UpdateChecker(proxyClient()).parse(releaseJson("v26.07.00"))!!
+ assertNull(info.apkAsset())
+ assertNull(info.checksumsAsset())
+ }
+
// ----- fetchLatest (fail-soft over MockWebServer) -----
@Test
diff --git a/app/src/test/java/org/searchmob/update/UpdateInstallerTest.kt b/app/src/test/java/org/searchmob/update/UpdateInstallerTest.kt
new file mode 100644
index 0000000..635969b
--- /dev/null
+++ b/app/src/test/java/org/searchmob/update/UpdateInstallerTest.kt
@@ -0,0 +1,153 @@
+package org.searchmob.update
+
+import kotlinx.coroutines.test.runTest
+import okhttp3.CookieJar
+import okhttp3.OkHttpClient
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import okio.Buffer
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+import java.security.MessageDigest
+
+class UpdateInstallerTest {
+ private fun client() = OkHttpClient.Builder().cookieJar(CookieJar.NO_COOKIES).build()
+
+ private fun sha256Hex(bytes: ByteArray): String =
+ MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
+
+ private fun tempDir(): File =
+ File.createTempFile("updtest", "").let {
+ it.delete()
+ it.mkdirs()
+ it
+ }
+
+ // ----- parseSha256Sums -----
+
+ @Test
+ fun parse_readsTwoSpaceAndBinaryMarkerAndSkipsJunk() {
+ val d = "a".repeat(64)
+ val text = "$d plain.apk\n$d *binary.apk\n\n# comment\nbad line\n"
+ assertEquals(mapOf("plain.apk" to d, "binary.apk" to d), UpdateInstaller.parseSha256Sums(text))
+ }
+
+ @Test
+ fun parse_lowercasesAndRejectsNonHexOrShort() {
+ assertEquals(mapOf("x.apk" to "a".repeat(64)), UpdateInstaller.parseSha256Sums("${"A".repeat(64)} x.apk"))
+ assertTrue(UpdateInstaller.parseSha256Sums("zzzz bad.apk\nABCDEF short.apk").isEmpty())
+ }
+
+ @Test
+ fun expectedDigest_nullWhenAbsent() {
+ assertNull(UpdateInstaller.expectedDigest("", "missing.apk"))
+ }
+
+ // ----- downloadAndVerify -----
+
+ private fun apkAsset(server: MockWebServer) = ReleaseAsset("searchmob.apk", server.url("/searchmob.apk").toString())
+
+ private fun sumsAsset(server: MockWebServer) =
+ ReleaseAsset(SHA256SUMS_ASSET_NAME, server.url("/SHA256SUMS").toString())
+
+ @Test
+ fun downloadAndVerify_writesFileWhenChecksumMatches() =
+ runTest {
+ val payload = ("installer-bytes").repeat(100).toByteArray()
+ val server = MockWebServer()
+ // Order: the verifier fetches SHA256SUMS first, then streams the APK.
+ server.enqueue(MockResponse().setBody("${sha256Hex(payload)} searchmob.apk\n"))
+ server.enqueue(MockResponse().setBody(Buffer().write(payload)))
+ server.start()
+ try {
+ val dir = tempDir()
+ val file = UpdateInstaller.downloadAndVerify(client(), apkAsset(server), sumsAsset(server), dir)
+ assertEquals("searchmob.apk", file.name)
+ assertArrayEquals(payload, file.readBytes())
+ // Only the final file is left; no .part temp remains.
+ assertEquals(listOf("searchmob.apk"), dir.listFiles()!!.map { it.name })
+ } finally {
+ server.shutdown()
+ }
+ }
+
+ @Test
+ fun downloadAndVerify_rejectsChecksumMismatch() =
+ runTest {
+ val payload = "the-real-bytes".toByteArray()
+ val server = MockWebServer()
+ server.enqueue(MockResponse().setBody("${sha256Hex("other".toByteArray())} searchmob.apk\n"))
+ server.enqueue(MockResponse().setBody(Buffer().write(payload)))
+ server.start()
+ try {
+ val dir = tempDir()
+ var failed = false
+ try {
+ UpdateInstaller.downloadAndVerify(client(), apkAsset(server), sumsAsset(server), dir)
+ } catch (e: UpdateDownloadException) {
+ failed = true
+ assertTrue(e.message!!.contains("checksum did not match"))
+ }
+ assertTrue("expected a checksum mismatch failure", failed)
+ // A mismatched download must not be left behind under any name.
+ assertFalse(File(dir, "searchmob.apk").exists())
+ assertEquals(emptyList(), dir.listFiles()!!.map { it.name })
+ } finally {
+ server.shutdown()
+ }
+ }
+
+ @Test
+ fun downloadAndVerify_failsWhenNoChecksumEntry() =
+ runTest {
+ val server = MockWebServer()
+ server.enqueue(MockResponse().setBody("${"a".repeat(64)} other.apk\n"))
+ server.enqueue(MockResponse().setBody("x"))
+ server.start()
+ try {
+ var msg: String? = null
+ try {
+ UpdateInstaller.downloadAndVerify(client(), apkAsset(server), sumsAsset(server), tempDir())
+ } catch (e: UpdateDownloadException) {
+ msg = e.message
+ }
+ assertTrue(msg!!.contains("No published checksum"))
+ } finally {
+ server.shutdown()
+ }
+ }
+
+ @Test
+ fun downloadAndVerify_enforcesSizeCap() =
+ runTest {
+ val payload = "x".repeat(5000).toByteArray()
+ val server = MockWebServer()
+ server.enqueue(MockResponse().setBody("${sha256Hex(payload)} searchmob.apk\n"))
+ server.enqueue(MockResponse().setBody(Buffer().write(payload)))
+ server.start()
+ try {
+ val dir = tempDir()
+ var msg: String? = null
+ try {
+ UpdateInstaller.downloadAndVerify(
+ client(),
+ apkAsset(server),
+ sumsAsset(server),
+ dir,
+ maxBytes = 1000,
+ )
+ } catch (e: UpdateDownloadException) {
+ msg = e.message
+ }
+ assertTrue(msg!!.contains("exceeded the expected size"))
+ assertEquals(emptyList(), dir.listFiles()!!.map { it.name })
+ } finally {
+ server.shutdown()
+ }
+ }
+}