From 2f0f6121341c33a89b11dae2e41c0056ccd06d1c Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:02:14 -0400 Subject: [PATCH] prep for release --- .env.example | 4 + .github/workflows/android-candidate.yml | 288 +++++++++++++++++++++++ .gitignore | 1 + PRIVACY.md | 49 +++- app.config.js | 37 +++ app.json | 7 +- package.json | 4 + plugins/withAstraAndroidRelease.js | 175 ++++++++++++++ plugins/withAstraAndroidRelease.test.mjs | 67 ++++++ release.json | 3 + scripts/release/android-release.mjs | 175 ++++++++++++++ scripts/release/android-release.test.mjs | 20 ++ scripts/run-release-tests.mjs | 35 +++ src/app/(tabs)/settings.tsx | 5 +- src/app/settings/info.tsx | 26 +- src/release/buildInfo.test.mts | 32 +++ src/release/buildInfo.ts | 39 +++ 17 files changed, 943 insertions(+), 24 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/android-candidate.yml create mode 100644 app.config.js create mode 100644 plugins/withAstraAndroidRelease.js create mode 100644 plugins/withAstraAndroidRelease.test.mjs create mode 100644 release.json create mode 100644 scripts/release/android-release.mjs create mode 100644 scripts/release/android-release.test.mjs create mode 100644 scripts/run-release-tests.mjs create mode 100644 src/release/buildInfo.test.mts create mode 100644 src/release/buildInfo.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..415cc47 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# These values are embedded in release bundles. Keep the real values in the +# protected GitHub release environment and in an ignored local .env file. +EXPO_PUBLIC_LASTFM_API_KEY= +EXPO_PUBLIC_LASTFM_SHARED_SECRET= diff --git a/.github/workflows/android-candidate.yml b/.github/workflows/android-candidate.yml new file mode 100644 index 0000000..b92dd9a --- /dev/null +++ b/.github/workflows/android-candidate.yml @@ -0,0 +1,288 @@ +name: Build Android Candidate + +on: + workflow_dispatch: + inputs: + candidate_label: + description: Optional label for the Actions artifacts, such as smoke or rc1 + required: false + default: '' + type: string + +permissions: + contents: read + +concurrency: + group: android-candidate-${{ github.ref }} + cancel-in-progress: false + +env: + NODE_VERSION: 22.x + JAVA_VERSION: '17' + +jobs: + validate: + name: Validate candidate source + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Require main branch + shell: bash + run: | + if [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "Android signing candidates must be built from main; received $GITHUB_REF." >&2 + exit 1 + fi + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Validate release identity + run: npm run release:validate + + - name: Typecheck + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Run deterministic tests + run: npm run test:release + + github-apk: + name: Build signed GitHub APK + needs: validate + runs-on: ubuntu-latest + environment: android-release + env: + ASTRA_DISTRIBUTION: github + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Install dependencies + run: npm ci + + - name: Validate GitHub release identity + run: node scripts/release/android-release.mjs validate github + + - name: Generate Android project from tracked configuration + run: npx expo prebuild --platform android --no-install --clean + + - name: Verify generated native customizations + shell: bash + run: | + grep -Fq "ASTRA VENDORED KOTLIN AUDIO" android/settings.gradle + grep -Fq "ASTRA KOTLIN AUDIO SUBSTITUTION" android/build.gradle + grep -Fq "ASTRA RELEASE SIGNING" android/app/build.gradle + grep -Fq 'android:shell="true"' android/app/src/main/AndroidManifest.xml + + - name: Decode app-signing keystore + env: + ASTRA_KEYSTORE_BASE64: ${{ secrets.ANDROID_APP_SIGNING_KEYSTORE_BASE64 }} + shell: bash + run: | + if [ -z "$ASTRA_KEYSTORE_BASE64" ]; then + echo "ANDROID_APP_SIGNING_KEYSTORE_BASE64 is not configured." >&2 + exit 1 + fi + printf '%s' "$ASTRA_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/astra-app-signing.jks" + chmod 600 "$RUNNER_TEMP/astra-app-signing.jks" + + - name: Build ARM-universal release APK + working-directory: android + env: + EXPO_PUBLIC_LASTFM_API_KEY: ${{ secrets.EXPO_PUBLIC_LASTFM_API_KEY }} + EXPO_PUBLIC_LASTFM_SHARED_SECRET: ${{ secrets.EXPO_PUBLIC_LASTFM_SHARED_SECRET }} + ASTRA_ANDROID_KEYSTORE_PATH: ${{ runner.temp }}/astra-app-signing.jks + ASTRA_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_APP_SIGNING_KEYSTORE_PASSWORD }} + ASTRA_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_APP_SIGNING_KEY_ALIAS }} + ASTRA_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_APP_SIGNING_KEY_PASSWORD }} + run: ./gradlew :app:assembleRelease -PreactNativeArchitectures=armeabi-v7a,arm64-v8a --no-daemon + + - name: Verify release dependency substitution + working-directory: android + shell: bash + run: | + ./gradlew :app:dependencies --configuration releaseRuntimeClasspath --no-daemon > "$RUNNER_TEMP/release-dependencies.txt" + grep -Fq "project :kotlin-audio" "$RUNNER_TEMP/release-dependencies.txt" + + - name: Verify APK identity, signature, permissions, and ABIs + env: + ASTRA_EXPECTED_CERT_SHA256: ${{ vars.ANDROID_APP_SIGNING_CERT_SHA256 }} + shell: bash + run: | + APK="android/app/build/outputs/apk/release/app-release.apk" + AAPT="$ANDROID_HOME/build-tools/36.0.0/aapt" + APKSIGNER="$ANDROID_HOME/build-tools/36.0.0/apksigner" + EXPECTED="$(printf '%s' "$ASTRA_EXPECTED_CERT_SHA256" | tr -d ' :' | tr '[:lower:]' '[:upper:]')" + ACTUAL="$($APKSIGNER verify --print-certs "$APK" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p' | tr '[:lower:]' '[:upper:]')" + + if [ -z "$EXPECTED" ] || [ "$ACTUAL" != "$EXPECTED" ]; then + echo "APK signing certificate does not match the protected app-signing fingerprint." >&2 + exit 1 + fi + + node scripts/release/android-release.mjs verify-apk-metadata github android/app/build/outputs/apk/release/output-metadata.json + unzip -Z1 "$APK" | grep -Fq 'lib/armeabi-v7a/' + unzip -Z1 "$APK" | grep -Fq 'lib/arm64-v8a/' + if unzip -Z1 "$APK" | grep -Eq '^lib/(x86|x86_64)/'; then + echo "GitHub APK unexpectedly contains emulator ABIs." >&2 + exit 1 + fi + if ! $AAPT dump badging "$APK" | grep -Fq "uses-feature-not-required: name='android.hardware.camera'"; then + echo "GitHub APK incorrectly requires camera hardware." >&2 + exit 1 + fi + + for permission in \ + android.permission.READ_EXTERNAL_STORAGE \ + android.permission.RECORD_AUDIO \ + android.permission.SYSTEM_ALERT_WINDOW \ + android.permission.WRITE_EXTERNAL_STORAGE; do + if $AAPT dump permissions "$APK" | grep -Fq "$permission"; then + echo "GitHub APK contains forbidden permission $permission." >&2 + exit 1 + fi + done + + - name: Prepare GitHub artifact bundle + run: node scripts/release/android-release.mjs prepare github android/app/build/outputs/apk/release/app-release.apk dist/android/github + + - name: Upload GitHub APK candidate + uses: actions/upload-artifact@v4 + with: + name: astra-github-${{ inputs.candidate_label || format('run-{0}', github.run_number) }} + path: dist/android/github/ + if-no-files-found: error + retention-days: 30 + + google-play-aab: + name: Build signed Google Play AAB + needs: validate + runs-on: ubuntu-latest + environment: android-release + env: + ASTRA_DISTRIBUTION: google-play + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Install dependencies + run: npm ci + + - name: Validate Play release identity + run: node scripts/release/android-release.mjs validate google-play + + - name: Generate Android project from tracked configuration + run: npx expo prebuild --platform android --no-install --clean + + - name: Verify generated native customizations + shell: bash + run: | + grep -Fq "ASTRA VENDORED KOTLIN AUDIO" android/settings.gradle + grep -Fq "ASTRA KOTLIN AUDIO SUBSTITUTION" android/build.gradle + grep -Fq "ASTRA RELEASE SIGNING" android/app/build.gradle + grep -Fq 'android:shell="true"' android/app/src/main/AndroidManifest.xml + + - name: Run Astra native unit tests + working-directory: android + run: >- + ./gradlew + :astra-audio-route:testDebugUnitTest + :astra-desktop-transport:testDebugUnitTest + :astra-library-scanner:testDebugUnitTest + :astra-scope:testDebugUnitTest + :kotlin-audio:testDebugUnitTest + --no-daemon + + - name: Decode upload keystore + env: + ASTRA_KEYSTORE_BASE64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_BASE64 }} + shell: bash + run: | + if [ -z "$ASTRA_KEYSTORE_BASE64" ]; then + echo "ANDROID_UPLOAD_KEYSTORE_BASE64 is not configured." >&2 + exit 1 + fi + printf '%s' "$ASTRA_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/astra-upload.jks" + chmod 600 "$RUNNER_TEMP/astra-upload.jks" + + - name: Build Play bundle + working-directory: android + env: + EXPO_PUBLIC_LASTFM_API_KEY: ${{ secrets.EXPO_PUBLIC_LASTFM_API_KEY }} + EXPO_PUBLIC_LASTFM_SHARED_SECRET: ${{ secrets.EXPO_PUBLIC_LASTFM_SHARED_SECRET }} + ASTRA_ANDROID_KEYSTORE_PATH: ${{ runner.temp }}/astra-upload.jks + ASTRA_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_PASSWORD }} + ASTRA_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_UPLOAD_KEY_ALIAS }} + ASTRA_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEY_PASSWORD }} + run: ./gradlew :app:bundleRelease --no-daemon + + - name: Verify release dependency substitution + working-directory: android + shell: bash + run: | + ./gradlew :app:dependencies --configuration releaseRuntimeClasspath --no-daemon > "$RUNNER_TEMP/release-dependencies.txt" + grep -Fq "project :kotlin-audio" "$RUNNER_TEMP/release-dependencies.txt" + + - name: Verify AAB signature + env: + ASTRA_EXPECTED_CERT_SHA256: ${{ vars.ANDROID_UPLOAD_CERT_SHA256 }} + shell: bash + run: | + AAB="android/app/build/outputs/bundle/release/app-release.aab" + EXPECTED="$(printf '%s' "$ASTRA_EXPECTED_CERT_SHA256" | tr -d ' :' | tr '[:lower:]' '[:upper:]')" + ACTUAL="$(keytool -printcert -jarfile "$AAB" | awk '/SHA256:/{gsub(":", "", $2); print toupper($2); exit}')" + + jarsigner -verify "$AAB" >/dev/null + if [ -z "$EXPECTED" ] || [ "$ACTUAL" != "$EXPECTED" ]; then + echo "AAB signing certificate does not match the protected upload-key fingerprint." >&2 + exit 1 + fi + + - name: Prepare Google Play artifact bundle + run: node scripts/release/android-release.mjs prepare google-play android/app/build/outputs/bundle/release/app-release.aab dist/android/google-play + + - name: Upload Google Play AAB candidate + uses: actions/upload-artifact@v4 + with: + name: astra-google-play-${{ inputs.candidate_label || format('run-{0}', github.run_number) }} + path: dist/android/google-play/ + if-no-files-found: error + retention-days: 30 diff --git a/.gitignore b/.gitignore index 8a7aec7..61a584d 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,4 @@ vendor/kotlinaudio/kotlin-audio/.cxx/ HANDOFF.md DESIGN.md +docs/release/android.md diff --git a/PRIVACY.md b/PRIVACY.md index 25592a5..8510be3 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,31 +1,56 @@ # Privacy Policy — Astra -**Last updated: June 15, 2026** +**Last updated: July 15, 2026** ## Overview -Astra is a local music player application. This policy describes how Astra handles your data — which is to say, it doesn't collect any. +Astra is a local-first music player. It does not require an Astra account and does not include advertising, analytics, behavioral tracking, or crash-reporting SDKs. -## Data Collection +Most Astra data stays on your device. Optional features connect only when you choose to use them, as described below. -Astra does not collect, transmit, store, or share any personal data or usage information. All music library data, playback history, and preferences are stored exclusively on your device and are never sent anywhere. +## Data stored on your device + +Astra stores your selected library folders, indexed music metadata, playlists, playback history, preferences, cached artwork and lyrics, and optional service configuration on your device. + +Passwords, session keys, and paired-desktop control tokens are stored using Android-backed secure storage. Other app data is stored in Astra's local database and files. + +## Optional network features + +Depending on the features you enable, Astra may send data to the following destinations: + +- **Lyrics providers:** Track title, artist, album, and duration may be sent to LRCLIB or XLRCDB to find lyrics. +- **Remote music servers:** If you add a Subsonic-compatible or Jellyfin server, Astra sends connection details, credentials, catalog requests, and playback requests directly to the server you configured. +- **Scrobbling services:** If you enable Last.fm-compatible scrobbling, ListenBrainz, or another configured destination, Astra sends track and playback information to that service. +- **Paired Astra Desktop:** If you pair a desktop, Astra exchanges remote-control, library-sync, playlist, favorite, queue, and playback data directly with that paired desktop. +- **External links:** Opening repository, community, license, support, or other web links transfers you to the selected website or app. + +Those services may receive ordinary connection information such as your IP address and may handle data under their own privacy policies. Astra does not control user-selected servers or third-party services. + +Astra permits connections to local servers over either HTTP or HTTPS because many self-hosted music servers are available only on a private local network. You choose the server and connection method. ## Permissions -Astra requests access to local storage solely to read audio files you choose to play. These permissions are used only on-device and do not involve any external servers or third parties. +Astra may request: -## Third-Party Services +- **Folder and file access** through Android's system picker, so you can choose music folders and import or export supported files. +- **Camera access** only when you open a QR-code scanner for desktop pairing, EQ presets, or Signal sharing. +- **Notification access** for playback controls, library scans, and paired-desktop sessions. +- **Local-network and internet access** for optional servers, scrobbling, lyrics, sharing, and desktop features. -Astra does not integrate any analytics, advertising, crash reporting, or tracking SDKs. No third-party services receive any data from your use of this app. +Astra does not request microphone access. -## Children's Privacy +## Data retention and deletion -Astra does not collect data from any users, including children under the age of 13. +Local data remains until you remove it in Astra, clear Astra's app data, or uninstall the app. Disconnecting an optional service removes its locally stored credentials where the feature provides that action, but it does not delete data already received by that service. Use the service's own controls for that data. -## Changes to This Policy +## Children's privacy -If this policy changes in a future version (for example, if optional features involving network access are added), this document will be updated and the "Last updated" date will reflect that change. +Astra is not designed for or directed to children. Astra does not knowingly collect children's personal information through an Astra account or telemetry service because neither exists. + +## Changes to this policy + +This policy may change as Astra's features change. The updated policy will be published at this URL with a revised date. ## Contact -For questions about this policy, contact: contact@novaml.ai +For privacy questions, contact: contact@novaml.ai diff --git a/app.config.js b/app.config.js new file mode 100644 index 0000000..fb5c831 --- /dev/null +++ b/app.config.js @@ -0,0 +1,37 @@ +const packageJson = require('./package.json'); +const release = require('./release.json'); + +const DISTRIBUTIONS = new Set(['development', 'github', 'google-play']); +const BLOCKED_ANDROID_PERMISSIONS = [ + 'android.permission.READ_EXTERNAL_STORAGE', + 'android.permission.RECORD_AUDIO', + 'android.permission.SYSTEM_ALERT_WINDOW', + 'android.permission.WRITE_EXTERNAL_STORAGE', +]; + +module.exports = ({ config }) => { + const distribution = (process.env.ASTRA_DISTRIBUTION ?? 'development').trim(); + if (!DISTRIBUTIONS.has(distribution)) { + throw new Error( + `ASTRA_DISTRIBUTION must be one of ${Array.from(DISTRIBUTIONS).join(', ')}; received ${JSON.stringify(distribution)}.` + ); + } + + if (!Number.isInteger(release.androidVersionCode) || release.androidVersionCode <= 0) { + throw new Error('release.json androidVersionCode must be a positive integer.'); + } + + return { + ...config, + version: packageJson.version, + android: { + ...config.android, + versionCode: release.androidVersionCode, + blockedPermissions: BLOCKED_ANDROID_PERMISSIONS, + }, + extra: { + ...config.extra, + distribution, + }, + }; +}; diff --git a/app.json b/app.json index 1fff79d..e593873 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,6 @@ "expo": { "name": "Astra", "slug": "astra-mobile", - "version": "0.1.0", "platforms": ["android"], "orientation": "default", "icon": "./assets/images/icon.png", @@ -32,7 +31,8 @@ [ "expo-camera", { - "cameraPermission": "Allow Astra to scan desktop pairing and EQ preset QR codes." + "cameraPermission": "Allow Astra to scan desktop pairing and EQ preset QR codes.", + "recordAudioAndroid": false } ], [ @@ -54,7 +54,8 @@ } } ], - "expo-sharing" + "expo-sharing", + "./plugins/withAstraAndroidRelease" ], "experiments": { "typedRoutes": true, diff --git a/package.json b/package.json index 8c6ea61..d5c6e07 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "start": "expo start", "android": "expo run:android", "android:release": "rm -rf android/app/build/generated/assets/react && ORG_GRADLE_PROJECT_reactNativeArchitectures=arm64-v8a expo run:android --variant release", + "android:preview": "rm -rf android/app/build/generated/assets/react && ASTRA_ALLOW_INSECURE_RELEASE_SIGNING=true ORG_GRADLE_PROJECT_reactNativeArchitectures=arm64-v8a expo run:android --variant release", "ios": "expo run:ios", "web": "expo start --web", "lint": "expo lint", @@ -86,6 +87,9 @@ "test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts", "test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts", "test:session": "node --experimental-strip-types --test src/session/sessionState.test.mts src/session/playbackMaterialization.test.mts", + "test:release-config": "node --experimental-strip-types --test plugins/withAstraAndroidRelease.test.mjs scripts/release/android-release.test.mjs src/release/buildInfo.test.mts", + "test:release": "node scripts/run-release-tests.mjs", + "release:validate": "node scripts/release/android-release.mjs validate github && node scripts/release/android-release.mjs validate google-play", "typecheck": "tsc --noEmit", "postinstall": "patch-package" }, diff --git a/plugins/withAstraAndroidRelease.js b/plugins/withAstraAndroidRelease.js new file mode 100644 index 0000000..d0180dd --- /dev/null +++ b/plugins/withAstraAndroidRelease.js @@ -0,0 +1,175 @@ +const { + AndroidConfig, + withAndroidManifest, + withAppBuildGradle, + withProjectBuildGradle, + withSettingsGradle, +} = require('expo/config-plugins'); + +const SETTINGS_MARKER = '// ASTRA VENDORED KOTLIN AUDIO'; +const PROJECT_BUILD_MARKER = '// ASTRA KOTLIN AUDIO SUBSTITUTION'; +const SIGNING_MARKER = '// ASTRA RELEASE SIGNING'; +const CAMERA_FEATURE = 'android.hardware.camera'; + +const SETTINGS_BLOCK = ` + +${SETTINGS_MARKER} +include ':kotlin-audio' +project(':kotlin-audio').projectDir = new File(rootDir, '../vendor/kotlinaudio/kotlin-audio') +`; + +const PROJECT_BUILD_BLOCK = ` + +${PROJECT_BUILD_MARKER} +subprojects { + configurations.configureEach { + resolutionStrategy.dependencySubstitution { + substitute module('com.github.doublesymmetry:kotlinaudio') using project(':kotlin-audio') + } + } +} +`; + +const SIGNING_CONFIGURATION = `${SIGNING_MARKER} +def astraReleaseStorePath = System.getenv('ASTRA_ANDROID_KEYSTORE_PATH') +def astraReleaseStorePassword = System.getenv('ASTRA_ANDROID_KEYSTORE_PASSWORD') +def astraReleaseKeyAlias = System.getenv('ASTRA_ANDROID_KEY_ALIAS') +def astraReleaseKeyPassword = System.getenv('ASTRA_ANDROID_KEY_PASSWORD') +def astraAllowInsecureReleaseSigning = (System.getenv('ASTRA_ALLOW_INSECURE_RELEASE_SIGNING') ?: 'false').toBoolean() +def astraReleaseSigningValues = [ + astraReleaseStorePath, + astraReleaseStorePassword, + astraReleaseKeyAlias, + astraReleaseKeyPassword, +] +def astraReleaseSigningConfigured = astraReleaseSigningValues.every { value -> value != null && !value.trim().isEmpty() } + +gradle.taskGraph.whenReady { taskGraph -> + def astraReleaseTaskRequested = taskGraph.allTasks.any { task -> task.name.toLowerCase().contains('release') } + if (astraReleaseTaskRequested && !astraReleaseSigningConfigured && !astraAllowInsecureReleaseSigning) { + throw new GradleException('Release signing is not configured. Supply the ASTRA_ANDROID_KEYSTORE_* variables, or explicitly set ASTRA_ALLOW_INSECURE_RELEASE_SIGNING=true for a non-publishable local preview.') + } +} + +if (astraReleaseSigningConfigured && !file(astraReleaseStorePath).isFile()) { + throw new GradleException('ASTRA_ANDROID_KEYSTORE_PATH does not point to a file: ' + astraReleaseStorePath) +} +`; + +const RELEASE_SIGNING_CONFIG = ` if (astraReleaseSigningConfigured) { + release { + storeFile file(astraReleaseStorePath) + storePassword astraReleaseStorePassword + keyAlias astraReleaseKeyAlias + keyPassword astraReleaseKeyPassword + } + } +`; + +function appendBlock(contents, marker, block) { + return contents.includes(marker) ? contents : `${contents.trimEnd()}${block}`; +} + +function addReleaseSigning(contents) { + if (contents.includes(SIGNING_MARKER)) return contents; + + const androidAnchor = 'android {'; + const signingAnchor = ' signingConfigs {\n debug {'; + const debugSigning = 'signingConfig signingConfigs.debug'; + + if (!contents.includes(androidAnchor)) { + throw new Error('Unable to add Astra release signing: android block was not found.'); + } + if (!contents.includes(signingAnchor)) { + throw new Error('Unable to add Astra release signing: signingConfigs debug block was not found.'); + } + + let result = contents.replace(androidAnchor, `${SIGNING_CONFIGURATION}\n${androidAnchor}`); + result = result.replace( + signingAnchor, + ` signingConfigs {\n${RELEASE_SIGNING_CONFIG} debug {` + ); + + const lastDebugSigning = result.lastIndexOf(debugSigning); + if (lastDebugSigning < 0) { + throw new Error('Unable to add Astra release signing: release signing assignment was not found.'); + } + + return `${result.slice(0, lastDebugSigning)}signingConfig astraReleaseSigningConfigured ? signingConfigs.release : signingConfigs.debug${result.slice(lastDebugSigning + debugSigning.length)}`; +} + +function withVendoredKotlinAudio(config) { + config = withSettingsGradle(config, (mod) => { + mod.modResults.contents = appendBlock(mod.modResults.contents, SETTINGS_MARKER, SETTINGS_BLOCK); + return mod; + }); + + return withProjectBuildGradle(config, (mod) => { + mod.modResults.contents = appendBlock( + mod.modResults.contents, + PROJECT_BUILD_MARKER, + PROJECT_BUILD_BLOCK + ); + return mod; + }); +} + +function withReleaseSigning(config) { + return withAppBuildGradle(config, (mod) => { + mod.modResults.contents = addReleaseSigning(mod.modResults.contents); + return mod; + }); +} + +function withProfileableRelease(config) { + return withAndroidManifest(config, (mod) => { + AndroidConfig.Manifest.ensureToolsAvailable(mod.modResults); + ensureOptionalCameraFeature(mod.modResults); + const application = AndroidConfig.Manifest.getMainApplicationOrThrow(mod.modResults); + application.profileable = [ + { + $: { + 'android:shell': 'true', + 'tools:targetApi': '29', + }, + }, + ]; + return mod; + }); +} + +function ensureOptionalCameraFeature(androidManifest) { + const manifest = androidManifest.manifest; + const features = manifest['uses-feature'] ?? []; + const cameraFeature = features.find( + (feature) => feature.$?.['android:name'] === CAMERA_FEATURE + ); + + if (cameraFeature) { + cameraFeature.$['android:required'] = 'false'; + } else { + features.push({ + $: { + 'android:name': CAMERA_FEATURE, + 'android:required': 'false', + }, + }); + } + manifest['uses-feature'] = features; +} + +function withAstraAndroidRelease(config) { + config = withVendoredKotlinAudio(config); + config = withReleaseSigning(config); + return withProfileableRelease(config); +} + +module.exports = withAstraAndroidRelease; +module.exports._internal = { + PROJECT_BUILD_MARKER, + SETTINGS_MARKER, + SIGNING_MARKER, + addReleaseSigning, + appendBlock, + ensureOptionalCameraFeature, +}; diff --git a/plugins/withAstraAndroidRelease.test.mjs b/plugins/withAstraAndroidRelease.test.mjs new file mode 100644 index 0000000..b95fa1b --- /dev/null +++ b/plugins/withAstraAndroidRelease.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import test from 'node:test'; + +const require = createRequire(import.meta.url); +const { _internal } = require('./withAstraAndroidRelease.js'); + +const APP_GRADLE = `apply plugin: "com.android.application" + +android { + signingConfigs { + debug { + storeFile file('debug.keystore') + } + } + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + signingConfig signingConfigs.debug + } + } +} +`; + +test('adds fail-closed release signing without changing debug signing', () => { + const transformed = _internal.addReleaseSigning(APP_GRADLE); + + assert.match(transformed, /ASTRA_ALLOW_INSECURE_RELEASE_SIGNING/); + assert.match(transformed, /throw new GradleException\('Release signing is not configured/); + assert.match(transformed, /debug \{\n signingConfig signingConfigs\.debug/); + assert.match( + transformed, + /release \{\n signingConfig astraReleaseSigningConfigured \? signingConfigs\.release : signingConfigs\.debug/ + ); +}); + +test('release signing transform is idempotent', () => { + const transformed = _internal.addReleaseSigning(APP_GRADLE); + assert.equal(_internal.addReleaseSigning(transformed), transformed); + assert.equal(transformed.split(_internal.SIGNING_MARKER).length - 1, 1); +}); + +test('appendBlock adds a native block exactly once', () => { + const once = _internal.appendBlock('base\n', _internal.SETTINGS_MARKER, `\n${_internal.SETTINGS_MARKER}\nblock\n`); + const twice = _internal.appendBlock(once, _internal.SETTINGS_MARKER, `\n${_internal.SETTINGS_MARKER}\nblock\n`); + + assert.equal(once, twice); + assert.equal(once.split(_internal.SETTINGS_MARKER).length - 1, 1); +}); + +test('marks QR scanning camera hardware as optional without duplicating it', () => { + const manifest = { manifest: {} }; + + _internal.ensureOptionalCameraFeature(manifest); + _internal.ensureOptionalCameraFeature(manifest); + + assert.deepEqual(manifest.manifest['uses-feature'], [ + { + $: { + 'android:name': 'android.hardware.camera', + 'android:required': 'false', + }, + }, + ]); +}); diff --git a/release.json b/release.json new file mode 100644 index 0000000..1ca9239 --- /dev/null +++ b/release.json @@ -0,0 +1,3 @@ +{ + "androidVersionCode": 1 +} diff --git a/scripts/release/android-release.mjs b/scripts/release/android-release.mjs new file mode 100644 index 0000000..384ec44 --- /dev/null +++ b/scripts/release/android-release.mjs @@ -0,0 +1,175 @@ +import { createHash } from 'node:crypto'; +import { copyFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const require = createRequire(import.meta.url); +const PACKAGE_ID = 'io.github.boof2015.astra'; +const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u; + +const DISTRIBUTIONS = { + github: { + artifactSuffix: 'GitHub-arm-universal', + extension: 'apk', + label: 'GitHub', + }, + 'google-play': { + artifactSuffix: 'GooglePlay', + extension: 'aab', + label: 'Google Play', + }, +}; + +function readJson(relativePath) { + return JSON.parse(readFileSync(path.join(ROOT, relativePath), 'utf8')); +} + +export function getReleaseIdentity(distribution) { + const distributionConfig = DISTRIBUTIONS[distribution]; + if (!distributionConfig) { + throw new Error(`Unsupported distribution ${JSON.stringify(distribution)}.`); + } + + const packageJson = readJson('package.json'); + const packageLock = readJson('package-lock.json'); + const release = readJson('release.json'); + const appJson = readJson('app.json'); + const versionName = packageJson.version; + const versionCode = release.androidVersionCode; + + if (typeof versionName !== 'string' || !SEMVER_PATTERN.test(versionName)) { + throw new Error(`package.json version must be valid SemVer; received ${JSON.stringify(versionName)}.`); + } + if (packageLock.version !== versionName || packageLock.packages?.['']?.version !== versionName) { + throw new Error('package-lock.json root versions must match package.json version. Run npm install after changing the version.'); + } + if (!Number.isInteger(versionCode) || versionCode <= 0) { + throw new Error('release.json androidVersionCode must be a positive integer.'); + } + if (appJson.expo?.version !== undefined) { + throw new Error('app.json must not duplicate the app version; app.config.js reads it from package.json.'); + } + if (appJson.expo?.android?.package !== PACKAGE_ID) { + throw new Error(`Android package ID must remain ${PACKAGE_ID}.`); + } + if (!appJson.expo?.plugins?.includes('./plugins/withAstraAndroidRelease')) { + throw new Error('The Astra Android release config plugin is missing from app.json.'); + } + + const previousDistribution = process.env.ASTRA_DISTRIBUTION; + process.env.ASTRA_DISTRIBUTION = distribution; + let resolvedConfig; + try { + const createAppConfig = require(path.join(ROOT, 'app.config.js')); + resolvedConfig = createAppConfig({ config: appJson.expo }); + } finally { + if (previousDistribution === undefined) { + delete process.env.ASTRA_DISTRIBUTION; + } else { + process.env.ASTRA_DISTRIBUTION = previousDistribution; + } + } + + if ( + resolvedConfig.version !== versionName || + resolvedConfig.android?.versionCode !== versionCode || + resolvedConfig.extra?.distribution !== distribution + ) { + throw new Error('Resolved Expo version, version code, or distribution does not match the tracked release identity.'); + } + + return { + artifactFileName: `Astra-${versionName}-${versionCode}-${distributionConfig.artifactSuffix}.${distributionConfig.extension}`, + distribution, + distributionLabel: distributionConfig.label, + packageId: PACKAGE_ID, + versionCode, + versionName, + }; +} + +export function prepareArtifact(distribution, sourcePath, outputDirectory) { + const identity = getReleaseIdentity(distribution); + const absoluteSource = path.resolve(ROOT, sourcePath); + const absoluteOutput = path.resolve(ROOT, outputDirectory); + const artifactPath = path.join(absoluteOutput, identity.artifactFileName); + + mkdirSync(absoluteOutput, { recursive: true }); + copyFileSync(absoluteSource, artifactPath); + + const artifactBytes = readFileSync(artifactPath); + const sha256 = createHash('sha256').update(artifactBytes).digest('hex'); + writeFileSync(`${artifactPath}.sha256`, `${sha256} ${identity.artifactFileName}\n`, 'utf8'); + + const metadata = { + schemaVersion: 1, + ...identity, + source: { + commit: process.env.GITHUB_SHA ?? null, + repository: process.env.GITHUB_REPOSITORY ?? null, + runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? null, + runId: process.env.GITHUB_RUN_ID ?? null, + }, + artifact: { + fileName: identity.artifactFileName, + sha256, + sizeBytes: statSync(artifactPath).size, + }, + generatedAt: new Date().toISOString(), + }; + writeFileSync(path.join(absoluteOutput, 'build-metadata.json'), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8'); + + return { artifactPath, metadata }; +} + +export function verifyApkMetadata(distribution, metadataPath) { + const identity = getReleaseIdentity(distribution); + const metadata = readJson(path.relative(ROOT, path.resolve(ROOT, metadataPath))); + const element = metadata.elements?.[0]; + + if ( + metadata.applicationId !== identity.packageId || + element?.versionCode !== identity.versionCode || + element?.versionName !== identity.versionName + ) { + throw new Error( + `APK metadata does not match ${identity.packageId} ${identity.versionName} (${identity.versionCode}).` + ); + } + + return identity; +} + +function printUsage() { + console.error('Usage: node scripts/release/android-release.mjs validate '); + console.error(' or: node scripts/release/android-release.mjs prepare '); + console.error(' or: node scripts/release/android-release.mjs verify-apk-metadata github '); +} + +function main() { + const [command, distribution, sourcePath, outputDirectory] = process.argv.slice(2); + if (command === 'validate' && distribution && !sourcePath && !outputDirectory) { + const identity = getReleaseIdentity(distribution); + console.log(`Validated ${identity.distributionLabel} ${identity.versionName} (${identity.versionCode}).`); + return; + } + if (command === 'prepare' && distribution && sourcePath && outputDirectory) { + const result = prepareArtifact(distribution, sourcePath, outputDirectory); + console.log(`Prepared ${path.relative(ROOT, result.artifactPath)}.`); + return; + } + if (command === 'verify-apk-metadata' && distribution === 'github' && sourcePath && !outputDirectory) { + const identity = verifyApkMetadata(distribution, sourcePath); + console.log(`Verified APK metadata for ${identity.versionName} (${identity.versionCode}).`); + return; + } + + printUsage(); + process.exitCode = 2; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/release/android-release.test.mjs b/scripts/release/android-release.test.mjs new file mode 100644 index 0000000..46d6fbe --- /dev/null +++ b/scripts/release/android-release.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { getReleaseIdentity } from './android-release.mjs'; + +test('builds stable artifact names from the tracked release identity', () => { + assert.deepEqual(getReleaseIdentity('github'), { + artifactFileName: 'Astra-0.1.0-1-GitHub-arm-universal.apk', + distribution: 'github', + distributionLabel: 'GitHub', + packageId: 'io.github.boof2015.astra', + versionCode: 1, + versionName: '0.1.0', + }); + assert.equal(getReleaseIdentity('google-play').artifactFileName, 'Astra-0.1.0-1-GooglePlay.aab'); +}); + +test('rejects unknown distribution channels', () => { + assert.throws(() => getReleaseIdentity('nightly'), /Unsupported distribution/); +}); diff --git a/scripts/run-release-tests.mjs b/scripts/run-release-tests.mjs new file mode 100644 index 0000000..ce6080a --- /dev/null +++ b/scripts/run-release-tests.mjs @@ -0,0 +1,35 @@ +import { spawnSync } from 'node:child_process'; + +const TEST_SCRIPTS = [ + 'test:release-config', + 'test:queue-actions', + 'test:desktop-remote', + 'test:dynamic-playlists', + 'test:album-grouping', + 'test:artist-grouping', + 'test:desktop-sync', + 'test:eq-share', + 'test:signal', + 'test:eq-math', + 'test:audio-startup', + 'test:seek-bar', + 'test:lyrics', + 'test:sleep', + 'test:troubleshooting', + 'test:settings-search', + 'test:now-playing-layout', + 'test:memory-lifecycle', + 'test:haptics', + 'test:home-greeting', + 'test:session', +]; + +for (const script of TEST_SCRIPTS) { + const result = spawnSync('npm', ['run', script], { + stdio: 'inherit', + shell: process.platform === 'win32', + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx index 0f9c17f..ed2c53f 100644 --- a/src/app/(tabs)/settings.tsx +++ b/src/app/(tabs)/settings.tsx @@ -25,6 +25,7 @@ import { useDesktopSyncStore } from '@/stores/desktopSyncStore'; import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore'; import { useLibraryStore } from '@/stores/libraryStore'; import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore'; +import { createBuildInfo } from '@/release/buildInfo'; import { useThemeStore } from '@/stores/themeStore'; import { useSleepTimerStore } from '@/stores/sleepTimerStore'; import { formatSleepTimerStatus } from '@/audio/sleepTimerState'; @@ -63,7 +64,7 @@ export default function SettingsScreen() { const totalTracks = folders.reduce((sum, folder) => sum + folder.track_count, 0); const connectedScrobblers = lastFmStatus?.profiles.filter((p) => p.connected).length ?? 0; - const appVersion = Constants.expoConfig?.version; + const buildInfo = createBuildInfo(Constants.expoConfig); const librarySubtitle = folders.length === 0 ? 'Folders, artist grouping, album singles.' @@ -138,7 +139,7 @@ export default function SettingsScreen() { router.push('/settings/info' as never)} /> diff --git a/src/app/settings/info.tsx b/src/app/settings/info.tsx index ef67013..6512d8a 100644 --- a/src/app/settings/info.tsx +++ b/src/app/settings/info.tsx @@ -11,12 +11,14 @@ import { SettingsSectionScreen, } from '@/components/settings/SettingsSectionScaffold'; import { Text } from '@/components/Text'; +import { createBuildInfo } from '@/release/buildInfo'; import { spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; const ASTRA_REPOSITORY_URL = 'https://github.com/Boof2015/astra-mobile'; const ASTRA_DISCORD_URL = 'https://discord.gg/hsKK8Kr9Nj'; const ASTRA_SUPPORT_URL = 'https://ko-fi.com/boof2015'; +const ASTRA_PRIVACY_URL = 'https://github.com/Boof2015/astra-mobile/blob/main/PRIVACY.md'; const ASTRA_LICENSE_URL = 'https://github.com/Boof2015/astra-mobile/blob/main/LICENSE'; const GPL_V3_URL = 'https://www.gnu.org/licenses/gpl-3.0.html'; @@ -31,8 +33,7 @@ async function openExternalLink(url: string, label: string) { export default function InfoSettingsScreen() { const styles = useStyles(); const colors = useColors(); - const appVersion = Constants.expoConfig?.version; - const appVersionLabel = appVersion ? `v${appVersion}` : 'Unavailable'; + const buildInfo = createBuildInfo(Constants.expoConfig); return ( @@ -42,7 +43,7 @@ export default function InfoSettingsScreen() { App Version - {appVersionLabel} + {buildInfo.versionLabel} @@ -69,12 +70,23 @@ export default function InfoSettingsScreen() { rightIcon="open-outline" onPress={() => void openExternalLink(ASTRA_DISCORD_URL, 'Discord')} /> + {buildInfo.showExternalSupportLink ? ( + void openExternalLink(ASTRA_SUPPORT_URL, 'Ko-fi')} + /> + ) : null} + + PRIVACY void openExternalLink(ASTRA_SUPPORT_URL, 'Ko-fi')} + onPress={() => void openExternalLink(ASTRA_PRIVACY_URL, 'the Privacy Policy')} /> LICENSE diff --git a/src/release/buildInfo.test.mts b/src/release/buildInfo.test.mts new file mode 100644 index 0000000..dd143b9 --- /dev/null +++ b/src/release/buildInfo.test.mts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createBuildInfo, normalizeDistributionChannel } from './buildInfo.ts'; + +test('formats channel-specific release labels', () => { + assert.equal( + createBuildInfo({ version: '0.1.0', extra: { distribution: 'google-play' } }).versionLabel, + 'v0.1.0 (Google Play)' + ); + assert.equal( + createBuildInfo({ version: '0.1.0', extra: { distribution: 'github' } }).versionLabel, + 'v0.1.0 (GitHub)' + ); +}); + +test('only the Google Play build hides external support links', () => { + assert.equal( + createBuildInfo({ version: '0.1.0', extra: { distribution: 'google-play' } }).showExternalSupportLink, + false + ); + assert.equal( + createBuildInfo({ version: '0.1.0', extra: { distribution: 'github' } }).showExternalSupportLink, + true + ); +}); + +test('unknown or absent distributions safely fall back to development', () => { + assert.equal(normalizeDistributionChannel('nightly'), 'development'); + assert.equal(createBuildInfo({ version: '0.1.0' }).versionLabel, 'v0.1.0 (Development)'); + assert.equal(createBuildInfo(null).versionLabel, 'Unavailable (Development)'); +}); diff --git a/src/release/buildInfo.ts b/src/release/buildInfo.ts new file mode 100644 index 0000000..c3ace35 --- /dev/null +++ b/src/release/buildInfo.ts @@ -0,0 +1,39 @@ +export type DistributionChannel = 'development' | 'github' | 'google-play'; + +export interface ExpoBuildConfig { + version?: string | null; + extra?: Record | null; +} + +export interface BuildInfo { + distribution: DistributionChannel; + distributionLabel: 'Development' | 'GitHub' | 'Google Play'; + showExternalSupportLink: boolean; + version: string | null; + versionLabel: string; +} + +const DISTRIBUTION_LABELS: Record = { + development: 'Development', + github: 'GitHub', + 'google-play': 'Google Play', +}; + +export function normalizeDistributionChannel(value: unknown): DistributionChannel { + return value === 'github' || value === 'google-play' ? value : 'development'; +} + +export function createBuildInfo(config: ExpoBuildConfig | null | undefined): BuildInfo { + const rawVersion = typeof config?.version === 'string' ? config.version.trim() : ''; + const version = rawVersion || null; + const distribution = normalizeDistributionChannel(config?.extra?.distribution); + const distributionLabel = DISTRIBUTION_LABELS[distribution]; + + return { + distribution, + distributionLabel, + showExternalSupportLink: distribution !== 'google-play', + version, + versionLabel: version ? `v${version} (${distributionLabel})` : `Unavailable (${distributionLabel})`, + }; +}