Skip to content

Commit 97f9666

Browse files
committed
Build the release APK, and check the artifact rather than a variant of it
assembleRelease had never run. It does now, and what it found was worth the running: three things had to change before a minified build existed at all, and one of them is a rule this project's tunnel depends on. Signing is conditional. The keystore is the maintainer's, lives nowhere in this repository, and a build that failed without one would make "can this be built" and "can this be published" the same question when they are not. Without a keystore the build falls back to the debug key: an APK signed with it cannot be published — every store refuses it — so it cannot be mistaken for a release, while it can be installed, which is what makes the artifact testable at all. checkReleaseArtifact reads the shipped file. Fourteen JNI names must survive R8 under their own names, and native libraries must be present for both ABIs. Verified to fail, and the failure taught something: removing the `native <methods>` rule changes nothing, because the platform's default file carries the same one. Removing `-keep class NativeBridge` loses four — nativeTcpWrite, onTcpAccept, onTcpRecv, onUdpRecv — because the callbacks C calls into are ordinary Kotlin methods and the native-method rule does not reach them. Both facts are now written where the rules are, measured rather than assumed. Running the instrumentation suite against the minified build was tried first and abandoned, and the reason belongs in the record. The two APKs are shrunk by two separate R8 runs, so the test APK's calls resolve to names the application no longer has; the keeps that fix that would also keep every JNI entry point, masking exactly the mistake the run exists to catch. Reading the artifact is a stronger answer to the question actually being asked. release-smoke.sh does the other half: install, start, still running six seconds later. Two checks were written into it first and both reported a failure that was the check's rather than the app's — the native library is loaded when a tunnel starts rather than when a screen opens, and a modern APK does not extract its libraries at all, so an install directory's lib/ is empty on a working install. What the file carries moved to the gate that reads the file. D-17's obligation is discharged: `build-deps.sh --bundle-source` produces the corresponding source for the statically linked ngtcp2 and aws-lc, verified at 145 MB from the same verified trees the build used. Nothing is distributed. D-02b says `eu.nodepass.somewhere` is free to change until the first signed, published build, and this is the first signed build — so the review point has arrived and the decision is the requester's. Gates green; device suite 51 cases; release APK 14,572,101 bytes.
1 parent f134309 commit 97f9666

3 files changed

Lines changed: 254 additions & 7 deletions

File tree

‎app/build.gradle.kts‎

Lines changed: 149 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,20 +101,40 @@ android {
101101
}
102102
}
103103

