Files
thoughtsync/android/app/src/main/AndroidManifest.xml
T
bvandeusen 81695fa0c8
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m28s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m36s
android: update the app from the server it syncs with (2727, M12 step 7)
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.
2026-08-21 08:44:08 -04:00

140 lines
6.8 KiB
XML

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!--
INTERNET is requested but nothing uses it until the user links a server.
The app is local-first: the store, capture and the whole board work with
this permission never exercised.
-->
<uses-permission android:name="android.permission.INTERNET" />
<!--
Four more permissions are NOT declared here and still reach the merged
manifest, contributed by WorkManager for the automatic sync:
RECEIVE_BOOT_COMPLETED reschedules the periodic sync after a restart,
instead of it silently stopping until the app is
next opened by hand
ACCESS_NETWORK_STATE evaluates the "needs a network" constraint, so a
run is not attempted with no route to the server
WAKE_LOCK holds the device awake for the seconds a sync
takes, so it is not suspended mid-request
FOREGROUND_SERVICE used only for expedited work; nothing here asks
for it, and it arrives with the library
Verified against the built APK's merged manifest, not assumed. Noted here
because all four appear in the app's permission list and nothing else in
this file would explain where they came from.
-->
<!--
usesCleartextTraffic, deliberately.
Android blocks plain HTTP by default from API 28, and the core explicitly
supports a self-hosted server on a LAN — `http://192.168.1.10:8000` is a
case it has a test for. Leaving the platform default would make this app
unusable for exactly the people it is built for, with a transport error
they could do nothing about.
Scoped by the fact that the app talks to ONE host: the server the user
typed in. There is no ad SDK, no analytics, nothing else making requests.
A network-security-config would be tighter in principle, but it matches on
domains and IP literals rather than CIDR ranges, so it cannot express
"any address on my own network" — the case that actually matters here.
The trade is not made silently: the sync screen shows an unmissable
warning when the probed address is http://, BEFORE any credential field
appears. See SyncScreen.kt.
-->
<!--
Reminders.
POST_NOTIFICATIONS is a runtime permission from API 33. It is asked for in
context — the first time the app opens holding a reminder that could fire,
never at launch on an empty board, where there would be nothing to explain
why it is being asked.
SCHEDULE_EXACT_ALARM rather than USE_EXACT_ALARM. USE_EXACT_ALARM is granted
at install with no prompt, and is reserved for apps whose whole purpose is an
alarm clock or calendar; a note app claiming it would be claiming something
untrue. SCHEDULE_EXACT_ALARM is the one the person can grant or refuse, and
refusing costs precision, not the feature — see Reminders.scheduleNext.
RECEIVE_BOOT_COMPLETED already arrives via WorkManager (below), but is
declared here too because ReminderReceiver now depends on it directly. A
permission this file relies on should be visible in this file.
-->
<!--
Updating this app from the server it syncs with (M12 step 7).
REQUEST_INSTALL_PACKAGES lets the app hand an APK to the system installer at
all. It is NOT what makes an install look suspicious to on-device heuristics
— Mihon declares it too — the legacy ACTION_VIEW install intent was, and this
app uses a PackageInstaller session instead. See AppUpdate.kt and Scribe note
2437. The person must additionally grant "install unknown apps" in system
settings; the update card asks before downloading anything.
UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+) is what removes the install
confirmation on the UPDATE path, and only there — Android will not let an app
silently put a NEW package on a device, which is correct. It also only applies
when the new build is signed with the same key as the installed one, which is
why signing had to land before any of this could work.
-->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".ThoughtSyncApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ThoughtSync"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.ThoughtSync">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!--
Not exported: every intent that reaches it is one this app created, with
an explicit component. Exporting would let any app on the device mark
someone's reminders as done.
The two system broadcasts are the exception and need the filter, because
the system is the sender. Both exist for the same reason — pending alarms
do not survive either a reboot or an app update, so without this a phone
that restarts overnight would quietly stop reminding anyone of anything.
-->
<!--
Where the system reports what happened to an install we committed. Not
exported: the only sender is the PendingIntent this app handed to
PackageInstaller. Without it a failed install would be indistinguishable
from someone declining the dialog (Scribe #2438).
-->
<receiver
android:name=".UpdateReceiver"
android:exported="false" />
<receiver
android:name=".ReminderReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>