fix(mobile): use neutral camera permission copy (#1504)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-05-06 11:45:37 -07:00
committed by GitHub
co-authored by Orca
parent b5d2b288b6
commit bba744a8cb
4 changed files with 51 additions and 21 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.stably.orca.mobile",
"buildNumber": "10",
"buildNumber": "11",
"infoPlist": {
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.",
"NSAppTransportSecurity": {
+1 -1
View File
@@ -567,7 +567,7 @@ export default function HomeScreen() {
</Text>
<Pressable style={styles.primaryButton} onPress={() => router.push('/pair-scan')}>
<QrCode size={17} color={colors.bgBase} />
<Text style={styles.primaryButtonText}>Scan Pairing Code</Text>
<Text style={styles.primaryButtonText}>Pair Desktop</Text>
</Pressable>
</View>
+38 -7
View File
@@ -1,9 +1,9 @@
import { useState, useRef, useCallback } from 'react'
import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native'
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Linking } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { CameraView, useCameraPermissions } from 'expo-camera'
import { useRouter } from 'expo-router'
import { ChevronLeft, Clipboard as ClipboardIcon } from 'lucide-react-native'
import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native'
import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing'
import { connect } from '../src/transport/rpc-client'
import { saveHost, getNextHostName } from '../src/transport/host-store'
@@ -146,20 +146,46 @@ export default function PairScanScreen() {
}
if (!permission.granted) {
const canAskAgain = permission.canAskAgain !== false
return (
<View style={[styles.container, containerPadding]}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<View style={styles.centered}>
<Text style={styles.title}>Camera Permission</Text>
<Text style={styles.subtitle}>
Orca needs camera access to scan the pairing QR code from your desktop.
<Text style={styles.title}>
{canAskAgain ? 'Pair with desktop' : 'Camera Access Disabled'}
</Text>
<Pressable style={styles.primaryButton} onPress={requestPermission}>
<Text style={styles.primaryButtonText}>Grant Camera Access</Text>
<Text style={styles.subtitle}>
{canAskAgain
? 'Scan the QR code from Orca on your desktop, or paste the pairing code instead.'
: 'Enable camera access in Settings, or paste the pairing code instead.'}
</Text>
<Pressable
style={styles.primaryButton}
onPress={canAskAgain ? requestPermission : () => void Linking.openSettings()}
>
{canAskAgain && <QrCode size={16} color={colors.bgBase} />}
<Text style={styles.primaryButtonText}>
{canAskAgain ? 'Continue' : 'Open Settings'}
</Text>
</Pressable>
<Pressable
style={({ pressed }) => [styles.pasteButton, pressed && styles.pasteButtonPressed]}
onPress={() => setPasteVisible(true)}
>
<ClipboardIcon size={16} color={colors.textSecondary} />
<Text style={styles.pasteButtonText}>Paste code instead</Text>
</Pressable>
</View>
<TextInputModal
visible={pasteVisible}
title="Paste pairing code"
message="Copy the code shown under the QR on your computer."
placeholder="orca://pair#... or paste the code"
onSubmit={handlePasteSubmit}
onCancel={() => setPasteVisible(false)}
/>
</View>
)
}
@@ -360,6 +386,7 @@ const styles = StyleSheet.create({
marginBottom: spacing.sm
},
subtitle: {
maxWidth: 310,
fontSize: typography.bodySize,
color: colors.textSecondary,
textAlign: 'center',
@@ -379,6 +406,10 @@ const styles = StyleSheet.create({
lineHeight: 20
},
primaryButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
backgroundColor: colors.textPrimary,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm + 2,
@@ -7,13 +7,15 @@ import { hashOrcaHookScript } from './orca-hook-trust'
const hooksCheckMock = vi.fn()
const readIssueCommandMock = vi.fn()
;(globalThis as { window: unknown }).window = {
api: {
hooks: {
check: hooksCheckMock,
readIssueCommand: readIssueCommandMock
function installHooksApiMock(): void {
vi.stubGlobal('window', {
api: {
hooks: {
check: hooksCheckMock,
readIssueCommand: readIssueCommandMock
}
}
}
})
}
type PendingPrompt = {
@@ -48,6 +50,7 @@ describe('ensureHooksConfirmed', () => {
beforeEach(() => {
hooksCheckMock.mockReset()
readIssueCommandMock.mockReset()
installHooksApiMock()
__resetTrustPromptChainForTests()
})
@@ -171,17 +174,13 @@ describe('ensureHooksConfirmed', () => {
const first = ensureHooksConfirmed(state, 'repo-1', 'setup')
const second = ensureHooksConfirmed(state, 'repo-1', 'archive')
await flush()
expect(pending).toHaveLength(1)
await vi.waitFor(() => expect(pending).toHaveLength(1))
expect(pending[0].data.scriptKind).toBe('setup')
pending[0].resolve('skip')
await expect(first).resolves.toBe('skip')
await flush()
expect(pending).toHaveLength(2)
await vi.waitFor(() => expect(pending).toHaveLength(2))
expect(pending[1].data.scriptKind).toBe('archive')
pending[1].resolve('run')