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 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 }