From f296a9a127fb44fccf93969dfd39c345c0fba967 Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Thu, 3 Sep 2026 13:49:07 +0700 Subject: [PATCH] Harden Android screen-share startup --- .github/workflows/mobile-checks.yml | 6 + apps/mobile/README.md | 23 +- apps/mobile/app.config.ts | 1 + apps/mobile/eas.json | 5 +- apps/mobile/package.json | 3 +- .../scripts/verify-android-screen-share.mjs | 84 +++++++ apps/mobile/src/hooks/useScreenShare.test.ts | 227 +++++++++++++++++- apps/mobile/src/hooks/useScreenShare.ts | 50 +++- apps/mobile/src/hooks/useWebRTCHost.test.ts | 85 ++++++- apps/mobile/src/hooks/useWebRTCHost.ts | 40 ++- .../src/lib/android-native-prompt.test.ts | 58 +++++ apps/mobile/src/lib/android-native-prompt.ts | 100 ++++++++ apps/mobile/src/test/setup.ts | 20 +- 13 files changed, 679 insertions(+), 23 deletions(-) create mode 100644 apps/mobile/scripts/verify-android-screen-share.mjs create mode 100644 apps/mobile/src/lib/android-native-prompt.test.ts create mode 100644 apps/mobile/src/lib/android-native-prompt.ts diff --git a/.github/workflows/mobile-checks.yml b/.github/workflows/mobile-checks.yml index e9008610..a7a5df15 100644 --- a/.github/workflows/mobile-checks.yml +++ b/.github/workflows/mobile-checks.yml @@ -52,3 +52,9 @@ jobs: - name: Validate mobile app run: pnpm check:mobile + + - name: Generate Android native project + run: pnpm --filter @pairux/mobile exec expo prebuild --platform android --clean --no-install + + - name: Verify Android screen-share native config + run: pnpm --filter @pairux/mobile verify:android-screen-share diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 80f9f9b0..f21e0d81 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -71,7 +71,10 @@ recovery, not background audio support. An active screen share ends when the app reaches the background and must be started again after returning. The capture hook owns that teardown so its UI cannot report a stopped native track as -still sharing. A brief iOS `inactive` transition alone does not tear down the call or screen share. +still sharing. Android permission and MediaProjection dialogs briefly report the app as backgrounded; +those prompt-owned transitions are ignored while a real background transition still tears down after +the resume grace period. A brief iOS `inactive` transition alone does not tear down the call or screen +share. ## EAS builds @@ -89,6 +92,10 @@ eas build --platform android --profile preview eas build --platform ios --profile preview ``` +The Android preview profile produces an installable APK for internal testing. +Running an EAS cloud build may consume the Profullstack account's build quota or +paid plan, so confirm account billing before starting it. + Signed device builds and store submission additionally require the matching Google Play and Apple Developer credentials. Keep those credentials in EAS or the platform account, never in the repository. @@ -97,12 +104,14 @@ the repository. Android prebuilds enable the foreground MediaProjection service bundled with `react-native-webrtc`. This is required for screen capture on current Android releases. On Android -13 and newer, a production app should declare and request `POST_NOTIFICATIONS` before screen -capture if the foreground-service notification must remain visible in the notification drawer. -MediaProjection can still start without that permission, but Android shows the foreground-service -notice only in Task Manager when notification permission is denied. The generated app removes the -camera and system-overlay permissions inherited from the WebRTC dependency because PairUX currently -uses screen capture and voice, not camera capture or overlay windows. +13 and newer, PairUX declares and requests `POST_NOTIFICATIONS` before screen capture so the +foreground-service notification can remain visible in the notification drawer. Denial does not +block MediaProjection; Android instead shows the foreground-service notice in Task Manager. The +generated app removes the camera and system-overlay permissions inherited from the WebRTC dependency +because PairUX currently uses screen capture and voice, not camera capture or overlay windows. + +Mobile CI runs a clean Android prebuild and verifies the required permissions, blocked permissions, +and MediaProjection service initialization against the generated native project. The host UI reports sharing as active only after the captured stream has been published to the current viewers. Capture permission, publication, active sharing, and shutdown are serialized so diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 51025f29..1e737b8c 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -49,6 +49,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ permissions: [ 'INTERNET', 'RECORD_AUDIO', + 'POST_NOTIFICATIONS', 'FOREGROUND_SERVICE', 'FOREGROUND_SERVICE_MEDIA_PROJECTION', ], diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 9d395287..89f4d385 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -10,7 +10,10 @@ }, "preview": { "distribution": "internal", - "environment": "preview" + "environment": "preview", + "android": { + "buildType": "apk" + } }, "production": { "autoIncrement": true, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 23b50776..527545ff 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -12,7 +12,8 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", - "verify:bundle": "node scripts/verify-bundle-react.mjs" + "verify:bundle": "node scripts/verify-bundle-react.mjs", + "verify:android-screen-share": "node scripts/verify-android-screen-share.mjs" }, "dependencies": { "@config-plugins/react-native-webrtc": "10.0.0", diff --git a/apps/mobile/scripts/verify-android-screen-share.mjs b/apps/mobile/scripts/verify-android-screen-share.mjs new file mode 100644 index 00000000..326dcd6b --- /dev/null +++ b/apps/mobile/scripts/verify-android-screen-share.mjs @@ -0,0 +1,84 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath, URL } from 'node:url'; + +const mobileRoot = fileURLToPath(new URL('..', import.meta.url)); +const androidRoot = join(mobileRoot, 'android'); +const manifestPath = join(androidRoot, 'app', 'src', 'main', 'AndroidManifest.xml'); + +function fail(message) { + console.error(`Android screen-share verification failed: ${message}`); + process.exit(1); +} + +function findFile(root, names) { + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) { + const found = findFile(path, names); + if (found) return found; + } else if (names.has(entry.name)) { + return path; + } + } + return null; +} + +if (!existsSync(manifestPath)) { + fail('run Expo Android prebuild before this check'); +} + +const manifest = readFileSync(manifestPath, 'utf8'); +const permissionTags = [...manifest.matchAll(/])[^>]*\/?>/g)].map( + ([tag]) => tag +); + +function permissionEntries(name) { + return permissionTags.filter((tag) => tag.includes(`android:name="${name}"`)); +} + +function expectActivePermission(name) { + const active = permissionEntries(name).filter((tag) => !tag.includes('tools:node="remove"')); + if (active.length !== 1) { + fail(`${name} must appear exactly once as an active permission (found ${active.length})`); + } +} + +function expectBlockedPermission(name) { + const entries = permissionEntries(name); + const active = entries.filter((tag) => !tag.includes('tools:node="remove"')); + const removals = entries.filter((tag) => tag.includes('tools:node="remove"')); + if (active.length > 0 || removals.length !== 1) { + fail(`${name} must be blocked exactly once and never requested`); + } +} + +for (const permission of [ + 'android.permission.RECORD_AUDIO', + 'android.permission.POST_NOTIFICATIONS', + 'android.permission.FOREGROUND_SERVICE', + 'android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION', +]) { + expectActivePermission(permission); +} + +expectBlockedPermission('android.permission.CAMERA'); +expectBlockedPermission('android.permission.SYSTEM_ALERT_WINDOW'); + +const javaRoot = join(androidRoot, 'app', 'src', 'main', 'java'); +if (!existsSync(javaRoot)) { + fail('generated Android Java/Kotlin source directory was not found'); +} + +const applicationPath = findFile(javaRoot, new Set(['MainApplication.kt', 'MainApplication.java'])); +if (!applicationPath) { + fail('generated MainApplication file was not found'); +} + +const application = readFileSync(applicationPath, 'utf8'); +const enableCalls = application.match(/enableMediaProjectionService\s*=\s*true/g) || []; +if (enableCalls.length !== 1) { + fail(`MediaProjection service must be enabled exactly once (found ${enableCalls.length})`); +} + +console.log('Android screen-share manifest and native initialization are valid.'); diff --git a/apps/mobile/src/hooks/useScreenShare.test.ts b/apps/mobile/src/hooks/useScreenShare.test.ts index 6a762497..56e34622 100644 --- a/apps/mobile/src/hooks/useScreenShare.test.ts +++ b/apps/mobile/src/hooks/useScreenShare.test.ts @@ -1,5 +1,6 @@ import { act, renderHook, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PermissionsAndroid, Platform } from 'react-native'; import { mediaDevices } from 'react-native-webrtc'; import type { MediaStream, MediaStreamTrack } from 'react-native-webrtc'; import { useScreenShare } from './useScreenShare'; @@ -42,6 +43,229 @@ function createCapture() { describe('useScreenShare', () => { beforeEach(() => { vi.mocked(mediaDevices.getDisplayMedia).mockReset(); + vi.mocked(PermissionsAndroid.request) + .mockReset() + .mockResolvedValue(PermissionsAndroid.RESULTS.GRANTED); + }); + + it('requests Android 13 notification permission before starting capture', async () => { + Object.assign(Platform, { OS: 'android', Version: 33 }); + const capture = createCapture(); + vi.mocked(mediaDevices.getDisplayMedia).mockResolvedValue(capture.stream); + const publishStream = vi.fn().mockResolvedValue(undefined); + const unpublishStream = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useScreenShare({ publishStream, unpublishStream })); + + await act(async () => { + await expect(result.current.start()).resolves.toBe(true); + }); + + expect(PermissionsAndroid.request).toHaveBeenCalledWith( + PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS + ); + expect(vi.mocked(PermissionsAndroid.request).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(mediaDevices.getDisplayMedia).mock.invocationCallOrder[0] + ); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it.each([PermissionsAndroid.RESULTS.DENIED, PermissionsAndroid.RESULTS.NEVER_ASK_AGAIN])( + 'continues screen capture when notification permission is %s', + async (permission) => { + Object.assign(Platform, { OS: 'android', Version: 34 }); + vi.mocked(PermissionsAndroid.request).mockResolvedValue(permission); + const capture = createCapture(); + vi.mocked(mediaDevices.getDisplayMedia).mockResolvedValue(capture.stream); + const publishStream = vi.fn().mockResolvedValue(undefined); + const unpublishStream = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useScreenShare({ publishStream, unpublishStream })); + + await act(async () => { + await expect(result.current.start()).resolves.toBe(true); + }); + + expect(result.current.state).toBe('active'); + expect(mediaDevices.getDisplayMedia).toHaveBeenCalledTimes(1); + } + ); + + it('continues screen capture when the notification permission request throws', async () => { + Object.assign(Platform, { OS: 'android', Version: 33 }); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.mocked(PermissionsAndroid.request).mockRejectedValue(new Error('permission API failed')); + const capture = createCapture(); + vi.mocked(mediaDevices.getDisplayMedia).mockResolvedValue(capture.stream); + const publishStream = vi.fn().mockResolvedValue(undefined); + const unpublishStream = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useScreenShare({ publishStream, unpublishStream })); + + await act(async () => { + await expect(result.current.start()).resolves.toBe(true); + }); + + expect(result.current.state).toBe('active'); + expect(mediaDevices.getDisplayMedia).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['Android 12', { OS: 'android', Version: 32 }], + ['iOS', { OS: 'ios', Version: 18 }], + ])('does not request notification permission on %s', async (_label, platform) => { + Object.assign(Platform, platform); + const capture = createCapture(); + vi.mocked(mediaDevices.getDisplayMedia).mockResolvedValue(capture.stream); + const publishStream = vi.fn().mockResolvedValue(undefined); + const unpublishStream = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useScreenShare({ publishStream, unpublishStream })); + + await act(async () => { + await expect(result.current.start()).resolves.toBe(true); + }); + + expect(PermissionsAndroid.request).not.toHaveBeenCalled(); + }); + + it('does not start capture if stop wins while notification permission is pending', async () => { + Object.assign(Platform, { OS: 'android', Version: 33 }); + const permission = deferred>>(); + vi.mocked(PermissionsAndroid.request).mockReturnValue(permission.promise); + const publishStream = vi.fn().mockResolvedValue(undefined); + const unpublishStream = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useScreenShare({ publishStream, unpublishStream })); + + let startPromise!: Promise; + act(() => { + startPromise = result.current.start(); + }); + await waitFor(() => expect(PermissionsAndroid.request).toHaveBeenCalledTimes(1)); + await act(async () => { + await result.current.stop(); + }); + + permission.resolve(PermissionsAndroid.RESULTS.GRANTED); + await act(async () => { + await expect(startPromise).resolves.toBe(false); + }); + + expect(mediaDevices.getDisplayMedia).not.toHaveBeenCalled(); + expect(result.current.state).toBe('idle'); + }); + + it('ignores the transient Android background event from the notification prompt', async () => { + Object.assign(Platform, { OS: 'android', Version: 33 }); + const permission = deferred>>(); + vi.mocked(PermissionsAndroid.request).mockReturnValue(permission.promise); + const capture = createCapture(); + vi.mocked(mediaDevices.getDisplayMedia).mockResolvedValue(capture.stream); + const publishStream = vi.fn().mockResolvedValue(undefined); + const unpublishStream = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useScreenShare({ publishStream, unpublishStream })); + + let startPromise!: Promise; + act(() => { + startPromise = result.current.start(); + }); + await waitFor(() => expect(PermissionsAndroid.request).toHaveBeenCalledTimes(1)); + act(() => { + emitAppStateChange('background'); + }); + expect(result.current.state).toBe('requesting'); + + await act(async () => { + permission.resolve(PermissionsAndroid.RESULTS.GRANTED); + emitAppStateChange('active'); + await expect(startPromise).resolves.toBe(true); + }); + expect(result.current.state).toBe('active'); + expect(mediaDevices.getDisplayMedia).toHaveBeenCalledTimes(1); + }); + + it('ignores the transient Android background event from MediaProjection consent', async () => { + Object.assign(Platform, { OS: 'android', Version: 32 }); + const capture = createCapture(); + const captureRequest = deferred(); + vi.mocked(mediaDevices.getDisplayMedia).mockReturnValue(captureRequest.promise); + const publishStream = vi.fn().mockResolvedValue(undefined); + const unpublishStream = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useScreenShare({ publishStream, unpublishStream })); + + let startPromise!: Promise; + act(() => { + startPromise = result.current.start(); + }); + await waitFor(() => expect(mediaDevices.getDisplayMedia).toHaveBeenCalledTimes(1)); + act(() => { + emitAppStateChange('background'); + }); + expect(result.current.state).toBe('requesting'); + + await act(async () => { + captureRequest.resolve(capture.stream); + emitAppStateChange('active'); + await expect(startPromise).resolves.toBe(true); + }); + + expect(result.current.state).toBe('active'); + expect(publishStream).toHaveBeenCalledWith(capture.stream); + }); + + it('abandons capture when Android stays backgrounded after MediaProjection consent', async () => { + vi.useFakeTimers(); + Object.assign(Platform, { OS: 'android', Version: 32 }); + const capture = createCapture(); + const captureRequest = deferred(); + vi.mocked(mediaDevices.getDisplayMedia).mockReturnValue(captureRequest.promise); + const publishStream = vi.fn().mockResolvedValue(undefined); + const unpublishStream = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useScreenShare({ publishStream, unpublishStream })); + + let startPromise!: Promise; + act(() => { + startPromise = result.current.start(); + }); + await vi.waitFor(() => expect(mediaDevices.getDisplayMedia).toHaveBeenCalledTimes(1)); + act(() => { + emitAppStateChange('background'); + }); + + await act(async () => { + captureRequest.resolve(capture.stream); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(1_500); + await expect(startPromise).resolves.toBe(false); + }); + + expect(capture.track.stop).toHaveBeenCalledTimes(1); + expect(publishStream).not.toHaveBeenCalled(); + expect(unpublishStream).not.toHaveBeenCalled(); + expect(result.current.state).toBe('idle'); + }); + + it('does not start capture after unmount during the permission prompt', async () => { + Object.assign(Platform, { OS: 'android', Version: 33 }); + const permission = deferred>>(); + vi.mocked(PermissionsAndroid.request).mockReturnValue(permission.promise); + const publishStream = vi.fn().mockResolvedValue(undefined); + const unpublishStream = vi.fn().mockResolvedValue(undefined); + const { result, unmount } = renderHook(() => + useScreenShare({ publishStream, unpublishStream }) + ); + + let startPromise!: Promise; + act(() => { + startPromise = result.current.start(); + }); + await waitFor(() => expect(PermissionsAndroid.request).toHaveBeenCalledTimes(1)); + unmount(); + + permission.resolve(PermissionsAndroid.RESULTS.GRANTED); + await act(async () => { + await expect(startPromise).resolves.toBe(false); + }); + expect(mediaDevices.getDisplayMedia).not.toHaveBeenCalled(); }); it('only reports active after capture publishing succeeds', async () => { @@ -145,6 +369,7 @@ describe('useScreenShare', () => { act(() => { startPromise = result.current.start(); }); + await waitFor(() => expect(mediaDevices.getDisplayMedia).toHaveBeenCalledTimes(1)); await act(async () => { await result.current.stop(); }); diff --git a/apps/mobile/src/hooks/useScreenShare.ts b/apps/mobile/src/hooks/useScreenShare.ts index 2e9e8420..2e7ad35f 100644 --- a/apps/mobile/src/hooks/useScreenShare.ts +++ b/apps/mobile/src/hooks/useScreenShare.ts @@ -1,7 +1,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { AppState, type AppStateStatus } from 'react-native'; +import { AppState, PermissionsAndroid, Platform, type AppStateStatus } from 'react-native'; import { mediaDevices } from 'react-native-webrtc'; import type { MediaStream, MediaStreamTrack } from 'react-native-webrtc'; +import { + isAndroidNativePromptActive, + runAndroidNativePrompt, + subscribeAndroidNativePrompt, +} from '../lib/android-native-prompt'; export type ScreenShareState = 'idle' | 'requesting' | 'publishing' | 'active' | 'stopping'; @@ -33,6 +38,24 @@ function getStartError(error: unknown): string { return 'Unable to start screen sharing. Please check the capture permission and try again.'; } +async function requestScreenShareNotificationPermission(): Promise { + if (Platform.OS !== 'android') return true; + + const androidVersion = Platform.Version; + if (!Number.isFinite(androidVersion) || androidVersion < 33) return true; + + const prompt = await runAndroidNativePrompt(async () => { + try { + // Android still allows MediaProjection when this is denied; its service is + // then visible in Task Manager instead of the notification drawer. + await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS); + } catch (permissionError) { + console.warn('[ScreenShare] Could not request notification permission:', permissionError); + } + }); + return prompt.resumed; +} + /** Owns the native capture stream and serializes every screen-share transition. */ export function useScreenShare({ publishStream, @@ -49,6 +72,7 @@ export function useScreenShare({ const publicationRef = useRef | null>(null); const endedListenersRef = useRef void>>(new Map()); const appStateRef = useRef(AppState.currentState); + const deferredBackgroundRef = useRef(false); const transition = useCallback((nextState: ScreenShareState) => { stateRef.current = nextState; @@ -132,9 +156,15 @@ export function useScreenShare({ let stream: MediaStream | null = null; let publication: Promise | null = null; try { - stream = await mediaDevices.getDisplayMedia(); + const notificationPromptResumed = await requestScreenShareNotificationPermission(); + if (!notificationPromptResumed || generationRef.current !== generation) { + return false; + } + + const capturePrompt = await runAndroidNativePrompt(() => mediaDevices.getDisplayMedia()); + stream = capturePrompt.value; - if (generationRef.current !== generation) { + if (!capturePrompt.resumed || generationRef.current !== generation) { stopTracks(stream); return false; } @@ -197,12 +227,26 @@ export function useScreenShare({ const previousState = appStateRef.current; appStateRef.current = nextState; if (nextState === 'background' && previousState !== 'background') { + if (isAndroidNativePromptActive()) { + deferredBackgroundRef.current = true; + return; + } + void stop(); + } else if (nextState === 'active') { + deferredBackgroundRef.current = false; + } + }); + const unsubscribePrompt = subscribeAndroidNativePrompt((active) => { + if (!active && deferredBackgroundRef.current && appStateRef.current === 'background') { + deferredBackgroundRef.current = false; void stop(); } }); return () => { subscription.remove(); + unsubscribePrompt(); + deferredBackgroundRef.current = false; }; }, [stop]); diff --git a/apps/mobile/src/hooks/useWebRTCHost.test.ts b/apps/mobile/src/hooks/useWebRTCHost.test.ts index 4421ba45..d804a0f5 100644 --- a/apps/mobile/src/hooks/useWebRTCHost.test.ts +++ b/apps/mobile/src/hooks/useWebRTCHost.test.ts @@ -1,11 +1,21 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; +import { Platform } from 'react-native'; import { useWebRTCHost } from './useWebRTCHost'; import { createEventSource } from '../lib/event-source'; import { getStoredAuth } from '../lib/secure-storage'; import { mediaDevices } from 'react-native-webrtc'; import type { MediaStream, MediaStreamTrack } from 'react-native-webrtc'; import { emitAppStateChange } from '../test/setup'; +import { runAndroidNativePrompt } from '../lib/android-native-prompt'; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} vi.mock('../config', () => ({ API_BASE_URL: 'https://pairux.com', @@ -58,6 +68,10 @@ describe('useWebRTCHost', () => { } as Response); }); + afterEach(() => { + vi.useRealTimers(); + }); + it('should initialize with default state', () => { const { result } = renderHook(() => useWebRTCHost({ @@ -351,6 +365,75 @@ describe('useWebRTCHost', () => { expect(createEventSource).toHaveBeenCalledTimes(2); }); + it('keeps hosting through the transient background event from an Android prompt', async () => { + Object.assign(Platform, { OS: 'android', Version: 34 }); + let resolvePrompt!: () => void; + const promptAction = new Promise((resolve) => { + resolvePrompt = resolve; + }); + const { result } = renderHook(() => + useWebRTCHost({ + sessionId: 'session-1', + hostId: 'host-1', + }) + ); + + await act(async () => { + await result.current.startHosting(); + }); + const prompt = runAndroidNativePrompt(() => promptAction); + + act(() => { + emitAppStateChange('background'); + }); + expect(mockClose).not.toHaveBeenCalled(); + + await act(async () => { + resolvePrompt(); + emitAppStateChange('active'); + await prompt; + }); + + expect(mockClose).not.toHaveBeenCalled(); + expect(createEventSource).toHaveBeenCalledTimes(1); + }); + + it('suspends hosting when Android stays backgrounded after a prompt settles', async () => { + vi.useFakeTimers(); + Object.assign(Platform, { OS: 'android', Version: 34 }); + const promptAction = deferred(); + const { result } = renderHook(() => + useWebRTCHost({ + sessionId: 'session-1', + hostId: 'host-1', + }) + ); + + await act(async () => { + await result.current.startHosting(); + }); + act(() => { + mockEventSources[0]?.listeners.get('connected')?.({ data: '{}' }); + }); + expect(result.current.isHosting).toBe(true); + const prompt = runAndroidNativePrompt(() => promptAction.promise); + + act(() => { + emitAppStateChange('background'); + }); + expect(mockClose).not.toHaveBeenCalled(); + + await act(async () => { + promptAction.resolve(undefined); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(1_500); + await expect(prompt).resolves.toMatchObject({ resumed: false }); + }); + + expect(mockClose).toHaveBeenCalledTimes(1); + expect(result.current.isHosting).toBe(false); + }); + it('accepts a viewer presence event during a transient inactive window', async () => { const { result } = renderHook(() => useWebRTCHost({ diff --git a/apps/mobile/src/hooks/useWebRTCHost.ts b/apps/mobile/src/hooks/useWebRTCHost.ts index ab0ea0c5..6b4d18fe 100644 --- a/apps/mobile/src/hooks/useWebRTCHost.ts +++ b/apps/mobile/src/hooks/useWebRTCHost.ts @@ -27,6 +27,10 @@ import { markTrackAsSpeech, } from '@pairux/shared-types'; import { API_BASE_URL } from '../config'; +import { + isAndroidNativePromptActive, + subscribeAndroidNativePrompt, +} from '../lib/android-native-prompt'; import { getStoredAuth, isAuthExpired } from '../lib/secure-storage'; import { createEventSource, type SSEConnection } from '../lib/event-source'; @@ -149,6 +153,7 @@ export function useWebRTCHost({ const generationRef = useRef(0); const appStateRef = useRef(AppState.currentState); const resumeOnActiveRef = useRef(false); + const deferredBackgroundRef = useRef(false); const micEnabledIntentRef = useRef(true); const localStreamRef = useRef(null); const hostMicStreamRef = useRef(null); @@ -1064,32 +1069,51 @@ export function useWebRTCHost({ mountedRef.current = true; appStateRef.current = AppState.currentState; + const suspendForBackground = () => { + const hadActiveHost = + isStartingRef.current || eventSourceRef.current !== null || viewersRef.current.size > 0; + resumeOnActiveRef.current = resumeOnActiveRef.current || hadActiveHost; + if (hadActiveHost) { + stopHosting(); + } + }; + const subscription = AppState.addEventListener('change', (nextState) => { const previousState = appStateRef.current; appStateRef.current = nextState; if (nextState === 'background') { if (previousState !== 'background') { - const hadActiveHost = - isStartingRef.current || eventSourceRef.current !== null || viewersRef.current.size > 0; - resumeOnActiveRef.current = resumeOnActiveRef.current || hadActiveHost; - if (hadActiveHost) { - stopHosting(); + if (isAndroidNativePromptActive()) { + deferredBackgroundRef.current = true; + } else { + suspendForBackground(); } } return; } - if (nextState === 'active' && previousState !== 'active' && resumeOnActiveRef.current) { - resumeOnActiveRef.current = false; - void startHostingRef.current?.(); + if (nextState === 'active') { + deferredBackgroundRef.current = false; + if (previousState !== 'active' && resumeOnActiveRef.current) { + resumeOnActiveRef.current = false; + void startHostingRef.current?.(); + } + } + }); + const unsubscribePrompt = subscribeAndroidNativePrompt((active) => { + if (!active && deferredBackgroundRef.current && appStateRef.current === 'background') { + deferredBackgroundRef.current = false; + suspendForBackground(); } }); return () => { subscription.remove(); + unsubscribePrompt(); mountedRef.current = false; resumeOnActiveRef.current = false; + deferredBackgroundRef.current = false; stopHosting(); }; }, [stopHosting]); diff --git a/apps/mobile/src/lib/android-native-prompt.test.ts b/apps/mobile/src/lib/android-native-prompt.test.ts new file mode 100644 index 00000000..8591ab3c --- /dev/null +++ b/apps/mobile/src/lib/android-native-prompt.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Platform } from 'react-native'; +import { emitAppStateChange } from '../test/setup'; +import { + isAndroidNativePromptActive, + runAndroidNativePrompt, + subscribeAndroidNativePrompt, +} from './android-native-prompt'; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe('runAndroidNativePrompt', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('keeps the prompt active until Android returns to the foreground', async () => { + Object.assign(Platform, { OS: 'android', Version: 34 }); + const action = deferred(); + const states: boolean[] = []; + const unsubscribe = subscribeAndroidNativePrompt((active) => states.push(active)); + + const resultPromise = runAndroidNativePrompt(() => action.promise); + expect(isAndroidNativePromptActive()).toBe(true); + + emitAppStateChange('background'); + action.resolve('allowed'); + await Promise.resolve(); + expect(isAndroidNativePromptActive()).toBe(true); + emitAppStateChange('active'); + + await expect(resultPromise).resolves.toEqual({ value: 'allowed', resumed: true }); + expect(isAndroidNativePromptActive()).toBe(false); + expect(states).toEqual([true, false]); + unsubscribe(); + }); + + it('reports a real background when Android does not resume after the prompt', async () => { + vi.useFakeTimers(); + Object.assign(Platform, { OS: 'android', Version: 34 }); + const action = deferred(); + + const resultPromise = runAndroidNativePrompt(() => action.promise); + emitAppStateChange('background'); + action.resolve('allowed'); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(1_500); + + await expect(resultPromise).resolves.toEqual({ value: 'allowed', resumed: false }); + expect(isAndroidNativePromptActive()).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/android-native-prompt.ts b/apps/mobile/src/lib/android-native-prompt.ts new file mode 100644 index 00000000..d239076e --- /dev/null +++ b/apps/mobile/src/lib/android-native-prompt.ts @@ -0,0 +1,100 @@ +import { AppState, Platform } from 'react-native'; + +const ANDROID_PROMPT_RESUME_TIMEOUT_MS = 1_500; + +type PromptStateListener = (active: boolean) => void; + +let activePromptCount = 0; +const promptStateListeners = new Set(); + +function notifyPromptState(): void { + const active = activePromptCount > 0; + for (const listener of promptStateListeners) { + listener(active); + } +} + +function beginPrompt(): () => void { + const wasInactive = activePromptCount === 0; + activePromptCount += 1; + if (wasInactive) notifyPromptState(); + + let ended = false; + return () => { + if (ended) return; + ended = true; + activePromptCount = Math.max(0, activePromptCount - 1); + if (activePromptCount === 0) notifyPromptState(); + }; +} + +function waitForAndroidForeground(): Promise { + if (AppState.currentState === 'active') return Promise.resolve(true); + + return new Promise((resolve) => { + let settled = false; + let subscription: { remove: () => void } | null = null; + let timeout: ReturnType | null = null; + + const finish = (resumed: boolean) => { + if (settled) return; + settled = true; + subscription?.remove(); + if (timeout) clearTimeout(timeout); + resolve(resumed); + }; + + subscription = AppState.addEventListener('change', (nextState) => { + if (nextState === 'active') finish(true); + }); + timeout = setTimeout(() => { + finish(false); + }, ANDROID_PROMPT_RESUME_TIMEOUT_MS); + + // Close the gap between the initial read and listener registration. + if (AppState.currentState === 'active') finish(true); + }); +} + +export function isAndroidNativePromptActive(): boolean { + return Platform.OS === 'android' && activePromptCount > 0; +} + +export function subscribeAndroidNativePrompt(listener: PromptStateListener): () => void { + promptStateListeners.add(listener); + return () => { + promptStateListeners.delete(listener); + }; +} + +/** + * Android permission and MediaProjection dialogs pause the host Activity and + * emit AppState "background". Keep that transient pause distinct from the user + * actually leaving the app, including the small gap between promise resolution + * and the subsequent onHostResume event. + */ +export async function runAndroidNativePrompt( + action: () => Promise +): Promise<{ value: T; resumed: boolean }> { + if (Platform.OS !== 'android') { + return { value: await action(), resumed: true }; + } + + const endPrompt = beginPrompt(); + let value!: T; + let actionError: unknown; + let failed = false; + + try { + value = await action(); + } catch (error) { + failed = true; + actionError = error; + } + + const resumed = await waitForAndroidForeground(); + endPrompt(); + + if (failed) throw actionError; + return { value, resumed }; +} diff --git a/apps/mobile/src/test/setup.ts b/apps/mobile/src/test/setup.ts index 6ade3268..a4beef40 100644 --- a/apps/mobile/src/test/setup.ts +++ b/apps/mobile/src/test/setup.ts @@ -10,6 +10,11 @@ const appStateMock = vi.hoisted(() => ({ listeners: new Set<(state: string) => void>(), })); +const platformMock = vi.hoisted(() => ({ + OS: 'ios', + Version: 17 as string | number, +})); + export function emitAppStateChange(state: 'active' | 'background' | 'inactive'): void { appStateMock.currentState = state; for (const listener of appStateMock.listeners) { @@ -21,6 +26,8 @@ export function emitAppStateChange(state: 'active' | 'background' | 'inactive'): const originalConsole = { ...console }; beforeEach(() => { appStateMock.currentState = 'active'; + platformMock.OS = 'ios'; + platformMock.Version = 17; mockPeerConnections.length = 0; vi.stubGlobal('console', { ...originalConsole, @@ -74,7 +81,18 @@ vi.mock('react-native', () => ({ Alert: { alert: vi.fn() }, ActivityIndicator: 'ActivityIndicator', KeyboardAvoidingView: 'KeyboardAvoidingView', - Platform: { OS: 'ios' }, + Platform: platformMock, + PermissionsAndroid: { + PERMISSIONS: { + POST_NOTIFICATIONS: 'android.permission.POST_NOTIFICATIONS', + }, + RESULTS: { + GRANTED: 'granted', + DENIED: 'denied', + NEVER_ASK_AGAIN: 'never_ask_again', + }, + request: vi.fn().mockResolvedValue('granted'), + }, AppState: { get currentState() { return appStateMock.currentState;