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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/mobile-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 16 additions & 7 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
permissions: [
'INTERNET',
'RECORD_AUDIO',
'POST_NOTIFICATIONS',
'FOREGROUND_SERVICE',
'FOREGROUND_SERVICE_MEDIA_PROJECTION',
],
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
},
"preview": {
"distribution": "internal",
"environment": "preview"
"environment": "preview",
"android": {
"buildType": "apk"
}
},
"production": {
"autoIncrement": true,
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
84 changes: 84 additions & 0 deletions apps/mobile/scripts/verify-android-screen-share.mjs
Original file line number Diff line number Diff line change
@@ -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(/<uses-permission(?=[\s>])[^>]*\/?>/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.');
Loading
Loading