diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 22d3dc5..95fdb68 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,8 +1,8 @@ + android:versionCode="2" + android:versionName="1.1"> diff --git a/README.md b/README.md index bc87e49..efbf123 100644 --- a/README.md +++ b/README.md @@ -106,19 +106,28 @@ you should turn `proxy_ssl_verify` on. Menu key: Sync, New event, Calendars, Settings. Tap an event for details, Edit and Delete. +If installation fails, check the file arrived whole (53 KB; some browsers save a truncated or +renamed `.apk.zip`) and that there are a few MB free on the internal storage. On a PC with +`adb`, `adb install GingerCal.apk` prints the exact reason (`INSTALL_FAILED_...`). + ## Building -Needs JDK 11+, Python 3 with Pillow, and `d8` (Android SDK build-tools). No Gradle, no aapt: -`tools/mkapk.py` writes the binary manifest and resource table itself. +Needs JDK 11+, Python 3 with Pillow, `d8` and `apksigner.jar` (Android SDK build-tools), and +`apktool.jar` (2.9+, which bundles Google's `aapt` and smali). No Gradle. ```sh -./build.sh # uses ./stubs to compile -ANDROID_JAR=$SDK/platforms/android-10/android.jar ./build.sh # or the real platform jar +D8=d8 APKTOOL=~/apktool.jar APKSIGNER=$SDK/build-tools/34.0.0/lib/apksigner.jar ./build.sh +# add ANDROID_JAR=$SDK/platforms/android-10/android.jar to compile against the real platform ``` -`gingercal.keystore` (password `android`) is the key the supplied APK was signed with; keep -building with it so updates install over the existing app. A different key means uninstalling -first. Signing uses SHA-1 because Android before 4.3 rejects anything else. +Pipeline: javac → d8 → baksmali/smali (clean dex 035) → `aapt` v1 for manifest and resources → +`apksigner` v1 signature → zipalign. The script ends by running `apksigner verify` for API 8–10 +and checking the dex header, and fails rather than produce an APK that 2.3 would reject. + +Don't sign with `jarsigner`: modern versions add PKCS#7 signed attributes, which Android only +accepts from 4.4, so the phone refuses to install it. That was the bug in the first build. +`gingercal.keystore` (password `android`) is the signing key; keep using it so updates install +over the existing app. Tests (desktop JVM, need a CalDAV server such as Radicale on :5232 with user `xose`/`secret`): @@ -137,7 +146,8 @@ declarations. - `android/` activities, sync service, alarms, SQLite cache, Apache HttpClient transport - `stubs/` compile-only Android API 10 signatures (never packaged) - `bridge/` nginx legacy-TLS bridge: config, compose file, certificate script -- `tools/` APK packer, icon, zipalign, d8 workaround, API checker +- `res/` app name; the icon is drawn at build time (mdpi + ldpi for the Galaxy Mini) +- `tools/` icon, dex disassembler helper, zipalign, dex/API checkers, d8 workaround ## Security notes diff --git a/bridge/pangolin-vps-setup.txt b/bridge/pangolin-vps-setup.txt new file mode 100644 index 0000000..b099a6f --- /dev/null +++ b/bridge/pangolin-vps-setup.txt @@ -0,0 +1,113 @@ +TASK FOR CLAUDE CODE (on the Pangolin server, 141.227.129.182) +Add a raw TCP resource on port 8443 so the GingerCal app (Android 2.3) can reach Nextcloud CalDAV +================================================================================================== + +CONTEXT +------- +- This server runs Pangolin (Pangolin + Gerbil + Traefik in Docker). Traefik terminates HTTPS for + every public site with Let's Encrypt certificates, TLS 1.2 minimum, routed by SNI. +- Android 2.3 can't use that: TLS 1.0 only, no ISRG Root X1, unreliable SNI. So the TLS for this + app is NOT terminated here. The home server (172.30.1.123, site connector "newt") runs a + container "caldav-bridge" (nginx) on 172.30.1.123:8443. It speaks TLS 1.0 with a self-signed + certificate the app pins, and forwards only /remote.php/dav/ to Nextcloud (nxc.anosanube.ovh). + That part is already done and tested. +- This server only has to pass raw TCP on port 8443 through the newt tunnel to that bridge: + + phone --TLS 1.0--> VPS :8443 (gerbil/traefik, raw TCP) --WireGuard--> newt + --> 172.30.1.123:8443 (caldav-bridge) --HTTP--> nextcloud-aio-apache:11000 + +- Certificate fingerprint the bridge serves (SHA-256): + 56:70:CE:E5:41:6D:B1:31:E8:74:51:52:66:8C:70:27:96:AE:28:8F:69:3E:AB:EE:4A:FD:FD:6B:6C:21:17:65 + +RULES +----- +- Read the existing config before changing anything. Don't assume paths or service names; find them. +- Back up every file before editing it (cp file file.bak-YYYYMMDD-HHMM). +- Show the user the exact diff and get their OK before restarting any container or editing + docker-compose.yml. +- Don't change TLS options, routers, entrypoints or certificates used by any existing site. +- Don't touch Pangolin's database. + +STEPS +----- +1. Find the Pangolin install directory (docker-compose.yml defining pangolin, gerbil, traefik). + Note the Pangolin version (raw TCP/UDP resources need Pangolin 1.4 or later; if it's older, + stop and tell the user). + Check that nothing on the VPS already listens on 8443: + ss -ltnp | grep ':8443 ' + If 8443 is taken, stop and tell the user; another public port works too (any port, as long as + steps 2-5 use the same number), and the phone address changes to match. + +2. config/config.yml: enable raw resources. MERGE into an existing "flags:" key if there is one; + never create a duplicate top-level key. + flags: + allow_raw_resources: true + +3. docker-compose.yml, the "gerbil" service (Traefik shares gerbil's network namespace, so + public ports are published on gerbil): add to its ports list + - 8443:8443 + +4. config/traefik/traefik_config.yml, "entryPoints": add (the name MUST be tcp-; Pangolin + generates its TCP routers against that name) + entryPoints: + tcp-8443: + address: ":8443/tcp" + +5. Firewall: allow inbound TCP 8443. + - On the host: e.g. `ufw allow 8443/tcp` if ufw is active (check `ufw status`), or the + equivalent for nftables/iptables if that's what's in use. + - The cloud provider's firewall / security group, if there is one: tell the user to open + TCP 8443 there; you probably can't do that from the shell. + +6. Validate the YAML files: + python3 -c 'import yaml,sys;[yaml.safe_load(open(f)) for f in sys.argv[1:]]' \ + config/config.yml config/traefik/traefik_config.yml docker-compose.yml + Show the diff of all three files and ask the user before applying. Then: + docker compose down && docker compose up -d + (a plain restart won't pick up the new gerbil port). Check + docker compose ps + docker logs --since 2m traefik 2>&1 | grep -iE 'error|tcp-8443' + docker logs --since 2m pangolin 2>&1 | grep -iE 'error' + and confirm every existing site still loads. + +7. Pangolin dashboard (the user does this, or guide them through it): + - Org of the home server, Resources, Add Resource, type "Raw TCP/UDP resource". + - Name: GingerCal CalDAV. Protocol: TCP. Public port: 8443. + - Site: the site whose newt runs on the home server 172.30.1.123 (most likely the "casa" site). + - Target: IP 172.30.1.123, port 8443. Leave "Enable Proxy Protocol" OFF (the bridge doesn't + expect it; enabling only one side breaks every connection). + - Pangolin SSO doesn't apply to raw TCP resources. That's expected: Nextcloud asks for its + own login (the phone will use a Nextcloud app password). + +8. VERIFY from the VPS (or anywhere with OpenSSL 3): + # TLS 1.0 with a plain-RSA cipher (what Android 2.3 uses) must work: + echo | openssl s_client -connect 141.227.129.182:8443 -tls1 -cipher 'AES128-SHA:@SECLEVEL=0' 2>&1 \ + | grep -E 'Protocol *:|Cipher is|alert' + # expect: Protocol: TLSv1 / Cipher is AES128-SHA + # It must be the bridge's certificate (not a Let's Encrypt one): + echo | openssl s_client -connect 141.227.129.182:8443 2>/dev/null | openssl x509 -noout -fingerprint -sha256 + # expect: 56:70:CE:E5:41:6D:B1:31:E8:74:51:52:66:8C:70:27:96:AE:28:8F:69:3E:AB:EE:4A:FD:FD:6B:6C:21:17:65 + # Nextcloud must answer CalDAV through it: + curl -sk -o /dev/null -w '%{http_code}\n' -X PROPFIND -H 'Depth: 0' https://141.227.129.182:8443/remote.php/dav/ + # expect: 401 (and "/" should give 404: the web UI is deliberately not exposed) + # Existing sites must be unaffected, e.g.: + curl -sI https://nxc.anosanube.ovh | head -1 + If the TLS test times out: check firewall (step 5), the gerbil port (step 3), and that the + resource's site/target are right (step 7). If it connects but shows a different certificate, + the target is wrong. + +9. ROLLBACK if anything breaks: restore the .bak files, `docker compose down && docker compose up -d`, + delete the resource in the dashboard. + +10. REPORT to the user: files changed (with diff), backup paths, firewall changes, output of every + check in step 8, and anything left to do (e.g. the cloud firewall). + +PHONE SETUP (after step 8 passes) +--------------------------------- +GingerCal, Menu, Settings: + Server: https://141.227.129.182:8443 (any *.oxose.org name works too, e.g. + https://cal-old.oxose.org:8443: raw TCP ignores the hostname and the app pins the + certificate, not the name) + User: your Nextcloud user + Password: a Nextcloud app password (Nextcloud, Settings, Security, Devices & sessions) +Save and test connection. Tap Trust only if the fingerprint shown is the one above. diff --git a/build.sh b/build.sh index 7d100cf..6b80d3a 100755 --- a/build.sh +++ b/build.sh @@ -1,46 +1,87 @@ #!/bin/sh -# Builds GingerCal.apk for Android 2.3 (API 10) without Gradle or aapt. +# Builds GingerCal.apk for Android 2.3 (API 10) without Gradle. # -# Needs: JDK 11+ (javac, keytool, jarsigner), python3 with Pillow (icon), and d8. -# D8 command that runs d8 (default: "d8" from Android SDK build-tools on PATH) -# ANDROID_JAR optional: a real platforms/android-10/android.jar; otherwise ./stubs is used -# KEYSTORE optional: signing keystore (created on first build; keep it to allow upgrades) +# Needs: JDK 11+ (javac, keytool), python3 with Pillow (icon), and three tools: +# D8 command that runs d8 (Android SDK build-tools; default "d8" on PATH) +# APKTOOL path to apktool.jar (https://apktool.org; bundles aapt and smali) +# APKSIGNER path to apksigner.jar (Android SDK build-tools//lib/apksigner.jar) +# Optional: +# ANDROID_JAR a real platforms/android-10/android.jar; otherwise ./stubs is used to compile +# KEYSTORE signing keystore (created on first build; keep it so updates install over) set -eu cd "$(dirname "$0")" OUT=build D8=${D8:-d8} +APKTOOL=${APKTOOL:?set APKTOOL=/path/to/apktool.jar} +APKSIGNER=${APKSIGNER:?set APKSIGNER=/path/to/apksigner.jar} KS=${KEYSTORE:-gingercal.keystore} +VERSION_CODE=$(sed -n 's/.*android:versionCode="\([0-9]*\)".*/\1/p' AndroidManifest.xml) +VERSION_NAME=$(sed -n 's/.*android:versionName="\([^"]*\)".*/\1/p' AndroidManifest.xml) rm -rf "$OUT" -mkdir -p "$OUT/stubs" "$OUT/classes" "$OUT/dex" +mkdir -p "$OUT/stubs" "$OUT/classes" "$OUT/dex" "$OUT/apk/res/drawable" "$OUT/apk/res/drawable-ldpi" +# 1. compile if [ -n "${ANDROID_JAR:-}" ]; then CP="$ANDROID_JAR" else javac -nowarn -Xlint:none --release 8 -d "$OUT/stubs" $(find stubs -name "*.java") 2>/dev/null CP="$OUT/stubs" fi - # Java 8 bytecode, Java 6-era APIs only (Android 2.3 runs a Harmony class library). javac --release 8 -encoding UTF-8 -Xlint:-options -cp "$CP" -d "$OUT/classes" $(find src android -name '*.java') - python3 tools/strip_method_params.py $(find "$OUT/classes" -name '*.class') -# dex format 035 for Dalvik -# (d8 warns about library classes it can't see; harmless since nothing needs desugaring) +# 2. dex (format 035 for Dalvik), then disassemble so smali re-assembles a clean dex $D8 --min-api 9 --release --output "$OUT/dex" $(find "$OUT/classes" -name '*.class') >"$OUT/d8.log" 2>&1 \ || { cat "$OUT/d8.log"; exit 1; } +mkdir -p "$OUT/tools" +javac -nowarn -cp "$APKTOOL" -d "$OUT/tools" tools/Disassemble.java +java -cp "$APKTOOL:$OUT/tools" Disassemble "$OUT/dex/classes.dex" "$OUT/apk/smali" 8 -python3 tools/mkicon.py "$OUT/icon.png" -python3 tools/mkapk.py AndroidManifest.xml "$OUT/dex/classes.dex" "$OUT/icon.png" "$OUT/unsigned.apk" +# 3. resources + manifest, compiled by real aapt (v1, the tool of the Gingerbread era) +cp AndroidManifest.xml "$OUT/apk/" +cp -r res/values "$OUT/apk/res/" +python3 tools/mkicon.py "$OUT/apk/res/drawable/icon.png" 48 +python3 tools/mkicon.py "$OUT/apk/res/drawable-ldpi/icon.png" 36 +cat > "$OUT/apk/apktool.yml" </dev/null fi -cp "$OUT/unsigned.apk" "$OUT/signed.apk" -# Android < 4.3 only verifies SHA-1 JAR signatures. -jarsigner -keystore "$KS" -storepass android -digestalg SHA1 -sigalg SHA1withRSA \ - "$OUT/signed.apk" gingercal >/dev/null +java -jar "$APKSIGNER" sign --ks "$KS" --ks-key-alias gingercal --ks-pass pass:android --key-pass pass:android \ + --min-sdk-version 8 --v1-signing-enabled true --v2-signing-enabled false --v3-signing-enabled false \ + --v4-signing-enabled false --out "$OUT/signed.apk" "$OUT/unsigned.apk" python3 tools/zipalign.py "$OUT/signed.apk" GingerCal.apk + +# 5. refuse to ship anything Android 2.3 would reject +java -jar "$APKSIGNER" verify --min-sdk-version 8 --max-sdk-version 10 GingerCal.apk +python3 tools/check_dex.py GingerCal.apk ls -l GingerCal.apk diff --git a/res/values/strings.xml b/res/values/strings.xml new file mode 100644 index 0000000..a2f45c3 --- /dev/null +++ b/res/values/strings.xml @@ -0,0 +1,4 @@ + + + GingerCal + diff --git a/tools/Disassemble.java b/tools/Disassemble.java new file mode 100644 index 0000000..d8bd262 --- /dev/null +++ b/tools/Disassemble.java @@ -0,0 +1,31 @@ +import java.io.File; + +import com.android.tools.smali.baksmali.Baksmali; +import com.android.tools.smali.baksmali.BaksmaliOptions; +import com.android.tools.smali.dexlib2.DexFileFactory; +import com.android.tools.smali.dexlib2.Opcodes; +import com.android.tools.smali.dexlib2.iface.DexFile; +import com.android.tools.smali.dexlib2.iface.MultiDexContainer; + +/** + * Disassembles d8's classes.dex to smali with the baksmali bundled in apktool.jar, so that + * apktool re-assembles it with dexlib2 (clean dex 035, correct checksums). + * usage: java -cp apktool.jar:. Disassemble classes.dex out-dir api-level + */ +public class Disassemble { + public static void main(String[] a) throws Exception { + int api = Integer.parseInt(a[2]); + MultiDexContainer c = DexFileFactory.loadDexContainer(new File(a[0]), Opcodes.forApi(api)); + if (c.getDexEntryNames().size() != 1) throw new IllegalStateException("expected one dex, got " + c.getDexEntryNames()); + DexFile dex = c.getEntry(c.getDexEntryNames().get(0)).getDexFile(); + BaksmaliOptions o = new BaksmaliOptions(); + o.apiLevel = api; + o.debugInfo = true; // keep line numbers for readable stack traces + o.localsDirective = true; + o.sequentialLabels = true; + if (!Baksmali.disassembleDexFile(dex, new File(a[1]), 1, o)) { + throw new IllegalStateException("baksmali reported errors"); + } + System.out.println("disassembled " + dex.getClasses().size() + " classes"); + } +} diff --git a/tools/check_dex.py b/tools/check_dex.py new file mode 100644 index 0000000..64d71d4 --- /dev/null +++ b/tools/check_dex.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Checks the dex inside an APK the way Dalvik's dexopt does at install time: +format version, file size, Adler-32 checksum, SHA-1 signature, and no post-035 sections. +usage: check_dex.py app.apk""" +import hashlib +import struct +import sys +import zipfile +import zlib + +d = zipfile.ZipFile(sys.argv[1]).read("classes.dex") +problems = [] +if d[:8] != b"dex\n035\x00": + problems.append("dex version %r (Android 2.3 needs 035)" % d[:8]) +if struct.unpack(" (type name, [(key, value)]) -RES_TYPES = [("drawable", [("icon", "res/drawable/icon.png")]), - ("string", [("app_name", "GingerCal")])] - - -def res_id(ref): - typ, key = ref.lstrip("@").split("/") - for ti, (tname, entries) in enumerate(RES_TYPES): - for ei, (k, _) in enumerate(entries): - if tname == typ and k == key: - return 0x7F000000 | ((ti + 1) << 16) | ei - raise SystemExit("unknown resource " + ref) - - -# ------------------------------------------------------------- string pool - -def string_pool(strings): - """UTF-16 ResStringPool (understood by every Android version).""" - offsets, data = [], b"" - for s in strings: - offsets.append(len(data)) - u = s.encode("utf-16-le") - n = len(u) // 2 - if n > 0x7FFF: - raise SystemExit("string too long") - data += struct.pack(" 2 else 48 +img = img.resize((size, size), Image.LANCZOS) img.save(sys.argv[1] if len(sys.argv) > 1 else "icon.png", optimize=True) diff --git a/tools/zipalign.py b/tools/zipalign.py index b769fc5..040e967 100644 --- a/tools/zipalign.py +++ b/tools/zipalign.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 -"""Minimal zipalign: stored entries start on 4-byte boundaries. Keeps v1 (JAR) signatures valid, -since those cover file contents, not the zip layout. usage: zipalign.py in.apk out.apk""" +"""Minimal zipalign: uncompressed entries start on 4-byte boundaries (like the SDK's +`zipalign 4`). Safe after v1 (JAR) signing, which covers entry contents, not zip layout. +usage: zipalign.py in.apk out.apk""" +import struct import sys import zipfile @@ -16,9 +18,11 @@ with zipfile.ZipFile(src) as zin, open(dst, "wb") as f: header_end = f.tell() + 30 + len(info.filename.encode("utf-8")) out.extra = b"\0" * ((4 - header_end % 4) % 4) zout.writestr(out, data) -with zipfile.ZipFile(dst) as z: + +with open(dst, "rb") as raw, zipfile.ZipFile(dst) as z: for info in z.infolist(): if info.compress_type == zipfile.ZIP_STORED: - start = info.header_offset + 30 + len(info.filename.encode("utf-8")) + len(info.extra) - assert start % 4 == 0, info.filename + raw.seek(info.header_offset + 26) + n, m = struct.unpack("