Closes M12. The phone can now notice that its server has a newer build and install it, instead of the operator copying an APK to a device by hand. **A PackageInstaller session, not an install intent.** The obvious route — ACTION_VIEW on the APK — is exactly what on-device install heuristics are tuned against, and it is what produced the "bypassing Android security" warning on Minstrel (Scribe note 2437). It also never tells the OS that this app is the legitimate updater of its own package, and it returns nothing: a failed install is indistinguishable from someone dismissing the dialog. The session says who is doing what, and on Android 12+ declares no user action required — which, with UPDATE_PACKAGES_WITHOUT_USER_ACTION, removes the confirmation entirely on the UPDATE path. Only there: Android will not let an app quietly put a NEW package on a device, which is right. It also only applies when the new build carries the same signing key as the installed one, which is why signing had to land first. Two things from that research deliberately NOT done: `setRequestUpdateOwnership` was chased and turned out to be a red herring, and REQUEST_INSTALL_PACKAGES is not the differentiator either — Mihon declares it too. The mechanism was the whole difference. **The outcome comes back.** `commit` takes an IntentSender and the result lands at `UpdateReceiver`, so a failure can be shown rather than guessed at, and STATUS_PENDING_USER_ACTION is handled — that is the ordinary path below API 31 and still possible above it, since the OS is entitled to ask anyway. Someone declining is reported as no error at all: calling a deliberate choice a failure is how an app sounds broken when it is not. **The network work stays in Rust.** Two FFI additions — `clientUpdate` and `downloadClientUpdate` — because the device token lives in the core, and pulling it into Kotlin to make an HTTP call would spread the one secret this app holds across two languages for nothing. The core also owns the comparison, so the rule "version CODE decides, never the name" lives in the layer that has to get it right for every surface. The download is streamed to disk, not buffered: 55 MiB in memory on a phone is how an update gets killed halfway through. It lands in `update.apk.part` and is renamed only once size and sha256 both match, so an interrupted download can never be mistaken for a finished one. The digest is not a trust anchor — the signature is, and Android checks it — but it catches a truncated transfer before the installer is bothered with it. The advertised path is joined to the base URL this device is LINKED to rather than followed as given, so a server cannot point the download at a host nobody agreed to. **Updates are linked-only, and it says so.** An unlinked install has no update path, so it gets one sentence explaining where updates come from rather than a Check button that silently finds nothing — the same lesson as the desktop's unlink copy (issue 2110). And the "install unknown apps" grant is asked for BEFORE downloading, so nobody spends 55 MiB to be told no. Every Android API here was read out of `android-36/android.jar` with javap first, and the two new FFI methods out of freshly generated bindings, rather than recalled: `suspend fun clientUpdate(installedVersionCode: Long): ClientUpdate?` and `downloadClientUpdate(destPath: String)`. Also fixes `check-symbols.py`, which reported four false positives on `UpdateOutcome.Result` — its object-member index collected functions and properties but not nested TYPES, and a data class inside an object is an ordinary member.
81 lines
3.7 KiB
YAML
81 lines
3.7 KiB
YAML
# Per-rule overrides layered on top of detekt's defaults
|
|
# (`--build-upon-default-config` on the CLI invocation in the Android lane).
|
|
#
|
|
# The pre-2.0 `build:` top-level was removed; failure is controlled by the CLI's
|
|
# exit code instead.
|
|
|
|
naming:
|
|
# Composables conventionally use PascalCase function names. Matches every
|
|
# mainstream Compose codebase, and mirrors the ktlint exemption in
|
|
# android/.editorconfig — the two tools have to agree or one of them is always
|
|
# wrong.
|
|
FunctionNaming:
|
|
ignoreAnnotated:
|
|
- "Composable"
|
|
|
|
style:
|
|
MagicNumber:
|
|
ignoreAnnotated:
|
|
- "Composable"
|
|
# Colour literals and dp constants are declared as named properties, which is
|
|
# exactly the "define it as a well-named constant" the rule asks for — the
|
|
# number simply appears in the declaration itself. Flagging
|
|
# `private val Brand = Color(0xFFF5C518)` would demand a constant holding the
|
|
# constant.
|
|
ignorePropertyDeclaration: true
|
|
|
|
complexity:
|
|
# Compose breaks the PREMISE of both rules below, not just their thresholds.
|
|
#
|
|
# * LongParameterList assumes a long list means an over-general function. A
|
|
# composable's parameters ARE its UI contract — Material's own TextField
|
|
# takes twenty — and collapsing them into a parameter object makes the call
|
|
# site worse, not better, because named arguments are what keep a Compose
|
|
# tree readable.
|
|
# * LongMethod assumes length tracks branching. A composable's length tracks
|
|
# how many ELEMENTS are on the screen; a full-screen editor with a title, a
|
|
# body, a checklist, labels and a reminder row is long because it renders
|
|
# five things, and cutting it into five one-call wrappers would add
|
|
# indirection without removing a single decision.
|
|
#
|
|
# Scoped to @Composable rather than disabled: on ordinary functions both rules
|
|
# are right, and one of them still fires below (see BoardViewModel).
|
|
LongParameterList:
|
|
ignoreAnnotated:
|
|
- "Composable"
|
|
LongMethod:
|
|
ignoreAnnotated:
|
|
- "Composable"
|
|
|
|
exceptions:
|
|
TooGenericExceptionCaught:
|
|
# Catching broadly is DELIBERATE in these two places, and each site says so.
|
|
#
|
|
# * the ViewModel — a note that fails to save must become a visible error
|
|
# banner, never a crash. Narrowing this would mean an unanticipated
|
|
# failure takes the app down instead of being reported, which is strictly
|
|
# worse for the user.
|
|
# * the Application — the store failing to open is the one thing that must
|
|
# still let the app start, so it can explain itself.
|
|
# * the background Worker — it runs with nobody present, so an escaping
|
|
# exception is a crash report for a job the person never asked for. Every
|
|
# realistic failure there (no route, server down, token rotating) has the
|
|
# same right answer, which is Result.retry().
|
|
# * the reminder BroadcastReceiver — same argument, one step worse: it can be
|
|
# woken at 3am by an alarm or by BOOT_COMPLETED, and every path inside it
|
|
# has already logged its own failure by the time this catches anything.
|
|
# * the self-updater — the install path throws IOException from three
|
|
# different calls and SecurityException when the "install unknown apps"
|
|
# grant has been revoked since it was checked. All of them mean one thing
|
|
# to the person ("it did not install"), and none should take the app down
|
|
# while it is holding their notes.
|
|
#
|
|
# Scoped to those paths rather than disabled globally: elsewhere the rule is
|
|
# right and still applies.
|
|
excludes:
|
|
- "**/ui/**"
|
|
- "**/ThoughtSyncApplication.kt"
|
|
- "**/SyncWorker.kt"
|
|
- "**/ReminderReceiver.kt"
|
|
- "**/AppUpdate.kt"
|