Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 73 additions & 21 deletions android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The same Rust core as the desktop, behind a thin JNI boundary
verifiable proof the app cannot phone home:

```bash
$ANDROID_HOME/build-tools/34.0.0/aapt2 dump permissions build/metascrub-arm64-v8a.apk
$ANDROID_HOME/build-tools/36.0.0/aapt2 dump permissions build/metascrub-arm64-v8a.apk
# prints the package name and nothing else — no <uses-permission>
```

Expand All @@ -29,8 +29,10 @@ The same Rust core as the desktop, behind a thin JNI boundary
## Build

Prerequisites are the same toolchain the Tox client uses, under
`$HOME/android-tools` by default (JDK 17, the SDK with build-tools 34.0.0 and
platform android-34, and an NDK); override `ANDROID_TOOLS` to point elsewhere.
`$HOME/android-tools` by default (JDK 17, the SDK with build-tools 36.0.0 and
platform android-36, and an NDK — r28 or newer for 16 KB pages); override
`ANDROID_TOOLS` to point elsewhere. Every SDK level, the version number and the
ABI list live in one file, `config.sh`, which both builds read.
Plus the Rust Android targets:

```bash
Expand Down Expand Up @@ -64,30 +66,80 @@ copy**. Confirm the saved file opens and carries no metadata.
`arm64-v8a` covers essentially every phone from ~2017 on (including the CMF
Phone 1). `armeabi-v7a` is built too, for older 32-bit devices.

## Releasing (website download / Accrescent)
## Releasing

The build signs with a **debug** key (`android/debug.keystore`, generated on
first run, never committed) — fine for testing, not for a public release. For a
release:
Two shapes, built from the same source and the same `config.sh`, so they cannot
disagree about what version they are:

1. Generate a release key **kept off this machine** and back it up — losing it
means never being able to update the app for existing installs:
| | command | output | for |
|---|---|---|---|
| One APK per ABI | `build-apk.sh <abi>` | `build/metascrub-<abi>.apk` | direct download, a handset |
| An APK set | `build-bundle.sh` | `build/metascrub.apks` (and the `.aab`) | a store |

```bash
keytool -genkeypair -v -keystore crake-release.jks -alias metascrub \
-keyalg RSA -keysize 4096 -validity 10000
```
### The signing key

2. Re-sign the aligned APK with it (swap the `apksigner` line's `--ks` /
passwords in `build-apk.sh`, or sign `build/aligned.apk` manually).
Without one, `build-apk.sh` signs with a **debug** key it generates on first run
(`android/debug.keystore`, never committed). That is right for a phone on the
desk and rejected by every store, which is what a key with a published password
deserves. Generate the real one once, keep it **off the build machine**, and back
it up — losing it means never being able to update the app for existing installs,
because Android identifies an app by its signature and nothing else:

3. Publish the APK for direct download over the onion/site, with its SHA-256 so
people can verify it. Sideloading needs "install unknown apps" enabled for the
browser — the app is not in Play, by design.
```bash
keytool -genkeypair -v -keystore metascrub-release.jks -alias metascrub \
-keyalg RSA -keysize 4096 -validity 10000
```

Both builds then take it from the environment. Leave the passwords out: the tools
ask at the terminal, so the passphrase never lands in a shell history or a
process listing.

```bash
export METASCRUB_KEYSTORE=/path/to/metascrub-release.jks
export METASCRUB_KEY_ALIAS=metascrub
android/build-bundle.sh
```

4. **Accrescent** (optional, later): Accrescent wants its own signing and a short
review; the zero-permission manifest and reproducible-ish direct-tools build
are a good fit. This is a follow-up, not a blocker for a website download.
`build-bundle.sh` verifies what it produced before it says it worked: every APK
in the set is checked for 16 KB alignment and a valid signature, the set is
checked against the 128 MiB ceiling, and anything still carrying a build
timestamp is reported rather than quietly shipped.

### Direct download

Build each ABI, publish the APKs with their SHA-256 so people can check what they
downloaded. Sideloading needs "install unknown apps" enabled for the browser; the
app is not in Play, by design.

### Accrescent

Where the app actually stands against [their publishing
requirements](https://accrescent.app/docs/guide/publish/requirements.html):

| Requirement | State |
|---|---|
| APK set from bundletool 1.11.4+, at most 128 MiB | `build-bundle.sh`, ~1 MB |
| Signed v2/v3, never a debug certificate, one certificate only | release key, above |
| targetSdk tracks Google Play's floor (36 from 31 August 2026) | `config.sh` |
| `debuggable`, `testOnly`, `usesCleartextTraffic` all absent | manifest declares none |
| No non-standard update mechanism | there is no networking code at all |
| A 512×512 PNG icon | `listing/icon-512.png`, generated from the launcher icon by `make-store-icon.py` |
| Listing text (what the app is, what it does not do) | to do |
| Developer console account (GitHub login, currently allowlisted) | to do — access has to be requested |
| A domain matching the app ID, verified by a DNS record they send | **blocked**: `org.crake.metascrub` requires `crake.org` |

The app ID is the one thing here that cannot be changed later: it is the app's
permanent identity on the store and on every device that installs it. So the name
question and the domain settle first, and everything else waits behind them.

One thing worth deciding before publishing anywhere, not after: from September
2026 Google requires developers of apps installed on certified Android devices to
register a legal identity, a government ID and a signing key with it, starting in
Brazil, Indonesia, Singapore and Thailand and spreading through 2027. It applies
to sideloading and to third-party stores, not only Play. Accrescent itself is
[largely unaffected](https://blog.accrescent.app/posts/android-developer-verification/)
because it always distributed developer-signed apps, but the burden lands on the
developer, and this project is deliberately pseudonymous.

## What is deliberately minimal in v1

Expand Down
9 changes: 9 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.crake.metascrub">

<!--
extractNativeLibs="false" is the manifest half of the 16 KB page work in
build-apk.sh: it says the library is to be mapped straight out of the
installed archive instead of being unpacked into the app's data directory
first. That needs the .so stored uncompressed and aligned, which the build
does and then verifies, and in exchange the app takes half the disk space
and has only one copy of the library on the device rather than two.
-->
<application
android:allowBackup="false"
android:extractNativeLibs="false"
android:largeHeap="true"
android:supportsRtl="true"
android:icon="@mipmap/ic_launcher"
Expand Down
34 changes: 34 additions & 0 deletions android/app/src/main/java/org/crake/metascrub/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.Insets;
import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.provider.DocumentsContract;
import android.provider.OpenableColumns;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.View;
import android.view.WindowInsets;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.LinearLayout;
Expand Down Expand Up @@ -150,6 +153,7 @@ private static Locale toLocale(String code) {
protected void onCreate(Bundle state) {
super.onCreate(state);
setContentView(R.layout.activity_main);
applyBarInsets();

findings = findViewById(R.id.findings_container);
handbookContainer = findViewById(R.id.handbook_container);
Expand Down Expand Up @@ -199,6 +203,36 @@ protected void onCreate(Bundle state) {
render();
}

/**
* Keep the layout out from under the status and navigation bars.
*
* From Android 15 an app targeting 35 or above is laid out edge to edge
* whether it asked to be or not, and the theme's statusBarColor and
* navigationBarColor are ignored. Left alone, the header would sit behind
* the clock and the buttons behind the gesture bar. Padding the root view by
* the system bars, and by a camera cutout (which is not one of them), is the
* whole fix and needs no support library.
*
* The padding shows the window background, so on this always-dark theme the
* bars keep reading as part of the app rather than as a light strip.
*/
@SuppressWarnings("deprecation") // pre-30 has no getInsets(int); these are what it has
private void applyBarInsets() {
findViewById(R.id.root).setOnApplyWindowInsetsListener((v, insets) -> {
if (Build.VERSION.SDK_INT >= 30) {
Insets bars = insets.getInsets(
WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout());
v.setPadding(bars.left, bars.top, bars.right, bars.bottom);
} else {
v.setPadding(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(),
insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom());
}
// Passed on rather than consumed: nothing here needs to be the last
// view to see them, and swallowing insets breaks anything added later.
return insets;
});
}

// --- tabs ------------------------------------------------------------

private void switchTab(boolean handbook) {
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
chapters are both built in code, so they can be styled and localised.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
Expand Down
94 changes: 74 additions & 20 deletions android/build-apk.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ JAR="$ANDROID_HOME/platforms/android-$TARGET_SDK/android.jar"
# Windows paths for the Java/Windows tools, POSIX for the shell.
win() { if command -v cygpath >/dev/null; then cygpath -w "$1"; else printf '%s' "$1"; fi; }

# Some build tools are a Java launcher: a .bat on Windows and an extensionless
# shell script everywhere else, both in the same directory. Pick whichever is
# actually there, so the same script runs on a Linux box (and one day in CI)
# without a second copy of the build that can drift from this one.
bt() {
local name="$1"; shift
if [ -f "$BT/$name.bat" ]; then "$BT/$name.bat" "$@"; else "$BT/$name" "$@"; fi
}

# python is called "python" on the Windows box this is usually built on and
# "python3" on most Linux ones; zip-time.py runs under either.
PY="${PYTHON:-python}"
command -v "$PY" >/dev/null || PY=python3

# Clean this build's intermediates and this ABI's own previous APK, but leave
# any other ABI's finished APK in place — a release ships several, and they are
# built one `build-apk.sh <abi>` at a time.
Expand Down Expand Up @@ -70,7 +84,20 @@ export CARGO_TARGET_DIR="$root/target-android"
# `trim-paths` in [profile.release] is the tidy form of this, but it is not
# stable in Cargo 1.97.1 and this workspace pins stable on purpose.
: "${CARGO_HOME:=$HOME/.cargo}"
export RUSTFLAGS="${RUSTFLAGS:-} --remap-path-prefix=$(win "$CARGO_HOME")=/cargo --remap-path-prefix=$(win "$root")=/src"

# Link the library for 16 KB memory pages.
#
# Android has always had 4 KB pages; newer arm64 devices have 16 KB ones, and an
# app targeting 35 or above is expected to load on both. A .so laid out for 4 KB
# pages cannot be mapped by a kernel using 16 KB ones, so the app dies at
# System.loadLibrary with a message about the library not being page-aligned.
#
# NDK r28 and later already link this way, but the flag is passed explicitly
# rather than assumed, because the NDK here is whichever one env.sh resolved and
# a build that quietly depends on the toolchain version is a build that breaks
# on someone else's machine. `zipalign -P 16` further down does the other half:
# the library must also sit on a 16 KB boundary inside the archive.
export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-Wl,-z,max-page-size=16384 --remap-path-prefix=$(win "$CARGO_HOME")=/cargo --remap-path-prefix=$(win "$root")=/src"

# cargo-ndk writes the stripped .so straight into the staging tree.
(cd "$root" && cargo ndk -t "$ABI" -o "$(win "$out/apk/lib")" build --release -p metascrub-android)
Expand All @@ -94,7 +121,7 @@ echo "==> resources"
--manifest "$app/AndroidManifest.xml" \
--java "$out/gen" \
--min-sdk-version "$MIN_SDK" --target-sdk-version "$TARGET_SDK" \
--version-code 1 --version-name 0.1.0 \
--version-code "$VERSION_CODE" --version-name "$VERSION_NAME" \
"$out/compiled/res.zip"

echo "==> java"
Expand All @@ -108,20 +135,29 @@ echo "==> java"
$(find "$out/gen" -name '*.java')

echo "==> dex"
"$BT/d8.bat" --min-api "$MIN_SDK" --release \
bt d8 --min-api "$MIN_SDK" --release \
--output "$(win "$out/dex")" \
$(find "$out/classes" -name '*.class')

echo "==> package"
# Native libraries are stored (not deflated) and page-aligned so the app can
# load its own .so on newer Android; --no-compress plus `zipalign -p` does that.
# load its own .so straight out of the archive; --no-compress plus `zipalign -P`
# does that, and the manifest's extractNativeLibs="false" is what asks for it.
cp "$out/base.apk" "$out/unaligned.apk"
cp "$out/dex/classes.dex" "$out/apk/classes.dex"
(cd "$out/apk" && "$JAVA_HOME/bin/jar" --update --no-compress \
--file "$(win "$out/unaligned.apk")" classes.dex "lib")

echo "==> align"
"$BT/zipalign" -p -f 4 "$out/unaligned.apk" "$out/aligned.apk"
# -P 16 places the stored .so on a 16 KB boundary, which is what a device with
# 16 KB pages needs in order to map it without extracting it first. It replaces
# the older `-p` (which means 4 KB) and needs build-tools 35 or newer.
"$BT/zipalign" -P 16 -f 4 "$out/unaligned.apk" "$out/aligned.apk"

# Checked, not assumed. A wrongly aligned library is invisible until the app
# starts on a 16 KB device, which is exactly the kind of failure that reaches a
# user before it reaches us.
"$BT/zipalign" -c -P 16 4 "$out/aligned.apk"

echo "==> flatten timestamps"
# ZIP records modification times as MS-DOS date/time, which has no timezone
Expand All @@ -133,24 +169,42 @@ echo "==> flatten timestamps"
#
# Between align and sign, and in that order for two reasons: the v2/v3
# signature covers these bytes, and the patcher edits them in place so
# `zipalign -p`'s page alignment of the .so survives untouched.
python "$here/zip-time.py" "$out/aligned.apk"
# `zipalign -P 16`'s page alignment of the .so survives untouched.
"$PY" "$here/zip-time.py" "$out/aligned.apk"

echo "==> sign"
# A local debug key, generated on first run and never committed. A public
# release is signed with a key kept off this machine — see README.md.
KS="$here/debug.keystore"
if [ ! -f "$KS" ]; then
"$JAVA_HOME/bin/keytool" -genkeypair -v \
-keystore "$(win "$KS")" -storepass android -keypass android \
-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Crake Debug, OU=, O=, L=, S=, C=" >/dev/null
# By default a local debug key, generated on first run and never committed:
# enough to put the app on a handset, and rejected by any store, which is the
# correct behaviour for a key that lives on the build machine.
#
# Set METASCRUB_KEYSTORE and METASCRUB_KEY_ALIAS to sign with the real release
# key instead (for a direct download; a store upload goes through
# build-bundle.sh). The passwords are deliberately NOT read from the
# environment: leave them unset and apksigner asks at the terminal, so the
# release passphrase never sits in a shell history, a process listing or a
# .bash_profile. METASCRUB_KS_PASS / METASCRUB_KEY_PASS exist for an unattended
# build and take apksigner's own syntax (`file:...`, `env:VAR`, `pass:...`).
sign_args=(--v1-signing-enabled false --v2-signing-enabled true --v3-signing-enabled true)
if [ -n "${METASCRUB_KEYSTORE:-}" ]; then
: "${METASCRUB_KEY_ALIAS:?set METASCRUB_KEY_ALIAS alongside METASCRUB_KEYSTORE}"
[ -f "$METASCRUB_KEYSTORE" ] || { echo "no such keystore: $METASCRUB_KEYSTORE" >&2; exit 1; }
echo " release key: $METASCRUB_KEYSTORE ($METASCRUB_KEY_ALIAS)"
sign_args+=(--ks "$(win "$METASCRUB_KEYSTORE")" --ks-key-alias "$METASCRUB_KEY_ALIAS")
if [ -n "${METASCRUB_KS_PASS:-}" ]; then sign_args+=(--ks-pass "$METASCRUB_KS_PASS"); fi
if [ -n "${METASCRUB_KEY_PASS:-}" ]; then sign_args+=(--key-pass "$METASCRUB_KEY_PASS"); fi
else
KS="$here/debug.keystore"
if [ ! -f "$KS" ]; then
"$JAVA_HOME/bin/keytool" -genkeypair -v \
-keystore "$(win "$KS")" -storepass android -keypass android \
-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Crake Debug, OU=, O=, L=, S=, C=" >/dev/null
fi
echo " DEBUG key — for a handset, never for a release"
sign_args+=(--ks "$(win "$KS")" --ks-pass pass:android --key-pass pass:android)
fi
"$BT/apksigner.bat" sign \
--ks "$(win "$KS")" --ks-pass pass:android --key-pass pass:android \
--v1-signing-enabled false --v2-signing-enabled true --v3-signing-enabled true \
--out "$out/metascrub-$ABI.apk" "$out/aligned.apk"
"$BT/apksigner.bat" verify --print-certs "$out/metascrub-$ABI.apk" | head -3
bt apksigner sign "${sign_args[@]}" --out "$out/metascrub-$ABI.apk" "$out/aligned.apk"
bt apksigner verify --print-certs "$out/metascrub-$ABI.apk" | head -3

# Leave only the signed APK(s) behind, not the staging tree.
rm -rf "$out/compiled" "$out/classes" "$out/dex" "$out/apk" "$out/gen" \
Expand Down
Loading
Loading