Skip to content

Commit a78d70b

Browse files
alex-poorclaude
andcommitted
Tidy up after the follower moved out, and fix two things it exposed
THE MIRROR IS GONE. SwarmFollowerBg wrote somebody else's glucose into AAPS's own table so AAPS would draw it, which needed a high-water mark, tags to stop those rows being re-published as your own, an invented EffectiveProfileSwitch, a guard in the drain and a build-flavour fence around the lot. The follower reads the vault directly now, so all of it is deleted — along with the three preference keys and two strings that existed only to serve it. The drain's cgm filter goes back to `isValid`, because the only rows in that table are ones this phone measured. initAndroid hands the JavaVM and Context to ndk_context at startup, in both apps. Without it iroh's connectivity watcher panics asking Android for a ConnectivityManager; the panic is raised in a tokio task, tokio catches it, the task disappears, and the phone stops noticing that the network changed. That has been happening on a phone driving a pump for as long as this has existed, and it was only visible because `panic = "abort"` briefly turned the silence into a crash. The follower polled every fifteen minutes with the screen open. EVERY_SECONDS was defined and never used — reported from the phone as a reading stuck six minutes old in somebody's hand. The UI now asks every two minutes while it is visible, and the periodic job stays underneath for the background. Driven from the UI rather than chained inside the worker on purpose: a worker that re-enqueues its own unique name with REPLACE cancels itself, which is a stall that looks exactly like a quiet network. CI is one repository again. No second checkout, no AAPS build, and nothing that could produce a pump APK even by accident. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 95a0bf7 commit a78d70b

15 files changed

Lines changed: 159 additions & 606 deletions

File tree

‎.github/workflows/follower.yml‎

Lines changed: 36 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,135 +1,79 @@
1-
# Build the FOLLOWER apk, and only ever the follower.
1+
# Build the follower app.
22
#
3-
# WHAT THIS MUST NEVER BUILD: the `full` flavour. That is the app that drives an
4-
# insulin pump, and this repository does not distribute it — see the README.
5-
# Every gradle invocation here names `Aapsclient` explicitly for that reason,
6-
# and the check job fails the run if a `full` artifact appears anyway.
3+
# ONE REPOSITORY, NO ANDROIDAPS. The follower used to be a flavour of AAPS with
4+
# the dosing stripped out, which meant this workflow checked out a second repo,
5+
# built a whole loop app, and had to actively refuse to publish the pump variant.
6+
# The follower is now its own application containing no AAPS code at all, so
7+
# none of that applies: there is nothing here that could build a loop.
78
#
8-
# The follower is a different proposition: a separate `applicationId`, no pump
9-
# drivers compiled in, and structurally unable to dose (decision D22). It is a
10-
# viewer for somebody else's glucose. Handing that to a person as a file they
11-
# can install is reasonable in a way that handing them a loop is not.
12-
#
13-
# The APK contains AndroidAPS, which is AGPL-3.0. Distributing a binary
14-
# therefore obliges us to publish the corresponding source: the fork and branch
15-
# it was built from are recorded in the release notes, at the exact commit.
9+
# That is also what makes it publishable. F-Droid builds one repository from
10+
# source, and this is one repository whose only unusual need is a Rust toolchain
11+
# to compile the record core into a .so — which is built here, never committed.
1612

1713
name: follower apk
1814

1915
on:
2016
workflow_dispatch:
21-
inputs:
22-
aaps_ref:
23-
description: "Branch or commit of the AAPS fork to build against"
24-
default: diaswarm-addon
2517
push:
2618
tags: ["follower-v*"]
19+
pull_request:
20+
paths: ["follower/**", "crates/**", ".github/workflows/follower.yml"]
2721

2822
permissions:
2923
contents: write # only to attach the apk to a release
3024

