From a59e1974dea3e82ae7ca926c4be1a00272ff7374 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Tue, 21 Jul 2026 12:38:53 +0200 Subject: [PATCH 01/12] feat(android): reuse shared Bitkit pubkys --- __tests__/sharedPubky.test.ts | 71 +++++++++++++++++++ src/hooks/useSharedPubkyDiscovery.ts | 19 +++++ src/i18n/locales/en.json | 7 ++ src/i18n/locales/es.json | 7 ++ src/navigation/RootNavigator.tsx | 6 ++ src/navigation/types.ts | 2 + src/screens/HomeScreen.tsx | 3 + src/sheets/ReuseSharedPubkySheet.tsx | 100 +++++++++++++++++++++++++++ src/sheets/sheetNavigation.tsx | 1 + src/sheets/types.ts | 9 ++- src/utils/pubky.ts | 3 + src/utils/sharedPubky.ts | 65 +++++++++++++++++ 12 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 __tests__/sharedPubky.test.ts create mode 100644 src/hooks/useSharedPubkyDiscovery.ts create mode 100644 src/sheets/ReuseSharedPubkySheet.tsx create mode 100644 src/utils/sharedPubky.ts diff --git a/__tests__/sharedPubky.test.ts b/__tests__/sharedPubky.test.ts new file mode 100644 index 00000000..6883116e --- /dev/null +++ b/__tests__/sharedPubky.test.ts @@ -0,0 +1,71 @@ +import { NativeModules, Platform } from 'react-native'; +import { discoverSharedPubkys, mirrorSharedPubky, removeSharedPubky } from '../src/utils/sharedPubky'; +import { getPublicKeyFromSecretKey } from '@synonymdev/react-native-pubky'; + +jest.mock('@synonymdev/react-native-pubky', () => ({ + getPublicKeyFromSecretKey: jest.fn(), +})); + +const derive = getPublicKeyFromSecretKey as jest.MockedFunction; +const mirror = jest.fn(); +const remove = jest.fn(); +const discover = jest.fn(); + +beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(Platform, 'OS', { configurable: true, value: 'android' }); + NativeModules.SharedPubky = { mirror, remove, discover }; +}); + +test('mirrors only pubky and secret key and removes by pubky', async () => { + mirror.mockResolvedValue(undefined); + remove.mockResolvedValue(undefined); + + await mirrorSharedPubky('pubky-a', 'secret-a'); + await removeSharedPubky('pubky-a'); + + expect(mirror).toHaveBeenCalledWith('pubky-a', 'secret-a'); + expect(remove).toHaveBeenCalledWith('pubky-a'); +}); + +test('filters owned, malformed, duplicate, and mismatched discoveries', async () => { + discover.mockResolvedValue([ + { pubky: 'owned', secretKey: 'owned-secret' }, + { pubky: 'valid', secret_key: 'valid-secret', mnemonic: 'must be ignored' }, + { pubky: 'valid', secretKey: 'duplicate' }, + { pubky: 'mismatch', secretKey: 'wrong-secret' }, + { pubky: '', secretKey: 'missing-pubky' }, + ]); + derive.mockImplementation(async secretKey => { + const public_key = secretKey === 'valid-secret' ? 'valid' : 'different'; + return { isOk: () => true, value: { public_key } } as never; + }); + + await expect(discoverSharedPubkys(['owned'])).resolves.toEqual([ + { pubky: 'valid', secretKey: 'valid-secret' }, + ]); +}); + +test('fails closed when native sharing is unavailable or rejects', async () => { + delete NativeModules.SharedPubky; + await expect(discoverSharedPubkys([])).resolves.toEqual([]); + + NativeModules.SharedPubky = { mirror, remove, discover }; + discover.mockRejectedValue(new Error('unavailable')); + await expect(discoverSharedPubkys([])).resolves.toEqual([]); + + mirror.mockRejectedValue(new Error('store failed')); + remove.mockRejectedValue(new Error('store failed')); + await expect(mirrorSharedPubky('pubky', 'secret')).resolves.toBeUndefined(); + await expect(removeSharedPubky('pubky')).resolves.toBeUndefined(); +}); + +test('does nothing outside Android', async () => { + Object.defineProperty(Platform, 'OS', { configurable: true, value: 'ios' }); + await mirrorSharedPubky('pubky', 'secret'); + await removeSharedPubky('pubky'); + await expect(discoverSharedPubkys([])).resolves.toEqual([]); + expect(mirror).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + expect(discover).not.toHaveBeenCalled(); +}); diff --git a/src/hooks/useSharedPubkyDiscovery.ts b/src/hooks/useSharedPubkyDiscovery.ts new file mode 100644 index 00000000..ca861af5 --- /dev/null +++ b/src/hooks/useSharedPubkyDiscovery.ts @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; +import { getPubkyKeys } from '../store/selectors/pubkySelectors.ts'; +import { getStore } from '../utils/store-helpers.ts'; +import { discoverSharedPubkys } from '../utils/sharedPubky.ts'; +import { showSheet } from '../sheets/sheetNavigation.tsx'; + +let checkedThisSession = false; + +export const useSharedPubkyDiscovery = (): void => { + useEffect(() => { + if (checkedThisSession) return; + checkedThisSession = true; + + void (async () => { + const identities = await discoverSharedPubkys(getPubkyKeys(getStore())); + if (identities.length > 0) showSheet('reuse-shared-pubky', { identities }); + })(); + }, []); +}; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ce565f80..129a8d02 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -332,6 +332,13 @@ "partialSuccess": "Imported {{imported}} keys, {{failed}} failed", "allFailed": "All keys failed to import" }, + "reuseSharedPubky": { + "title": "Reuse your pubky", + "description": "A pubky from Bitkit is available on this device. Choose whether to add it to Pubky Ring.", + "source": "Saved in Bitkit", + "add": "Add", + "added": "Added" + }, "loading": { "modalTitle": "Configuring Pubky", "title": "Your keys,\nyour identity.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index a2370abb..53acdef4 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -333,6 +333,13 @@ "partialSuccess": "Importadas las claves {{imported}}, {{failed}} ha fallado", "allFailed": "Falló la importación de todas las claves" }, + "reuseSharedPubky": { + "title": "Reutiliza tu pubky", + "description": "Hay una pubky de Bitkit disponible en este dispositivo. Elige si quieres añadirla a Pubky Ring.", + "source": "Guardada en Bitkit", + "add": "Añadir", + "added": "Añadida" + }, "loading": { "modalTitle": "Configuración de Pubky", "title": "Sus llaves,\nsu identidad.", diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx index 02041f51..5d28cfca 100644 --- a/src/navigation/RootNavigator.tsx +++ b/src/navigation/RootNavigator.tsx @@ -29,6 +29,7 @@ import EditPubkySheet from '../sheets/EditPubkySheet.tsx'; import AddPubkySheet from '../sheets/AddPubkySheet.tsx'; import MigrateSheet from '../sheets/MigrateSheet.tsx'; import LegacySunsetSheet from '../sheets/LegacySunsetSheet.tsx'; +import ReuseSharedPubkySheet from '../sheets/ReuseSharedPubkySheet.tsx'; import { useDeepLinkHandler } from '../hooks/useDeepLinkHandler.ts'; const Stack = createNativeStackNavigator(); @@ -101,6 +102,11 @@ const RootNavigator = (): ReactElement => { + ); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 3fb96061..395315d4 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -7,6 +7,7 @@ import type { EditPubkySheetParams, LegacySunsetSheetParams, MigrateSheetParams, + ReuseSharedPubkySheetParams, } from '../sheets/types.ts'; export interface PubkyData extends Pubky { @@ -34,4 +35,5 @@ export type RootStackParamList = { AddPubkySheet: AddPubkySheetParams; MigrateSheet: MigrateSheetParams; LegacySunsetSheet: LegacySunsetSheetParams; + ReuseSharedPubkySheet: ReuseSharedPubkySheetParams; }; diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 27a5d473..88271baf 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -19,6 +19,7 @@ import { Plus } from '../icons/index.ts'; import LegacySunsetBanner from '../components/LegacySunsetBanner.tsx'; import { useReplacementRelease } from '../hooks/useReplacementRelease.ts'; import { showSheet } from '../sheets/sheetNavigation.tsx'; +import { useSharedPubkyDiscovery } from '../hooks/useSharedPubkyDiscovery.ts'; // Extract gradient props to constants to prevent unnecessary re-renders const FADE_GRADIENT_COLORS = ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 1)']; @@ -75,6 +76,8 @@ const HomeScreen = (): ReactElement => { const pubkysProcessing = useSelector((state: RootState) => state.pubky.processing, shallowEqual); const { replacementRelease } = useReplacementRelease(); + useSharedPubkyDiscovery(); + const handleDragEnd = useCallback( ({ data }: { data: { key: string; value: Pubky }[] }) => { if (!data) { diff --git a/src/sheets/ReuseSharedPubkySheet.tsx b/src/sheets/ReuseSharedPubkySheet.tsx new file mode 100644 index 00000000..e9798627 --- /dev/null +++ b/src/sheets/ReuseSharedPubkySheet.tsx @@ -0,0 +1,100 @@ +import React, { memo, ReactElement, useCallback, useState } from 'react'; +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; +import { useDispatch } from 'react-redux'; +import Sheet from '../components/Sheet.tsx'; +import Button from '../components/Button.tsx'; +import { Key } from '../icons/index.ts'; +import type { RootStackParamList } from '../navigation/types.ts'; +import { BodyMText, BodyMSBText, CaptionText } from '../theme/typography.ts'; +import { showToast } from '../utils/helpers.ts'; +import { importPubky, truncateStr } from '../utils/pubky.ts'; +import type { SharedPubkyIdentity } from '../utils/sharedPubky.ts'; +import { hideSheet } from './sheetNavigation.tsx'; + +const ReuseSharedPubkySheet = ({ + route, +}: NativeStackScreenProps): ReactElement => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const { identities } = route.params; + const [importing, setImporting] = useState(); + const [imported, setImported] = useState([]); + + const add = useCallback( + async (identity: SharedPubkyIdentity): Promise => { + setImporting(identity.pubky); + try { + // importPubky derives and validates the public key again before saving. + const result = await importPubky({ secretKey: identity.secretKey, dispatch }); + if (result.isErr()) { + showToast({ type: 'error', title: t('common.error'), description: result.error.message }); + return; + } + setImported(current => [...current, identity.pubky]); + } catch (error) { + showToast({ + type: 'error', + title: t('common.error'), + description: error instanceof Error ? error.message : String(error), + }); + } finally { + setImporting(undefined); + } + }, + [dispatch, t], + ); + + return ( + + {t('reuseSharedPubky.description')} + + {identities.map(identity => { + const wasImported = imported.includes(identity.pubky); + return ( + + + + + + {truncateStr(identity.pubky).replace(/^pk:/, '')} + {t('reuseSharedPubky.source')} + +