Skip to content

Commit 188ccb8

Browse files
committed
support recovery
1 parent eba7c2c commit 188ccb8

11 files changed

Lines changed: 537 additions & 11 deletions

File tree

android/app/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@ android {
8686
applicationId "io.runonflux.sspkey"
8787
minSdkVersion rootProject.ext.minSdkVersion
8888
targetSdkVersion rootProject.ext.targetSdkVersion
89-
versionCode 53
90-
versionName "1.25.0"
89+
versionCode 54
90+
versionName "1.25.1"
9191
}
9292
signingConfigs {
9393
debug {

ios/SSPKey.xcodeproj/project.pbxproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@
312312
"$(inherited)",
313313
"@executable_path/Frameworks",
314314
);
315-
MARKETING_VERSION = 1.25.0;
315+
MARKETING_VERSION = 1.25.1;
316316
OTHER_LDFLAGS = (
317317
"$(inherited)",
318318
"-ObjC",
@@ -350,7 +350,7 @@
350350
"$(inherited)",
351351
"@executable_path/Frameworks",
352352
);
353-
MARKETING_VERSION = 1.25.0;
353+
MARKETING_VERSION = 1.25.1;
354354
ONLY_ACTIVE_ARCH = YES;
355355
OTHER_LDFLAGS = (
356356
"$(inherited)",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ssp-key",
3-
"version": "1.25.0",
3+
"version": "1.25.1",
44
"private": true,
55
"scripts": {
66
"android": "react-native run-android",

src/components/Authentication/Authentication.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ const Authentication = (props: {
8787
textForPrompt = t('home:auth_confirm_vault_signing');
8888
} else if (props.type === 'noncesync') {
8989
textForPrompt = t('home:auth_confirm_nonce_sync');
90+
} else if (props.type === 'recovery') {
91+
textForPrompt = t('home:auth_confirm_recovery');
9092
}
9193
console.log('Initiate Fingerprint');
9294
// if success continue, if fail, show error message and only allow password authentication
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import React, { useState } from 'react';
2+
import { View, Text, TouchableOpacity, ActivityIndicator } from 'react-native';
3+
import Icon from 'react-native-vector-icons/Feather';
4+
import { useTranslation } from 'react-i18next';
5+
import { useTheme } from '../../hooks';
6+
import Authentication from '../Authentication/Authentication';
7+
8+
/**
9+
* RecoveryRequest — approval UI for a wallet-issued randomParams recovery
10+
* request. Matches the existing request-component pattern:
11+
*
12+
* 1. Render approve / reject buttons with explanatory copy.
13+
* 2. On approve, open the shared `Authentication` modal (biometric first,
14+
* password fallback) with `type="recovery"` to set the prompt text.
15+
* 3. Only after Authentication reports success do we call `actionStatus(true)`
16+
* back to Home.tsx, which runs the actual sk_r derivation + transit
17+
* wrap + relay post.
18+
*
19+
* Reject flows straight through to `actionStatus(false)` (no auth needed).
20+
*/
21+
const RecoveryRequest = (props: {
22+
activityStatus: boolean;
23+
actionStatus: (status: boolean) => void;
24+
}) => {
25+
const { t } = useTranslation(['home', 'common']);
26+
const { Fonts, Gutters, Layout, Colors, Common } = useTheme();
27+
const [authenticationOpen, setAuthenticationOpen] = useState(false);
28+
29+
const approve = () => {
30+
console.log('Approve recovery');
31+
props.actionStatus(true);
32+
};
33+
const openAuthentication = () => {
34+
console.log('Open Authentication (recovery)');
35+
setAuthenticationOpen(true);
36+
};
37+
const reject = () => {
38+
console.log('Reject recovery');
39+
props.actionStatus(false);
40+
};
41+
42+
const handleAuthenticationOpen = (status: boolean) => {
43+
console.log('Recovery auth modal close, status:', status);
44+
setAuthenticationOpen(false);
45+
if (status === true) {
46+
approve();
47+
}
48+
};
49+
50+
return (
51+
<>
52+
<View
53+
style={[
54+
Layout.fill,
55+
Layout.relative,
56+
Layout.fullWidth,
57+
Layout.justifyContentCenter,
58+
Layout.alignItemsCenter,
59+
]}
60+
>
61+
<Icon name="refresh-cw" size={60} color={Colors.textGray400} />
62+
<Text
63+
style={[
64+
Fonts.textBold,
65+
Fonts.textCenter,
66+
Fonts.textRegular,
67+
Gutters.smallMargin,
68+
]}
69+
>
70+
{t('home:recovery_request')}
71+
</Text>
72+
<Text
73+
style={[
74+
Fonts.textSmall,
75+
Fonts.textCenter,
76+
Gutters.smallLMargin,
77+
Gutters.smallRMargin,
78+
]}
79+
>
80+
{t('home:ssp_recovery_request')}
81+
</Text>
82+
<Text
83+
style={[
84+
Fonts.textSmall,
85+
Fonts.textCenter,
86+
Gutters.smallLMargin,
87+
Gutters.smallRMargin,
88+
Gutters.tinyTMargin,
89+
]}
90+
>
91+
{t('home:ssp_recovery_request_warning')}
92+
</Text>
93+
</View>
94+
<View style={[Layout.justifyContentEnd]}>
95+
<TouchableOpacity
96+
style={[
97+
Common.button.rounded,
98+
Common.button.bluePrimary,
99+
Gutters.regularBMargin,
100+
Gutters.smallTMargin,
101+
]}
102+
disabled={authenticationOpen || props.activityStatus}
103+
onPressIn={() => openAuthentication()}
104+
>
105+
{(authenticationOpen || props.activityStatus) && (
106+
<ActivityIndicator
107+
size={'large'}
108+
style={[{ position: 'absolute' }]}
109+
/>
110+
)}
111+
<Text style={[Fonts.textRegular, Fonts.textWhite]}>
112+
{t('home:approve_request')}
113+
</Text>
114+
</TouchableOpacity>
115+
<TouchableOpacity
116+
disabled={authenticationOpen || props.activityStatus}
117+
onPressIn={() => reject()}
118+
>
119+
<Text
120+
style={[
121+
Fonts.textSmall,
122+
Fonts.textBluePrimary,
123+
Gutters.regularBMargin,
124+
Fonts.textCenter,
125+
]}
126+
>
127+
{t('home:reject')}
128+
</Text>
129+
</TouchableOpacity>
130+
</View>
131+
{authenticationOpen && (
132+
<Authentication
133+
actionStatus={handleAuthenticationOpen}
134+
type="recovery"
135+
biomatricsAllowed={true}
136+
/>
137+
)}
138+
</>
139+
);
140+
};
141+
142+
export default RecoveryRequest;

src/contexts/SocketContext.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
vaultSigningRequest,
1212
utxo,
1313
} from '../types';
14+
import type { RecoveryRequestPayload } from '../lib/recoveryHandler';
1415

1516
interface TxRequest {
1617
rawTx: string;
@@ -43,6 +44,8 @@ interface SocketContextType {
4344
clearKeyNonceSyncRequest?: () => void;
4445
fluxNodeStartRequest: Record<string, unknown> | null;
4546
clearFluxNodeStartRequest?: () => void;
47+
recoveryRequest: RecoveryRequestPayload | null;
48+
clearRecoveryRequest?: () => void;
4649
}
4750

4851
const defaultValue: SocketContextType = {
@@ -60,6 +63,7 @@ const defaultValue: SocketContextType = {
6063
vaultSigningRequest: null,
6164
keyNonceSyncRequest: false,
6265
fluxNodeStartRequest: null,
66+
recoveryRequest: null,
6367
};
6468

6569
export const SocketContext = createContext<SocketContextType>(defaultValue);
@@ -90,6 +94,8 @@ export const SocketProvider = ({ children }: { children: React.ReactNode }) => {
9094
string,
9195
unknown
9296
> | null>(null);
97+
const [recoveryRequest, setRecoveryRequest] =
98+
useState<RecoveryRequestPayload | null>(null);
9399

94100
/**
95101
* Emit an authenticated join event.
@@ -278,6 +284,30 @@ export const SocketProvider = ({ children }: { children: React.ReactNode }) => {
278284
setKeyNonceSyncRequest(true);
279285
});
280286

287+
// Handle RandomParams recovery request from the wallet. The wallet
288+
// issues this when its fingerprint drift causes L5 at Login and it
289+
// falls back to the ssp-key envelope recovery path.
290+
newSocket.on(
291+
'recoveryrequest',
292+
(data: { chain?: string; path?: string; payload: string }) => {
293+
console.log('[Socket] Recovery request received');
294+
try {
295+
const parsed = JSON.parse(data.payload) as RecoveryRequestPayload;
296+
if (
297+
typeof parsed.pkEph !== 'string' ||
298+
typeof parsed.nonce !== 'string' ||
299+
typeof parsed.timestamp !== 'number'
300+
) {
301+
console.error('[Socket] Malformed recovery request payload');
302+
return;
303+
}
304+
setRecoveryRequest(parsed);
305+
} catch {
306+
console.error('[Socket] Failed to parse recovery request payload');
307+
}
308+
},
309+
);
310+
281311
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: socket initialization must store the instance for context consumers
282312
setSocket(newSocket);
283313

@@ -350,6 +380,10 @@ export const SocketProvider = ({ children }: { children: React.ReactNode }) => {
350380
setKeyNonceSyncRequest(false);
351381
};
352382

383+
const clearRecoveryRequest = () => {
384+
setRecoveryRequest(null);
385+
};
386+
353387
return (
354388
<SocketContext.Provider
355389
value={{
@@ -370,6 +404,8 @@ export const SocketProvider = ({ children }: { children: React.ReactNode }) => {
370404
clearKeyNonceSyncRequest,
371405
fluxNodeStartRequest,
372406
clearFluxNodeStartRequest,
407+
recoveryRequest,
408+
clearRecoveryRequest,
373409
}}
374410
>
375411
{children}

src/lib/fcmHelper.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
AuthorizationStatus,
1212
} from '@react-native-firebase/messaging';
1313
import * as Keychain from 'react-native-keychain';
14-
import notifee from '@notifee/react-native';
14+
import notifee, { EventType } from '@notifee/react-native';
1515
import { AppState, Platform } from 'react-native';
1616

1717
export async function requestUserPermission() {
@@ -31,13 +31,45 @@ export async function requestUserPermission() {
3131
await notifee.requestPermission();
3232
}
3333

34+
/**
35+
* Register Notifee's background event handler.
36+
*
37+
* MUST be called from `index.js` (top level, before AppRegistry.registerComponent)
38+
* per Notifee's docs. Without this, tapping a Notifee-displayed notification
39+
* while the app is in the background does not resume the application —
40+
* Notifee drops the press event because no handler is attached to the
41+
* headless JS task that runs for background events.
42+
*
43+
* `pressAction: { id: 'default' }` on the displayed notification handles
44+
* bringing the app to foreground; this listener just has to exist so the
45+
* native side will actually dispatch the press.
46+
*/
47+
// eslint-disable-next-line @typescript-eslint/require-await
48+
notifee.onBackgroundEvent(async ({ type, detail }) => {
49+
if (type === EventType.PRESS) {
50+
// No-op: opening the app is the default action, handled by the native
51+
// side. When we add deep-link-to-screen logic later it goes here.
52+
console.log(
53+
'[fcm] background notification pressed:',
54+
detail.notification?.id,
55+
);
56+
}
57+
});
58+
3459
export function notificationListener() {
3560
const messaging = getMessaging();
3661

37-
// eslint-disable-next-line @typescript-eslint/require-await
38-
notifee.onBackgroundEvent(async ({ type, detail }) => {
39-
console.log('type ', type);
40-
console.log('detail ', detail);
62+
// Foreground tap handler — notifications displayed by Notifee while the
63+
// app is open don't route through `onNotificationOpenedApp` (that's FCM
64+
// auto-delivered only). Without this handler, tapping the in-app
65+
// notification does nothing.
66+
notifee.onForegroundEvent(({ type, detail }) => {
67+
if (type === EventType.PRESS) {
68+
console.log(
69+
'[fcm] foreground notification pressed:',
70+
detail.notification?.id,
71+
);
72+
}
4173
});
4274

4375
onNotificationOpenedApp(messaging, (remoteMessage) => {

0 commit comments

Comments
 (0)