diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index 76055de..a76202d 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -73,22 +73,64 @@ jobs: - name: Build debug APK working-directory: ./android + env: + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} run: ./gradlew assembleDebug --stacktrace - name: Build release APK working-directory: ./android + env: + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} run: ./gradlew assembleRelease --stacktrace - - name: Upload debug APKs - uses: actions/upload-artifact@v4 - with: - name: kqt-android-debug-${{ steps.git-info.outputs.sha_short }} - path: android/app/build/outputs/apk/debug/*.apk - if-no-files-found: error + - name: Collect and rename APKs + working-directory: ./android + run: ./gradlew collectAndRenameApks --stacktrace - - name: Upload release APKs + - name: Check if signed + id: signing-status + shell: bash + env: + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + run: | + if [ -n "$KEYSTORE_PASSWORD" ]; then + echo "signed=true" >> $GITHUB_OUTPUT + else + echo "signed=false" >> $GITHUB_OUTPUT + fi + + - name: Collect and rename APKs + shell: bash + run: | + # Create collection directory + mkdir -p upload + + COMMIT_HASH="${{ steps.git-info.outputs.sha_short }}" + SIGNED="${{ steps.signing-status.outputs.signed }}" + + # Determine suffix based on signing status + SUFFIX="" + if [ "$SIGNED" != "true" ]; then + SUFFIX="-UNSIGNED" + fi + + # Collect APKs + for apk in android/app/build/outputs/collected/kqt-*.apk; do + if [ -f "$apk" ]; then + filename=$(basename "$apk") + # Insert commit hash before .apk extension + new_name="${filename%.apk}-${COMMIT_HASH}${SUFFIX}.apk" + cp "$apk" "upload/$new_name" + echo "Collected: $new_name" + fi + done + + echo "All collected APKs:" + ls -1 upload/ + + - name: Upload APKs uses: actions/upload-artifact@v4 with: - name: kqt-android-release-${{ steps.git-info.outputs.sha_short }} - path: android/app/build/outputs/apk/release/*.apk + name: kqt-apks-${{ steps.git-info.outputs.sha_short }} + path: upload/*.apk if-no-files-found: error diff --git a/android/SIGNING.md b/android/SIGNING.md new file mode 100644 index 0000000..42036fa --- /dev/null +++ b/android/SIGNING.md @@ -0,0 +1,94 @@ +# Android APK Signing Configuration + +## Overview + +This document describes the APK signing setup for the Android build workflow. + +## CI Keystore + +The repository includes a debug signing keystore at `android/app/keys/ci.keystore` that is used for signing APKs in CI builds. + +### Keystore Details + +- **Location**: `android/app/keys/ci.keystore` +- **Alias**: `apk` +- **Algorithm**: ECDSA (EC with 256-bit key) +- **Format**: PKCS12 +- **Validity**: 10 years + +This is a **debug signing keystore** used only for development and CI builds. It is password-protected and committed to the repository for convenience. + +## GitHub Secrets Setup + +To enable APK signing in GitHub Actions, you need to configure the following secret: + +### Required Secret + +- **KEYSTORE_PASSWORD**: Password for the CI keystore + +### Adding Secret to GitHub + +1. Go to your repository on GitHub +2. Navigate to **Settings** → **Secrets and variables** → **Actions** +3. Click **New repository secret** +4. Add the secret: + - Name: `KEYSTORE_PASSWORD` + - Value: The keystore password + +## Workflow Behavior + +- **All branches**: APKs are built and signed using the CI keystore +- The keystore password is provided via the `KEYSTORE_PASSWORD` secret +- If the secret is not configured, the build will fail with an error message + +## Creating a New CI Keystore + +If you need to regenerate the CI keystore (e.g., if compromised), use: + +```bash +cd android/app/keys +keytool -genkeypair -v -keystore ci.keystore -alias apk -keyalg EC -validity 3650 +``` + +This command will interactively prompt you for: +- Keystore password (enter it twice for confirmation) +- Key password (you can press Enter to use the same password as the keystore) +- Your name, organizational unit, organization, city, state, and country + +**Notes:** +- The key password can be the same as the keystore password (press Enter when prompted for key password) +- PKCS12 is the default keystore type in modern keytool versions +- After creating the keystore, update the `KEYSTORE_PASSWORD` secret in GitHub with the password you chose + +## Local Development + +For local builds, set the environment variable: + +```bash +export KEYSTORE_PASSWORD=your-keystore-password +cd android +./gradlew assembleRelease +``` + +Or provide the password inline: + +```bash +cd android +KEYSTORE_PASSWORD=your-keystore-password ./gradlew assembleRelease +``` + +## Production Signing + +**Important**: The CI keystore is for **development and testing only**. For production releases to the Google Play Store: + +1. Create a separate production keystore with strong security +2. Store it securely (not in version control) +3. Use a different signing configuration for production builds +4. Never share or commit your production keystore + +## Security Notes + +- The CI keystore is intentionally committed to the repository for CI convenience +- It uses password protection as an additional security layer +- This is appropriate for debug/development builds but not for production releases +- Keep your production keystore separate and secure diff --git a/android/app/.gitignore b/android/app/.gitignore index 42afabf..458eefa 100644 --- a/android/app/.gitignore +++ b/android/app/.gitignore @@ -1 +1,4 @@ -/build \ No newline at end of file +/build +*.keystore +*.jks +!keys/ci.keystore \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f3a134d..f0057a9 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,8 +1,12 @@ +import com.android.build.OutputFile + plugins { alias(libs.plugins.android.application) alias(libs.plugins.ksp) } +val appVersionName = "1.0.0" + android { namespace = "plus.meow.kqt" compileSdk = 36 @@ -12,7 +16,7 @@ android { minSdk = 24 targetSdk = 36 versionCode = 1 - versionName = "1.0" + versionName = appVersionName testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" ndk { @@ -20,13 +24,36 @@ android { } } + val keystorePassword = System.getenv("KEYSTORE_PASSWORD")?.trim() + + signingConfigs { + create("release") { + if (keystorePassword != null) { + storeFile = file("keys/ci.keystore") + storePassword = keystorePassword + keyAlias = "apk" + keyPassword = keystorePassword + } + } + } + buildTypes { + debug { + // Only sign if KEYSTORE_PASSWORD is available + if (keystorePassword != null) { + signingConfig = signingConfigs.getByName("release") + } + } release { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + // Only sign if KEYSTORE_PASSWORD is available + if (keystorePassword != null) { + signingConfig = signingConfigs.getByName("release") + } } } @@ -45,6 +72,38 @@ android { } } +tasks.register("collectAndRenameApks") { + description = "Copies, flattens, and renames APKs to the upload directory" + group = "distribution" + + // Source: The standard output directory + from(layout.buildDirectory.dir("outputs/apk")) + + // Destination: Your upload folder + into(layout.buildDirectory.dir("outputs/collected")) + + include("**/*.apk") + + // Flattening and Renaming Logic + eachFile { + val matcher = "(.*)-(arm64-v8a|armeabi-v7a|universal)-(.*)\\.apk".toRegex().matchEntire(name) + + if (matcher != null) { + val (prefix, abi, buildType) = matcher.destructured + path = "kqt-$abi-$appVersionName-$buildType.apk" + } else { + path = name.replace(".apk", "-$appVersionName.apk") + } + } + + // Ensure we don't copy the empty "debug"/"release" folders + includeEmptyDirs = false +} + +tasks.named("assemble") { + finalizedBy("collectAndRenameApks") +} + kotlin { jvmToolchain(17) compilerOptions { @@ -76,4 +135,4 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) -} \ No newline at end of file +} diff --git a/android/app/keys/ci.keystore b/android/app/keys/ci.keystore new file mode 100644 index 0000000..461e44b Binary files /dev/null and b/android/app/keys/ci.keystore differ