Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f44ecf4871 |
No files matched your search
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="org.gingercal"
|
package="org.gingercal"
|
||||||
android:versionCode="1"
|
android:versionCode="2"
|
||||||
android:versionName="1.0">
|
android:versionName="1.1">
|
||||||
|
|
||||||
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
|
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## Building
|
||||||
|
|
||||||
Needs JDK 11+, Python 3 with Pillow, and `d8` (Android SDK build-tools). No Gradle, no aapt:
|
Needs JDK 11+, Python 3 with Pillow, `d8` and `apksigner.jar` (Android SDK build-tools), and
|
||||||
`tools/mkapk.py` writes the binary manifest and resource table itself.
|
`apktool.jar` (2.9+, which bundles Google's `aapt` and smali). No Gradle.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./build.sh # uses ./stubs to compile
|
D8=d8 APKTOOL=~/apktool.jar APKSIGNER=$SDK/build-tools/34.0.0/lib/apksigner.jar ./build.sh
|
||||||
ANDROID_JAR=$SDK/platforms/android-10/android.jar ./build.sh # or the real platform jar
|
# 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
|
Pipeline: javac → d8 → baksmali/smali (clean dex 035) → `aapt` v1 for manifest and resources →
|
||||||
building with it so updates install over the existing app. A different key means uninstalling
|
`apksigner` v1 signature → zipalign. The script ends by running `apksigner verify` for API 8–10
|
||||||
first. Signing uses SHA-1 because Android before 4.3 rejects anything else.
|
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`):
|
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
|
- `android/` activities, sync service, alarms, SQLite cache, Apache HttpClient transport
|
||||||
- `stubs/` compile-only Android API 10 signatures (never packaged)
|
- `stubs/` compile-only Android API 10 signatures (never packaged)
|
||||||
- `bridge/` nginx legacy-TLS bridge: config, compose file, certificate script
|
- `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
|
## Security notes
|
||||||
|
|
||||||
|
|||||||
@@ -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-<port>; 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.
|
||||||
@@ -1,46 +1,87 @@
|
|||||||
#!/bin/sh
|
#!/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.
|
# Needs: JDK 11+ (javac, keytool), python3 with Pillow (icon), and three tools:
|
||||||
# D8 command that runs d8 (default: "d8" from Android SDK build-tools on PATH)
|
# D8 command that runs d8 (Android SDK build-tools; default "d8" on PATH)
|
||||||
# ANDROID_JAR optional: a real platforms/android-10/android.jar; otherwise ./stubs is used
|
# APKTOOL path to apktool.jar (https://apktool.org; bundles aapt and smali)
|
||||||
# KEYSTORE optional: signing keystore (created on first build; keep it to allow upgrades)
|
# APKSIGNER path to apksigner.jar (Android SDK build-tools/<ver>/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
|
set -eu
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
OUT=build
|
OUT=build
|
||||||
D8=${D8:-d8}
|
D8=${D8:-d8}
|
||||||
|
APKTOOL=${APKTOOL:?set APKTOOL=/path/to/apktool.jar}
|
||||||
|
APKSIGNER=${APKSIGNER:?set APKSIGNER=/path/to/apksigner.jar}
|
||||||
KS=${KEYSTORE:-gingercal.keystore}
|
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"
|
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
|
if [ -n "${ANDROID_JAR:-}" ]; then
|
||||||
CP="$ANDROID_JAR"
|
CP="$ANDROID_JAR"
|
||||||
else
|
else
|
||||||
javac -nowarn -Xlint:none --release 8 -d "$OUT/stubs" $(find stubs -name "*.java") 2>/dev/null
|
javac -nowarn -Xlint:none --release 8 -d "$OUT/stubs" $(find stubs -name "*.java") 2>/dev/null
|
||||||
CP="$OUT/stubs"
|
CP="$OUT/stubs"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Java 8 bytecode, Java 6-era APIs only (Android 2.3 runs a Harmony class library).
|
# 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')
|
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')
|
python3 tools/strip_method_params.py $(find "$OUT/classes" -name '*.class')
|
||||||
|
|
||||||
# dex format 035 for Dalvik
|
# 2. dex (format 035 for Dalvik), then disassemble so smali re-assembles a clean dex
|
||||||
# (d8 warns about library classes it can't see; harmless since nothing needs desugaring)
|
|
||||||
$D8 --min-api 9 --release --output "$OUT/dex" $(find "$OUT/classes" -name '*.class') >"$OUT/d8.log" 2>&1 \
|
$D8 --min-api 9 --release --output "$OUT/dex" $(find "$OUT/classes" -name '*.class') >"$OUT/d8.log" 2>&1 \
|
||||||
|| { cat "$OUT/d8.log"; exit 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"
|
# 3. resources + manifest, compiled by real aapt (v1, the tool of the Gingerbread era)
|
||||||
python3 tools/mkapk.py AndroidManifest.xml "$OUT/dex/classes.dex" "$OUT/icon.png" "$OUT/unsigned.apk"
|
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" <<EOF
|
||||||
|
version: 2.9.3
|
||||||
|
apkFileName: GingerCal.apk
|
||||||
|
isFrameworkApk: false
|
||||||
|
usesFramework:
|
||||||
|
ids:
|
||||||
|
- 1
|
||||||
|
tag: null
|
||||||
|
sdkInfo:
|
||||||
|
minSdkVersion: 8
|
||||||
|
targetSdkVersion: 10
|
||||||
|
packageInfo:
|
||||||
|
forcedPackageId: 127
|
||||||
|
renameManifestPackage: null
|
||||||
|
versionInfo:
|
||||||
|
versionCode: $VERSION_CODE
|
||||||
|
versionName: $VERSION_NAME
|
||||||
|
resourcesAreCompressed: false
|
||||||
|
sharedLibrary: false
|
||||||
|
sparseResources: false
|
||||||
|
doNotCompress:
|
||||||
|
- resources.arsc
|
||||||
|
- png
|
||||||
|
EOF
|
||||||
|
java -jar "$APKTOOL" -q b --use-aapt1 -api 8 -f -o "$OUT/unsigned.apk" "$OUT/apk"
|
||||||
|
|
||||||
|
# 4. sign. Android < 4.3 needs a plain v1 (JAR) signature with SHA-1 and no PKCS#7 signed
|
||||||
|
# attributes; apksigner picks exactly that for --min-sdk-version 8. (jarsigner does not.)
|
||||||
if [ ! -f "$KS" ]; then
|
if [ ! -f "$KS" ]; then
|
||||||
keytool -genkeypair -keystore "$KS" -storetype PKCS12 -storepass android -keypass android \
|
keytool -genkeypair -keystore "$KS" -storetype PKCS12 -storepass android -keypass android \
|
||||||
-alias gingercal -keyalg RSA -keysize 2048 -sigalg SHA1withRSA -validity 10000 -dname "CN=GingerCal" 2>/dev/null
|
-alias gingercal -keyalg RSA -keysize 2048 -sigalg SHA1withRSA -validity 10000 -dname "CN=GingerCal" 2>/dev/null
|
||||||
fi
|
fi
|
||||||
cp "$OUT/unsigned.apk" "$OUT/signed.apk"
|
java -jar "$APKSIGNER" sign --ks "$KS" --ks-key-alias gingercal --ks-pass pass:android --key-pass pass:android \
|
||||||
# Android < 4.3 only verifies SHA-1 JAR signatures.
|
--min-sdk-version 8 --v1-signing-enabled true --v2-signing-enabled false --v3-signing-enabled false \
|
||||||
jarsigner -keystore "$KS" -storepass android -digestalg SHA1 -sigalg SHA1withRSA \
|
--v4-signing-enabled false --out "$OUT/signed.apk" "$OUT/unsigned.apk"
|
||||||
"$OUT/signed.apk" gingercal >/dev/null
|
|
||||||
python3 tools/zipalign.py "$OUT/signed.apk" GingerCal.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
|
ls -l GingerCal.apk
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">GingerCal</string>
|
||||||
|
</resources>
|
||||||
@@ -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<? extends DexFile> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("<I", d[32:36])[0] != len(d):
|
||||||
|
problems.append("file_size field wrong")
|
||||||
|
if zlib.adler32(d[12:]) & 0xFFFFFFFF != struct.unpack("<I", d[8:12])[0]:
|
||||||
|
problems.append("Adler-32 checksum wrong")
|
||||||
|
if hashlib.sha1(d[32:]).digest() != d[12:32]:
|
||||||
|
problems.append("SHA-1 signature wrong")
|
||||||
|
map_off = struct.unpack("<I", d[52:56])[0]
|
||||||
|
for i in range(struct.unpack("<I", d[map_off:map_off + 4])[0]):
|
||||||
|
t = struct.unpack("<H", d[map_off + 4 + i * 12:map_off + 6 + i * 12])[0]
|
||||||
|
if t in (7, 8):
|
||||||
|
problems.append("contains call_site/method_handle sections (needs Android 8+)")
|
||||||
|
for p in problems:
|
||||||
|
print("DEX PROBLEM:", p)
|
||||||
|
print("classes.dex: %d bytes, %s" % (len(d), "OK" if not problems else "REJECTED"))
|
||||||
|
sys.exit(1 if problems else 0)
|
||||||
-213
@@ -1,213 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Packs an APK without aapt: binary AndroidManifest.xml, a tiny resources.arsc
|
|
||||||
(icon + app name), classes.dex. Output is unsigned; build.sh signs and aligns it.
|
|
||||||
|
|
||||||
usage: mkapk.py AndroidManifest.xml classes.dex icon.png out.apk
|
|
||||||
"""
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
import zipfile
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
|
|
||||||
ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
|
||||||
|
|
||||||
# android.R.attr ids (stable since API 1-4)
|
|
||||||
ATTR_IDS = {
|
|
||||||
"theme": 0x01010000, "label": 0x01010001, "icon": 0x01010002, "name": 0x01010003,
|
|
||||||
"permission": 0x01010006, "enabled": 0x0101000e, "exported": 0x01010010,
|
|
||||||
"configChanges": 0x0101001f, "minSdkVersion": 0x0101020c, "versionCode": 0x0101021b,
|
|
||||||
"versionName": 0x0101021c, "windowSoftInputMode": 0x0101022b,
|
|
||||||
"targetSdkVersion": 0x01010270, "installLocation": 0x010102b7,
|
|
||||||
}
|
|
||||||
INT_ATTRS = {"versionCode", "minSdkVersion", "targetSdkVersion"}
|
|
||||||
BOOL_ATTRS = {"exported", "enabled"}
|
|
||||||
FLAGS = {
|
|
||||||
"configChanges": {"mcc": 1, "mnc": 2, "locale": 4, "touchscreen": 8, "keyboard": 0x10,
|
|
||||||
"keyboardHidden": 0x20, "navigation": 0x40, "orientation": 0x80,
|
|
||||||
"screenLayout": 0x100, "uiMode": 0x200, "fontScale": 0x40000000},
|
|
||||||
"windowSoftInputMode": {"stateUnspecified": 0, "stateHidden": 2, "stateAlwaysHidden": 3,
|
|
||||||
"adjustResize": 0x10, "adjustPan": 0x20},
|
|
||||||
"installLocation": {"auto": 0, "internalOnly": 1, "preferExternal": 2},
|
|
||||||
}
|
|
||||||
|
|
||||||
T_REF, T_STRING, T_INT_DEC, T_INT_HEX, T_BOOL = 0x01, 0x03, 0x10, 0x11, 0x12
|
|
||||||
|
|
||||||
# Our own resources: type id -> (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("<H", n) + u + b"\0\0"
|
|
||||||
while len(data) % 4:
|
|
||||||
data += b"\0"
|
|
||||||
header = 28
|
|
||||||
strings_start = header + 4 * len(strings)
|
|
||||||
size = strings_start + len(data)
|
|
||||||
return (struct.pack("<HHIIIIII", 0x0001, header, size, len(strings), 0, 0, strings_start, 0)
|
|
||||||
+ b"".join(struct.pack("<I", o) for o in offsets) + data)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- AXML
|
|
||||||
|
|
||||||
def axml(manifest_path):
|
|
||||||
tree = ET.parse(manifest_path)
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
# collect strings: attribute names with resource ids first (resource map order)
|
|
||||||
attr_names, other = [], []
|
|
||||||
|
|
||||||
def walk(e):
|
|
||||||
for k in e.attrib:
|
|
||||||
ns, local = split(k)
|
|
||||||
if ns == ANDROID_NS:
|
|
||||||
if local not in ATTR_IDS:
|
|
||||||
raise SystemExit("add resource id for android:" + local)
|
|
||||||
if local not in attr_names:
|
|
||||||
attr_names.append(local)
|
|
||||||
for c in e:
|
|
||||||
walk(c)
|
|
||||||
|
|
||||||
def split(k):
|
|
||||||
if k.startswith("{"):
|
|
||||||
ns, local = k[1:].split("}")
|
|
||||||
return ns, local
|
|
||||||
return None, k
|
|
||||||
|
|
||||||
walk(root)
|
|
||||||
attr_names.sort(key=lambda n: ATTR_IDS[n])
|
|
||||||
strings = list(attr_names)
|
|
||||||
|
|
||||||
def idx(s):
|
|
||||||
if s not in strings:
|
|
||||||
strings.append(s)
|
|
||||||
return strings.index(s)
|
|
||||||
|
|
||||||
idx("android")
|
|
||||||
idx(ANDROID_NS)
|
|
||||||
|
|
||||||
events = [] # built after strings are known; strings may grow while encoding
|
|
||||||
|
|
||||||
def encode_value(local, ns, v):
|
|
||||||
if v.startswith("@") and "/" in v and not v.startswith("@android:"):
|
|
||||||
return 0xFFFFFFFF, T_REF, res_id(v)
|
|
||||||
if ns == ANDROID_NS and local in INT_ATTRS:
|
|
||||||
return 0xFFFFFFFF, T_INT_DEC, int(v)
|
|
||||||
if ns == ANDROID_NS and local in BOOL_ATTRS:
|
|
||||||
return 0xFFFFFFFF, T_BOOL, 0xFFFFFFFF if v == "true" else 0
|
|
||||||
if ns == ANDROID_NS and local in FLAGS:
|
|
||||||
n = 0
|
|
||||||
for part in v.split("|"):
|
|
||||||
n |= FLAGS[local][part.strip()]
|
|
||||||
return 0xFFFFFFFF, T_INT_HEX, n
|
|
||||||
si = idx(v)
|
|
||||||
return si, T_STRING, si
|
|
||||||
|
|
||||||
def element(e, line):
|
|
||||||
ns, local = split(e.tag)
|
|
||||||
attrs = []
|
|
||||||
for k, v in e.attrib.items():
|
|
||||||
ans, alocal = split(k)
|
|
||||||
raw, typ, data = encode_value(alocal, ans, v)
|
|
||||||
attrs.append((ATTR_IDS.get(alocal, 0) if ans == ANDROID_NS else 0,
|
|
||||||
idx(ANDROID_NS) if ans == ANDROID_NS else 0xFFFFFFFF, idx(alocal), raw, typ, data))
|
|
||||||
attrs.sort(key=lambda a: a[0]) # Android looks attributes up in resource-id order
|
|
||||||
name = idx(local)
|
|
||||||
body = struct.pack("<IIHHHHHH", 0xFFFFFFFF, name, 20, 20, len(attrs), 0, 0, 0)
|
|
||||||
for _, ans, an, raw, typ, data in attrs:
|
|
||||||
body += struct.pack("<IIIHBBI", ans, an, raw, 8, 0, typ, data & 0xFFFFFFFF)
|
|
||||||
events.append(struct.pack("<HHIII", 0x0102, 16, 16 + len(body), line, 0xFFFFFFFF) + body)
|
|
||||||
for c in e:
|
|
||||||
line = element(c, line + 1)
|
|
||||||
events.append(struct.pack("<HHIIIII", 0x0103, 16, 24, line, 0xFFFFFFFF, 0xFFFFFFFF, name))
|
|
||||||
return line + 1
|
|
||||||
|
|
||||||
ns_chunk = lambda t: struct.pack("<HHIIIII", t, 16, 24, 1, 0xFFFFFFFF, idx("android"), idx(ANDROID_NS))
|
|
||||||
events.append(ns_chunk(0x0100))
|
|
||||||
element(root, 2)
|
|
||||||
events.append(ns_chunk(0x0101))
|
|
||||||
|
|
||||||
pool = string_pool(strings)
|
|
||||||
resmap = struct.pack("<HHI", 0x0180, 8, 8 + 4 * len(attr_names)) + b"".join(
|
|
||||||
struct.pack("<I", ATTR_IDS[n]) for n in attr_names)
|
|
||||||
body = pool + resmap + b"".join(events)
|
|
||||||
return struct.pack("<HHI", 0x0003, 8, 8 + len(body)) + body
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- ARSC
|
|
||||||
|
|
||||||
def arsc(package):
|
|
||||||
values = [v for _, entries in RES_TYPES for _, v in entries]
|
|
||||||
global_pool = string_pool(values)
|
|
||||||
type_pool = string_pool([t for t, _ in RES_TYPES])
|
|
||||||
key_pool = string_pool([k for _, entries in RES_TYPES for k, _ in entries])
|
|
||||||
|
|
||||||
chunks = b""
|
|
||||||
vi = 0
|
|
||||||
ki = 0
|
|
||||||
for ti, (tname, entries) in enumerate(RES_TYPES):
|
|
||||||
tid = ti + 1
|
|
||||||
n = len(entries)
|
|
||||||
spec = struct.pack("<HHIBBHI", 0x0202, 16, 16 + 4 * n, tid, 0, 0, n) + b"\0\0\0\0" * n
|
|
||||||
config = struct.pack("<I", 32) + b"\0" * 28 # default configuration
|
|
||||||
header = 20 + len(config)
|
|
||||||
entry_data = b""
|
|
||||||
offsets = b""
|
|
||||||
for _ in entries:
|
|
||||||
offsets += struct.pack("<I", len(entry_data))
|
|
||||||
entry_data += struct.pack("<HHI", 8, 0, ki) + struct.pack("<HBBI", 8, 0, T_STRING, vi)
|
|
||||||
ki += 1
|
|
||||||
vi += 1
|
|
||||||
entries_start = header + 4 * n
|
|
||||||
typ = struct.pack("<HHIBBHII", 0x0201, header, entries_start + len(entry_data), tid, 0, 0, n,
|
|
||||||
entries_start) + config + offsets + entry_data
|
|
||||||
chunks += spec + typ
|
|
||||||
|
|
||||||
pkg_header = 284 # pre-Lollipop ResTable_package (no typeIdOffset)
|
|
||||||
name = package.encode("utf-16-le")[:254].ljust(256, b"\0")
|
|
||||||
type_off = pkg_header
|
|
||||||
key_off = type_off + len(type_pool)
|
|
||||||
pkg_size = key_off + len(key_pool) + len(chunks)
|
|
||||||
pkg = struct.pack("<HHII", 0x0200, pkg_header, pkg_size, 0x7F) + name + struct.pack(
|
|
||||||
"<IIII", type_off, len(RES_TYPES), key_off, ki) + type_pool + key_pool + chunks
|
|
||||||
assert len(struct.pack("<HHII", 0, 0, 0, 0) + name + struct.pack("<IIII", 0, 0, 0, 0)) == pkg_header
|
|
||||||
body = global_pool + pkg
|
|
||||||
return struct.pack("<HHII", 0x0002, 12, 12 + len(body), 1) + body
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
manifest, dex, icon, out = sys.argv[1:5]
|
|
||||||
package = ET.parse(manifest).getroot().attrib["package"]
|
|
||||||
with zipfile.ZipFile(out, "w") as z:
|
|
||||||
z.writestr(zipfile.ZipInfo("AndroidManifest.xml", (2026, 1, 1, 0, 0, 0)), axml(manifest),
|
|
||||||
compress_type=zipfile.ZIP_DEFLATED)
|
|
||||||
z.writestr(zipfile.ZipInfo("classes.dex", (2026, 1, 1, 0, 0, 0)), open(dex, "rb").read(),
|
|
||||||
compress_type=zipfile.ZIP_DEFLATED)
|
|
||||||
z.writestr(zipfile.ZipInfo("resources.arsc", (2026, 1, 1, 0, 0, 0)), arsc(package),
|
|
||||||
compress_type=zipfile.ZIP_STORED)
|
|
||||||
z.writestr(zipfile.ZipInfo("res/drawable/icon.png", (2026, 1, 1, 0, 0, 0)), open(icon, "rb").read(),
|
|
||||||
compress_type=zipfile.ZIP_STORED)
|
|
||||||
print("wrote", out)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
+4
-2
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Draws the 48x48 (mdpi) launcher icon: a little calendar page in ginger tones."""
|
"""Draws the launcher icon: a little calendar page in ginger tones.
|
||||||
|
usage: mkicon.py out.png [size] (48 = mdpi, 36 = ldpi like the Galaxy Mini)"""
|
||||||
import sys
|
import sys
|
||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
@@ -27,5 +28,6 @@ for row in range(3):
|
|||||||
d.rounded_rectangle(box, radius=1.5 * S, fill=(0, 130, 201, 255)) # Nextcloud blue
|
d.rounded_rectangle(box, radius=1.5 * S, fill=(0, 130, 201, 255)) # Nextcloud blue
|
||||||
else:
|
else:
|
||||||
d.rounded_rectangle(box, radius=1.5 * S, fill=(200, 188, 172, 255))
|
d.rounded_rectangle(box, radius=1.5 * S, fill=(200, 188, 172, 255))
|
||||||
img = img.resize((48, 48), Image.LANCZOS)
|
size = int(sys.argv[2]) if len(sys.argv) > 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)
|
img.save(sys.argv[1] if len(sys.argv) > 1 else "icon.png", optimize=True)
|
||||||
+9
-5
@@ -1,6 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Minimal zipalign: stored entries start on 4-byte boundaries. Keeps v1 (JAR) signatures valid,
|
"""Minimal zipalign: uncompressed entries start on 4-byte boundaries (like the SDK's
|
||||||
since those cover file contents, not the zip layout. usage: zipalign.py in.apk out.apk"""
|
`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 sys
|
||||||
import zipfile
|
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"))
|
header_end = f.tell() + 30 + len(info.filename.encode("utf-8"))
|
||||||
out.extra = b"\0" * ((4 - header_end % 4) % 4)
|
out.extra = b"\0" * ((4 - header_end % 4) % 4)
|
||||||
zout.writestr(out, data)
|
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():
|
for info in z.infolist():
|
||||||
if info.compress_type == zipfile.ZIP_STORED:
|
if info.compress_type == zipfile.ZIP_STORED:
|
||||||
start = info.header_offset + 30 + len(info.filename.encode("utf-8")) + len(info.extra)
|
raw.seek(info.header_offset + 26)
|
||||||
assert start % 4 == 0, info.filename
|
n, m = struct.unpack("<HH", raw.read(4))
|
||||||
|
assert (info.header_offset + 30 + n + m) % 4 == 0, info.filename
|
||||||
print("aligned", dst)
|
print("aligned", dst)
|
||||||
Reference in new issue
Block a user