prep for release

This commit is contained in:
Boof2015
2026-07-15 18:02:14 -04:00
parent 5c57ed7a1a
commit 2f0f612134
17 changed files with 943 additions and 24 deletions
+4
View File
@@ -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=
+288
View File
@@ -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
+1
View File
@@ -56,3 +56,4 @@ vendor/kotlinaudio/kotlin-audio/.cxx/
HANDOFF.md
DESIGN.md
docs/release/android.md
+37 -12
View File
@@ -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
+37
View File
@@ -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,
},
};
};
+4 -3
View File
@@ -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,
+4
View File
@@ -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"
},
+175
View File
@@ -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,
};
+67
View File
@@ -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',
},
},
]);
});
+3
View File
@@ -0,0 +1,3 @@
{
"androidVersionCode": 1
}
+175
View File
@@ -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 <github|google-play>');
console.error(' or: node scripts/release/android-release.mjs prepare <github|google-play> <source> <output-directory>');
console.error(' or: node scripts/release/android-release.mjs verify-apk-metadata github <output-metadata.json>');
}
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();
}
+20
View File
@@ -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/);
});
+35
View File
@@ -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);
}
}
+3 -2
View File
@@ -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() {
<SettingsNavRow
icon="information-circle-outline"
title="Info"
subtitle={appVersion ? `v${appVersion}. Attribution, license, community links.` : 'Attribution, license, community links.'}
subtitle={`${buildInfo.versionLabel}. Attribution, license, community links.`}
onPress={() => router.push('/settings/info' as never)}
/>
</ScrollView>
+15 -3
View File
@@ -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 (
<SettingsSectionScreen title="Info">
@@ -42,7 +43,7 @@ export default function InfoSettingsScreen() {
<Text variant="caption" color={colors.textSecondary}>
App Version
</Text>
<Text variant="body">{appVersionLabel}</Text>
<Text variant="body">{buildInfo.versionLabel}</Text>
</View>
</SettingsCard>
@@ -69,6 +70,7 @@ export default function InfoSettingsScreen() {
rightIcon="open-outline"
onPress={() => void openExternalLink(ASTRA_DISCORD_URL, 'Discord')}
/>
{buildInfo.showExternalSupportLink ? (
<SettingsNavRow
icon="heart-outline"
title="Ko-fi"
@@ -76,6 +78,16 @@ export default function InfoSettingsScreen() {
rightIcon="open-outline"
onPress={() => void openExternalLink(ASTRA_SUPPORT_URL, 'Ko-fi')}
/>
) : null}
<SettingsSectionLabel spaced>PRIVACY</SettingsSectionLabel>
<SettingsNavRow
icon="shield-checkmark-outline"
title="Privacy Policy"
subtitle="How Astra handles local and optional service data"
rightIcon="open-outline"
onPress={() => void openExternalLink(ASTRA_PRIVACY_URL, 'the Privacy Policy')}
/>
<SettingsSectionLabel spaced>LICENSE</SettingsSectionLabel>
<SettingsCard>
+32
View File
@@ -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)');
});
+39
View File
@@ -0,0 +1,39 @@
export type DistributionChannel = 'development' | 'github' | 'google-play';
export interface ExpoBuildConfig {
version?: string | null;
extra?: Record<string, unknown> | null;
}
export interface BuildInfo {
distribution: DistributionChannel;
distributionLabel: 'Development' | 'GitHub' | 'Google Play';
showExternalSupportLink: boolean;
version: string | null;
versionLabel: string;
}
const DISTRIBUTION_LABELS: Record<DistributionChannel, BuildInfo['distributionLabel']> = {
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})`,
};
}