diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml new file mode 100644 index 0000000..6f36927 --- /dev/null +++ b/.github/workflows/test-build.yml @@ -0,0 +1,95 @@ +name: Test build + +# Build de test signé par la clé plateforme, SANS publication dans les Releases. +# Les APK sortent en artifacts du run (onglet Actions → run → section Artifacts). +# L'OTA interroge /releases/latest, qui ignore artifacts, drafts et prereleases : +# ce workflow ne peut donc PAS perturber les mises à jour des utilisateurs. +on: + workflow_dispatch: + inputs: + variant: + description: "Variante à construire" + type: choice + options: [both, online, offline] + default: both + build_type: + description: "Type de build" + type: choice + options: [release, debug] + default: release + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + env: + MG4_KEYSTORE_BASE64: ${{ secrets.MG4_KEYSTORE_BASE64 }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Decode platform keystore + if: ${{ env.MG4_KEYSTORE_BASE64 != '' }} + run: | + printf '%s' "$MG4_KEYSTORE_BASE64" | tr -d '[:space:]' | base64 -d > "$RUNNER_TEMP/platform.keystore" + if [ "$(head -c1 "$RUNNER_TEMP/platform.keystore" | od -An -tx1 | tr -d ' ')" != "30" ]; then + echo "::error::MG4_KEYSTORE_BASE64 est invalide. Recréer le secret avec : base64 -w0 platform.keystore" + exit 1 + fi + echo "MG4_KEYSTORE=$RUNNER_TEMP/platform.keystore" >> "$GITHUB_ENV" + + - name: Build APK(s) + env: + MG4_KEYSTORE_PASSWORD: ${{ secrets.MG4_KEYSTORE_PASSWORD }} + MG4_KEY_ALIAS: ${{ secrets.MG4_KEY_ALIAS }} + MG4_KEY_PASSWORD: ${{ secrets.MG4_KEY_PASSWORD }} + run: | + chmod +x gradlew + # "release" → assembleOnlineRelease ; "debug" → assembleOnlineDebug + case "${{ inputs.build_type }}" in + release) SUFFIX="Release" ;; + debug) SUFFIX="Debug" ;; + esac + case "${{ inputs.variant }}" in + both) TASKS="assembleOnline$SUFFIX assembleOffline$SUFFIX" ;; + online) TASKS="assembleOnline$SUFFIX" ;; + offline) TASKS="assembleOffline$SUFFIX" ;; + esac + echo "Tâches : $TASKS" + ./gradlew $TASKS --no-daemon + + - name: Collect APKs + run: | + mkdir -p dist + find app/build/outputs/apk -name "*.apk" -exec cp {} dist/ \; + ls -la dist/ + + # Affiche les schémas de signature réellement posés (v1 / v2 / v3) et l'empreinte du + # certificat. Permet de contrôler ce que produit VRAIMENT la CI, sans installer l'APK. + - name: Signature report + run: | + APKSIGNER=$(ls "$ANDROID_SDK_ROOT"/build-tools/*/apksigner | sort -V | tail -1) + echo "apksigner : $APKSIGNER" + for f in dist/*.apk; do + echo "── $(basename "$f") ──" + "$APKSIGNER" verify --print-certs -v "$f" 2>&1 \ + | grep -E 'Verified using v[0-9]|certificate SHA-256 digest' || echo " (non vérifiable)" + echo + done + + - name: Upload APKs (artifacts, PAS une release) + uses: actions/upload-artifact@v4 + with: + name: mg4control-test-${{ inputs.variant }}-${{ inputs.build_type }}-${{ github.run_number }} + path: dist/*.apk + retention-days: 14 + if-no-files-found: error diff --git a/app/src/main/java/com/mg4/control/update/ApkInstaller.kt b/app/src/main/java/com/mg4/control/update/ApkInstaller.kt index 83f8618..34484ab 100644 --- a/app/src/main/java/com/mg4/control/update/ApkInstaller.kt +++ b/app/src/main/java/com/mg4/control/update/ApkInstaller.kt @@ -31,17 +31,14 @@ object ApkInstaller { return "APK manquant ou vide : ${apkFile.absolutePath}" } - // [DÉSACTIVÉ 2026-08-05] Contrôle de signature (T-901) mis en pause : il refusait - // TOUTE mise à jour OTA sur AAOS 9, y compris avec un APK signé de la même clé - // plateforme en v1+v2+v3. Cause non encore élucidée (getPackageArchiveInfo ne - // remonte probablement pas la signature de l'archive sur cette ROM). - // Garde-fous restants : Android refuse une MAJ signée d'une autre clé, et - // ApkUrlPolicy restreint toujours l'origine du téléchargement (github/gitlab, https). - // Pour réactiver : décommenter le bloc ci-dessous. - // if (!ApkSignatureVerifier.matchesRunningApp(context, apkFile)) { - // apkFile.runCatching { delete() } - // return "Signature non conforme — APK rejeté et supprimé" - // } + // [RÉACTIVÉ 2026-08-05] Contrôle de signature (T-901). Les échecs précédents ont eu + // lieu avec une app installée signée à la main (v1 seul) face à un APK CI (v2/v3) : + // on retente avec les DEUX côtés produits par la CI, donc signés à l'identique. + // L'app tourne en uid.system : on n'installe que ce qui est signé par notre clé. + if (!ApkSignatureVerifier.matchesRunningApp(context, apkFile)) { + apkFile.runCatching { delete() } + return "Signature non conforme — APK rejeté et supprimé" + } // ── Stratégie 1 : pm install depuis le stockage externe ─────────────── val extDir = context.getExternalFilesDir(null) diff --git a/app/src/main/java/com/mg4/control/update/UpdateDialogManager.kt b/app/src/main/java/com/mg4/control/update/UpdateDialogManager.kt index 010ecf8..f9c0cdd 100644 --- a/app/src/main/java/com/mg4/control/update/UpdateDialogManager.kt +++ b/app/src/main/java/com/mg4/control/update/UpdateDialogManager.kt @@ -172,6 +172,20 @@ object UpdateDialogManager { .substringBefore('?') .ifBlank { "MG4Control-${info.versionName}.apk" } + // DownloadManager n'écrase JAMAIS un fichier existant : si "…-2.6.5.apk" est déjà là + // (téléchargement précédent de la même version), il écrit "…-2.6.5-1.apk". Or le code + // relit ensuite le nom d'origine → il vérifierait/installerait l'ANCIEN fichier. + // On supprime donc la cible avant d'enfiler la requête, pour garantir que le fichier + // téléchargé, le fichier vérifié et le fichier installé soient bien le même. + val target = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + fileName + ) + if (target.exists()) { + val deleted = target.runCatching { delete() }.getOrDefault(false) + Log.i(TAG, "Ancien APK homonyme trouvé (${target.name}) → suppression=$deleted") + } + val request = DownloadManager.Request(Uri.parse(info.apkUrl)).apply { setTitle("MG4Control ${info.versionName}") setDescription(activity.getString(R.string.update_downloading)) @@ -210,19 +224,18 @@ object UpdateDialogManager { when (status) { DownloadManager.STATUS_SUCCESSFUL -> { progressBar.progress = 100 - // [DÉSACTIVÉ 2026-08-05] Contrôle de signature (T-901) mis en pause : - // il refusait TOUTE mise à jour OTA sur AAOS 9, même avec un APK - // signé de la même clé plateforme en v1+v2+v3. Voir ApkInstaller. - // Pour réactiver : décommenter le bloc ci-dessous. - // val downloaded = File( - // Environment.getExternalStoragePublicDirectory( - // Environment.DIRECTORY_DOWNLOADS), fileName) - // if (!ApkSignatureVerifier.matchesRunningApp(activity, downloaded)) { - // downloaded.runCatching { delete() } - // tvStatus.setText(R.string.update_error_signature) - // btnCancel.setText(R.string.update_close) - // break - // } + // [RÉACTIVÉ 2026-08-05] Contrôle de signature (T-901) — voir ApkInstaller. + // L'APK est dans un dossier public : on vérifie qu'il est signé + // par NOTRE clé avant d'inviter l'utilisateur à l'installer. + val downloaded = File( + Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS), fileName) + if (!ApkSignatureVerifier.matchesRunningApp(activity, downloaded)) { + downloaded.runCatching { delete() } + tvStatus.setText(R.string.update_error_signature) + btnCancel.setText(R.string.update_close) + break + } // Nettoie les anciens APK dans Téléchargements (garde les 5 plus récents) ApkCleanup.cleanIfNeeded() // Ouvre le dossier Téléchargements dans le gestionnaire AAOS