diff --git a/.github/workflows/android-release-build.yml b/.github/workflows/android-release-build.yml
new file mode 100644
index 0000000..8344933
--- /dev/null
+++ b/.github/workflows/android-release-build.yml
@@ -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 }}" \
+ -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"
diff --git a/src/hooks/__tests__/useQuantumAuth.test.tsx b/src/hooks/__tests__/useQuantumAuth.test.tsx
index a738bcb..ec1d9a7 100644
--- a/src/hooks/__tests__/useQuantumAuth.test.tsx
+++ b/src/hooks/__tests__/useQuantumAuth.test.tsx
@@ -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,
@@ -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();
+
+ 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",
diff --git a/src/hooks/useQuantumAuth.ts b/src/hooks/useQuantumAuth.ts
index a56aa14..6b02a0b 100644
--- a/src/hooks/useQuantumAuth.ts
+++ b/src/hooks/useQuantumAuth.ts
@@ -30,6 +30,7 @@ export function useQuantumAuth() {
const [authStatus, setAuthStatus] = useState("checking");
const [authNotice, setAuthNotice] = useState("");
const operationRef = useRef(0);
+ const authPromptActiveRef = useRef(false);
const restoreStoredSession = useCallback(async () => {
const operationId = nextAuthOperation(operationRef);
@@ -73,6 +74,8 @@ export function useQuantumAuth() {
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState) => {
+ if (authPromptActiveRef.current) return;
+
if (nextState === "active") {
void restoreStoredSession();
return;
@@ -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) {
@@ -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));