From 1b1a834f5f349d1bdb4d34595673a8efc7685fc0 Mon Sep 17 00:00:00 2001 From: FlintWave Date: Wed, 3 Jun 2026 00:16:50 -0700 Subject: [PATCH] feat: surface update availability and add one-tap APK install (Android A-parity) Mirrors the desktop update-notifier work. The launch-time GitHub check already worked but barely notified (a one-shot dialog). This makes a found update visible and actionable while the app is open, and adds an in-app install path. - Notify while open: a system notification (own "App updates" channel) plus a dismissible top banner. Both driven by a persisted pending-update record so they survive a restart and self-clear once the user is current. - Owner-only served-page banner: home + results show the notice to the loopback owner only (a network visitor never sees it / the version is not leaked). - One-tap install: re-fetch the latest release, download the APK, verify its SHA-256 against the release SHA256SUMS, then hand it to the system PackageInstaller (which shows its own confirmation; nothing installs silently). Linux-style fallback to the release page on no-asset/failure. - New REQUEST_INSTALL_PACKAGES permission + PackageInstallReceiver to launch the system install-confirmation UI. UpdateInfo gains assets parsing (apkAsset/checksumsAsset); UpdateInstaller (download+verify+install), UpdateFlow (orchestration), UpdateNotifier (channel + post). Coordinator persists pending state; PreferencesRepository gains the pending fields. SearchServer threads an owner-only updateBanner provider. Tests: assets parsing/selection, installer SHA-256 verify (match/mismatch/ missing/oversize), coordinator pending persistence, served-banner owner route. ktlintCheck + lintDebug + testDebugUnitTest + assembleDebug all green. Verified on the API 35 emulator: in-app banner, system notification, and served-page banner all appear for a build older than the published release. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 12 + app/src/main/AndroidManifest.xml | 9 + .../main/java/org/searchmob/MainActivity.kt | 208 +++++++++++++----- .../java/org/searchmob/server/SearchServer.kt | 42 +++- .../org/searchmob/service/SearchMobService.kt | 27 +++ .../ui/prefs/PreferencesRepository.kt | 28 +++ .../org/searchmob/ui/prefs/UserPreferences.kt | 6 + .../update/PackageInstallReceiver.kt | 28 +++ .../update/UpdateCheckCoordinator.kt | 13 +- .../org/searchmob/update/UpdateChecker.kt | 40 +++- .../java/org/searchmob/update/UpdateFlow.kt | 39 ++++ .../org/searchmob/update/UpdateInstaller.kt | 177 +++++++++++++++ .../org/searchmob/update/UpdateNotifier.kt | 81 +++++++ app/src/main/res/values/strings.xml | 9 + .../searchmob/server/UpdateBannerRouteTest.kt | 85 +++++++ .../update/UpdateCheckCoordinatorTest.kt | 36 +++ .../org/searchmob/update/UpdateCheckerTest.kt | 34 +++ .../searchmob/update/UpdateInstallerTest.kt | 153 +++++++++++++ 18 files changed, 964 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/org/searchmob/update/PackageInstallReceiver.kt create mode 100644 app/src/main/java/org/searchmob/update/UpdateFlow.kt create mode 100644 app/src/main/java/org/searchmob/update/UpdateInstaller.kt create mode 100644 app/src/main/java/org/searchmob/update/UpdateNotifier.kt create mode 100644 app/src/test/java/org/searchmob/server/UpdateBannerRouteTest.kt create mode 100644 app/src/test/java/org/searchmob/update/UpdateInstallerTest.kt 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() + } + } +}