31-
env:
32-
AAPS_REPO: alex-poor/AndroidAPS
33-
NDK_VERSION: "28.2.13676358"
34-
3525
jobs:
3626
build:
3727
runs-on: ubuntu-latest
38-
# Whether a signing key is configured, decided once. A step's own `env:`
39-
# block is NOT visible to that step's `if:`, so testing the secret there
40-
# silently never fires and every build comes out unsigned.
4128
env:
4229
HAS_KEY: ${{ secrets.FOLLOWER_KEYSTORE_B64 != '' }}
4330
steps:
44-
- name: Check out diaswarm
45-
uses: actions/checkout@v4
46-
with:
47-
path: diaswarm
48-
49-
# The follower is a build of this fork of AAPS, not of upstream: the
50-
# screen it draws comes from the fork's own overview. AGPL means whatever
51-
# is built here has to be publicly readable, so this checkout failing is
52-
# a licence problem, not just a build problem.
53-
- name: Check out the AAPS fork
54-
uses: actions/checkout@v4
55-
with:
56-
repository: ${{ env.AAPS_REPO }}
57-
ref: ${{ github.event.inputs.aaps_ref || 'diaswarm-addon' }}
58-
path: aaps
31+
- uses: actions/checkout@v4
5932

60-
- name: JDK 21
61-
uses: actions/setup-java@v4
33+
- uses: actions/setup-java@v4
6234
with:
6335
distribution: temurin
6436
java-version: "21"
6537

6638
- name: NDK
6739
run: |
6840
set -euo pipefail
69-
# Prefer whatever the runner already ships: an NDK is ~1 GB and the
70-
# version matters only to cargo-ndk, which is not fussy.
7141
NDK="${ANDROID_NDK_LATEST_HOME:-}"
72-
if [ -z "$NDK" ] || [ ! -d "$NDK" ]; then
73-
SDKM="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
74-
# `yes |` dies of SIGPIPE the moment sdkmanager stops reading, and
75-
# under `pipefail` that fails the step even though the licences were
76-
# accepted. Feed it a fixed number of yeses instead.
77-
printf 'y\n%.0s' {1..50} | "$SDKM" --licenses >/dev/null 2>&1 || true
78-
"$SDKM" "ndk;${NDK_VERSION}" >/dev/null
79-
NDK="$ANDROID_HOME/ndk/${NDK_VERSION}"
80-
fi
42+
[ -n "$NDK" ] && [ -d "$NDK" ] || { echo "::error::no NDK on the runner"; exit 1; }
8143
echo "using NDK at $NDK"
8244
echo "ANDROID_NDK_HOME=$NDK" >> "$GITHUB_ENV"
8345
84-
# Pinned by rust-toolchain.toml in the diaswarm repo; the targets and
85-
# cargo-ndk are what turn the record core into the .so the plugin packages.
8646
- name: Rust for Android
87-
working-directory: diaswarm
8847
run: |
8948
set -euo pipefail
90-
rustup show active-toolchain
9149
rustup target add aarch64-linux-android armv7-linux-androideabi
9250
cargo install cargo-ndk --locked
9351
94-
- name: Cache gradle and cargo
95-
uses: actions/cache@v4
52+
- uses: actions/cache@v4
9653
with:
9754
path: |
9855
~/.gradle/caches
9956
~/.gradle/wrapper
10057
~/.cargo/registry
10158
~/.cargo/git
102-
diaswarm/crates/*/target
103-
key: follower-${{ runner.os }}-${{ hashFiles('diaswarm/**/Cargo.lock', 'aaps/**/*.gradle*') }}
59+
crates/*/target
60+
key: follower-${{ runner.os }}-${{ hashFiles('**/Cargo.lock', 'follower/**/*.gradle*') }}
10461
restore-keys: follower-${{ runner.os }}-
10562

63+
# The record core is asserted byte-identical to tools/canon.py over real
64+
# data; if that is broken there is no point building an app around it.
10665
- name: Test the record core
107-
working-directory: diaswarm/crates/diaswarm-core
66+
working-directory: crates/diaswarm-core
10867
run: cargo test
10968

