diff --git a/apps/mobile/app.config.js b/apps/mobile/app.config.js
index d92e71fd0..1f59e9b1b 100644
--- a/apps/mobile/app.config.js
+++ b/apps/mobile/app.config.js
@@ -84,6 +84,7 @@ module.exports = {
NSBluetoothPeripheralUsageDescription: '$(PRODUCT_NAME) will use Bluetooth to communicate with Ledger X.',
NSFaceIDUsageDescription: '$(PRODUCT_NAME) uses Face ID to secure access to your wallet.',
NSPhotoLibraryAddUsageDescription: '$(PRODUCT_NAME) will save QR codes to your photo library.',
+ NSPhotoLibraryUsageDescription: '$(PRODUCT_NAME) needs access to your photo library so you can pick a contact photo.',
NSLocationWhenInUseUsageDescription: '$(PRODUCT_NAME) needs access to your location for Bluetooth communication with Ledger devices.',
LSApplicationQueriesSchemes: ['itms-apps'],
UIRequiredDeviceCapabilities: ['arm64'],
@@ -237,6 +238,14 @@ module.exports = {
enableCodeScanner: true,
},
],
+ // Image picker for contact photos
+ [
+ 'expo-image-picker',
+ {
+ photosPermission: 'Pera needs access to your photo library so you can pick a contact photo.',
+ cameraPermission: 'Pera needs access to your camera.',
+ },
+ ],
// Note: The following packages are autolinked and don't require config plugins:
// - expo-sqlite
// - react-native-mmkv
diff --git a/apps/mobile/assets/icons/contacts.svg b/apps/mobile/assets/icons/contacts.svg
new file mode 100644
index 000000000..e4b8df0d6
--- /dev/null
+++ b/apps/mobile/assets/icons/contacts.svg
@@ -0,0 +1,10 @@
+
diff --git a/apps/mobile/assets/icons/moon.svg b/apps/mobile/assets/icons/moon.svg
index 155515cb2..e5ba9ae94 100644
--- a/apps/mobile/assets/icons/moon.svg
+++ b/apps/mobile/assets/icons/moon.svg
@@ -1,3 +1,3 @@
diff --git a/apps/mobile/assets/icons/pen-solid.svg b/apps/mobile/assets/icons/pen-solid.svg
new file mode 100644
index 000000000..ec2973fe1
--- /dev/null
+++ b/apps/mobile/assets/icons/pen-solid.svg
@@ -0,0 +1,10 @@
+
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 7b2453838..a525140ed 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -97,6 +97,7 @@
"expo-font": "~55.0.6",
"expo-haptics": "^55.0.14",
"expo-image": "^55.0.8",
+ "expo-image-picker": "55.0.18",
"expo-linear-gradient": "^55.0.13",
"expo-localization": "^55.0.13",
"expo-media-library": "^55.0.14",
diff --git a/apps/mobile/src/components/ConfirmActionBottomSheet/ConfirmActionBottomSheet.tsx b/apps/mobile/src/components/ConfirmActionBottomSheet/ConfirmActionBottomSheet.tsx
new file mode 100644
index 000000000..658d6dc4c
--- /dev/null
+++ b/apps/mobile/src/components/ConfirmActionBottomSheet/ConfirmActionBottomSheet.tsx
@@ -0,0 +1,95 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License
+ */
+
+import {
+ PWBottomSheet,
+ PWButton,
+ PWIcon,
+ PWText,
+ PWView,
+} from '@components/core'
+import { useStyles } from './styles'
+
+import type { IconName, PWButtonProps, PWIconVariant } from '@components/core'
+
+/**
+ * Generic confirmation bottom sheet. Callers supply labels, the
+ * confirm/cancel handlers, and — if they want one — an icon. No default
+ * icon is rendered so the component doesn't quietly assume a destructive
+ * context. Button variants default to primary/secondary; override per
+ * callsite when the design diverges.
+ */
+export type ConfirmActionBottomSheetProps = {
+ isVisible: boolean
+ onClose: () => void
+ onConfirm: () => void
+ icon?: IconName
+ iconVariant?: PWIconVariant
+ title: string
+ message: string
+ confirmLabel: string
+ cancelLabel: string
+ confirmVariant?: PWButtonProps['variant']
+ cancelVariant?: PWButtonProps['variant']
+ testID?: string
+}
+
+export const ConfirmActionBottomSheet = ({
+ isVisible,
+ onClose,
+ onConfirm,
+ icon,
+ iconVariant = 'primary',
+ title,
+ message,
+ confirmLabel,
+ cancelLabel,
+ confirmVariant = 'primary',
+ cancelVariant = 'secondary',
+ testID,
+}: ConfirmActionBottomSheetProps) => {
+ const styles = useStyles()
+
+ return (
+
+ {!!icon && (
+
+ )}
+ {title}
+ {message}
+
+
+
+
+
+ )
+}
diff --git a/apps/mobile/src/components/ConfirmActionBottomSheet/__tests__/ConfirmActionBottomSheet.spec.tsx b/apps/mobile/src/components/ConfirmActionBottomSheet/__tests__/ConfirmActionBottomSheet.spec.tsx
new file mode 100644
index 000000000..9b5c8eb55
--- /dev/null
+++ b/apps/mobile/src/components/ConfirmActionBottomSheet/__tests__/ConfirmActionBottomSheet.spec.tsx
@@ -0,0 +1,65 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License
+ */
+
+import { fireEvent, render, screen } from '@test-utils/render'
+import { describe, it, expect, vi } from 'vitest'
+import { ConfirmActionBottomSheet } from '../ConfirmActionBottomSheet'
+
+describe('ConfirmActionBottomSheet', () => {
+ const baseProps = {
+ isVisible: true,
+ title: 'Delete contact',
+ message: 'Are you sure you want to delete this contact?',
+ confirmLabel: 'Yes, delete contact',
+ cancelLabel: 'Keep it',
+ }
+
+ it('renders the title, message and actions when visible', () => {
+ render(
+ ,
+ )
+ expect(screen.getByText(baseProps.title)).toBeTruthy()
+ expect(screen.getByText(baseProps.message)).toBeTruthy()
+ expect(screen.getByText(baseProps.confirmLabel)).toBeTruthy()
+ expect(screen.getByText(baseProps.cancelLabel)).toBeTruthy()
+ })
+
+ it('calls onConfirm when the confirm button is pressed', () => {
+ const onConfirm = vi.fn()
+ render(
+ ,
+ )
+ fireEvent.click(screen.getByText(baseProps.confirmLabel))
+ expect(onConfirm).toHaveBeenCalledTimes(1)
+ })
+
+ it('calls onClose when the cancel button is pressed', () => {
+ const onClose = vi.fn()
+ render(
+ ,
+ )
+ fireEvent.click(screen.getByText(baseProps.cancelLabel))
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/apps/mobile/src/components/ConfirmActionBottomSheet/index.ts b/apps/mobile/src/components/ConfirmActionBottomSheet/index.ts
new file mode 100644
index 000000000..bfe55f52d
--- /dev/null
+++ b/apps/mobile/src/components/ConfirmActionBottomSheet/index.ts
@@ -0,0 +1,14 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License
+ */
+
+export { ConfirmActionBottomSheet } from './ConfirmActionBottomSheet'
+export type { ConfirmActionBottomSheetProps } from './ConfirmActionBottomSheet'
diff --git a/apps/mobile/src/components/ConfirmActionBottomSheet/styles.ts b/apps/mobile/src/components/ConfirmActionBottomSheet/styles.ts
new file mode 100644
index 000000000..8c6f0094b
--- /dev/null
+++ b/apps/mobile/src/components/ConfirmActionBottomSheet/styles.ts
@@ -0,0 +1,35 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License
+ */
+
+import { makeStyles } from '@rneui/themed'
+
+export const useStyles = makeStyles(theme => ({
+ container: {
+ paddingVertical: theme.spacing.xxl,
+ alignItems: 'center',
+ },
+ icon: {
+ marginBottom: theme.spacing.lg,
+ },
+ message: {
+ textAlign: 'center',
+ paddingHorizontal: theme.spacing.lg,
+ paddingVertical: theme.spacing.md,
+ color: theme.colors.textGray,
+ },
+ actions: {
+ width: '100%',
+ paddingHorizontal: theme.spacing.lg,
+ paddingTop: theme.spacing.md,
+ gap: theme.spacing.sm,
+ },
+}))
diff --git a/apps/mobile/src/components/PhotoPermissionDeniedSheet/PhotoPermissionDeniedSheet.tsx b/apps/mobile/src/components/PhotoPermissionDeniedSheet/PhotoPermissionDeniedSheet.tsx
new file mode 100644
index 000000000..85a7eccd0
--- /dev/null
+++ b/apps/mobile/src/components/PhotoPermissionDeniedSheet/PhotoPermissionDeniedSheet.tsx
@@ -0,0 +1,46 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License
+ */
+
+import { ConfirmActionBottomSheet } from '@components/ConfirmActionBottomSheet'
+import { useLanguage } from '@hooks/useLanguage'
+
+export type PhotoPermissionDeniedSheetProps = {
+ isVisible: boolean
+ onClose: () => void
+ onOpenSettings: () => void
+}
+
+/**
+ * Bottom sheet shown when the photo-library permission has been denied
+ * permanently (iOS / legacy Android). Guides the user to the system
+ * Settings app to grant access. Owns its own i18n copy so callers only
+ * have to wire visibility + handlers.
+ */
+export const PhotoPermissionDeniedSheet = ({
+ isVisible,
+ onClose,
+ onOpenSettings,
+}: PhotoPermissionDeniedSheetProps) => {
+ const { t } = useLanguage()
+
+ return (
+
+ )
+}
diff --git a/apps/mobile/src/components/PhotoPermissionDeniedSheet/__tests__/PhotoPermissionDeniedSheet.spec.tsx b/apps/mobile/src/components/PhotoPermissionDeniedSheet/__tests__/PhotoPermissionDeniedSheet.spec.tsx
new file mode 100644
index 000000000..4941e87e0
--- /dev/null
+++ b/apps/mobile/src/components/PhotoPermissionDeniedSheet/__tests__/PhotoPermissionDeniedSheet.spec.tsx
@@ -0,0 +1,57 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License
+ */
+
+import { fireEvent, render, screen } from '@test-utils/render'
+import { describe, it, expect, vi } from 'vitest'
+import { PhotoPermissionDeniedSheet } from '../PhotoPermissionDeniedSheet'
+
+describe('PhotoPermissionDeniedSheet', () => {
+ it('renders the permission copy when visible', () => {
+ render(
+ ,
+ )
+ expect(screen.getByText('image_picker.permission_title')).toBeTruthy()
+ expect(
+ screen.getByText('image_picker.open_settings.label'),
+ ).toBeTruthy()
+ })
+
+ it('invokes onOpenSettings when the confirm button is pressed', () => {
+ const onOpenSettings = vi.fn()
+ render(
+ ,
+ )
+ fireEvent.click(screen.getByText('image_picker.open_settings.label'))
+ expect(onOpenSettings).toHaveBeenCalledTimes(1)
+ })
+
+ it('invokes onClose when the cancel button is pressed', () => {
+ const onClose = vi.fn()
+ render(
+ ,
+ )
+ fireEvent.click(screen.getByText('common.cancel.label'))
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/apps/mobile/src/components/PhotoPermissionDeniedSheet/index.ts b/apps/mobile/src/components/PhotoPermissionDeniedSheet/index.ts
new file mode 100644
index 000000000..9b88689ce
--- /dev/null
+++ b/apps/mobile/src/components/PhotoPermissionDeniedSheet/index.ts
@@ -0,0 +1,14 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License
+ */
+
+export { PhotoPermissionDeniedSheet } from './PhotoPermissionDeniedSheet'
+export type { PhotoPermissionDeniedSheetProps } from './PhotoPermissionDeniedSheet'
diff --git a/apps/mobile/src/components/QRScannerView/QRScannerView.tsx b/apps/mobile/src/components/QRScannerView/QRScannerView.tsx
index 7f889ba89..1d3575d7f 100644
--- a/apps/mobile/src/components/QRScannerView/QRScannerView.tsx
+++ b/apps/mobile/src/components/QRScannerView/QRScannerView.tsx
@@ -33,11 +33,16 @@ export const QRScannerView = (props: QRScannerViewProps) => {
const styles = useStyles(insets)
const { t } = useLanguage()
- const { device, codeScanner, scanningEnabled, permissionDenied } =
- useQRScannerView({
- isVisible: props.isVisible,
- onSuccess: props.onSuccess,
- })
+ const {
+ device,
+ codeScanner,
+ scanningEnabled,
+ permissionDenied,
+ hasPermission,
+ } = useQRScannerView({
+ isVisible: props.isVisible,
+ onSuccess: props.onSuccess,
+ })
return (
{
visible={props.isVisible}
animationType={props.animationType}
>
- {device == null || permissionDenied ? (
+ {device == null || permissionDenied || !hasPermission ? (
<>
{
bottom: 0,
left: 0,
right: 0,
+ paddingHorizontal: theme.spacing.xl,
backgroundColor: theme.colors.background,
},
}
diff --git a/apps/mobile/src/components/QRScannerView/useQRScannerView.ts b/apps/mobile/src/components/QRScannerView/useQRScannerView.ts
index c50f30e56..3e93247aa 100644
--- a/apps/mobile/src/components/QRScannerView/useQRScannerView.ts
+++ b/apps/mobile/src/components/QRScannerView/useQRScannerView.ts
@@ -49,23 +49,26 @@ export const useQRScannerView = ({
try {
const url = codes.at(0)?.value
setScanningEnabled(false)
- if (url) {
- if (isValidDeepLink(url)) {
- handleDeepLink(
- url,
- true,
- 'qr',
- () => setScanningEnabled(true),
- () => {
- logger.debug(
- 'QRScannerView: Deep link handled successfully',
- { url },
- )
- onSuccess(url, () => setScanningEnabled(true))
- },
- )
- }
+ if (!url) return
+ if (!isValidDeepLink(url)) {
+ // Unrecognized code — re-arm the scanner so the user
+ // can try again without closing the modal.
+ setScanningEnabled(true)
+ return
}
+ handleDeepLink(
+ url,
+ true,
+ 'qr',
+ () => setScanningEnabled(true),
+ () => {
+ logger.debug(
+ 'QRScannerView: Deep link handled successfully',
+ { url },
+ )
+ onSuccess(url, () => setScanningEnabled(true))
+ },
+ )
} catch (error) {
logger.error('QRScannerView: QR scanner error:', { error })
}
@@ -73,14 +76,14 @@ export const useQRScannerView = ({
})
useEffect(() => {
- if (!hasPermission && isVisible) {
- requestPermission().then(result => {
- if (!result) {
- setPermissionDenied(true)
- } else {
- setPermissionDenied(false)
- }
- })
+ if (hasPermission || !isVisible) return
+ let active = true
+ requestPermission().then(result => {
+ if (!active) return
+ setPermissionDenied(!result)
+ })
+ return () => {
+ active = false
}
}, [isVisible, hasPermission, requestPermission])
diff --git a/apps/mobile/src/components/core/PWIcon/constants.ts b/apps/mobile/src/components/core/PWIcon/constants.ts
index 2e538a5da..596e4a2b9 100644
--- a/apps/mobile/src/components/core/PWIcon/constants.ts
+++ b/apps/mobile/src/components/core/PWIcon/constants.ts
@@ -28,6 +28,7 @@ import ChevronDownIcon from '@assets/icons/chevron-down.svg'
import ChevronLeftIcon from '@assets/icons/chevron-left.svg'
import ChevronRightIcon from '@assets/icons/chevron-right.svg'
import CodeIcon from '@assets/icons/code.svg'
+import ContactsIcon from '@assets/icons/contacts.svg'
import CopyIcon from '@assets/icons/copy.svg'
import CrossIcon from '@assets/icons/cross.svg'
import Cube3dIcon from '@assets/icons/cube-3d.svg'
@@ -66,6 +67,7 @@ import PersonKeyIcon from '@assets/icons/person-key.svg'
import PersonMenuIcon from '@assets/icons/person-menu.svg'
import PersonIcon from '@assets/icons/person.svg'
import PauseIcon from '@assets/icons/pause.svg'
+import PenSolidIcon from '@assets/icons/pen-solid.svg'
import PlayIcon from '@assets/icons/play.svg'
import PlusWithBorderIcon from '@assets/icons/plus-with-border.svg'
import PlusIcon from '@assets/icons/plus.svg'
@@ -158,6 +160,7 @@ export const ICON_LIBRARY = {
'chevron-down': ChevronDownIcon,
'chevron-left': ChevronLeftIcon,
'chevron-right': ChevronRightIcon,
+ contacts: ContactsIcon,
copy: CopyIcon,
code: CodeIcon,
cross: CrossIcon,
@@ -200,6 +203,7 @@ export const ICON_LIBRARY = {
person: PersonIcon,
'person-key': PersonKeyIcon,
pause: PauseIcon,
+ 'pen-solid': PenSolidIcon,
play: PlayIcon,
'plus-with-border': PlusWithBorderIcon,
plus: PlusIcon,
diff --git a/apps/mobile/src/constants/ui.ts b/apps/mobile/src/constants/ui.ts
index fe32c3201..7c839058b 100644
--- a/apps/mobile/src/constants/ui.ts
+++ b/apps/mobile/src/constants/ui.ts
@@ -46,6 +46,7 @@ export const SHORT_PROMPT_DISPLAY_DELAY = 300
export const LONG_PROMPT_DISPLAY_DELAY = 3000
export const LONG_ADDRESS_FORMAT = 20
+export const SHORT_ADDRESS_FORMAT = 9
export const SEARCH_DEBOUNCE_TIME = 400
export const SEARCH_DEBOUNCE_TIME_SHORT = 75
diff --git a/apps/mobile/src/hooks/__tests__/useImagePicker.spec.ts b/apps/mobile/src/hooks/__tests__/useImagePicker.spec.ts
new file mode 100644
index 000000000..0b49392e4
--- /dev/null
+++ b/apps/mobile/src/hooks/__tests__/useImagePicker.spec.ts
@@ -0,0 +1,139 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License
+ */
+
+import { renderHook, act } from '@testing-library/react'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import * as ImagePicker from 'expo-image-picker'
+import { Linking } from 'react-native'
+import { useImagePicker } from '../useImagePicker'
+
+vi.mock('expo-image-picker', () => ({
+ getMediaLibraryPermissionsAsync: vi.fn(),
+ requestMediaLibraryPermissionsAsync: vi.fn(),
+ launchImageLibraryAsync: vi.fn(),
+}))
+
+describe('useImagePicker', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('returns the picked URI when permission is granted and user selects', async () => {
+ vi.mocked(
+ ImagePicker.getMediaLibraryPermissionsAsync,
+ ).mockResolvedValue({
+ granted: true,
+ canAskAgain: true,
+ } as Awaited<
+ ReturnType
+ >)
+ vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({
+ canceled: false,
+ assets: [{ uri: 'file:///tmp/photo.jpg' }],
+ } as Awaited>)
+
+ const { result } = renderHook(() => useImagePicker())
+ const uri = await result.current.pickFromGallery()
+
+ expect(uri).toBe('file:///tmp/photo.jpg')
+ })
+
+ it('returns null when user cancels the picker', async () => {
+ vi.mocked(
+ ImagePicker.getMediaLibraryPermissionsAsync,
+ ).mockResolvedValue({
+ granted: true,
+ canAskAgain: true,
+ } as Awaited<
+ ReturnType
+ >)
+ vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({
+ canceled: true,
+ } as Awaited>)
+
+ const { result } = renderHook(() => useImagePicker())
+ const uri = await result.current.pickFromGallery()
+
+ expect(uri).toBeNull()
+ })
+
+ it('exposes the permission-denied state when permission is not reprompt-able', async () => {
+ vi.mocked(
+ ImagePicker.getMediaLibraryPermissionsAsync,
+ ).mockResolvedValue({
+ granted: false,
+ canAskAgain: false,
+ } as Awaited<
+ ReturnType
+ >)
+
+ const { result } = renderHook(() => useImagePicker())
+ expect(result.current.permissionDenied.isVisible).toBe(false)
+
+ let uri: string | null = null
+ await act(async () => {
+ uri = await result.current.pickFromGallery()
+ })
+
+ expect(uri).toBeNull()
+ expect(ImagePicker.launchImageLibraryAsync).not.toHaveBeenCalled()
+ expect(result.current.permissionDenied.isVisible).toBe(true)
+ })
+
+ it('hides the permission-denied state when close is called', async () => {
+ vi.mocked(
+ ImagePicker.getMediaLibraryPermissionsAsync,
+ ).mockResolvedValue({
+ granted: false,
+ canAskAgain: false,
+ } as Awaited<
+ ReturnType
+ >)
+
+ const { result } = renderHook(() => useImagePicker())
+ await act(async () => {
+ await result.current.pickFromGallery()
+ })
+ expect(result.current.permissionDenied.isVisible).toBe(true)
+
+ act(() => {
+ result.current.permissionDenied.close()
+ })
+ expect(result.current.permissionDenied.isVisible).toBe(false)
+ })
+
+ it('invokes Linking.openSettings and hides the sheet when openSettings is called', async () => {
+ vi.mocked(
+ ImagePicker.getMediaLibraryPermissionsAsync,
+ ).mockResolvedValue({
+ granted: false,
+ canAskAgain: false,
+ } as Awaited<
+ ReturnType
+ >)
+ const openSettingsSpy = vi
+ .spyOn(Linking, 'openSettings')
+ .mockResolvedValue(undefined)
+
+ const { result } = renderHook(() => useImagePicker())
+ await act(async () => {
+ await result.current.pickFromGallery()
+ })
+
+ act(() => {
+ result.current.permissionDenied.openSettings()
+ })
+
+ expect(openSettingsSpy).toHaveBeenCalledTimes(1)
+ expect(result.current.permissionDenied.isVisible).toBe(false)
+ })
+})
diff --git a/apps/mobile/src/hooks/useImagePicker.ts b/apps/mobile/src/hooks/useImagePicker.ts
new file mode 100644
index 000000000..53b60a17a
--- /dev/null
+++ b/apps/mobile/src/hooks/useImagePicker.ts
@@ -0,0 +1,93 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License
+ */
+
+import { useCallback, useMemo, useState } from 'react'
+import { Linking } from 'react-native'
+import * as ImagePicker from 'expo-image-picker'
+
+export type PermissionDeniedState = {
+ isVisible: boolean
+ close: () => void
+ openSettings: () => void
+}
+
+export type UseImagePickerResult = {
+ pickFromGallery: () => Promise
+ /**
+ * State + handlers for the "permission denied and not re-askable" bottom
+ * sheet. Pair with `` at the screen root.
+ */
+ permissionDenied: PermissionDeniedState
+}
+
+/**
+ * Returns a URI for an image the user picks from their photo library.
+ * Handles the iOS/Android permission prompt. When permission has been
+ * denied permanently, surfaces a `permissionDenied` state the caller
+ * renders via `PhotoPermissionDeniedSheet`.
+ */
+export const useImagePicker = (): UseImagePickerResult => {
+ const [permissionDeniedVisible, setPermissionDeniedVisible] =
+ useState(false)
+
+ const closePermissionDenied = useCallback(
+ () => setPermissionDeniedVisible(false),
+ [],
+ )
+ const openSettings = useCallback(() => {
+ setPermissionDeniedVisible(false)
+ Linking.openSettings()
+ }, [])
+
+ const ensurePermission = useCallback(async () => {
+ const current = await ImagePicker.getMediaLibraryPermissionsAsync()
+ if (current.granted) {
+ return true
+ }
+ if (!current.canAskAgain) {
+ setPermissionDeniedVisible(true)
+ return false
+ }
+ const next = await ImagePicker.requestMediaLibraryPermissionsAsync()
+ return next.granted
+ }, [])
+
+ const pickFromGallery = useCallback(async () => {
+ const granted = await ensurePermission()
+ if (!granted) {
+ return null
+ }
+ const result = await ImagePicker.launchImageLibraryAsync({
+ mediaTypes: ['images'],
+ allowsEditing: true,
+ aspect: [1, 1],
+ quality: 0.8,
+ // Strip EXIF — avatars should not leak GPS/camera metadata.
+ exif: false,
+ })
+ if (result.canceled) {
+ return null
+ }
+ return result.assets[0]?.uri ?? null
+ }, [ensurePermission])
+
+ const permissionDenied = useMemo(
+ () => ({
+ isVisible: permissionDeniedVisible,
+ close: closePermissionDenied,
+ openSettings,
+ }),
+ [permissionDeniedVisible, closePermissionDenied, openSettings],
+ )
+
+ return { pickFromGallery, permissionDenied }
+}
diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json
index fb64eb568..c35390647 100644
--- a/apps/mobile/src/i18n/locales/en.json
+++ b/apps/mobile/src/i18n/locales/en.json
@@ -606,6 +606,13 @@
"unsupported_url": "Unsupported URL: {{url}}"
}
},
+ "image_picker": {
+ "permission_title": "Photo library access needed",
+ "permission_body": "Pera needs permission to access your photo library so you can pick a contact avatar. Open Settings to enable it.",
+ "open_settings": {
+ "label": "Open Settings"
+ }
+ },
"menu": {
"cards": "Cards",
"title": "Menu",
diff --git a/apps/mobile/vitest.setup.ts b/apps/mobile/vitest.setup.ts
index 25bd4bf9c..608dade5d 100644
--- a/apps/mobile/vitest.setup.ts
+++ b/apps/mobile/vitest.setup.ts
@@ -971,18 +971,21 @@ vi.mock('react-native', () => {
)
},
),
- TextInput: vi.fn().mockImplementation(({ testID, ...props }) =>
- require('react').createElement(
- 'input',
- {
- ...props,
- ...(testID
- ? { 'data-testid': testID, testid: testID }
- : {}),
- },
- props.children,
+ TextInput: vi
+ .fn()
+ .mockImplementation(({ testID, onChangeText, ...props }) =>
+ require('react').createElement(
+ 'input',
+ {
+ ...props,
+ onChange: (e: any) => onChangeText?.(e.target.value),
+ ...(testID
+ ? { 'data-testid': testID, testid: testID }
+ : {}),
+ },
+ props.children,
+ ),
),
- ),
Modal: vi.fn().mockImplementation((args: any) => {
const {
@@ -1046,6 +1049,7 @@ vi.mock('react-native', () => {
},
Linking: {
openURL: vi.fn(),
+ openSettings: vi.fn(),
canOpenURL: vi.fn(),
getInitialURL: vi.fn(),
addEventListener: vi.fn(),
diff --git a/packages/contacts/src/errors.ts b/packages/contacts/src/errors.ts
new file mode 100644
index 000000000..04a9b6c73
--- /dev/null
+++ b/packages/contacts/src/errors.ts
@@ -0,0 +1,26 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License
+ */
+
+/**
+ * Thrown by `saveContact` when a contact with the same address already
+ * exists (and is not the contact being updated). UI layers catch this and
+ * surface it as a form-level error.
+ */
+export class DuplicateAddressError extends Error {
+ readonly address: string
+
+ constructor(address: string) {
+ super(`A contact with address ${address} already exists`)
+ this.name = 'DuplicateAddressError'
+ this.address = address
+ }
+}
diff --git a/packages/contacts/src/index.ts b/packages/contacts/src/index.ts
index b7ab23be2..a9ff77f57 100644
--- a/packages/contacts/src/index.ts
+++ b/packages/contacts/src/index.ts
@@ -12,6 +12,7 @@
export const name = '@perawallet/wallet-core-contacts'
+export * from './errors'
export * from './models'
export * from './schema'
export * from './hooks'
diff --git a/packages/contacts/src/models/index.ts b/packages/contacts/src/models/index.ts
index f25860646..4735c3539 100644
--- a/packages/contacts/src/models/index.ts
+++ b/packages/contacts/src/models/index.ts
@@ -24,7 +24,6 @@ export type ContactsState = BaseStoreState & {
contacts: Contact[]
selectedContact: Nullable
setSelectedContact: (contact: Nullable) => void
- setContacts: (contacts: Contact[]) => void
- saveContact: (contact: Contact) => void
+ saveContact: (contact: Contact) => boolean
deleteContact: (contact: Contact) => boolean
}
diff --git a/packages/contacts/src/schema/index.ts b/packages/contacts/src/schema/index.ts
index c15e556b9..bc427429b 100644
--- a/packages/contacts/src/schema/index.ts
+++ b/packages/contacts/src/schema/index.ts
@@ -13,6 +13,7 @@
import { z } from 'zod'
export const contactSchema = z.object({
+ id: z.string().optional(),
name: z
.string('Please enter a valid name')
.min(1, { message: 'Please enter a valid name' }),
@@ -28,4 +29,5 @@ export const contactSchema = z.object({
message: 'Please enter a valid NF Domain name',
})
.optional(),
+ image: z.string().optional(),
})
diff --git a/packages/contacts/src/store/__tests__/store.test.ts b/packages/contacts/src/store/__tests__/store.test.ts
index d21979d92..4fe2ca509 100644
--- a/packages/contacts/src/store/__tests__/store.test.ts
+++ b/packages/contacts/src/store/__tests__/store.test.ts
@@ -13,6 +13,7 @@
import { describe, test, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import type { Contact } from '../../models'
+import { DuplicateAddressError } from '../../errors'
const registerStoreMock = vi.fn()
@@ -67,7 +68,57 @@ describe('ContactsStore', () => {
expect(result.current.contacts[0].id).toBeTruthy()
})
- test('saveContact returns false for duplicate id', async () => {
+ test('saveContact throws DuplicateAddressError when another contact uses that address', async () => {
+ const { useContactsStore } = await import('../index')
+ const { result } = renderHook(() => useContactsStore())
+
+ act(() => {
+ result.current.saveContact({
+ id: 'alice-id',
+ name: 'Alice',
+ address: 'SHARED_ADDRESS',
+ })
+ })
+
+ expect(() =>
+ result.current.saveContact({
+ id: 'bob-id',
+ name: 'Bob',
+ address: 'SHARED_ADDRESS',
+ }),
+ ).toThrow(DuplicateAddressError)
+ // Original contact unchanged.
+ expect(result.current.contacts).toHaveLength(1)
+ expect(result.current.contacts[0]?.name).toBe('Alice')
+ })
+
+ test('saveContact allows updating the same contact with its own address', async () => {
+ const { useContactsStore } = await import('../index')
+ const { result } = renderHook(() => useContactsStore())
+
+ act(() => {
+ result.current.saveContact({
+ id: 'alice-id',
+ name: 'Alice',
+ address: 'SHARED_ADDRESS',
+ })
+ })
+
+ act(() => {
+ // Same id, same address, new name — should succeed (it's the same
+ // row being updated, not a new duplicate).
+ result.current.saveContact({
+ id: 'alice-id',
+ name: 'Alice Updated',
+ address: 'SHARED_ADDRESS',
+ })
+ })
+
+ expect(result.current.contacts).toHaveLength(1)
+ expect(result.current.contacts[0]?.name).toBe('Alice Updated')
+ })
+
+ test('saveContact updates an existing contact by id', async () => {
const { useContactsStore } = await import('../index')
const { result } = renderHook(() => useContactsStore())
const contact: Contact = {
@@ -80,13 +131,17 @@ describe('ContactsStore', () => {
result.current.saveContact(contact)
})
- let added: boolean | undefined
+ let saved: boolean | undefined
act(() => {
- added = result.current.saveContact(contact)
+ saved = result.current.saveContact({
+ ...contact,
+ name: 'Alice Updated',
+ })
})
- expect(added).toBe(false)
+ expect(saved).toBe(true)
expect(result.current.contacts).toHaveLength(1)
+ expect(result.current.contacts[0]?.name).toBe('Alice Updated')
})
test('deleteContact removes an existing contact', async () => {
@@ -145,21 +200,6 @@ describe('ContactsStore', () => {
expect(result.current.selectedContact).toBeNull()
})
- test('setContacts replaces the contacts list', async () => {
- const { useContactsStore } = await import('../index')
- const { result } = renderHook(() => useContactsStore())
- const contacts: Contact[] = [
- { id: '1', name: 'Alice', address: 'A' },
- { id: '2', name: 'Bob', address: 'B' },
- ]
-
- act(() => {
- result.current.setContacts(contacts)
- })
-
- expect(result.current.contacts).toEqual(contacts)
- })
-
test('registerStore wires clearStorage and resetState', async () => {
await import('../index')
@@ -170,7 +210,7 @@ describe('ContactsStore', () => {
act(() => {
useContactsStore
.getState()
- .setContacts([{ id: '1', name: 'Alice', address: 'A' }])
+ .saveContact({ id: '1', name: 'Alice', address: 'A' })
})
expect(useContactsStore.getState().contacts).toHaveLength(1)
diff --git a/packages/contacts/src/store/store.ts b/packages/contacts/src/store/store.ts
index 84ed82d29..a521fd0be 100644
--- a/packages/contacts/src/store/store.ts
+++ b/packages/contacts/src/store/store.ts
@@ -13,6 +13,7 @@
import { create, type StoreApi, type UseBoundStore } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import type { Contact, ContactsState } from '../models'
+import { DuplicateAddressError } from '../errors'
import {
generateOrderedUniqueId,
registerStore,
@@ -36,28 +37,38 @@ export const useContactsStore: UseBoundStore<
...initialState,
setSelectedContact: (contact: Nullable) =>
set({ selectedContact: contact }),
- setContacts: (contacts: Contact[]) => set({ contacts }),
saveContact: (contact: Contact) => {
- const existing = get().contacts ?? []
const newContact = {
...contact,
id: contact.id ?? generateOrderedUniqueId(),
}
- if (!existing.find(r => r.id === newContact.id)) {
+ const existing = get().contacts ?? []
+ const duplicate = existing.find(
+ c =>
+ c.address === newContact.address &&
+ c.id !== newContact.id,
+ )
+ if (duplicate) {
+ throw new DuplicateAddressError(newContact.address)
+ }
+ const existingIndex = existing.findIndex(
+ c => c.id === newContact.id,
+ )
+ if (existingIndex >= 0) {
+ const updated = [...existing]
+ updated[existingIndex] = newContact
+ set({ contacts: updated })
+ } else {
set({ contacts: [...existing, newContact] })
- return true
}
- return false
+ return true
},
deleteContact: (contact: Contact) => {
const existing = get().contacts ?? []
const remaining = existing.filter(r => r.id !== contact.id)
-
- if (remaining.length != existing.length) {
- set({ contacts: remaining })
- }
-
- return remaining.length != existing.length
+ if (remaining.length === existing.length) return false
+ set({ contacts: remaining })
+ return true
},
resetState: () => set(initialState),
}),
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c609d29f0..0ceb84f68 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -405,6 +405,9 @@ importers:
expo-image:
specifier: ^55.0.8
version: 55.0.8(expo@55.0.15)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)
+ expo-image-picker:
+ specifier: 55.0.18
+ version: 55.0.18(expo@55.0.15)
expo-linear-gradient:
specifier: ^55.0.13
version: 55.0.13(expo@55.0.15)(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)
@@ -6800,6 +6803,16 @@ packages:
peerDependencies:
expo: '*'
+ expo-image-loader@55.0.0:
+ resolution: {integrity: sha512-NOjp56wDrfuA5aiNAybBIjqIn1IxKeGJ8CECWZncQ/GzjZfyTYAHTCyeApYkdKkMBLHINzI4BbTGSlbCa0fXXQ==}
+ peerDependencies:
+ expo: '*'
+
+ expo-image-picker@55.0.18:
+ resolution: {integrity: sha512-lGpPGRu+7mE8qN0ma2boRsCmfOGbdHZ2bXTpWVeWly0JCZdogGlTrYFnhTqgS8+lmiRb/UCOs7iTm2P5Rra6kw==}
+ peerDependencies:
+ expo: '*'
+
expo-image@55.0.8:
resolution: {integrity: sha512-fNdvdYVcGn3g1x6o5AXHKzk4xX8U6rg2W9vFdE1pQO80kWCNReh003ypqSrGy4dD+zA8FtZjrNF3oMDGnPpIGQ==}
peerDependencies:
@@ -14394,6 +14407,15 @@ snapshots:
dependencies:
expo: 55.0.15(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(react-dom@19.2.5(react@19.2.5))(react-native-webview@13.16.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@5.9.3)
+ expo-image-loader@55.0.0(expo@55.0.15):
+ dependencies:
+ expo: 55.0.15(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(react-dom@19.2.5(react@19.2.5))(react-native-webview@13.16.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@5.9.3)
+
+ expo-image-picker@55.0.18(expo@55.0.15):
+ dependencies:
+ expo: 55.0.15(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(react-dom@19.2.5(react@19.2.5))(react-native-webview@13.16.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@5.9.3)
+ expo-image-loader: 55.0.0(expo@55.0.15)
+
expo-image@55.0.8(expo@55.0.15)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5):
dependencies:
expo: 55.0.15(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(react-dom@19.2.5(react@19.2.5))(react-native-webview@13.16.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@5.9.3)