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>
105 lines
5.2 KiB
Kotlin
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
|
|
}
|