Three things Bryan asked for, and one the second of them exposed.
**Notifications.** A session that asks a question or finishes a turn now
says so on the phone, per session, switchable from its settings screen.
The switch is stored on the backend rather than the phone, because it is a
fact about the session: one that runs unattended overnight should be quiet
on every device, and answering that question again on each device is how two
of them come to disagree. It is on by default -- a notification nobody
wanted is turned off in one tap, where one that never arrived is not
diagnosable at all.
Which moments count is `notification_for`, and the asymmetry in it is the
point. *Waiting on a person* is worth saying however it was reached.
*Finished* is only worth saying when this server watched the work happen:
sessions settle into idle for several reasons that are not "your work
ended", including every one of them being adopted at startup, and announcing
those would put "finished" on the phone for the whole config on every
backend restart. That is the failure that makes somebody switch the feature
off, so it has a test naming every transition rather than the two that
work.
The stream is `GET /notifications`, live only and with no cursor -- the one
place this server does not offer to catch a client up. A notification is a
claim about now; replaying "your turn" from an hour ago sends somebody to a
session that may have been answered from another device since, and a
notification that is wrong costs the trip *and* the credibility of the next
one. What was missed is still on the session list, which says what is
waiting without claiming to be news.
On the phone it is a foreground service, because Android has had no
long-lived background service since 8.0 -- it is what Syncthing does, and
Discord is not a counter-example since it takes a push from Google, which
would mean this backend talking to Google about somebody's sessions. The
ongoing notification Android charges for it sits on an `IMPORTANCE_MIN`
channel: no sound, no status-bar icon, bottom of the shade. `specialUse`
rather than `dataSync`, which is what it looks like: Android 15 caps
dataSync at six hours a day, and a connection that stops listening after six
hours misses the overnight run it exists for.
**A stop is not an error.** The CLI reports an interrupted turn exactly as
it reports a broken one -- `is_error` on a `result` -- so pressing Stop
showed "the turn ended with an error" for doing what the button says. The
line cannot distinguish them; what does is that this side asked, so the
driver says so before the request goes out and the translator spends that on
the next result. The test's second half is the one that matters: the naive
fix passes the first half and silences every genuine failure after it.
**Every status says which one it is.** The session screen's status row named
only `exited` and left the rest blank, so idle and "nobody could read it"
looked identical -- and a just-stopped turn showed nothing, which reads as
the app having lost the session rather than as the stop having worked. The
words are the session list's own, so a state is not called two things
depending which screen you are on. Red on a quota bar now starts at 90%.
**`GET /sessions/{id}`**, which the notification switch found missing. A
screen opened from a list row carries the row the list last fetched: fine
for a title, wrong for a switch, which is *set to* something. Caught on the
emulator, where the switch read on against a backend that said off, with
nothing on screen to say which was true. The screen now reads the session
when it opens, and until that answers the switch is disabled and says so --
a two-position control cannot say "I do not know", so it does not pretend
to.
Verified on the emulator with the app backgrounded: the service holds the
stream (`isForeground=true types=0x40000000`), a finished turn posts
"Finished" and a question replaces it with "Waiting for you" on the same
tag, turning the switch off silences it with no restart, and turning it back
on from the phone reaches config.ron. The interrupt is a translator test
rather than a live turn, which is where that logic is anyway.
100 lines
5.6 KiB
XML
100 lines
5.6 KiB
XML
<?xml version="1.0" encoding="utf-8"?>
|
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
xmlns:tools="http://schemas.android.com/tools">
|
|
<uses-permission android:name="android.permission.INTERNET" />
|
|
<!-- Android 17 (API 37) made Local Network Protection mandatory: an app
|
|
targeting 37+ needs this runtime permission to reach *any* local
|
|
network address, including a plain socket to a LAN IP literal.
|
|
Without it the traffic is silently dropped, surfacing only as a
|
|
connect timeout. See MainActivity.kt's runtime request, and
|
|
dev-updater's manifest for the full story. -->
|
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
|
|
|
<!-- Telling somebody a session wants them. POST_NOTIFICATIONS is a
|
|
runtime permission from Android 13; the foreground-service pair
|
|
below is what lets the connection outlive the app being closed,
|
|
which is the entire point (see Notifications.kt). -->
|
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
|
|
|
<!-- tools:ignore MissingApplicationIcon: there is no icon yet, and
|
|
that is a decision rather than an oversight. An app with no icon
|
|
of its own is obvious to anyone who opens a launcher, so the
|
|
warning tells nobody here anything they cannot already see, and
|
|
the fix is a judgement about how this app should look. Drop this
|
|
suppression when a real icon lands. -->
|
|
<application
|
|
android:label="AI Sessions"
|
|
android:allowBackup="true"
|
|
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
|
tools:ignore="MissingApplicationIcon">
|
|
<!-- adjustResize (not the system's default pan): the layout handles
|
|
the keyboard itself via imePadding(), so the window must resize
|
|
rather than slide the top bar off screen. -->
|
|
<activity
|
|
android:name=".MainActivity"
|
|
android:exported="true"
|
|
android:launchMode="singleTop"
|
|
android:windowSoftInputMode="adjustResize"
|
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
|
<intent-filter>
|
|
<action android:name="android.intent.action.MAIN" />
|
|
<category android:name="android.intent.category.LAUNCHER" />
|
|
</intent-filter>
|
|
<!-- Enrollment: the server prints its aiapp://enroll QR to the
|
|
terminal. This intent filter is the fallback path for a
|
|
camera app that redirects a scanned aiapp:// URI here
|
|
directly; the Settings screen's own "Scan QR code" button
|
|
(zxing-android-embedded) is the primary path and needs no
|
|
filter, since it decodes the QR itself and hands the URI
|
|
to parseEnrollmentUri in-process. -->
|
|
<intent-filter>
|
|
<action android:name="android.intent.action.VIEW" />
|
|
<category android:name="android.intent.category.DEFAULT" />
|
|
<category android:name="android.intent.category.BROWSABLE" />
|
|
<data android:scheme="aiapp" android:host="enroll" />
|
|
</intent-filter>
|
|
</activity>
|
|
|
|
<!-- specialUse rather than dataSync, which is the type this looks
|
|
like: Android 15 caps dataSync at six hours a day, and a
|
|
connection that stops listening after six hours is one that
|
|
misses the overnight run it exists for. The subtype below is
|
|
the reason string that type requires. -->
|
|
<service
|
|
android:name=".NotificationService"
|
|
android:exported="false"
|
|
android:foregroundServiceType="specialUse">
|
|
<property
|
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
|
android:value="Holds one connection to the user's own backend so a session
|
|
that needs an answer can be reported while the app is closed. There is no
|
|
push service: the backend is reachable only over the user's WireGuard
|
|
tunnel and never talks to a third party." />
|
|
</service>
|
|
|
|
<!-- The scanner behind Settings' "Scan QR code". Declared here so
|
|
it can drop the library CaptureActivity's landscape pin: the
|
|
code being scanned is usually on a monitor in front of someone
|
|
holding the phone upright. zxing_CaptureTheme is the library's
|
|
own fullscreen theme, which is all the activity needs. -->
|
|
<!-- tools:ignore DiscouragedApi: lint flags every fixed
|
|
screenOrientation, because Android 16 ignores most of them.
|
|
This one is not a pin but its removal. fullSensor is what
|
|
drops the library's landscape lock, so the activity follows
|
|
the phone rather than asking anyone to turn it, and where the
|
|
platform ignores the attribute the behaviour is the one this
|
|
asked for anyway. Scoped to this activity, so a genuine pin
|
|
elsewhere would still be reported. -->
|
|
<activity
|
|
android:name="com.example.wgapplink.EnrollmentScanActivity"
|
|
android:clearTaskOnLaunch="true"
|
|
android:screenOrientation="fullSensor"
|
|
android:stateNotNeeded="true"
|
|
android:theme="@style/zxing_CaptureTheme"
|
|
android:windowSoftInputMode="stateAlwaysHidden"
|
|
tools:ignore="DiscouragedApi" />
|
|
</application>
|
|
</manifest>
|