Skip to content

Commit f9879f4

Browse files
authored
[Tauri] Add APK signing (#4685)
* Add APK signing * Fix APK signing: zipalign before signing The previous implementation had the wrong order: 1. It signed the unsigned APK in-place 2. Then tried to zipalign from the unsigned APK to a signed APK This caused installation failures with 'You can't install the app on your device'. The correct order is: 1. Zipalign the unsigned APK to create an aligned APK 2. Sign the aligned APK 3. Move to final signed APK name 4. Verify the signature Also added signature verification step to catch issues early. * Set minimum Android SDK to 28 (Android 9.0) This ensures the APK can only be installed on Android 9.0 (Pie) and beyond. Previous minSdkVersion was 22 (Android 5.1). * Set minimum Android SDK to 28 for Tauri (not Capacitor) Added android.minSdkVersion to tauri.conf.json bundle configuration. Reverted the Capacitor android/variables.gradle change since that folder will be replaced by Tauri's generated Android project. * Android: sign APKs with apksigner (v2/v3), verify; ignore keystores\n\n- Zipalign before signing\n- Use apksigner to produce modern signatures (fixes install failures)\n- Verify with apksigner verify\n- Remove committed debug.keystore and ignore keystore files * Use secrets context directly
1 parent 7e09d93 commit f9879f4

File tree

5 files changed

+290
-6
lines changed

5 files changed

+290
-6
lines changed

.github/ANDROID_SIGNING.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Android APK Signing Guide
2+
3+
## Overview
4+
5+
Android requires all APKs to be signed before they can be installed on devices. This repository supports both **debug signing** (for development/testing) and **release signing** (for production releases).
6+
7+
## CI/PR Builds (Debug Signing)
8+
9+
PR builds in the `ci.yml` workflow automatically sign APKs with a **debug keystore**. These are suitable for:
10+
- Testing on development devices
11+
- Internal QA
12+
- Feature validation
13+
14+
The debug-signed APKs can be downloaded from the PR's workflow artifacts and installed on any Android device with developer mode enabled.
15+
16+
## Release Builds (Production Signing)
17+
18+
For production releases via the `release.yml` workflow, you should configure a **release keystore** using GitHub Secrets.
19+
20+
### Creating a Release Keystore
21+
22+
If you don't already have a release keystore, create one:
23+
24+
```bash
25+
keytool -genkeypair -v \
26+
-keystore betaflight-release.keystore \
27+
-storepass <YOUR_STORE_PASSWORD> \
28+
-alias betaflight \
29+
-keypass <YOUR_KEY_PASSWORD> \
30+
-keyalg RSA \
31+
-keysize 2048 \
32+
-validity 10000 \
33+
-dname "CN=Betaflight,O=Betaflight,C=US"
34+
```
35+
36+
**⚠️ IMPORTANT**: Keep this keystore file and passwords secure. If you lose them, you cannot update your app on the Play Store!
37+
38+
### Configure GitHub Secrets
39+
40+
Add the following secrets to your GitHub repository (Settings → Secrets and variables → Actions):
41+
42+
1. **ANDROID_KEYSTORE_BASE64**
43+
```bash
44+
base64 -w 0 betaflight-release.keystore
45+
```
46+
Copy the output and paste it as the secret value.
47+
48+
2. **ANDROID_KEYSTORE_PASSWORD**
49+
The password you used for `-storepass` when creating the keystore.
50+
51+
3. **ANDROID_KEY_ALIAS**
52+
The alias you used (e.g., `betaflight`).
53+
54+
4. **ANDROID_KEY_PASSWORD**
55+
The password you used for `-keypass` when creating the keystore.
56+
57+
### How Release Signing Works
58+
59+
When you trigger a release build:
60+
61+
1. If **all four secrets are configured**, the workflow will:
62+
- Decode the base64 keystore
63+
- Sign both the APK and AAB with your release key
64+
- Upload signed artifacts with `-release-signed` suffix
65+
66+
2. If **secrets are NOT configured**, the workflow will:
67+
- Fall back to debug signing
68+
- Upload APKs with `-debug-signed` suffix
69+
- ⚠️ These cannot be uploaded to the Play Store!
70+
71+
## Verifying Signed APKs
72+
73+
To verify an APK signature:
74+
75+
```bash
76+
# Check signature
77+
jarsigner -verify -verbose -certs your-app.apk
78+
79+
# View certificate details
80+
keytool -printcert -jarfile your-app.apk
81+
```
82+
83+
For release APKs, the certificate should match your release keystore.
84+
For debug APKs, the certificate will show "CN=Android Debug".
85+
86+
## Installing Signed APKs
87+
88+
### Debug-signed APKs
89+
- Enable "Install from unknown sources" or "Install unknown apps" on your Android device
90+
- Download the APK from GitHub Actions artifacts
91+
- Install via file manager or `adb install path/to/app.apk`
92+
93+
### Release-signed APKs
94+
- Can be installed the same way as debug APKs
95+
- Can be uploaded to Google Play Store for distribution
96+
- Must be signed with the same keystore for app updates
97+
98+
## Security Best Practices
99+
100+
1. **Never commit keystores to the repository**
101+
2. **Keep keystore passwords in GitHub Secrets only**
102+
3. **Back up your release keystore securely** (preferably in multiple secure locations)
103+
4. **Use strong passwords** for both keystore and key alias
104+
5. **Limit access** to GitHub Secrets to trusted maintainers only
105+
106+
## Troubleshooting
107+
108+
### "APK not signed" error during installation
109+
- The APK must be signed (either debug or release)
110+
- Check workflow logs to ensure signing step completed successfully
111+
112+
### "App not installed" or "Package conflicts" error
113+
- You may have an existing version signed with a different key
114+
- Uninstall the old version first, then install the new APK
115+
116+
### Release workflow falls back to debug signing
117+
- Check that all four GitHub Secrets are configured correctly
118+
- Verify the base64-encoded keystore is valid: `echo "$SECRET" | base64 -d > test.keystore`
119+
- Check workflow logs for any error messages in the "Setup release keystore" step

.github/workflows/ci.yml

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,18 +157,74 @@ jobs:
157157
tauriScript: yarn tauri android
158158
includeUpdaterJson: false
159159

160+
- name: Generate debug keystore
161+
shell: bash
162+
run: |
163+
set -euo pipefail
164+
KEYSTORE_PATH="${HOME}/.android/debug.keystore"
165+
mkdir -p "${HOME}/.android"
166+
if [ ! -f "$KEYSTORE_PATH" ]; then
167+
echo "Generating debug keystore at $KEYSTORE_PATH"
168+
keytool -genkeypair -v \
169+
-keystore "$KEYSTORE_PATH" \
170+
-storepass android \
171+
-alias androiddebugkey \
172+
-keypass android \
173+
-keyalg RSA \
174+
-keysize 2048 \
175+
-validity 10000 \
176+
-dname "CN=Android Debug,O=Android,C=US"
177+
else
178+
echo "Debug keystore already exists at $KEYSTORE_PATH"
179+
fi
180+
181+
- name: Sign APK with debug key
182+
shell: bash
183+
run: |
184+
set -euo pipefail
185+
UNSIGNED_APK=$(find src-tauri/gen/android/app/build/outputs/apk -name "*-unsigned.apk" | head -1)
186+
if [ -z "$UNSIGNED_APK" ]; then
187+
echo "Error: No unsigned APK found"
188+
exit 1
189+
fi
190+
echo "Found unsigned APK: $UNSIGNED_APK"
191+
SIGNED_APK="${UNSIGNED_APK%-unsigned.apk}-debug-signed.apk"
192+
ALIGNED_APK="${UNSIGNED_APK%-unsigned.apk}-aligned.apk"
193+
194+
# First zipalign the unsigned APK
195+
echo "Zipaligning APK..."
196+
${ANDROID_HOME}/build-tools/$(ls ${ANDROID_HOME}/build-tools | tail -1)/zipalign -v -p 4 "$UNSIGNED_APK" "$ALIGNED_APK"
197+
198+
# Then sign the aligned APK with v2/v3 signature using apksigner
199+
echo "Signing APK to: $SIGNED_APK"
200+
BUILD_TOOLS_VERSION=$(ls ${ANDROID_HOME}/build-tools | tail -1)
201+
${ANDROID_HOME}/build-tools/${BUILD_TOOLS_VERSION}/apksigner sign \
202+
--ks "${HOME}/.android/debug.keystore" \
203+
--ks-key-alias androiddebugkey \
204+
--ks-pass pass:android \
205+
--key-pass pass:android \
206+
--v1-signing-enabled true \
207+
--out "$SIGNED_APK" \
208+
"$ALIGNED_APK"
209+
210+
echo "Signed APK created: $SIGNED_APK"
211+
ls -lh "$SIGNED_APK"
212+
213+
# Verify the signature
214+
${ANDROID_HOME}/build-tools/${BUILD_TOOLS_VERSION}/apksigner verify --verbose --print-certs "$SIGNED_APK"
215+
160216
- name: Inspect Android bundle output
161217
if: always()
162218
run: |
163219
echo "Android APK artifacts:"
164220
find src-tauri/gen/android -type f -name "*.apk" -print || true
165221
166-
- name: Upload Android APK
222+
- name: Upload signed Android APK
167223
uses: actions/upload-artifact@v4
168224
with:
169-
name: android-apk
225+
name: android-apk-debug-signed
170226
path: |
171-
src-tauri/gen/android/app/build/outputs/apk/**/*.apk
227+
src-tauri/gen/android/app/build/outputs/apk/**/*-debug-signed.apk
172228
if-no-files-found: warn
173229
retention-days: 14
174230

.github/workflows/release.yml

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,114 @@ jobs:
9999
run: |
100100
yarn android:release
101101
102+
- name: Setup release keystore (if available)
103+
if: ${{ secrets.ANDROID_KEYSTORE_BASE64 != '' }}
104+
env:
105+
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
106+
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
107+
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
108+
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
109+
shell: bash
110+
run: |
111+
set -euo pipefail
112+
echo "Setting up release keystore from secrets"
113+
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > release.keystore
114+
echo "KEYSTORE_PATH=$(pwd)/release.keystore" >> $GITHUB_ENV
115+
echo "KEYSTORE_PASSWORD=$ANDROID_KEYSTORE_PASSWORD" >> $GITHUB_ENV
116+
echo "KEY_ALIAS=$ANDROID_KEY_ALIAS" >> $GITHUB_ENV
117+
echo "KEY_PASSWORD=$ANDROID_KEY_PASSWORD" >> $GITHUB_ENV
118+
119+
- name: Sign APK/AAB (release or debug)
120+
shell: bash
121+
run: |
122+
set -euo pipefail
123+
124+
# Determine signing configuration
125+
if [ -f "release.keystore" ]; then
126+
echo "Using release keystore for signing"
127+
KEYSTORE="release.keystore"
128+
STORE_PASS="${KEYSTORE_PASSWORD}"
129+
KEY_ALIAS="${KEY_ALIAS}"
130+
KEY_PASS="${KEY_PASSWORD}"
131+
SUFFIX="release-signed"
132+
else
133+
echo "No release keystore found - using debug keystore"
134+
mkdir -p "${HOME}/.android"
135+
KEYSTORE="${HOME}/.android/debug.keystore"
136+
if [ ! -f "$KEYSTORE" ]; then
137+
echo "Generating debug keystore"
138+
keytool -genkeypair -v \
139+
-keystore "$KEYSTORE" \
140+
-storepass android \
141+
-alias androiddebugkey \
142+
-keypass android \
143+
-keyalg RSA \
144+
-keysize 2048 \
145+
-validity 10000 \
146+
-dname "CN=Android Debug,O=Android,C=US"
147+
fi
148+
STORE_PASS="android"
149+
KEY_ALIAS="androiddebugkey"
150+
KEY_PASS="android"
151+
SUFFIX="debug-signed"
152+
fi
153+
154+
# Sign APK
155+
UNSIGNED_APK=$(find src-tauri/gen/android/app/build/outputs/apk -name "*-unsigned.apk" | head -1)
156+
if [ -n "$UNSIGNED_APK" ]; then
157+
echo "Signing APK: $UNSIGNED_APK"
158+
SIGNED_APK="${UNSIGNED_APK%-unsigned.apk}-${SUFFIX}.apk"
159+
ALIGNED_APK="${UNSIGNED_APK%-unsigned.apk}-aligned.apk"
160+
161+
# First zipalign the unsigned APK
162+
echo "Zipaligning APK..."
163+
BUILD_TOOLS_VERSION=$(ls ${ANDROID_HOME}/build-tools | tail -1)
164+
${ANDROID_HOME}/build-tools/${BUILD_TOOLS_VERSION}/zipalign -v -p 4 "$UNSIGNED_APK" "$ALIGNED_APK"
165+
166+
# Then sign the aligned APK with v2/v3 signatures using apksigner
167+
echo "Signing aligned APK..."
168+
${ANDROID_HOME}/build-tools/${BUILD_TOOLS_VERSION}/apksigner sign \
169+
--ks "$KEYSTORE" \
170+
--ks-key-alias "$KEY_ALIAS" \
171+
--ks-pass pass:"$STORE_PASS" \
172+
--key-pass pass:"$KEY_PASS" \
173+
--v1-signing-enabled true \
174+
--out "$SIGNED_APK" \
175+
"$ALIGNED_APK"
176+
echo "Signed APK created: $SIGNED_APK"
177+
ls -lh "$SIGNED_APK"
178+
179+
# Verify the signature
180+
${ANDROID_HOME}/build-tools/${BUILD_TOOLS_VERSION}/apksigner verify --verbose --print-certs "$SIGNED_APK"
181+
else
182+
echo "Warning: No unsigned APK found"
183+
fi
184+
185+
# Sign AAB (if release keystore available)
186+
if [ -f "release.keystore" ]; then
187+
UNSIGNED_AAB=$(find src-tauri/gen/android/app/build/outputs/bundle -name "*.aab" | head -1)
188+
if [ -n "$UNSIGNED_AAB" ]; then
189+
echo "Signing AAB: $UNSIGNED_AAB"
190+
SIGNED_AAB="${UNSIGNED_AAB%.aab}-signed.aab"
191+
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
192+
-keystore "$KEYSTORE" \
193+
-storepass "$STORE_PASS" \
194+
-keypass "$KEY_PASS" \
195+
"$UNSIGNED_AAB" \
196+
"$KEY_ALIAS"
197+
mv "$UNSIGNED_AAB" "$SIGNED_AAB"
198+
echo "Signed AAB created: $SIGNED_AAB"
199+
ls -lh "$SIGNED_AAB"
200+
fi
201+
fi
202+
102203
- name: Upload APK/AAB artifacts
103204
uses: actions/upload-artifact@v4
104205
with:
105206
name: betaflight-android
106207
path: |
107-
android/app/build/outputs/apk/release/*.apk
108-
android/app/build/outputs/bundle/release/*.aab
208+
src-tauri/gen/android/app/build/outputs/apk/universal/release/*-signed.apk
209+
src-tauri/gen/android/app/build/outputs/bundle/universalRelease/*-signed.aab
109210
if-no-files-found: warn
110211
retention-days: 30
111212

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,8 @@ build
5151
# Ignore Tauri build artifacts
5252
src-tauri/target/
5353
src-tauri/gen/
54+
55+
# Keystores (never commit signing keys)
56+
*.keystore
57+
*.jks
58+
debug.keystore

src-tauri/tauri.conf.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@
2828
"bundle": {
2929
"active": true,
3030
"targets": ["deb", "appimage", "dmg", "nsis"],
31-
"icon": ["icons/bf_icon_128.png"]
31+
"icon": ["icons/bf_icon_128.png"],
32+
"android": {
33+
"minSdkVersion": 28
34+
}
3235
},
3336
"plugins": {
3437
"shell": {

0 commit comments

Comments
 (0)