Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions .github/workflows/android-release-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
name: Android Release Build

on:
workflow_dispatch:
inputs:
architectures:
description: "Android ABIs to build"
required: false
default: "armeabi-v7a,arm64-v8a,x86,x86_64"
release_name:
description: "Release name/version"
required: false
default: "release-build"
push:
branches:
- main
- production
tags:
- 'v*'

concurrency:
group: android-release-build-${{ github.ref }}
cancel-in-progress: false

jobs:
build-release:
name: Build Android Release APK
runs-on: ubuntu-latest
timeout-minutes: 90

env:
NODE_ENV: production
EXPO_NO_TELEMETRY: "1"
SENTRY_DISABLE_AUTO_UPLOAD: "true"
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.jvmargs=-Xmx4g"

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: true

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.13.x

- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: Setup Android SDK
uses: android-actions/setup-android@v3

- name: Install Android packages
run: |
set -euo pipefail
packages=("platform-tools" "platforms;android-36" "build-tools;36.0.0" "ndk;27.1.12297006" "cmake;3.22.1")
for attempt in 1 2 3; do
if sdkmanager "${packages[@]}"; then
exit 0
fi

echo "Android package install failed on attempt ${attempt}; clearing partial SDK downloads before retry."
rm -rf "$ANDROID_HOME/.temp" "$ANDROID_HOME/temp" "$ANDROID_HOME/ndk/27.1.12297006"
sleep $((attempt * 10))
done

sdkmanager "${packages[@]}"

- name: Install dependencies
run: npm install --legacy-peer-deps --include=dev

- name: Generate native code
run: npx expo prebuild --platform android

- name: Decode keystore
id: decode_keystore
run: |
mkdir -p "$GITHUB_WORKSPACE/android/app" && KEYSTORE_PATH="$GITHUB_WORKSPACE/android/app/keystore.jks"
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > "$KEYSTORE_PATH"
test -s "$KEYSTORE_PATH"
echo "keystore_path=$KEYSTORE_PATH" >> $GITHUB_OUTPUT

- name: Validate Expo config
run: npx expo config --type public

- name: Build release APK
working-directory: android
run: |
chmod +x ./gradlew
./gradlew "app:assembleRelease" \
-x lint \
-x test \
--stacktrace \
--console=plain \
-PreactNativeDevServerPort=8081 \
-PreactNativeArchitectures="${ARCHITECTURES}" \
-Pandroid.injected.signing.store.file="${{ steps.decode_keystore.outputs.keystore_path }}" \
-Pandroid.injected.signing.store.password="${{ secrets.KEYSTORE_PASSWORD }}" \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass signing secrets through env vars

When a keystore password contains shell metacharacters such as $, backticks, or quotes, substituting ${{ secrets.KEYSTORE_PASSWORD }} directly into the run script lets bash interpret those characters before Gradle receives the value, so release signing can fail with a corrupted password; the same pattern is repeated in the bundle step. Put the signing secrets in env and pass quoted shell variables instead so the secret value is not re-expanded by the shell.

Useful? React with 👍 / 👎.

-Pandroid.injected.signing.key.alias="${{ secrets.KEYSTORE_ALIAS }}" \
-Pandroid.injected.signing.key.password="${{ secrets.KEYSTORE_KEY_PASSWORD }}"
env:
ARCHITECTURES: ${{ github.event.inputs.architectures || 'armeabi-v7a,arm64-v8a,x86,x86_64' }}

- name: Build release Bundle (AAB)
working-directory: android
run: |
./gradlew "app:bundleRelease" \
-x lint \
-x test \
--stacktrace \
--console=plain \
-PreactNativeDevServerPort=8081 \
-Pandroid.injected.signing.store.file="${{ steps.decode_keystore.outputs.keystore_path }}" \
-Pandroid.injected.signing.store.password="${{ secrets.KEYSTORE_PASSWORD }}" \
-Pandroid.injected.signing.key.alias="${{ secrets.KEYSTORE_ALIAS }}" \
-Pandroid.injected.signing.key.password="${{ secrets.KEYSTORE_KEY_PASSWORD }}"

- name: Generate build metadata
id: build_info
run: |
BUILD_TIME=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
GIT_SHA=${{ github.sha }}
GIT_REF=${{ github.ref }}
echo "build_time=${BUILD_TIME}" >> $GITHUB_OUTPUT
echo "git_sha=${GIT_SHA:0:7}" >> $GITHUB_OUTPUT
echo "git_ref=${GIT_REF##*/}" >> $GITHUB_OUTPUT

