Files
dev-updater/app/androidApp/src/main/kotlin/com/example/devupdater/InstalledBuilds.kt
T
irisandClaude Opus 5 f983db154d Say a build is signed with the wrong key before the installer does
Three things.

A download whose signing certificate does not match the installed
copy is stopped with a dialog naming both digests and offering the
one thing that gets past it: removing the old app. Android's own
answer is "App not installed" with no cause, which reads as the
download having failed. Where either certificate cannot be read the
answer is "don't know" and the install goes ahead as before. The
download is carried on the component's state so that the removal is
followed by the install it was for rather than by a second download.

Pressing that revealed that ACTION_DELETE now needs
REQUEST_DELETE_PACKAGES, and fails invisibly without it -- so the
"Remove the old app" offer for a renamed package had presumably
never worked either.

The build mode and the installed variant are one dropdown, not two.
They answer to the same words, so two pickers offering debug and
release read as one choice asked twice. Where a component declares
modes the mode is the whole answer, and the server serves the build
named after it rather than the newest. Every dropdown now hangs from
one outlined pill with a chevron.

And a checkout parked on a chosen commit is no longer called out of
date, with HEAD in the commit picker as the way back to following
the branch. The commit list comes from that branch rather than from
HEAD, so parking no longer hides the commits after it -- the same
one-way door the tracked-only dirty check closed, in a place that
check did not reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 08:27:03 -04:00

105 lines
5.2 KiB
Kotlin

package com.example.devupdater
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import java.io.File
// These are ad hoc local rebuilds with no CI bumping a version code, so it
// can't tell "already have this build" apart from "update available" --
// during active development the version code routinely stays put across
// many rebuilds. PackageManager tracks something better for this purpose
// regardless: `lastUpdateTime`, the epoch millis of when the currently
// installed copy was actually installed, maintained by the OS itself on
// every install (including a plain `adb install -r`, unlike a
// download-tracked-in-SharedPreferences approach, which would only learn
// about installs that went through this app's own download button).
// Compared directly against `/manifest`'s build-mtime epoch (see
// UpdateManifest.kt) -- both are wall-clock timestamps, so as long as the
// device and dev machine roughly agree on the time (true for an emulator
// or a phone on the same LAN), "installed after the currently-served build
// was produced" is a reliable proxy for "already have that build."
//
// Querying another app's PackageInfo needs package visibility on API 30+,
// normally granted per-package via this app's own <queries> in
// AndroidManifest.xml -- but that would mean a manifest edit (and a
// rebuild) every time a new app is added to the server's /manifest. This
// app instead holds QUERY_ALL_PACKAGES, which lets it query any installed
// package by name with no such declaration. That permission is a Play
// Store *policy* restriction, not something the OS itself enforces, so
// it's free to use here since this app is never distributed through Play (F-Droid
// takes the same approach for the same reason -- see AndroidManifest.xml).
fun installedLastUpdateTimeMillis(context: Context, packageName: String): Long? =
try {
context.packageManager.getPackageInfo(packageName, 0).lastUpdateTime
} catch (_: PackageManager.NameNotFoundException) {
null
}
// Whether this package is on this device at all -- same query, asked as
// the question the caller actually has.
fun isInstalled(context: Context, packageName: String): Boolean =
installedLastUpdateTimeMillis(context, packageName) != null
// The installed APK's own file size, for showing "old size -> new size" next
// to an available update -- same package-visibility caveat as above.
fun installedApkSizeBytes(context: Context, packageName: String): Long? =
try {
val sourceDir =
context.packageManager.getPackageInfo(packageName, 0).applicationInfo?.sourceDir
sourceDir?.let { File(it).length() }
} catch (_: PackageManager.NameNotFoundException) {
null
}
// PACKAGE_ADDED/PACKAGE_REPLACED are protected system broadcasts -- only
// the OS can send them -- fired the moment PackageManager finishes
// registering an install, which happens before the installer's own "App
// installed" confirmation screen appears. That makes this a strictly
// earlier and more precise signal than polling or waiting for this app's
// activity to next resume (the latter only happens once the user backs out
// of that confirmation screen). Context-registered rather than
// manifest-declared since this app only cares about it while some screen
// is actually observing install state, not for the whole time it's
// installed -- see the paired unregisterReceiver call at the caller's
// DisposableEffect.
//
// RECEIVER_NOT_EXPORTED is correct, not just required (API 33+ rejects a
// context-registered receiver with neither flag): nothing but the system
// can send this broadcast regardless, so there's no legitimate case for
// another app to inject it here.
fun registerPackageChangeReceiver(
context: Context,
onPackageChanged: (packageName: String) -> Unit,
): BroadcastReceiver {
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val packageName = intent.data?.schemeSpecificPart ?: return
// An install over an existing copy sends REMOVED and then
// ADDED, with EXTRA_REPLACING marking the pair. Reporting
// the first would put "this package is gone" on screen for
// the moment between them, which is a state nothing here
// is in.
if (intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) return
onPackageChanged(packageName)
}
}
val filter =
IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REPLACED)
// Removals as well as arrivals, because one of them is a step
// in something this app started: an app signed with a
// different key has to be removed before the download can go
// on, and this is what says the way is clear.
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
}
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
return receiver
}