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));