- name: Upload release APK
uses: actions/upload-artifact@v4
with:
name: quantum-app-release-apk-${{ steps.build_info.outputs.git_ref }}-${{ steps.build_info.outputs.git_sha }}
path: android/app/build/outputs/apk/release/*.apk
retention-days: 30
if-no-files-found: error

- name: Upload release AAB (App Bundle)
uses: actions/upload-artifact@v4
with:
name: quantum-app-release-aab-${{ steps.build_info.outputs.git_ref }}-${{ steps.build_info.outputs.git_sha }}
path: android/app/build/outputs/bundle/release/*.aab
retention-days: 30
if-no-files-found: error

- name: Generate checksums
run: |
cd android/app/build/outputs
find . -type f \( -name "*.apk" -o -name "*.aab" \) -exec sha256sum {} \; > checksums.txt
cat checksums.txt

- name: Upload checksums
uses: actions/upload-artifact@v4
with:
name: quantum-app-checksums-${{ steps.build_info.outputs.git_ref }}-${{ steps.build_info.outputs.git_sha }}
path: android/app/build/outputs/checksums.txt
retention-days: 30

- name: Create release notes
run: |
cat > release-notes.md << 'EOF'
# Quantum App Release Build

**Build Information:**
- Build Time: ${{ steps.build_info.outputs.build_time }}
- Git Reference: ${{ steps.build_info.outputs.git_ref }}
- Git SHA: ${{ steps.build_info.outputs.git_sha }}
- Architectures: ${{ github.event.inputs.architectures || 'armeabi-v7a,arm64-v8a,x86,x86_64' }}

**Artifacts:**
- main APK (all architectures)
- App Bundle (AAB) for Google Play Store
- SHA256 checksums

**Version:** ${{ github.ref_name }}
**Build by:** GitHub Actions
EOF
cat release-notes.md

- name: Upload release notes
uses: actions/upload-artifact@v4
with:
name: quantum-app-release-notes-${{ steps.build_info.outputs.git_ref }}-${{ steps.build_info.outputs.git_sha }}
path: release-notes.md
retention-days: 30

- name: Build summary
run: |
echo "✅ Release build completed successfully"
echo "📦 Artifacts generated:"
ls -lh android/app/build/outputs/apk/release/ 2>/dev/null || echo "No APKs found"
ls -lh android/app/build/outputs/bundle/release/ 2>/dev/null || echo "No AABs found"
48 changes: 47 additions & 1 deletion src/hooks/__tests__/useQuantumAuth.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fireEvent, render, waitFor } from "@testing-library/react-native";
import { Pressable, Text, View } from "react-native";
import { AppState, Pressable, Text, View } from "react-native";

import {
clearStoredAuthSession,
Expand Down Expand Up @@ -95,6 +95,52 @@ describe("useQuantumAuth", () => {
);
});

it("keeps the OAuth operation alive while the auth browser backgrounds the app", async () => {
const signedInSession: StoredAuthSession = {
accessToken: "access-token",
expiresAt: Math.floor(Date.now() / 1000) + 3600,
idToken: "id-token",
issuedAt: Math.floor(Date.now() / 1000),
refreshToken: "refresh-token",
user: {
email: "user@chefuinc.com",
uid: "user-1",
},
};
let appStateListener: ((state: string) => void) | undefined;
jest
.spyOn(AppState, "addEventListener")
.mockImplementation((_event, listener) => {
appStateListener = listener as (state: string) => void;
return { remove: jest.fn() } as never;
});
let finishSignIn: ((session: StoredAuthSession) => void) | undefined;
jest.mocked(startQuantumSignIn).mockImplementation(
() =>
new Promise(resolve => {
finishSignIn = resolve;
}),
);

const view = await render(<AuthHarness />);

await waitFor(() =>
expect(view.getByTestId("status").props.children).toBe("guest"),
);
fireEvent.press(view.getByText("Sign in"));
appStateListener?.("background");
finishSignIn?.(signedInSession);

await waitFor(() =>
expect(view.getByTestId("status").props.children).toBe("authenticated"),
);
expect(saveStoredAuthSession).toHaveBeenCalledWith(signedInSession);
expect(view.getByTestId("notice").props.children).toBe(
"Signed in to CheFu Account.",
);
view.unmount();
});

it("clears expired sessions and prompts the user to sign in again", async () => {
const expiredSession: StoredAuthSession = {
accessToken: "expired-access",
Expand Down
6 changes: 6 additions & 0 deletions src/hooks/useQuantumAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function useQuantumAuth() {
const [authStatus, setAuthStatus] = useState<AuthStatus>("checking");
const [authNotice, setAuthNotice] = useState("");
const operationRef = useRef(0);
const authPromptActiveRef = useRef(false);

const restoreStoredSession = useCallback(async () => {
const operationId = nextAuthOperation(operationRef);
Expand Down Expand Up @@ -73,6 +74,8 @@ export function useQuantumAuth() {

useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState) => {
if (authPromptActiveRef.current) return;

if (nextState === "active") {
void restoreStoredSession();
return;
Expand Down Expand Up @@ -124,7 +127,9 @@ export function useQuantumAuth() {
setAuthStatus("checking");

try {
authPromptActiveRef.current = true;
const nextSession = await startQuantumSignIn();
authPromptActiveRef.current = false;
if (!isCurrentAuthOperation(operationRef, operationId)) return;

if (!nextSession) {
Expand All @@ -137,6 +142,7 @@ export function useQuantumAuth() {
setAuthStatus("authenticated");
setAuthNotice("Signed in to CheFu Account.");
} catch (error) {
authPromptActiveRef.current = false;
if (!isCurrentAuthOperation(operationRef, operationId)) return;
setAuthStatus(session ? "authenticated" : "guest");
setAuthNotice(authErrorMessage(error));
Expand Down
Loading