104+
// Signing material is the maintainer's and lives nowhere in this repository:
105+
// `local.properties` is gitignored, and `*.jks` is refused outright.
106+
//
107+
// The config is created only when a keystore is actually there, so that a
108+
// release build is something anyone who clones this can run. Without it the
109+
// APK comes out unsigned, which is the honest outcome — an unsigned APK
110+
// cannot be installed by an ordinary user, and a build that failed instead
111+
// would make "can this be built" and "can this be published" the same
112+
// question when they are not.
113+
val keystore = localProperties.getProperty("KEYSTORE_FILE")?.let { file(it) }?.takeIf { it.exists() }
114+
104115
signingConfigs {
105-
create("release") {
106-
storeFile = file(localProperties.getProperty("KEYSTORE_FILE", "../keystore.jks"))
107-
storePassword = localProperties.getProperty("KEYSTORE_PASSWORD", "")
108-
keyAlias = localProperties.getProperty("KEY_ALIAS", "release")
109-
keyPassword = localProperties.getProperty("KEY_PASSWORD", "")
116+
if (keystore != null) {
117+
create("release") {
118+
storeFile = keystore
119+
storePassword = localProperties.getProperty("KEYSTORE_PASSWORD", "")
120+
keyAlias = localProperties.getProperty("KEY_ALIAS", "release")
121+
keyPassword = localProperties.getProperty("KEY_PASSWORD", "")
122+
}
110123
}
111124
}
112125

113126
buildTypes {
114127
release {
115128
isMinifyEnabled = true
116129
isShrinkResources = true
117-
signingConfig = signingConfigs.getByName("release")
130+
// The maintainer's key when there is one, and the debug key when
131+
// there is not. Not a shortcut: an APK signed with the debug key
132+
// cannot be published — every store refuses it — so it cannot be
133+
// mistaken for a release, while it *can* be installed, which is
134+
// what makes the minified build testable at all. R8 is where this
135+
// project's recurring failure mode lives, and a release artifact
136+
// nobody can run is one nobody has run.
137+
signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
118138
proguardFiles(
119139
getDefaultProguardFile("proguard-android-optimize.txt"),
120140
"proguard-rules.pro",
@@ -602,6 +622,129 @@ tasks.withType<Test>().configureEach {
602622
}
603623
}
604624

625+
// ── The shipped release APK, checked as a file ──────────────────────────────
626+
// R8 is where this project's recurring failure mode lives — green build, green
627+
// lint, green tests, broken on the artifact nobody ran — and the JNI bridge is
628+
// the part a shrinker is most confidently wrong about. Nothing in Kotlin calls
629+
// `nativeInit`'s callbacks and nothing in C calls anything, so R8 sees an
630+
// unreferenced method on both sides and renames or removes it. The failure is
631+
// not a crash: `nativeInit` logs that a callback did not resolve and returns,
632+
// so the tunnel comes up and answers nothing.
633+
//
634+
// Running the instrumentation suite against the minified build was tried first
635+
// and abandoned. The two APKs are shrunk by two separate R8 runs, so the test
636+
// APK's calls are compiled against names the application no longer has, and the
637+
// keeps that fix *that* (`-keep class eu.nodepass.somewhere.**`) would also
638+
// keep every JNI entry point — masking exactly the mistake this is for.
639+
//
640+
// So the artifact is read instead of run. This is a stronger check for the
641+
// question asked: it inspects the file that would ship, rather than a variant
642+
// built to be testable.
643+
644+
abstract class ReleaseArtifactChecksTask : DefaultTask() {
645+
@get:InputFile
646+
abstract val apk: RegularFileProperty
647+
648+
@get:Input
649+
abstract val entryPoints: ListProperty<String>
650+
651+
@get:Input
652+
abstract val requiredAbis: ListProperty<String>
653+
654+
@TaskAction
655+
fun check() {
656+
val dex = StringBuilder()
657+
ZipFile(apk.get().asFile).use { zip ->
658+
zip.entries().asSequence().filter { it.name.endsWith(".dex") }.forEach { entry ->
659+
// Names live in the dex string table as plain UTF-8, so a
660+
// substring search over the file answers "did this survive
661+
// R8 under this name" without a dex parser.
662+
zip.getInputStream(entry).use { dex.append(String(it.readBytes(), Charsets.ISO_8859_1)) }
663+
}
664+
}
665+
val text = dex.toString()
666+
667+
// The libraries the file must carry, for the ABIs it claims to support.
668+
// Modern packaging leaves them compressed and page-aligned inside the
669+
// APK rather than extracting them at install, so the file is the only
670+
// place this can be asked -- an install directory's `lib/` is empty on
671+
// a perfectly working install.
672+
val libraries =
673+
ZipFile(apk.get().asFile).use { zip ->
674+
zip
675+
.entries()
676+
.asSequence()
677+
.map { it.name }
678+
.filter { it.endsWith("libsomewhere_native.so") }
679+
.toList()
680+
}
681+
val missingAbis = requiredAbis.get().filterNot { abi -> libraries.any { it.contains("/$abi/") } }
682+
if (missingAbis.isNotEmpty()) {
683+
throw GradleException(
684+
"the release APK carries no native library for ${missingAbis.joinToString()}",
685+
)
686+
}
687+
688+
val missing = entryPoints.get().filterNot { text.contains(it) }
689+
if (missing.isNotEmpty()) {
690+
throw GradleException(
691+
"the release APK does not carry ${missing.size} JNI entry point(s): " +
692+
missing.joinToString() + "\n\n" +
693+
"R8 renamed or removed them, and the native library resolves them by name at " +
694+
"call time. Nothing crashes: nativeInit logs that a callback did not resolve " +
695+
"and returns, so the tunnel comes up and carries nothing. See " +
696+
"app/proguard-rules.pro.",
697+
)
698+
}
699+
logger.lifecycle(
700+
"release APK: ${apk.get().asFile.length()} bytes, " +
701+
"${entryPoints.get().size} JNI entry points present under their own names, " +
702+
"native libraries for ${requiredAbis.get().joinToString()}",
703+
)
704+
}
705+
}
706+
707+
val releaseArtifactChecks =
708+
tasks.register<ReleaseArtifactChecksTask>("checkReleaseArtifact") {
709+
group = "verification"
710+
description = "The shipped APK still carries the names the native library resolves."
711+
dependsOn("assembleRelease")
712+
apk.set(
713+
layout.buildDirectory.file("outputs/apk/release/app-release.apk").flatMap { signed ->
714+
layout.buildDirectory.file(
715+
if (signed.asFile.exists()) {
716+
"outputs/apk/release/app-release.apk"
717+
} else {
718+
"outputs/apk/release/app-release-unsigned.apk"
719+
},
720+
)
721+
},
722+
)
723+
entryPoints.set(
724+
listOf(
725+
// The Kotlin halves, which R8 can rename.
726+
"eu/nodepass/somewhere/vpn/NativeBridge",
727+
"eu/nodepass/somewhere/quic/QuicConnection",
728+
"eu/nodepass/somewhere/quic/QuicStack",
729+
// The names the C side looks up. `nativeInit` resolves the
730+
// callbacks by name *and* signature, and a rename of either
731+
// half breaks it silently.
732+
"nativeInit",
733+
"nativeInput",
734+
"nativeTcpWrite",
735+
"onTcpAccept",
736+
"onTcpRecv",
737+
"onUdpRecv",
738+
"nativeOpen",
739+
"nativeReceive",
740+
"nativeWrite",
741+
"nativeSendDatagram",
742+
"nativeExportKeyingMaterial",
743+
),
744+
)
745+
requiredAbis.set(listOf("arm64-v8a", "x86_64"))
746+
}
747+
605748
val classpathConsistencyTasks =
606749
listOf("Debug" to "debug", "Release" to "release").map { (capitalised, lowercase) ->
607750
tasks.register<ClasspathConsistencyTask>("checkClasspathConsistency$capitalised") {

‎app/proguard-rules.pro‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,47 @@
1-
# Keep kotlinx.serialization metadata for @Serializable classes.
1+
# R8 rules.
2+
#
3+
# Everything here is a thing R8 cannot see being used. That is the whole
4+
# category: a shrinker removes what nothing references, and a JNI entry point,
5+
# a reflective serializer lookup and a class loaded by name are all referenced
6+
# from somewhere R8 does not read. The failure mode is the one this project has
7+
# met three times in other forms — green build, green lint, green tests, broken
8+
# on the artifact nobody ran.
9+
10+
# ── JNI ─────────────────────────────────────────────────────────────────────
11+
# The native library resolves these by name and signature at call time. R8 sees
12+
# no Kotlin caller for the C side's callbacks and no C caller for anything, so
13+
# without this it renames both halves of the bridge and `nativeInit` fails to
14+
# resolve its callbacks — which it reports by logging and returning, so the
15+
# tunnel comes up and answers nothing.
16+
# Duplicated from `proguard-android-optimize.txt` on purpose. Removing it
17+
# changes nothing today — measured — because the platform's default file
18+
# carries the same rule, and that is precisely why it is written out here: a
19+
# rule this project depends on should be visible in this project, not inherited
20+
# from a file that could change under it.
21+
-keepclasseswithmembernames,includedescriptorclasses class * {
22+
native <methods>;
23+
}
24+
# **This one is load-bearing, and measured to be.** The callbacks C calls into
25+
# are ordinary Kotlin methods, not native ones, so the rule above does not
26+
# reach them: without this, `nativeTcpWrite`, `onTcpAccept`, `onTcpRecv` and
27+
# `onUdpRecv` all leave the APK.
28+
-keep class eu.nodepass.somewhere.vpn.NativeBridge { *; }
29+
-keep class eu.nodepass.somewhere.quic.QuicConnection { *; }
30+
-keep class eu.nodepass.somewhere.quic.QuicStack { *; }
31+
32+
# ── kotlinx.serialization ───────────────────────────────────────────────────
33+
# Serializers are found by a generated companion R8 cannot trace to a caller.
234
-keepattributes *Annotation*, InnerClasses
335
-dontnote kotlinx.serialization.**
436
-keepclassmembers class **$$serializer { *; }
537
-keepclasseswithmembers class ** {
638
kotlinx.serialization.KSerializer serializer(...);
739
}
40+
41+
# ── Compile-only annotations ────────────────────────────────────────────────
42+
# Test dependencies bring annotations whose own dependencies exist only at
43+
# compile time. Nothing loads them at runtime, and R8 refuses to shrink an APK
44+
# with references it cannot resolve unless told the absence is expected.
45+
-dontwarn javax.lang.model.element.**
46+
-dontwarn com.google.errorprone.annotations.**
47+
-dontwarn javax.annotation.**
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#!/usr/bin/env bash
2+
# Installs the release APK and starts it.
3+
#
4+
# `checkReleaseArtifact` reads the file and answers "are the names still there".
5+
# This answers the other half: does the thing run. R8 breaks startup in ways a
6+
# name check cannot see — a reflective lookup with no keep, a Compose entry
7+
# point shrunk away, a resource the shrinker decided was unused — and all of
8+
# them present as a process that dies before anything is on screen.
9+
#
10+
# Not a substitute for the device suite, which runs against the debug build for
11+
# reasons recorded in app/build.gradle.kts. This is the minimum that can be said
12+
# about the artifact that would actually ship.
13+
set -uo pipefail
14+
15+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
16+
PROJECT="$(cd "$ROOT/.." && pwd)"
17+
APP_ID="eu.nodepass.somewhere"
18+
DEVICE="${NOWHERE_DEVICE:-}"
19+
20+
fail() { echo "FAIL: $*" >&2; exit 1; }
21+
step() { printf '\n== %s ==\n' "$*"; }
22+
23+
command -v adb >/dev/null || fail "adb is not on PATH"
24+
[ -n "$DEVICE" ] || DEVICE="$(adb devices | awk '$2=="device"{print $1; exit}')"
25+
[ -n "$DEVICE" ] || fail "no device; see scripts/device-connect.sh"
26+
27+
APK="$PROJECT/app/build/outputs/apk/release/app-release.apk"
28+
[ -f "$APK" ] || APK="$PROJECT/app/build/outputs/apk/release/app-release-unsigned.apk"
29+
[ -f "$APK" ] || fail "no release APK; run ./gradlew assembleRelease"
30+
31+
step "install $(basename "$APK")"
32+
adb -s "$DEVICE" uninstall "$APP_ID" >/dev/null 2>&1
33+
adb -s "$DEVICE" install -r "$APK" 2>&1 | tail -1 | grep -q Success \
34+
|| fail "the release APK would not install. An unsigned APK cannot be; see the signing config."
35+
36+
step "start it"
37+
adb -s "$DEVICE" logcat -c
38+
adb -s "$DEVICE" shell am start -n "$APP_ID/.MainActivity" >/dev/null 2>&1 \
39+
|| fail "the launcher activity would not start"
40+
sleep 6
41+
42+
step "did it survive"
43+
CRASH="$(adb -s "$DEVICE" logcat -d 2>/dev/null | grep -E "FATAL EXCEPTION|AndroidRuntime: .*$APP_ID" | head -5)"
44+
[ -z "$CRASH" ] || { echo "$CRASH"; fail "the release build crashed on startup"; }
45+
46+
PID="$(adb -s "$DEVICE" shell pidof "$APP_ID" 2>/dev/null | tr -d '\r ')"
47+
[ -n "$PID" ] || fail "the process is not running six seconds after start"
48+
echo "OK running as pid $PID"
49+
50+
# Deliberately not checked here, twice over.
51+
#
52+
# `System.loadLibrary` runs when the tunnel starts or the QUIC stack is first
53+
# touched, so a freshly opened screen has none of it mapped -- there is nothing
54+
# to look for. And a modern APK does not extract its native libraries at all:
55+
# they stay page-aligned inside the file and are mapped from there, so
56+
# `lib/arm64` under the install directory is empty on a working install. Both of
57+
# those were checked here first and both reported a failure that was the check's
58+
# rather than the app's.
59+
#
60+
# What the APK carries is `checkReleaseArtifact`'s, which reads the file.
61+
62+
echo
63+
echo "PASS: the release APK installs, starts, and is still running six seconds"
64+
echo " later."

0 commit comments

Comments
 (0)