From 5f95073d5ccf049ff17cc1ec6a7b56ddff26bb7f Mon Sep 17 00:00:00 2001 From: virtus Date: Wed, 8 Apr 2026 14:48:28 +0700 Subject: [PATCH] feat: Enhance Android release signing process and add verification script --- .gitea/workflows/build-apk.yml | 52 +++++++++++++++++++++++++++ .gitignore | 5 +++ android/app/build.gradle.kts | 32 +++++++++++++++-- scripts/release_signing_doctor.sh | 60 +++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 3 deletions(-) create mode 100755 scripts/release_signing_doctor.sh diff --git a/.gitea/workflows/build-apk.yml b/.gitea/workflows/build-apk.yml index 2bbc3a4..5c6c3d3 100644 --- a/.gitea/workflows/build-apk.yml +++ b/.gitea/workflows/build-apk.yml @@ -14,6 +14,11 @@ jobs: GOOGLE_SERVER_CLIENT_ID: ${{ secrets.GOOGLE_SERVER_CLIENT_ID }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} TOKEN: ${{ secrets.TOKEN }} + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + EXPECTED_ANDROID_SHA1: ${{ secrets.EXPECTED_ANDROID_SHA1 }} steps: - name: Checkout @@ -58,6 +63,23 @@ jobs: - name: Install release tooling run: sudo apt-get update && sudo apt-get install -y jq + - name: Prepare Android release signing + run: | + if [ -n "${ANDROID_KEYSTORE_BASE64}" ] && [ -n "${ANDROID_KEYSTORE_PASSWORD}" ] && [ -n "${ANDROID_KEY_ALIAS}" ] && [ -n "${ANDROID_KEY_PASSWORD}" ]; then + echo "Preparing release keystore from secrets" + echo "${ANDROID_KEYSTORE_BASE64}" | base64 -d > android/release.keystore + { + echo "storeFile=release.keystore" + echo "storePassword=${ANDROID_KEYSTORE_PASSWORD}" + echo "keyAlias=${ANDROID_KEY_ALIAS}" + echo "keyPassword=${ANDROID_KEY_PASSWORD}" + } > android/key.properties + else + echo "Release signing secrets are required for tagged release builds." + echo "Please configure: ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD" + exit 1 + fi + - name: Build release APK run: | BASE_URL_VALUE="${BASE_URL:-http://127.0.0.1:8000}" @@ -77,6 +99,36 @@ jobs: "${FLUTTER_CMD[@]}" + - name: Verify APK signing certificate + run: | + APK_PATH="build/app/outputs/flutter-apk/app-release.apk" + APKSIGNER_PATH="${ANDROID_SDK_ROOT}/build-tools/35.0.0/apksigner" + + if [ ! -f "$APK_PATH" ]; then + echo "APK not found at $APK_PATH" + exit 1 + fi + + if [ ! -x "$APKSIGNER_PATH" ]; then + echo "apksigner not found at $APKSIGNER_PATH" + exit 1 + fi + + CERT_OUTPUT="$($APKSIGNER_PATH verify --print-certs "$APK_PATH")" + echo "$CERT_OUTPUT" + + if [ -n "${EXPECTED_ANDROID_SHA1}" ]; then + APK_SHA1=$(echo "$CERT_OUTPUT" | sed -n 's/.*certificate SHA-1 digest: //p' | head -n1 | tr '[:lower:]' '[:upper:]') + EXPECTED_SHA1_UPPER=$(echo "${EXPECTED_ANDROID_SHA1}" | tr '[:lower:]' '[:upper:]') + + if [ "$APK_SHA1" != "$EXPECTED_SHA1_UPPER" ]; then + echo "APK SHA-1 mismatch" + echo "Expected: $EXPECTED_SHA1_UPPER" + echo "Actual : $APK_SHA1" + exit 1 + fi + fi + - name: Create or update Gitea release and upload APK run: | if [ -z "${TOKEN}" ]; then diff --git a/.gitignore b/.gitignore index de2ab05..2cbee7c 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,8 @@ app.*.map.json # Local mobile runtime defines .env.mobile + +# Android release signing files (local/CI generated) +android/key.properties +android/*.jks +android/*.keystore diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2835fd3..3f6062d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.io.FileInputStream +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") @@ -6,6 +9,15 @@ plugins { id("com.google.gms.google-services") } +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + +val releaseStoreFile = keystoreProperties.getProperty("storeFile") +val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() + android { namespace = "com.example.reader_app" compileSdk = flutter.compileSdkVersion @@ -31,11 +43,25 @@ android { versionName = flutter.versionName } + signingConfigs { + if (hasReleaseSigning) { + create("release") { + storeFile = file(releaseStoreFile!!) + storePassword = keystoreProperties.getProperty("storePassword") + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // Use release keystore when available; fallback to debug for local dev builds. + signingConfig = if (hasReleaseSigning) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } } diff --git a/scripts/release_signing_doctor.sh b/scripts/release_signing_doctor.sh new file mode 100755 index 0000000..67437fe --- /dev/null +++ b/scripts/release_signing_doctor.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +ANDROID_DIR="$ROOT_DIR/android" +KEY_PROPERTIES="$ANDROID_DIR/key.properties" +APK_PATH="${1:-$ROOT_DIR/build/app/outputs/flutter-apk/app-release.apk}" + +echo "== Android Release Signing Doctor ==" +echo "Project: $ROOT_DIR" + +echo "\n[1] Checking key.properties" +if [[ -f "$KEY_PROPERTIES" ]]; then + echo "FOUND: $KEY_PROPERTIES" + sed 's/password=.*/password=***HIDDEN***/' "$KEY_PROPERTIES" +else + echo "MISSING: $KEY_PROPERTIES" + echo "Create this file for release signing." +fi + +echo "\n[2] Checking keystore from key.properties" +if [[ -f "$KEY_PROPERTIES" ]]; then + STORE_FILE_REL="$(grep '^storeFile=' "$KEY_PROPERTIES" | head -n1 | cut -d'=' -f2-)" + STORE_FILE="$ANDROID_DIR/$STORE_FILE_REL" + KEY_ALIAS="$(grep '^keyAlias=' "$KEY_PROPERTIES" | head -n1 | cut -d'=' -f2-)" + STORE_PASS="$(grep '^storePassword=' "$KEY_PROPERTIES" | head -n1 | cut -d'=' -f2-)" + KEY_PASS="$(grep '^keyPassword=' "$KEY_PROPERTIES" | head -n1 | cut -d'=' -f2-)" + + if [[ -f "$STORE_FILE" ]]; then + echo "FOUND keystore: $STORE_FILE" + echo "Alias: $KEY_ALIAS" + echo "Fingerprints:" + keytool -list -v -keystore "$STORE_FILE" -alias "$KEY_ALIAS" -storepass "$STORE_PASS" -keypass "$KEY_PASS" | grep -E 'SHA1:|SHA256:' + else + echo "Keystore not found: $STORE_FILE" + fi +fi + +echo "\n[3] Checking APK signature" +if [[ -f "$APK_PATH" ]]; then + echo "APK: $APK_PATH" + if command -v apksigner >/dev/null 2>&1; then + apksigner verify --print-certs "$APK_PATH" + else + echo "apksigner not found in PATH." + echo "Try: \$ANDROID_HOME/build-tools//apksigner verify --print-certs $APK_PATH" + fi +else + echo "APK not found at: $APK_PATH" +fi + +echo "\n[4] CI secrets required" +echo "- TOKEN" +echo "- BASE_URL" +echo "- GOOGLE_SERVER_CLIENT_ID" +echo "- ANDROID_KEYSTORE_BASE64" +echo "- ANDROID_KEYSTORE_PASSWORD" +echo "- ANDROID_KEY_ALIAS" +echo "- ANDROID_KEY_PASSWORD" +echo "- EXPECTED_ANDROID_SHA1 (optional but recommended)"