110-
# Unsigned on purpose: the `release` build type carries no signingConfig,
111-
# so nothing here can accidentally reuse the debug keystore that the loop
112-
# build depends on.
113-
- name: Build the follower
114-
working-directory: aaps
115-
env:
116-
DIASWARM_PLUGIN_DIR: ${{ github.workspace }}/diaswarm/plugin
117-
run: ./gradlew :app:assembleAapsclientRelease -PappVersionSuffix=follower --console=plain
118-
119-
- name: Refuse to ship a pump build
120-
run: |
121-
set -euo pipefail
122-
if compgen -G "aaps/app/build/outputs/apk/full/**/*.apk" > /dev/null; then
123-
echo "::error::a 'full' (pump) APK was produced — this workflow must build the follower only"
124-
exit 1
125-
fi
126-
ls -la aaps/app/build/outputs/apk/aapsclient/release/
69+
- name: Build
70+
working-directory: follower
71+
run: ./gradlew assembleRelease --console=plain
12772

12873
# Signed only when a key is configured. Without one the run still produces
129-
# an unsigned artifact, which is installable by nobody and reviewable by
130-
# anybody — the right outcome for a fork or a pull request.
74+
# an artifact — unsigned, so installable by nobody and reviewable by
75+
# anybody, which is right for a fork or a pull request.
13176
- name: Sign
132-
id: sign
13377
if: env.HAS_KEY == 'true'
13478
env:
13579
KEYSTORE_B64: ${{ secrets.FOLLOWER_KEYSTORE_B64 }}
@@ -140,22 +84,21 @@ jobs:
14084
set -euo pipefail
14185
BT=$(ls -1d "$ANDROID_HOME"/build-tools/*/ | sort -V | tail -1)
14286
echo "$KEYSTORE_B64" | base64 -d > /tmp/follower.jks
143-
IN=aaps/app/build/outputs/apk/aapsclient/release/app-aapsclient-release-unsigned.apk
144-
[ -f "$IN" ] || IN=$(ls aaps/app/build/outputs/apk/aapsclient/release/*.apk | head -1)
145-
"${BT}zipalign" -p 4 "$IN" /tmp/follower-aligned.apk
87+
IN=$(ls follower/build/outputs/apk/release/*.apk | head -1)
88+
"${BT}zipalign" -f -p 4 "$IN" /tmp/aligned.apk
14689
"${BT}apksigner" sign \
14790
--ks /tmp/follower.jks --ks-pass "env:KS_PASS" \
14891
--ks-key-alias "$KEY_ALIAS" --key-pass "env:KEY_PASS" \
149-
--out diaswarm-follower.apk /tmp/follower-aligned.apk
92+
--out diaswarm-follower.apk /tmp/aligned.apk
15093
shred -u /tmp/follower.jks
15194
"${BT}apksigner" verify --print-certs diaswarm-follower.apk | tee signer.txt
95+
ls -l diaswarm-follower.apk
15296
15397
- name: Name the unsigned build for what it is
15498
if: env.HAS_KEY != 'true'
155-
run: cp $(ls aaps/app/build/outputs/apk/aapsclient/release/*.apk | head -1) diaswarm-follower-UNSIGNED.apk
99+
run: cp $(ls follower/build/outputs/apk/release/*.apk | head -1) diaswarm-follower-UNSIGNED.apk
156100

157-
- name: Upload
158-
uses: actions/upload-artifact@v4
101+
- uses: actions/upload-artifact@v4
159102
with:
160103
name: diaswarm-follower
161104
path: |
@@ -168,21 +111,14 @@ jobs:
168111
GH_TOKEN: ${{ github.token }}
169112
run: |
170113
set -euo pipefail
171-
AAPS_SHA=$(git -C aaps rev-parse HEAD)
172114
cat > notes.md <<EOF
173-
Follower app — shows somebody else's glucose. **It cannot dose.**
174-
175-
Separate \`applicationId\` (\`info.nightscout.aapsclient\`), no pump drivers
176-
compiled in, installs alongside AAPS rather than over it.
177-
178-
**This is not the loop app, and this repository does not distribute one.**
115+
Shows somebody else's glucose. **It cannot dose, and contains no loop code.**
179116
180-
Built from:
181-
* diaswarm \`${GITHUB_SHA}\`
182-
* AndroidAPS \`${AAPS_REPO}@${AAPS_SHA}\`
117+
This is not AndroidAPS and does not contain it. To share your own data you
118+
need the AAPS add-on, which you build yourself — this repository does not
119+
distribute a loop and never will.
183120
184-
AndroidAPS is AGPL-3.0. The corresponding source for this binary is the
185-
two commits above; both repositories are public.
121+
Built from \`${GITHUB_SHA}\`.
186122
EOF
187123
gh release create "${GITHUB_REF_NAME}" diaswarm-follower*.apk \
188124
--repo "${GITHUB_REPOSITORY}" --title "${GITHUB_REF_NAME}" --notes-file notes.md

‎crates/diaswarm-android/Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/diaswarm-android/Cargo.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ jni = "0.21"
1818
serde_json = "1"
1919
p2panda-core = "=0.7.1"
2020
diaswarm-spaces = { path = "../diaswarm-spaces" }
21+
# Hands Android's Context to the iroh stack — see `initAndroid`.
22+
ndk-context = "0.1.1"
2123

2224
# WHAT SHIPS ON A PHONE, and it was 39 MB per ABI before this.
2325
#

‎crates/diaswarm-android/src/lib.rs‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,46 @@ use std::path::{Path, PathBuf};
2828
use diaswarm_core::vault::{hex, Identity, Vault};
2929
use diaswarm_core::{epoch_of, header, Emitted, Record};
3030

31+
/// Give the network stack Android's `Context`, once, before anything else.
32+
///
33+
/// **WITHOUT THIS, A TASK DIES EVERY RUN AND NOTHING SAYS SO.** iroh watches for
34+
/// network changes by asking Android for a ConnectivityManager through
35+
/// `ndk_context`, which panics with "android context was not initialized" if
36+
/// nobody has handed one over. That panic happens inside a tokio task, so tokio
37+
/// catches it, the task disappears, and the swarm carries on — without ever
38+
/// noticing that wifi dropped and mobile data took over. It was doing exactly
39+
/// that on a phone driving a pump for as long as this has existed, and it was
40+
/// only found because turning on `panic = "abort"` for a smaller binary turned
41+
/// the silence into a crash on launch.
42+
///
43+
/// The Context is held as a global ref for the life of the process on purpose:
44+
/// `ndk_context` keeps the raw pointer, so letting the JNI local reference lapse
45+
/// would leave it dangling.
46+
///
47+
/// Safe to call more than once; the second call does nothing rather than
48+
/// re-registering a second Context.
49+
#[no_mangle]
50+
pub extern "system" fn Java_nz_diaswarm_jni_SwarmNative_initAndroid<'a>(
51+
env: JNIEnv<'a>,
52+
_class: JClass<'a>,
53+
context: jni::objects::JObject<'a>,
54+
) {
55+
static DONE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
56+
if DONE.swap(true, std::sync::atomic::Ordering::SeqCst) {
57+
return;
58+
}
59+
let Ok(vm) = env.get_java_vm() else { return };
60+
let Ok(global) = env.new_global_ref(context) else { return };
61+
unsafe {
62+
ndk_context::initialize_android_context(
63+
vm.get_java_vm_pointer() as *mut std::ffi::c_void,
64+
global.as_raw() as *mut std::ffi::c_void,
65+
);
66+
}
67+
// The pointer above outlives this scope, so the reference has to as well.
68+
std::mem::forget(global);
69+
}
70+
3171
/// Which version of `spec/records.md` this build implements.
3272
///
3373
/// The plugin should refuse to publish if this disagrees with what it expects:

‎follower/src/main/kotlin/nz/diaswarm/follower/Endpoint.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ object Endpoint {
3333
fun start(context: Context) {
3434
if (handle != 0L) return
3535
SwarmNative.check()
36+
// BEFORE THE ENDPOINT, because the network stack reaches for it as soon
37+
// as it starts watching for connectivity changes.
38+
SwarmNative.initAndroid(context.applicationContext)
3639
val h = SwarmNative.swarmJoin(
3740
SwarmPaths.store(context).absolutePath,
3841
SwarmPaths.nodeKey(context).absolutePath

‎follower/src/main/kotlin/nz/diaswarm/follower/FollowerApp.kt‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,22 @@ fun FollowerApp(onScan: () -> Unit, scanned: String?, onScanHandled: () -> Unit)
3636
// about.
3737
LaunchedEffect(Unit) { while (true) { delay(15_000); tick++ } }
3838

39+
// AND ACTUALLY FETCH WHILE SOMEBODY IS WATCHING. The periodic job's floor is
40+
// fifteen minutes, which is useless to a person looking at the screen: a
41+
// reading arrives every minute or five and they would see it a quarter of an
42+
// hour later. This was exactly the symptom — an app open in the hand,
43+
// showing a number six minutes old and never moving.
44+
//
45+
// Driven from the UI rather than chained from inside the worker on purpose.
46+
// A worker that re-enqueues its OWN unique name with REPLACE cancels itself,
47+
// which is a silent stall that looks precisely like a quiet network.
48+
LaunchedEffect(Unit) {
49+
while (true) {
50+
Sync.now(context)
51+
delay(SyncWorker.EVERY_SECONDS * 1000)
52+
}
53+
}
54+
3955
scanned?.let { text ->
4056
LaunchedEffect(text) {
4157
Invites.queue(context, text)

‎follower/src/main/kotlin/nz/diaswarm/follower/MainActivity.kt‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,7 @@ class MainActivity : ComponentActivity() {
4646
super.onResume()
4747
// Ask now rather than waiting for the poll: somebody opening the app is
4848
// the one moment they are definitely waiting for an answer.
49-
WorkManager.getInstance(this).enqueueUniqueWork(
50-
SyncWorker.UNIQUE + "-now",
51-
ExistingWorkPolicy.KEEP,
52-
OneTimeWorkRequestBuilder<SyncWorker>().build()
53-
)
49+
Sync.now(this)
5450
}
5551

5652
private fun scanOptions() = ScanOptions().apply {

‎follower/src/main/kotlin/nz/diaswarm/follower/SyncWorker.kt‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,34 @@ class SyncWorker(context: Context, params: WorkerParameters) : Worker(context, p
5555
companion object {
5656
const val TAG = "diaswarm"
5757
const val UNIQUE = "diaswarm-sync"
58+
59+
/**
60+
* How often to ask while somebody is looking at the screen.
61+
*
62+
* A CGM produces a reading every one to five minutes, so asking faster
63+
* mostly discovers that nothing changed — and still costs a connection.
64+
*/
5865
const val EVERY_SECONDS = 120L
5966
}
6067
}
68+
69+
/** The one place that asks for a pass, so there is one policy and not three. */
70+
object Sync {
71+
72+
private const val NOW = "diaswarm-sync-now"
73+
74+
/**
75+
* Ask for a pass now.
76+
*
77+
* `KEEP`, so a pass already running is left to finish: it is about to do
78+
* this work anyway, and cancelling it mid-fetch to start again is how a
79+
* refresh loop becomes a refresh stall.
80+
*/
81+
fun now(context: android.content.Context) {
82+
androidx.work.WorkManager.getInstance(context).enqueueUniqueWork(
83+
NOW,
84+
androidx.work.ExistingWorkPolicy.KEEP,
85+
androidx.work.OneTimeWorkRequestBuilder<SyncWorker>().build()
86+
)
87+
}
88+
}

‎follower/src/main/kotlin/nz/diaswarm/jni/SwarmNative.kt‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ package nz.diaswarm.jni
2020
object SwarmNative {
2121

2222
/** Must match the spec version the plugin was written against. See [check]. */
23+
/**
24+
* Hand Android's `Context` to the network stack, once, before anything else.
25+
*
26+
* iroh watches for network changes through Android's ConnectivityManager;
27+
* without a Context it panics inside a tokio task, the task vanishes, and
28+
* the swarm keeps running with no idea that wifi dropped. Pass the
29+
* APPLICATION context — an Activity would be leaked for the life of the
30+
* process, because the native side keeps the reference for ever.
31+
*/
32+
external fun initAndroid(context: android.content.Context)
33+
2334
external fun specVersion(): Int
2435

2536
/**

0 commit comments

Comments
 (0)