From 7bb149a282b3d0344b5ebb07dd6fa6ba1a4486cd Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 09:24:45 +0100 Subject: [PATCH 01/38] fix(android): break the post-logout stuck-splash reload loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four cooperating defects kept a dead session resurrecting itself and the webview reloading in a flash loop that only 'clear all app data' escaped: - CapacitorUpdater.set() reloads the webview IMMEDIATELY — the comment believed it deferred to next launch. Use next(), which actually defers. - MainActivity served pre-rendered HTML from APK assets even while an OTA bundle was active, so any hard navigation (logout goes to /setup) loaded a stale export whose chunks no longer exist. Resolve HTML against the active server base path first, assets only when no bundle is live. - logout cleared the JWT before cancelling queries, so an in-flight /users/me sliding refresh re-persisted the very token being wiped. Cancel + clear the query cache first. - the sliding refresh now checks a clear-epoch counter and drops the re-minted token when the session was cleared mid-flight; the 401/404 clearAuthToken is awaited so Preferences.remove lands before teardown. --- .../java/me/peanut/wallet/MainActivity.java | 33 ++++++++++++++++--- src/context/authContext.tsx | 20 ++++++----- src/hooks/query/__tests__/user.test.tsx | 16 +++++++++ src/hooks/query/user.ts | 13 ++++++-- src/utils/auth-token.ts | 14 ++++++++ src/utils/capgo-updater.ts | 5 +-- 6 files changed, 84 insertions(+), 17 deletions(-) diff --git a/android/app/src/main/java/me/peanut/wallet/MainActivity.java b/android/app/src/main/java/me/peanut/wallet/MainActivity.java index 943813c9fc..c2b07d2be1 100644 --- a/android/app/src/main/java/me/peanut/wallet/MainActivity.java +++ b/android/app/src/main/java/me/peanut/wallet/MainActivity.java @@ -8,6 +8,8 @@ import com.getcapacitor.BridgeActivity; import com.getcapacitor.Bridge; +import java.io.File; +import java.io.FileInputStream; import java.io.InputStream; public class MainActivity extends BridgeActivity { @@ -55,7 +57,7 @@ private WebResourceResponse findPageHtml(WebView view, String path) { // 1. try exact path try { String cleanPath = path.endsWith("/") ? path : path + "/"; - InputStream is = view.getContext().getAssets().open("public" + cleanPath + "index.html"); + InputStream is = openAppContent(view, cleanPath); return new WebResourceResponse("text/html", "UTF-8", is); } catch (Exception ignored) {} @@ -69,7 +71,7 @@ private WebResourceResponse findPageHtml(WebView view, String path) { String tryPath = String.join("/", segments); if (!tryPath.endsWith("/")) tryPath += "/"; try { - InputStream is = view.getContext().getAssets().open("public" + tryPath + "index.html"); + InputStream is = openAppContent(view, tryPath); return new WebResourceResponse("text/html", "UTF-8", is); } catch (Exception ignored) { segments[i] = original; @@ -85,19 +87,42 @@ private WebResourceResponse findPageHtml(WebView view, String path) { parentPath = parentPath.substring(0, parentPath.lastIndexOf("/")); if (parentPath.isEmpty()) break; try { - InputStream is = view.getContext().getAssets().open("public" + parentPath + "/index.html"); + InputStream is = openAppContent(view, parentPath + "/"); return new WebResourceResponse("text/html", "UTF-8", is); } catch (Exception ignored) {} } // 4. root fallback try { - InputStream is = view.getContext().getAssets().open("public/index.html"); + InputStream is = openAppContent(view, "/"); return new WebResourceResponse("text/html", "UTF-8", is); } catch (Exception ignored) {} return null; } + + /** + * Opens the index.html for a directory-style path ("/setup/"), + * honoring an active OTA bundle. When CapacitorUpdater has + * pointed the server base path at an on-disk bundle, HTML must + * come from that bundle — the APK's assets are a stale export + * whose chunk references no longer exist, and serving them + * bricks navigation (stuck splash loop after logout). Only when + * no bundle is active (base path isn't a directory) do we read + * the bundled assets. + */ + private InputStream openAppContent(WebView view, String cleanPath) throws Exception { + String rel = (cleanPath.startsWith("/") ? cleanPath.substring(1) : cleanPath) + "index.html"; + Bridge activeBridge = getBridge(); + String basePath = activeBridge != null ? activeBridge.getServerBasePath() : null; + if (basePath != null && !basePath.isEmpty()) { + File base = new File(basePath); + if (base.isDirectory()) { + return new FileInputStream(new File(base, rel)); + } + } + return view.getContext().getAssets().open("public/" + rel); + } }); } } diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index c0604d5800..dc97bc4770 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -194,6 +194,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { // clear user preferences (webauthn key in localStorage) updateUserPreferences(user?.user.userId, { webAuthnKey: undefined }) + /* + * Cancel queries BEFORE wiping the token: an in-flight /users/me can carry a + * sliding-refresh token and would re-persist it into native Preferences right + * after the clear, so logout never sticks (Android splash-loop, kuxhagra). + */ + try { + await queryClient.cancelQueries() + queryClient.clear() + } catch (e) { + console.warn('failed to clear queries on logout:', e) + } + // clear auth tokens (localStorage in capacitor, cookie on web) removeFromCookie(WEB_AUTHN_COOKIE_KEY) await clearAuthToken() @@ -201,14 +213,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { // clear redirect url clearRedirectUrl() - // cancel + remove all queries to prevent refetches with cleared jwt - try { - queryClient.cancelQueries() - queryClient.clear() - } catch (e) { - console.warn('failed to clear queries on logout:', e) - } - // reset redux state (user, setup, zerodev) dispatch(userActions.setUser(null)) dispatch(setupActions.resetSetup()) diff --git a/src/hooks/query/__tests__/user.test.tsx b/src/hooks/query/__tests__/user.test.tsx index a666bbaea0..5fbbb67799 100644 --- a/src/hooks/query/__tests__/user.test.tsx +++ b/src/hooks/query/__tests__/user.test.tsx @@ -10,6 +10,7 @@ jest.mock('@/utils/api-fetch', () => ({ apiFetch: jest.fn() })) jest.mock('@/utils/auth-token', () => ({ setAuthToken: jest.fn(), clearAuthToken: jest.fn(), + getClearEpoch: jest.fn(() => 0), })) jest.mock('@/hooks/usePWAStatus', () => ({ usePWAStatus: () => false })) jest.mock('@/hooks/useGetDeviceType', () => ({ useDeviceType: () => ({ deviceType: 'desktop' }) })) @@ -58,6 +59,21 @@ describe('useUserQuery — JWT sliding refresh', () => { expect(mockSetAuthToken).toHaveBeenCalledTimes(1) }) + it('drops a refreshed token when the session was cleared mid-flight (epoch changed)', async () => { + const { getClearEpoch } = jest.requireMock('@/utils/auth-token') + // epoch reads: once before the request, once after — logout in between + getClearEpoch.mockReturnValueOnce(0).mockReturnValueOnce(1) + mockApiFetch.mockResolvedValueOnce( + mockResponse(200, { user: { userId: 'u1', username: 'alice' }, token: 'resurrected.jwt' }) + ) + + const { result } = renderHook(() => useUserQuery(), { wrapper: makeWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(mockSetAuthToken).not.toHaveBeenCalled() + expect(result.current.data).not.toHaveProperty('token') + }) + it('does NOT call setAuthToken when the response has no token field', async () => { mockApiFetch.mockResolvedValueOnce(mockResponse(200, { user: { userId: 'u1', username: 'alice' } })) diff --git a/src/hooks/query/user.ts b/src/hooks/query/user.ts index 2ad1db169f..7aa771e9a4 100644 --- a/src/hooks/query/user.ts +++ b/src/hooks/query/user.ts @@ -8,7 +8,7 @@ import { usePWAStatus } from '../usePWAStatus' import { useDeviceType } from '../useGetDeviceType' import { USER } from '@/constants/query.consts' import { apiFetch } from '@/utils/api-fetch' -import { clearAuthToken, setAuthToken } from '@/utils/auth-token' +import { clearAuthToken, getClearEpoch, setAuthToken } from '@/utils/auth-token' import { isDemoMode } from '@/utils/demo' import { DEMO_USER } from '@/constants/demo-data' @@ -35,6 +35,7 @@ export const useUserQuery = (dependsOn: boolean = true) => { return DEMO_USER } + const epochAtRequest = getClearEpoch() const userResponse = await apiFetch('/users/me', { method: 'GET' }) if (userResponse.ok) { const payload: (IUserProfile & { token?: string }) | null = await userResponse.json() @@ -44,8 +45,11 @@ export const useUserQuery = (dependsOn: boolean = true) => { // it in client-side so active users never hit the 30d hard logout. // Strip `token` unconditionally so auth state never leaks into the // user store, even if the backend ever sends a falsy value. + // epoch guard: if logout cleared the session while this request + // was in flight, re-persisting the refreshed token would resurrect + // it (Android stuck-splash loop) — drop it instead. if (payload && 'token' in payload) { - if (payload.token) setAuthToken(payload.token) + if (payload.token && getClearEpoch() === epochAtRequest) setAuthToken(payload.token) delete payload.token } @@ -68,8 +72,11 @@ export const useUserQuery = (dependsOn: boolean = true) => { // DB re-seeded out from under a stale cookie) both mean the JWT is // irrecoverable. Wipe the token so the next render escapes to /setup // instead of looping on the same dead JWT. + // await: the native Preferences.remove must be dispatched before the + // redirect-to-/setup teardown, or the dead JWT survives into the next + // cold start and re-enters the home→401→setup loop. if (userResponse.status === 401 || userResponse.status === 404) { - clearAuthToken() + await clearAuthToken() } // 4xx = auth failure, clear stale redux so layout redirects to /setup diff --git a/src/utils/auth-token.ts b/src/utils/auth-token.ts index d023e7bbc3..7ee90a305a 100644 --- a/src/utils/auth-token.ts +++ b/src/utils/auth-token.ts @@ -19,6 +19,9 @@ const JWT_STORAGE_KEY = 'jwt-token' let nativeToken: string | null = null let hydration: Promise | null = null +// bumped on every clearAuthToken; lets in-flight requests detect that the +// session was wiped underneath them (see useUserQuery's sliding refresh) +let clearEpoch = 0 async function hydrateFromPreferences(): Promise { try { @@ -99,6 +102,7 @@ export async function hasNativeSession(): Promise { * reloading; other callers may safely ignore it. */ export function clearAuthToken(): Promise { + clearEpoch++ let nativeClear: Promise = Promise.resolve() if (isCapacitor()) { nativeToken = null @@ -117,6 +121,16 @@ export function clearAuthToken(): Promise { return nativeClear } +/** + * monotonic counter incremented by every clearAuthToken. Capture it before an + * authenticated request and compare after: a changed value means the session + * was cleared while the request was in flight, so any token the response + * carries must not be re-persisted. + */ +export function getClearEpoch(): number { + return clearEpoch +} + /** * builds headers for authenticated api calls: Authorization bearer token on * both web and capacitor when a token is available. diff --git a/src/utils/capgo-updater.ts b/src/utils/capgo-updater.ts index 7fedd99bb5..044516e53b 100644 --- a/src/utils/capgo-updater.ts +++ b/src/utils/capgo-updater.ts @@ -63,8 +63,9 @@ export async function initCapgoUpdater( }) onUpdateAvailable?.(bundle) // apply on next launch (no mid-session reload — avoids yanking the - // UI out from under the user). - await CapacitorUpdater.set({ id: bundle.id }) + // UI out from under the user). set() reloads IMMEDIATELY; next() + // is the deferred variant. + await CapacitorUpdater.next({ id: bundle.id }) } } catch (err) { const message = err instanceof Error ? err.message : String(err ?? '') From 58be2e434514c6f32d349570ca072ef8c27275aa Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 09:25:02 +0100 Subject: [PATCH 02/38] fix(setup): fill the bottom inset white on Android too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The white-panel-above-the-inset layout is identical on both platforms, but the white fill was gated to isIOSNative — Android 15 edge-to-edge painted the nav-bar inset periwinkle under the white panel, reading as a stray blue strip on the onboarding screen. --- src/app/(setup)/layout.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app/(setup)/layout.tsx b/src/app/(setup)/layout.tsx index 1daaceaa67..29695bf7a5 100644 --- a/src/app/(setup)/layout.tsx +++ b/src/app/(setup)/layout.tsx @@ -11,7 +11,7 @@ import { Banner } from '@/components/Global/Banner' import SupportDrawer from '@/components/Global/SupportDrawer' import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' import { usePullToRefresh } from '@/hooks/usePullToRefresh' -import { isCapacitor, isIOSNative } from '@/utils/capacitor' +import { isCapacitor } from '@/utils/capacitor' function SetupLayoutContent({ children }: { children?: React.ReactNode }) { const dispatch = useAppDispatch() @@ -19,15 +19,15 @@ function SetupLayoutContent({ children }: { children?: React.ReactNode }) { const { deviceType } = useDeviceType() /* - * Bottom-inset fill color. Periwinkle is for Android 15 edge-to-edge (matches - * the status-bar strip). On iOS the content directly above the home-indicator - * inset is the white panel, so periwinkle reads as a stray bar on Face ID - * devices — fill with white there instead. State + effect (not a render-time - * platform check) so the static export's prerendered HTML hydrates cleanly. + * Bottom-inset fill color. On both native platforms the content directly above + * the bottom inset (iOS home indicator / Android 15 edge-to-edge nav bar) is the + * setup flow's white panel, so a periwinkle fill reads as a stray strip — fill + * with white instead. State + effect (not a render-time platform check) so the + * static export's prerendered HTML hydrates cleanly. */ const [bottomInsetFill, setBottomInsetFill] = useState('bg-secondary-3') useEffect(() => { - if (isIOSNative()) setBottomInsetFill('bg-white') + if (isCapacitor()) setBottomInsetFill('bg-white') }, []) // configure status bar for native. the setup/onboarding flow has a periwinkle From f52be9ad2b213c6c6bba5f36bd6f5e21fd17ef0b Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 09:25:02 +0100 Subject: [PATCH 03/38] fix(mascot): serve the GIF fallbacks on Android native MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The animated-WebP mascots drop frames in the Android WebView — the same symptom the GIF fallback fixed on legacy iOS — but the fallback was gated on isLegacyWebKit, which can never match an Android UA. --- src/assets/mascot/index.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/assets/mascot/index.ts b/src/assets/mascot/index.ts index 2a0422eaa6..91ec1ad3c7 100644 --- a/src/assets/mascot/index.ts +++ b/src/assets/mascot/index.ts @@ -4,6 +4,7 @@ import type { StaticImageData } from 'next/image' +import { isAndroidNative } from '@/utils/capacitor' import { isLegacyWebKit } from '@/utils/webkit.utils' import cheeringGif from './peanut-cheering.gif' @@ -27,9 +28,12 @@ import wavingHelloWebp from './peanut-waving-hello.webp' import whistlingGif from './peanut-whistling.gif' import whistlingWebp from './peanut-whistling.webp' -// Legacy/unverifiable WebKit can't animate WebP (see isLegacyWebKit) — it gets the -// GIF fallbacks (bigger files, 1-bit alpha); everyone else the smaller WebP. -const pick = (webp: StaticImageData, gif: StaticImageData): StaticImageData => (isLegacyWebKit() ? gif : webp) +// Legacy/unverifiable WebKit can't animate WebP (see isLegacyWebKit) and the +// Android WebView decodes it too slowly to hold the frame rate (same symptom +// the GIF fallback fixed on old iOS) — both get the GIF fallbacks (bigger +// files, 1-bit alpha); everyone else the smaller WebP. +const pick = (webp: StaticImageData, gif: StaticImageData): StaticImageData => + isLegacyWebKit() || isAndroidNative() ? gif : webp // Animated mascots (alpha background — downscaled 512→320px; webp via gif2webp -q 70) export const PeanutWhistling = pick(whistlingWebp, whistlingGif) // whistling, peace-sign, mid-stride — chill / effortless: landing hero, setup intro, low-key "you're in" wins From fd5dd5bed34dd630f5c127010fc1f4c7bc7f54fc Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 09:25:02 +0100 Subject: [PATCH 04/38] perf(modal): promote the dialog panel to a compositor layer The scale/opacity enter tween hitched on first-frame rasterization in the Android WebView; transform-gpu + will-change-transform pre-promote the layer. Also drops the dead max-h-[] class. --- src/components/Global/Modal/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/Global/Modal/index.tsx b/src/components/Global/Modal/index.tsx index afd894bee4..be3f6c0711 100644 --- a/src/components/Global/Modal/index.tsx +++ b/src/components/Global/Modal/index.tsx @@ -81,7 +81,10 @@ const Modal = ({ > Date: Fri, 17 Jul 2026 09:25:18 +0100 Subject: [PATCH 05/38] fix(login): don't paint /home before the passkey ceremony finishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The redirect keyed on isloginClicked && user, and isloginClicked is set before the ceremony — a stale user re-appearing mid-ceremony (the OS passkey sheet blur/refocus fires refetchOnWindowFocus) pushed /home with balance and activity rendered before any authentication. Gate the redirect on loginResolved as well. --- src/hooks/useLogin.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/hooks/useLogin.tsx b/src/hooks/useLogin.tsx index 475694e062..207efb060d 100644 --- a/src/hooks/useLogin.tsx +++ b/src/hooks/useLogin.tsx @@ -38,8 +38,14 @@ export const useLogin = () => { // wait for user to be fetched, then redirect useEffect(() => { - // run only if login button is clicked to prevent un-intentional redirects - if (isloginClicked && user) { + /* + * Gate on loginResolved (ceremony finished), not just the click: a stale + * user can re-appear mid-ceremony (refetchOnWindowFocus fires when the + * OS passkey sheet blurs/refocuses the webview) and `isloginClicked && + * user` would then paint /home — balance and activity included — before + * the passkey was ever verified. + */ + if (isloginClicked && loginResolved && user) { // redirect based on query params or saved redirect url const localStorageRedirect = getRedirectUrl() const redirect_uri = searchParams.get('redirect_uri') @@ -56,7 +62,7 @@ export const useLogin = () => { setIsloginClicked(false) setLoginResolved(false) } - }, [user, router, searchParams, isloginClicked]) + }, [user, router, searchParams, isloginClicked, loginResolved]) // the ceremony succeeded but the user object never arrived (e.g. token not // stored / fetch failed) — without this the UI idles on the setup screen forever. From 094accd2c6a7ab5c1184b1b419b3bd0f30de7f53 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 09:25:19 +0100 Subject: [PATCH 06/38] fix(setup): auto-continue completed sessions past the sign-in interstitial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A valid, completed session (hasAppAccess) that cold-starts on /setup was stopped at 'You're already signed in — Continue as ' whose CTA just pushes /home. Route those straight home; keep the interstitial for the half-finished-signup case it was written for. --- src/app/(setup)/setup/page.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/app/(setup)/setup/page.tsx b/src/app/(setup)/setup/page.tsx index 04cca6bcd7..b4103ea755 100644 --- a/src/app/(setup)/setup/page.tsx +++ b/src/app/(setup)/setup/page.tsx @@ -55,12 +55,23 @@ function SetupPageContent() { if (sessionChecked || isFetchingUser) return setSessionChecked(true) if (user?.user?.username) { + /* + * A COMPLETED session (hasAppAccess) that lands back on /setup — e.g. a + * native cold start that restored this route — goes straight home; the + * interstitial is reserved for the half-finished-signup case it was + * written for (durable credentials, setup never completed). + */ + if (user.user.hasAppAccess) { + posthog.capture(ANALYTICS_EVENTS.SIGNUP_EXISTING_SESSION_CONTINUED, { auto: true }) + router.replace('/home') + return + } setExistingSessionUsername(user.user.username) posthog.capture(ANALYTICS_EVENTS.SIGNUP_EXISTING_SESSION_PROMPTED, { has_app_access: !!user.user.hasAppAccess, }) } - }, [sessionChecked, isFetchingUser, user]) + }, [sessionChecked, isFetchingUser, user, router]) const handleContinueSession = () => { posthog.capture(ANALYTICS_EVENTS.SIGNUP_EXISTING_SESSION_CONTINUED) From 1e096670c7c101425becaf861197497f19f72b33 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 13:00:45 +0100 Subject: [PATCH 07/38] feat(auth): revoke the session server-side on logout Calls the new POST /users/logout (peanut-api-ts tokenVersion bump) with the still-valid JWT before clearing local state, so logout now kills the session on every device instead of only wiping this one's storage. Best-effort: a dead backend falls through to local logout (and the forced-logout path skips it entirely, as before). --- src/context/authContext.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index dc97bc4770..6b9de2acdb 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -263,9 +263,21 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { setIsLoggingOut(true) try { - // No server-side session to invalidate — the JWT lives client-side - // only (the old logout-user route just dropped a cookie). Once we - // add a tokenVersion column we can revoke server-side too. + /* + * Revoke server-side FIRST (needs the still-valid JWT): POST + * /users/logout bumps the account's tokenVersion so every + * outstanding JWT — this device and any other — stops + * verifying. Best-effort: a dead backend must never trap the + * user in a session, so failures fall through to local logout. + */ + if (!options?.skipBackendCall) { + try { + await apiFetch('/users/logout', { method: 'POST' }) + } catch (e) { + console.warn('server-side session revocation failed, continuing local logout:', e) + } + } + await clearLocalAuthState() // fetch user (should return null after logout) - skip for capacitor From 8a7d0400d41c6f828505201f52bd9c7fc100cd95 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 14:56:38 +0100 Subject: [PATCH 08/38] fix(native): ship production APNs entitlements and require FCM config Release builds signed the App Store configuration with App.entitlements, which carries aps-environment=development. TestFlight/App Store binaries therefore registered against the APNs sandbox while OneSignal sends via production, so every push silently reached zero devices. Point the release config at a new AppRelease.entitlements with aps-environment=production. Android had the mirror-image hole: android-release.yml skipped google-services.json when ANDROID_GOOGLE_SERVICES_JSON was unset and build.gradle swallowed the missing file, so release AABs shipped with the google-services plugin silently omitted and no FCM token registration. Both now fail the build instead of shipping dead push, and the iOS workflow verifies aps-environment in the provisioning profile and in the exported IPA. --- .github/workflows/android-release.yml | 29 +++++++++++++++++++-------- .github/workflows/ios-release.yml | 26 ++++++++++++++++++++++++ android/app/build.gradle | 14 ++++++------- docs/NATIVE-RELEASE.md | 5 ++++- ios/App/App.xcodeproj/project.pbxproj | 2 +- ios/App/App/AppRelease.entitlements | 17 ++++++++++++++++ 6 files changed, 76 insertions(+), 17 deletions(-) create mode 100644 ios/App/App/AppRelease.entitlements diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index cb2db905fe..78a9666018 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -32,6 +32,11 @@ on: description: 'versionName override (optional; defaults to package.json)' required: false type: string + pushDebug: + description: 'Enable OneSignal verbose logging in this build' + required: false + default: false + type: boolean concurrency: group: android-release-${{ github.ref }} @@ -103,20 +108,28 @@ jobs: NEXT_PUBLIC_SAFARI_WEB_ID=${{ vars.NEXT_PUBLIC_SAFARI_WEB_ID }} NEXT_PUBLIC_ONESIGNAL_WEBHOOK=${{ vars.NEXT_PUBLIC_ONESIGNAL_WEBHOOK }} EOF + if [ "${{ github.event.inputs.pushDebug }}" = "true" ]; then + echo "NEXT_PUBLIC_ONESIGNAL_DEBUG=true" >> .env.production.local + fi - name: Decode google-services.json - # FCM credentials for native push. When the secret is unset the file - # is skipped and build.gradle simply omits the google-services plugin - # (push disabled, build still succeeds). + # FCM credentials for native push. Must come from the Firebase project + # wired to OneSignal's Android (FCM v1) config or token registration fails. env: ANDROID_GOOGLE_SERVICES_JSON: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON }} run: | - if [ -n "$ANDROID_GOOGLE_SERVICES_JSON" ]; then - echo "$ANDROID_GOOGLE_SERVICES_JSON" | base64 -d > android/app/google-services.json - echo "✅ google-services.json written" - else - echo "⚠️ ANDROID_GOOGLE_SERVICES_JSON unset — native push disabled for this build" + if [ -z "$ANDROID_GOOGLE_SERVICES_JSON" ]; then + echo "::error::ANDROID_GOOGLE_SERVICES_JSON unset — release builds must ship FCM push. See docs/NATIVE-RELEASE.md" + exit 1 fi + echo "$ANDROID_GOOGLE_SERVICES_JSON" | base64 -d > android/app/google-services.json + python3 -c " + import json + d = json.load(open('android/app/google-services.json')) + clients = [c['client_info']['android_client_info']['package_name'] for c in d['client']] + assert 'me.peanut.wallet' in clients, f'google-services.json has no client for me.peanut.wallet (found: {clients})' + " + echo "✅ google-services.json written and validated" - name: Build signed AAB run: | diff --git a/.github/workflows/ios-release.yml b/.github/workflows/ios-release.yml index 8ca6b9e983..358bfd0b11 100644 --- a/.github/workflows/ios-release.yml +++ b/.github/workflows/ios-release.yml @@ -27,6 +27,11 @@ on: description: 'versionName override (optional; defaults to project MARKETING_VERSION)' required: false type: string + pushDebug: + description: 'Enable OneSignal verbose logging in this build' + required: false + default: false + type: boolean concurrency: group: ios-release-${{ github.ref }} @@ -82,6 +87,9 @@ jobs: NEXT_PUBLIC_SAFARI_WEB_ID=${{ vars.NEXT_PUBLIC_SAFARI_WEB_ID }} NEXT_PUBLIC_ONESIGNAL_WEBHOOK=${{ vars.NEXT_PUBLIC_ONESIGNAL_WEBHOOK }} EOF + if [ "${{ github.event.inputs.pushDebug }}" = "true" ]; then + echo "NEXT_PUBLIC_ONESIGNAL_DEBUG=true" >> .env.production.local + fi - name: Build web + sync iOS run: | @@ -109,6 +117,13 @@ jobs: cp /tmp/profile.mobileprovision "$PROFILE_DIR/$PROFILE_UUID.mobileprovision" echo "name=$PROFILE_NAME" >> "$GITHUB_OUTPUT" echo "Installed provisioning profile: $PROFILE_NAME ($PROFILE_UUID)" + # A profile without aps-environment ships an app that can never + # register with APNs, even when the entitlements file asks for it. + PROFILE_APS=$(/usr/libexec/PlistBuddy -c 'Print :Entitlements:aps-environment' /tmp/profile.plist 2>/dev/null || true) + if [ "$PROFILE_APS" != "production" ]; then + echo "::error::provisioning profile aps-environment is '${PROFILE_APS:-missing}' — enable Push Notifications on the App ID, regenerate the profile, and update IOS_PROVISIONING_PROFILE_BASE64" + exit 1 + fi - name: Archive & export IPA env: @@ -170,6 +185,17 @@ jobs: -exportPath build/ios \ -exportOptionsPlist /tmp/ExportOptions.plist + - name: Verify push entitlements in exported IPA + run: | + unzip -q build/ios/App.ipa -d /tmp/ipa-check + APS=$(codesign -d --entitlements :- /tmp/ipa-check/Payload/App.app 2>/dev/null \ + | plutil -extract aps-environment raw -o - - || true) + echo "aps-environment=$APS" + if [ "$APS" != "production" ]; then + echo "::error::exported ipa aps-environment is '${APS:-missing}', expected production — push would be dead in this build" + exit 1 + fi + - name: Upload to TestFlight uses: apple-actions/upload-testflight-build@v5 with: diff --git a/android/app/build.gradle b/android/app/build.gradle index cf4bb6fbc3..9c71f4713c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -100,11 +100,11 @@ dependencies { apply from: 'capacitor.build.gradle' -try { - def servicesJSON = file('google-services.json') - if (servicesJSON.text) { - apply plugin: 'com.google.gms.google-services' - } -} catch(Exception e) { - logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +def servicesJSON = file('google-services.json') +if (servicesJSON.exists() && servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' +} else if (gradle.startParameter.taskNames.any { def t = it.toLowerCase(); t.contains('release') || t.contains('bundle') }) { + throw new GradleException("google-services.json missing — a release build would silently ship without push. See docs/NATIVE-RELEASE.md") +} else { + logger.warn("google-services.json not found — push disabled for this dev build") } diff --git a/docs/NATIVE-RELEASE.md b/docs/NATIVE-RELEASE.md index da469da3d5..ad5b5b3a68 100644 --- a/docs/NATIVE-RELEASE.md +++ b/docs/NATIVE-RELEASE.md @@ -204,6 +204,7 @@ the build is reproducible, the AAB lands on a Play track. | `ANDROID_KEYSTORE_BASE64` | `base64 -w0 peanut-release.keystore` | | `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD` | signing creds | | `PLAY_SERVICE_ACCOUNT_JSON` | Google Play Developer API service account (least-priv "Release manager") | +| `ANDROID_GOOGLE_SERVICES_JSON` | `base64 -w0 google-services.json` — FCM config; **required**, the release workflow fails without it | | `SUBMODULE_TOKEN` | read access to the `src/content` submodule | | `CAPGO_API_KEY` | OTA (already used by `capgo-deploy.yml`) | | prod `NEXT_PUBLIC_*` | the values the static export bakes in (OneSignal, Sentry, chain, …) | @@ -277,7 +278,9 @@ with **no backend or sequence changes**. The web/native split lives behind accounts → Generate private key). 3. **CI:** set the `ANDROID_GOOGLE_SERVICES_JSON` secret to `base64 -w0 google-services.json`. The `Decode google-services.json` step in `android-release.yml` writes it before the - build (and skips gracefully when unset). + build and **fails the workflow when unset** (a release without it ships with push + silently dead). Local release/bundle Gradle tasks fail the same way; debug builds + only warn. **Verify (real device/emulator with Play Services):** `node scripts/native-build.js && npx cap sync android && ./gradlew assembleDebug`, install, diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index b1b57f23eb..ad8d1dbf4c 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -322,7 +322,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; DEVELOPMENT_TEAM = PW388G893L; PROVISIONING_PROFILE_SPECIFIER = "Peanut Wallet App Store"; - CURRENT_PROJECT_VERSION = 1; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; + CURRENT_PROJECT_VERSION = 1; CODE_SIGN_ENTITLEMENTS = App/AppRelease.entitlements; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; diff --git a/ios/App/App/AppRelease.entitlements b/ios/App/App/AppRelease.entitlements new file mode 100644 index 0000000000..c6ec3ac040 --- /dev/null +++ b/ios/App/App/AppRelease.entitlements @@ -0,0 +1,17 @@ + + + + + com.apple.developer.associated-domains + + applinks:peanut.me + webcredentials:peanut.me + + aps-environment + production + com.apple.security.application-groups + + group.me.peanut.wallet.onesignal + + + From 969dbeba1dcea378cb14960fe3c0dea113203748 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 14:56:39 +0100 Subject: [PATCH 09/38] fix(notifications): surface OneSignal login failures and add debug logging A failed OneSignal login() left the device subscription without the external_id the backend targets, so pushes resolved to no recipients with nothing in Sentry. Capture the failure, and only commit lastLinkedExternalId on success so transient errors retry on the next sync rather than latching the device into a permanently unlinked state. Add opt-in verbose SDK logging (NEXT_PUBLIC_ONESIGNAL_DEBUG, baked in via the pushDebug workflow input, or localStorage.__onesignal_debug) for diagnosing release builds where NODE_ENV-gated tooling is eliminated. --- src/hooks/useNotifications.ts | 20 +++++++++++++++++--- src/services/onesignal/debug.ts | 14 ++++++++++++++ src/services/onesignal/native.adapter.ts | 4 +++- src/services/onesignal/web.adapter.ts | 3 +++ 4 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 src/services/onesignal/debug.ts diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 5486daeca7..3e0127d630 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -1,7 +1,7 @@ 'use client' import { useEffect, useSyncExternalStore } from 'react' -import { captureException } from '@sentry/nextjs' +import { addBreadcrumb, captureException } from '@sentry/nextjs' import { getOneSignalAdapter, type NotificationPermissionState } from '@/services/onesignal' import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils' import { isDemoMode } from '@/utils/demo' @@ -64,6 +64,15 @@ function handleLoginError(err: unknown) { disableExternalIdLogin = true console.warn('OneSignal external_id login disabled due to identity verification error') } + /* + * A failed login() means the device subscription never gets the external_id + * the backend targets by — pushes silently reach zero recipients. Keep this + * loud in Sentry. + */ + captureException(err, { + tags: { source: 'onesignal_login' }, + extra: { disabledExternalIdLogin: disableExternalIdLogin }, + }) } // link/unlink the OneSignal subscription to the logged-in user @@ -71,11 +80,12 @@ async function syncExternalIdLink() { if (!state.oneSignalInitialized) return const id = currentExternalId if (id && lastLinkedExternalId !== id) { - lastLinkedExternalId = id if (disableExternalIdLogin) return try { const adapter = await getOneSignalAdapter() await adapter.login(id) + // commit only on success so transient failures retry on the next sync + lastLinkedExternalId = id } catch (err: unknown) { handleLoginError(err) } @@ -84,7 +94,9 @@ async function syncExternalIdLink() { try { const adapter = await getOneSignalAdapter() await adapter.logout() - } catch (_) {} + } catch (err) { + addBreadcrumb({ category: 'onesignal', message: 'logout failed', data: { error: String(err) } }) + } } } @@ -152,6 +164,7 @@ async function ensureInitialized() { await adapter.init() adapter.onPermissionChange((permissionState) => { + addBreadcrumb({ category: 'onesignal', message: 'permission change', data: { permissionState } }) // update permission state and immediately re-evaluate ui visibility setState({ permissionState }) evaluateVisibility() @@ -165,6 +178,7 @@ async function ensureInitialized() { }) adapter.onSubscriptionChange(async (optedIn) => { + addBreadcrumb({ category: 'onesignal', message: 'subscription change', data: { optedIn } }) // link subscription to logged-in user if available if (currentExternalId && !disableExternalIdLogin) { try { diff --git a/src/services/onesignal/debug.ts b/src/services/onesignal/debug.ts new file mode 100644 index 0000000000..15fb29d5d8 --- /dev/null +++ b/src/services/onesignal/debug.ts @@ -0,0 +1,14 @@ +/* + * Verbose OneSignal SDK logging for diagnosing push issues in release builds, + * where NODE_ENV-gated debug tooling is dead-code-eliminated. Enable via the + * pushDebug workflow input (bakes NEXT_PUBLIC_ONESIGNAL_DEBUG=true) or by + * setting localStorage.__onesignal_debug = 'true' on a device. + */ +export function isOneSignalDebug(): boolean { + if (process.env.NEXT_PUBLIC_ONESIGNAL_DEBUG === 'true') return true + try { + return localStorage.getItem('__onesignal_debug') === 'true' + } catch { + return false + } +} diff --git a/src/services/onesignal/native.adapter.ts b/src/services/onesignal/native.adapter.ts index f59b04f9d0..6b898c6774 100644 --- a/src/services/onesignal/native.adapter.ts +++ b/src/services/onesignal/native.adapter.ts @@ -1,6 +1,7 @@ -import OneSignal from '@onesignal/capacitor-plugin' +import OneSignal, { LogLevel } from '@onesignal/capacitor-plugin' import type { PushSubscriptionChangedState } from '@onesignal/capacitor-plugin' import type { NotificationPermissionState, OneSignalAdapter } from './types' +import { isOneSignalDebug } from './debug' async function nativePermission(): Promise { if (await OneSignal.Notifications.hasPermission()) return 'granted' @@ -37,6 +38,7 @@ export const nativeOneSignalAdapter: OneSignalAdapter = { if (!appId) { throw new Error('OneSignal configuration missing: NEXT_PUBLIC_ONESIGNAL_APP_ID is required') } + if (isOneSignalDebug()) OneSignal.Debug.setLogLevel(LogLevel.Verbose) await OneSignal.initialize(appId) attachUnderlyingListeners() })() diff --git a/src/services/onesignal/web.adapter.ts b/src/services/onesignal/web.adapter.ts index bc06ad2363..dc99d688c4 100644 --- a/src/services/onesignal/web.adapter.ts +++ b/src/services/onesignal/web.adapter.ts @@ -1,5 +1,6 @@ import OneSignal from 'react-onesignal' import type { NotificationPermissionState, OneSignalAdapter } from './types' +import { isOneSignalDebug } from './debug' function browserPermission(): NotificationPermissionState { if (typeof Notification === 'undefined') return 'default' @@ -66,6 +67,8 @@ export const webOneSignalAdapter: OneSignalAdapter = { }, }) + if (isOneSignalDebug()) OneSignal.Debug.setLogLevel('trace') + attachUnderlyingListeners() })() return initPromise From c6d3291d5e5125d52ff5c59374326ff9cc6f5da1 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 15:50:23 +0100 Subject: [PATCH 10/38] fix(android): make google-services.json optional again The earlier hard-fail was based on a wrong premise. OneSignal's Android SDK never reads google-services.json: PushRegistratorFCM builds FirebaseOptions in code from its own FCM_DEFAULT_* constants plus the sender ID served by the OneSignal dashboard, and com.onesignal:notifications pulls firebase-messaging in transitively. The google-services plugin only wires Firebase's automatic initialization, which OneSignal bypasses. The "Push Notifications won't work" comment that motivated the guard is Capacitor's stock boilerplate for @capacitor/push-notifications, which does need the file. This app uses OneSignal, which does not. Keep validating the file when the secret is set; skip it when unset. Android push depends on the OneSignal dashboard's FCM v1 config, not on this file. --- .github/workflows/android-release.yml | 11 +++++++---- android/app/build.gradle | 6 ++---- docs/NATIVE-RELEASE.md | 17 +++++++++++------ 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index ce1eba89b1..2ee23c2bfa 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -114,14 +114,17 @@ jobs: fi - name: Decode google-services.json - # FCM credentials for native push. Must come from the Firebase project - # wired to OneSignal's Android (FCM v1) config or token registration fails. + # Optional. OneSignal's SDK does not read this file — it builds + # FirebaseOptions in code from its own defaults plus the sender ID + # configured on the OneSignal dashboard, and pulls firebase-messaging + # in transitively. The file is only needed if a future dependency + # wants Firebase's automatic initialization. Validated when present. env: ANDROID_GOOGLE_SERVICES_JSON: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON }} run: | if [ -z "$ANDROID_GOOGLE_SERVICES_JSON" ]; then - echo "::error::ANDROID_GOOGLE_SERVICES_JSON unset — release builds must ship FCM push. See docs/NATIVE-RELEASE.md" - exit 1 + echo "ℹ️ ANDROID_GOOGLE_SERVICES_JSON unset — building without it (OneSignal push does not require it)" + exit 0 fi echo "$ANDROID_GOOGLE_SERVICES_JSON" | base64 -d > android/app/google-services.json python3 -c " diff --git a/android/app/build.gradle b/android/app/build.gradle index 9c71f4713c..ad47293ba2 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -100,11 +100,9 @@ dependencies { apply from: 'capacitor.build.gradle' +// Optional: OneSignal registers with FCM without it (FirebaseOptions built in +// code from the dashboard's sender ID), so its absence does not disable push. def servicesJSON = file('google-services.json') if (servicesJSON.exists() && servicesJSON.text) { apply plugin: 'com.google.gms.google-services' -} else if (gradle.startParameter.taskNames.any { def t = it.toLowerCase(); t.contains('release') || t.contains('bundle') }) { - throw new GradleException("google-services.json missing — a release build would silently ship without push. See docs/NATIVE-RELEASE.md") -} else { - logger.warn("google-services.json not found — push disabled for this dev build") } diff --git a/docs/NATIVE-RELEASE.md b/docs/NATIVE-RELEASE.md index ad5b5b3a68..c93c223fcb 100644 --- a/docs/NATIVE-RELEASE.md +++ b/docs/NATIVE-RELEASE.md @@ -204,7 +204,7 @@ the build is reproducible, the AAB lands on a Play track. | `ANDROID_KEYSTORE_BASE64` | `base64 -w0 peanut-release.keystore` | | `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD` | signing creds | | `PLAY_SERVICE_ACCOUNT_JSON` | Google Play Developer API service account (least-priv "Release manager") | -| `ANDROID_GOOGLE_SERVICES_JSON` | `base64 -w0 google-services.json` — FCM config; **required**, the release workflow fails without it | +| `ANDROID_GOOGLE_SERVICES_JSON` | `base64 -w0 google-services.json` — **optional**; OneSignal push does not read it (see §Android push) | | `SUBMODULE_TOKEN` | read access to the `src/content` submodule | | `CAPGO_API_KEY` | OTA (already used by `capgo-deploy.yml`) | | prod `NEXT_PUBLIC_*` | the values the static export bakes in (OneSignal, Sentry, chain, …) | @@ -276,11 +276,16 @@ with **no backend or sequence changes**. The web/native split lives behind 2. **OneSignal dashboard** → the existing app → **Google Android (FCM)** platform → upload the **FCM v1 service account JSON** (Firebase → Project settings → Service accounts → Generate private key). -3. **CI:** set the `ANDROID_GOOGLE_SERVICES_JSON` secret to `base64 -w0 google-services.json`. - The `Decode google-services.json` step in `android-release.yml` writes it before the - build and **fails the workflow when unset** (a release without it ships with push - silently dead). Local release/bundle Gradle tasks fail the same way; debug builds - only warn. +3. **CI:** `ANDROID_GOOGLE_SERVICES_JSON` is **optional** and currently unset. OneSignal's + Android SDK never reads `google-services.json`: `PushRegistratorFCM` builds + `FirebaseOptions` in code from its own baked-in defaults plus the sender ID served by + the OneSignal dashboard, and `com.onesignal:notifications` pulls `firebase-messaging` + in transitively. The `google-services` Gradle plugin only wires up Firebase's + *automatic* initialization, which OneSignal bypasses. Set the secret only if a future + dependency needs that path; the workflow validates the file when present and skips it + when absent. + + Android push therefore depends on the **dashboard** config in step 2, not on this file. **Verify (real device/emulator with Play Services):** `node scripts/native-build.js && npx cap sync android && ./gradlew assembleDebug`, install, From 2ecf06174872ece7366d14350e9f300807776f5c Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 16:01:49 +0100 Subject: [PATCH 11/38] fix(android): remove google-services.json from the build entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OneSignal's Android SDK registers with FCM on its own — PushRegistratorFCM builds FirebaseOptions in code from the sender ID served by the OneSignal dashboard, and firebase-messaging arrives transitively via com.onesignal:notifications. The google-services plugin and its JSON only wire Firebase's automatic initialization, which OneSignal bypasses, so they were dead weight. Drop the com.google.gms:google-services classpath, the conditional plugin apply, and the CI "Decode google-services.json" step. Android push now depends solely on the OneSignal dashboard's FCM v1 config (a service-account key, which is a different Firebase file than google-services.json). --- .github/workflows/android-release.yml | 22 ----------------- android/app/build.gradle | 10 ++++---- android/build.gradle | 1 - docs/NATIVE-RELEASE.md | 34 +++++++++++++-------------- 4 files changed, 20 insertions(+), 47 deletions(-) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index 2ee23c2bfa..3e4192a590 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -113,28 +113,6 @@ jobs: echo "NEXT_PUBLIC_ONESIGNAL_DEBUG=true" >> .env.production.local fi - - name: Decode google-services.json - # Optional. OneSignal's SDK does not read this file — it builds - # FirebaseOptions in code from its own defaults plus the sender ID - # configured on the OneSignal dashboard, and pulls firebase-messaging - # in transitively. The file is only needed if a future dependency - # wants Firebase's automatic initialization. Validated when present. - env: - ANDROID_GOOGLE_SERVICES_JSON: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON }} - run: | - if [ -z "$ANDROID_GOOGLE_SERVICES_JSON" ]; then - echo "ℹ️ ANDROID_GOOGLE_SERVICES_JSON unset — building without it (OneSignal push does not require it)" - exit 0 - fi - echo "$ANDROID_GOOGLE_SERVICES_JSON" | base64 -d > android/app/google-services.json - python3 -c " - import json - d = json.load(open('android/app/google-services.json')) - clients = [c['client_info']['android_client_info']['package_name'] for c in d['client']] - assert 'me.peanut.wallet' in clients, f'google-services.json has no client for me.peanut.wallet (found: {clients})' - " - echo "✅ google-services.json written and validated" - - name: Build signed AAB run: | # versionName: manual dispatch input wins; else the tag name minus diff --git a/android/app/build.gradle b/android/app/build.gradle index ad47293ba2..c6a65a06a5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -100,9 +100,7 @@ dependencies { apply from: 'capacitor.build.gradle' -// Optional: OneSignal registers with FCM without it (FirebaseOptions built in -// code from the dashboard's sender ID), so its absence does not disable push. -def servicesJSON = file('google-services.json') -if (servicesJSON.exists() && servicesJSON.text) { - apply plugin: 'com.google.gms.google-services' -} +// No google-services plugin: OneSignal registers with FCM on its own — +// PushRegistratorFCM builds FirebaseOptions in code from the sender ID served +// by the OneSignal dashboard, and firebase-messaging arrives transitively via +// com.onesignal:notifications. See docs/NATIVE-RELEASE.md §Android push. diff --git a/android/build.gradle b/android/build.gradle index 549e498190..802a70454f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -8,7 +8,6 @@ buildscript { } dependencies { classpath 'com.android.tools.build:gradle:8.13.0' - classpath 'com.google.gms:google-services:4.4.4' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/docs/NATIVE-RELEASE.md b/docs/NATIVE-RELEASE.md index c93c223fcb..d7b4d5924f 100644 --- a/docs/NATIVE-RELEASE.md +++ b/docs/NATIVE-RELEASE.md @@ -264,28 +264,26 @@ with **no backend or sequence changes**. The web/native split lives behind deps autolink on `cap sync`. - `AndroidManifest.xml`: `POST_NOTIFICATIONS` (the Android 13+ runtime prompt, driven by the plugin's `requestPermission()`). -- `android/app/build.gradle` already conditionally applies the `google-services` plugin - **only when `google-services.json` is present** — its absence disables push but never - fails the build. +- No `google-services.json` and no `google-services` Gradle plugin: OneSignal's Android + SDK never reads that file. `PushRegistratorFCM` builds `FirebaseOptions` in code from its + own baked-in defaults plus the sender ID served by the OneSignal dashboard, and + `com.onesignal:notifications` pulls `firebase-messaging` in transitively. The plugin only + wires Firebase's *automatic* initialization, which OneSignal bypasses. - `scripts/native-build.js` warns when `NEXT_PUBLIC_ONESIGNAL_APP_ID` is unset (the app id is inlined into the static bundle; without it the native SDK can't initialize). -**Provider setup (do once, no code):** -1. **Firebase:** create/locate the Firebase project for `me.peanut.wallet`, download - `google-services.json`, place it at `android/app/google-services.json` (gitignored). -2. **OneSignal dashboard** → the existing app → **Google Android (FCM)** platform → +**Provider setup (do once, no code, dashboard only):** +1. **OneSignal dashboard** → the existing app → **Google Android (FCM)** platform → upload the **FCM v1 service account JSON** (Firebase → Project settings → Service - accounts → Generate private key). -3. **CI:** `ANDROID_GOOGLE_SERVICES_JSON` is **optional** and currently unset. OneSignal's - Android SDK never reads `google-services.json`: `PushRegistratorFCM` builds - `FirebaseOptions` in code from its own baked-in defaults plus the sender ID served by - the OneSignal dashboard, and `com.onesignal:notifications` pulls `firebase-messaging` - in transitively. The `google-services` Gradle plugin only wires up Firebase's - *automatic* initialization, which OneSignal bypasses. Set the secret only if a future - dependency needs that path; the workflow validates the file when present and skips it - when absent. - - Android push therefore depends on the **dashboard** config in step 2, not on this file. + accounts → Generate private key) and set the **Sender ID** (Firebase project number). + This is the *only* thing Android push depends on — there is no build-side FCM config. + + Note: the file OneSignal wants here is the **service account private key**, not + `google-services.json`. The two are different Firebase files: `google-services.json` is + an app-side client config; the service account JSON is a server credential OneSignal + uses to send. Uploading `google-services.json` into OneSignal will not work. + +No `ANDROID_GOOGLE_SERVICES_JSON` secret is needed; it has been removed from the build. **Verify (real device/emulator with Play Services):** `node scripts/native-build.js && npx cap sync android && ./gradlew assembleDebug`, install, From b9c64ee1f1c9492ac397e3659351b5a2b2e6ecbe Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 16:23:59 +0100 Subject: [PATCH 12/38] fix(native-build): recognize P0_TRANSFORMS routes in the anti-rot guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isCoveredByDisableList only consulted ITEMS_TO_DISABLE, so routes handled via P0_TRANSFORMS — whose replacements strip force-dynamic/generateMetadata — were reported as uncovered server-only routes and failed the native build. (mobile-ui)/claim/page.tsx is the case that broke it: it lives in P0_TRANSFORMS, not ITEMS_TO_DISABLE, so the guard never saw its transform. invite survived only because it also has a dir entry in ITEMS_TO_DISABLE. Treat a route as covered if it's in either list. This regressed native Android builds since ~v1.0.20; the bug is present on main too. --- scripts/native-build.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/native-build.js b/scripts/native-build.js index 09934ec733..8be16debd0 100644 --- a/scripts/native-build.js +++ b/scripts/native-build.js @@ -238,7 +238,7 @@ function copyComponentsBeforeDisable() { // and fails LOUDLY with the exact offending paths so the fix is obvious: add them // to ITEMS_TO_DISABLE (or give the page a generateStaticParams). function isCoveredByDisableList(relPath) { - return ITEMS_TO_DISABLE.some((item) => { + const inDisableList = ITEMS_TO_DISABLE.some((item) => { if (item.type === 'dir') { return ( relPath === item.path || relPath.startsWith(item.path + path.sep) || relPath.startsWith(item.path + '/') @@ -246,6 +246,11 @@ function isCoveredByDisableList(relPath) { } return relPath === item.path }) + if (inDisableList) return true + // P0_TRANSFORMS replace a route's content with a static-export-safe version + // (stripping force-dynamic / generateMetadata), so those paths are handled too + // and must not trip the guard — e.g. (mobile-ui)/claim/page.tsx. + return P0_TRANSFORMS.some((item) => relPath === item.path) } function detectUncoveredServerRoutes(dir = APP_DIR, found = []) { From b1efa5c5bc0d3f2561a210664a649e360da9fd26 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 16:39:53 +0100 Subject: [PATCH 13/38] fix(ci): stop setting changesNotSentForReview on the Play upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google now rejects the Play Edit commit with "Changes are sent for review automatically. The query parameter changesNotSentForReview must not be set." once the app has a reviewed base. The flag was only needed for the very first release; with a reviewed base the edit must go to review automatically. The AAB itself uploaded fine — only the commit step failed on this flag. --- .github/workflows/android-release.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index 3e4192a590..fb22f250c7 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -138,9 +138,6 @@ jobs: releaseFiles: android/app/build/outputs/bundle/release/app-release.aab tracks: ${{ github.event.inputs.track || 'internal' }} status: completed - # First releases have no reviewed base, so Play can't auto-submit - # for review; commit the edit and review from the Console instead. - changesNotSentForReview: true # Staged production rollout: set status: inProgress + userFraction: 0.1, # then promote in Play Console once crash/error rates look clean. From f0fd4fc05462ccc1e9fa27be68878b5ea69296f8 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 18:12:13 +0100 Subject: [PATCH 14/38] fix(ios-release): fail fast when cap sync drops the SumSub Cordova plugin cap sync ios silently omits sources/SumsubCordovaIdensicMobileSdkPlugin (exit 0, no folder) when node_modules isn't fully materialized, and the committed CapApp-SPM/Package.swift then fails the archive a minute later with a cryptic SwiftPM "folder doesn't exist" error. - native-ios-postsync.js: hard-fail with an actionable message when the sources dir is missing but the plugin is still installed or still referenced by CapApp-SPM/Package.swift; only skip on genuine removal. - ios-release.yml: verify the SumSub Cordova plugin materialized right after pnpm install, catching the partial-install root cause at the point where a job re-run recovers. --- .github/workflows/ios-release.yml | 14 ++++++++++ scripts/native-ios-postsync.js | 43 +++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ios-release.yml b/.github/workflows/ios-release.yml index 8a4cff95b5..f1f1890d6a 100644 --- a/.github/workflows/ios-release.yml +++ b/.github/workflows/ios-release.yml @@ -65,6 +65,20 @@ jobs: - name: Install dependencies run: pnpm install + - name: Verify SumSub Cordova plugin materialized + # `cap sync ios` silently drops a Cordova plugin (exit 0, no folder) + # when node_modules isn't fully materialized, and the archive then + # fails a minute later with a cryptic SwiftPM error. Catch the real + # cause — a partial pnpm install — here, where a re-run recovers. + run: | + P=node_modules/@sumsub/cordova-idensic-mobile-sdk-plugin + if [ ! -f "$P/package.json" ] || [ ! -f "$P/plugin.xml" ]; then + echo "::error::$P is not fully installed (package.json/plugin.xml missing). This is the intermittent iOS-release flake — cap sync would drop the plugin and the archive would fail cryptically. Re-run the job (pnpm install did not materialize the plugin)." + ls -la "$P" 2>&1 || true + exit 1 + fi + echo "SumSub Cordova plugin materialized." + - name: Production web env run: | # NEXT_PUBLIC_* values are baked into the static export at build time. diff --git a/scripts/native-ios-postsync.js b/scripts/native-ios-postsync.js index a9432580aa..535926d20b 100755 --- a/scripts/native-ios-postsync.js +++ b/scripts/native-ios-postsync.js @@ -24,14 +24,53 @@ const { execSync } = require('child_process') const SUMSUB_VERSION = '1.42.0' const repoRoot = path.join(__dirname, '..') +const pluginPkg = '@sumsub/cordova-idensic-mobile-sdk-plugin' const pluginDir = path.join(repoRoot, 'ios/capacitor-cordova-ios-plugins/sources/SumsubCordovaIdensicMobileSdkPlugin') const frameworksDir = path.join(pluginDir, 'Frameworks') const xcframework = path.join(frameworksDir, 'IdensicMobileSDK.xcframework') const pkgSwiftPath = path.join(pluginDir, 'Package.swift') +const capAppPkgSwift = path.join(repoRoot, 'ios/App/CapApp-SPM/Package.swift') + +const pluginInstalled = (() => { + try { + require.resolve(`${pluginPkg}/package.json`, { paths: [repoRoot] }) + return true + } catch { + return false + } +})() + +const capAppReferencesPlugin = (() => { + try { + return fs.readFileSync(capAppPkgSwift, 'utf8').includes('SumsubCordovaIdensicMobileSdkPlugin') + } catch { + return false + } +})() if (!fs.existsSync(pluginDir)) { - console.log('[postsync] SumSub plugin dir not present — skipping (plugin removed?)') - process.exit(0) + // `cap sync ios` regenerates this dir from the installed Cordova plugin, but + // when node_modules isn't fully materialized (a partial/interrupted pnpm + // install) cap sync silently drops the plugin and STILL exits 0 — so the dir + // is absent even though the committed CapApp-SPM/Package.swift hard-references + // it, and the archive then dies a minute later with a cryptic SwiftPM + // "folder doesn't exist" error. Skip only when the plugin is genuinely gone. + if (!pluginInstalled && !capAppReferencesPlugin) { + console.log( + `[postsync] ${pluginPkg} not installed and not referenced by CapApp-SPM — plugin removed; nothing to vendor.` + ) + process.exit(0) + } + console.error( + `[postsync] ERROR: ${pluginDir} is missing after \`cap sync ios\`.\n` + + ` It is generated from ${pluginPkg}, which ${pluginInstalled ? 'IS installed' : 'is NOT installed'} in node_modules.\n` + + (pluginInstalled + ? ' cap sync failed to detect it — usually a partial/interrupted `pnpm install` that left the package unresolved at sync time.\n' + : ' The package is missing from node_modules — `pnpm install` did not materialize it.\n') + + ` ios/App/CapApp-SPM/Package.swift ${capAppReferencesPlugin ? 'references' : 'does not reference'} this package, so the archive would fail with a cryptic SwiftPM error.\n` + + ' Fix: re-run `pnpm install && npx cap sync ios && node scripts/native-ios-postsync.js`.' + ) + process.exit(1) } // 1. Vendor the xcframework (download once; it survives within a single CI run). From d0ea330169d58f66f26c0fa743ad64b2b903ab3d Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 21:41:26 +0100 Subject: [PATCH 15/38] fix(ios-release): derive MARKETING_VERSION from the pushed tag A bare `vX.Y.Z` tag push only set CURRENT_PROJECT_VERSION and left MARKETING_VERSION at the project default 1.0, so tag releases shipped to TestFlight as version 1.0 (flagged outdated). Fall back to the tag name (v1.0.31 -> 1.0.31) when no explicit versionName input is given, matching android-release.yml. --- .github/workflows/ios-release.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ios-release.yml b/.github/workflows/ios-release.yml index f1f1890d6a..0cd90ab139 100644 --- a/.github/workflows/ios-release.yml +++ b/.github/workflows/ios-release.yml @@ -147,11 +147,15 @@ jobs: # Monotonic, always-increments-even-on-rerun. TestFlight requires each # upload's build number to exceed the last. IOS_BUILD_NUMBER: ${{ github.run_number }} - IOS_VERSION_NAME: ${{ github.event.inputs.versionName || '' }} + IOS_VERSION_INPUT: ${{ github.event.inputs.versionName || '' }} + IOS_TAG_NAME: ${{ github.ref_type == 'tag' && github.ref_name || '' }} run: | - # Only override MARKETING_VERSION when an explicit versionName is supplied; - # otherwise keep the project's MARKETING_VERSION. CURRENT_PROJECT_VERSION is - # always the CI run number. Info.plist reads both via $(...) build settings. + # MARKETING_VERSION comes from the explicit versionName input, else from + # the pushed tag (v1.0.31 -> 1.0.31); a bare tag push must not silently + # ship the project default 1.0. CURRENT_PROJECT_VERSION is always the CI + # run number. Info.plist reads both via $(...) build settings. + IOS_VERSION_NAME="$IOS_VERSION_INPUT" + if [ -z "$IOS_VERSION_NAME" ] && [ -n "$IOS_TAG_NAME" ]; then IOS_VERSION_NAME="${IOS_TAG_NAME#v}"; fi MARKETING_ARG=() if [ -n "$IOS_VERSION_NAME" ]; then MARKETING_ARG=("MARKETING_VERSION=$IOS_VERSION_NAME"); fi From bf1883fb32f306a37877c193662f9b17179d3dc1 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 22:01:46 +0100 Subject: [PATCH 16/38] chore(ci): point capgo iOS OTA trigger at renamed mobile-release branch --- .github/workflows/capgo-deploy-ios.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/capgo-deploy-ios.yml b/.github/workflows/capgo-deploy-ios.yml index 684399f0c1..3d80abf999 100644 --- a/.github/workflows/capgo-deploy-ios.yml +++ b/.github/workflows/capgo-deploy-ios.yml @@ -8,7 +8,7 @@ name: Deploy OTA Update — iOS only (Capgo) on: push: - branches: [feat/mobile-release] + branches: [mobile-release] workflow_dispatch: inputs: channel: From 948046068085ca1931937a319c8bd692ed47b5e9 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 22:16:10 +0100 Subject: [PATCH 17/38] test(kyc): wrap SumsubKycWrapper tests in NextIntlClientProvider The suite was added on main while the component still used hardcoded strings; the i18n branch converted it to useTranslations, so the two only break when merged. Use the same IntlWrapper pattern as the rest of the localized suites. --- .../Kyc/__tests__/SumsubKycWrapper.test.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx b/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx index 317380af89..cb825eb1ea 100644 --- a/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx +++ b/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx @@ -1,7 +1,15 @@ import { render, waitFor } from '@testing-library/react' -import { useEffect, useState } from 'react' +import { NextIntlClientProvider } from 'next-intl' +import { ReactNode, useEffect, useState } from 'react' +import en from '@/i18n/app/messages/en.json' import { SumsubKycWrapper } from '../SumsubKycWrapper' +const IntlWrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +) + // The real Modal is a headlessui /, which renders through a // Portal: the portal target is created in the portal's OWN effect, so children // mount a commit AFTER `visible` flips true. That one-commit delay is the whole @@ -68,7 +76,7 @@ describe('SumsubKycWrapper', () => { // what makes `visible` the LAST dep to flip — if sdkLoaded flipped after // it instead, that flip would re-run the init effect with the container // already mounted and mask the bug entirely. - const { rerender } = render() + const { rerender } = render(, { wrapper: IntlWrapper }) expect(launch).not.toHaveBeenCalled() rerender() @@ -87,7 +95,8 @@ describe('SumsubKycWrapper', () => { onClose={jest.fn()} onComplete={jest.fn()} onRefreshToken={jest.fn().mockResolvedValue('tok_abc')} - /> + />, + { wrapper: IntlWrapper } ) await new Promise((r) => setTimeout(r, 0)) expect(launch).not.toHaveBeenCalled() @@ -101,7 +110,8 @@ describe('SumsubKycWrapper', () => { onClose={jest.fn()} onComplete={jest.fn()} onRefreshToken={jest.fn().mockResolvedValue('tok_abc')} - /> + />, + { wrapper: IntlWrapper } ) await new Promise((r) => setTimeout(r, 0)) expect(launch).not.toHaveBeenCalled() From 18886e271fab74df5e282b5704b6e8d7784d449a Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 22:24:23 +0100 Subject: [PATCH 18/38] fix(types): type-only ReactNode import in SumsubKycWrapper test --- src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx b/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx index cb825eb1ea..e7c133e59c 100644 --- a/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx +++ b/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx @@ -1,6 +1,6 @@ import { render, waitFor } from '@testing-library/react' import { NextIntlClientProvider } from 'next-intl' -import { ReactNode, useEffect, useState } from 'react' +import { type ReactNode, useEffect, useState } from 'react' import en from '@/i18n/app/messages/en.json' import { SumsubKycWrapper } from '../SumsubKycWrapper' From 48b1c386f6c7ba98f198e1c42e99c0abba1ef394 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Sat, 18 Jul 2026 14:16:45 +0100 Subject: [PATCH 19/38] fix(native): widen the R5F fetch diagnostics and evict stale WebView SWs Three changes that together make PEANUT-UI-R5F diagnosable in the field: - Split the single direct-fetch canary into three probes (unauthenticated GET, authorized GET, POST) so reachability, CORS preflight and the GET-vs-POST asymmetry seen on-device can be told apart. Probes run in parallel and report in a fixed order. - Tag the binary version/build on every native Sentry event. With OTA the JS `release` and the installed binary diverge, which made R5F look like it came from a build it didn't. - Actively unregister service workers inside the Capacitor WebView. Builds before 2026-04 registered the PWA SW there; the native bundle ships no sw.js, so those registrations can never self-update and sit frozen in front of all GET traffic. --- instrumentation-client.ts | 16 +++ src/app/layout.tsx | 12 +++ src/utils/__tests__/native-canary.test.ts | 115 +++++++++++++++++++++ src/utils/native-canary.ts | 117 +++++++++++++++++----- 4 files changed, 236 insertions(+), 24 deletions(-) create mode 100644 src/utils/__tests__/native-canary.test.ts diff --git a/instrumentation-client.ts b/instrumentation-client.ts index 53dec5d251..9188d6986c 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -54,6 +54,22 @@ if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'development') { beforeSend: beforeSendHandler, integrations: [Sentry.captureConsoleIntegration({ levels: ['error', 'warn'] })], }) + + /* + * `release` above is the JS bundle's commit — with OTA updates it can differ + * from the installed binary, which made PEANUT-UI-R5F look like it came from + * a build it didn't. Tag the binary identity on every event so the skew is + * always visible; swControlled flags a stale pre-2026-04 service worker + * still intercepting requests inside the WebView. + */ + Sentry.setTag('swControlled', String(!!navigator.serviceWorker?.controller)) + import('@capacitor/app') + .then(({ App }) => App.getInfo()) + .then((info) => { + Sentry.setTag('binaryVersion', info.version) + Sentry.setTag('binaryBuild', info.build) + }) + .catch(() => {}) } // Brave identifies as Chrome in User-Agent — detect it and set a person property diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2dfe74b21f..efa5de64c7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -174,6 +174,18 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {process.env.NODE_ENV !== 'development' && (