SEL-444: Fix android cloud backup (#697)

* feat(android): migrate google backup

* update lock and google services config

* add bulk format command

* backup fixes

* working drive settings!!!!!!!!

* remove unneeded intent filter

* add tests

* coderabbit feedback

* coderabbit feedback

* abstract google method

* coderabbit feedback and fix test

* more coderabbit suggestions and tests fixes
This commit is contained in:
Justin Hernandez
2025-06-29 02:54:07 -07:00
committed by GitHub
parent 4e3f764f09
commit 5435190199
11 changed files with 712 additions and 175 deletions

View File

@@ -88,6 +88,7 @@ android {
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 78
versionName "2.5.5"
manifestPlaceholders = [appAuthRedirectScheme: 'com.proofofpassportapp']
externalNativeBuild {
cmake {
cppFlags += "-fexceptions -frtti -std=c++11"

View File

@@ -12,7 +12,23 @@
"package_name": "com.proofofpassportapp"
}
},
"oauth_client": [],
"oauth_client": [
{
"client_id": "148489200613-m7f49n6qav3ufcvai9j9k2dp4v0671qk.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.proofofpassportapp",
"certificate_hash": [
"88:5D:A6:EF:1D:DA:05:C7:F0:55:F1:F4:C4:16:4C:6E:93:4E:76:78",
"AB:FE:10:57:76:E5:F9:FC:4F:95:6B:59:FC:78:7A:4D:D6:47:DA:BC"
]
}
},
{
"client_id": "148489200613-grc1n6g4aspl2m5euk9neqagf4atnrs5.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyDbMZ52EpHA-5BbMgTQK3OMOVcarGJQ-es"

View File

@@ -31,26 +31,27 @@
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="redirect.self.xyz" />
</intent-filter>
<meta-data
android:name="android.nfc.action.TECH_DISCOVERED"
android:resource="@xml/nfc_tech_filter" />
</activity>
<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="${appAuthRedirectScheme}" android:host="oauth2redirect" />
</intent-filter>
</activity>
<service
android:name="com.google.firebase.messaging.FirebaseMessagingService"
android:exported="false">

View File

@@ -1,9 +1,9 @@
PODS:
- AppAuth (1.7.6):
- AppAuth/Core (= 1.7.6)
- AppAuth/ExternalUserAgent (= 1.7.6)
- AppAuth/Core (1.7.6)
- AppAuth/ExternalUserAgent (1.7.6):
- AppAuth (2.0.0):
- AppAuth/Core (= 2.0.0)
- AppAuth/ExternalUserAgent (= 2.0.0)
- AppAuth/Core (2.0.0)
- AppAuth/ExternalUserAgent (2.0.0):
- AppAuth/Core
- boost (1.84.0)
- DoubleConversion (1.1.6)
@@ -84,10 +84,6 @@ PODS:
- GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30911.0, >= 2.30908.0)
- PromisesObjC (< 3.0, >= 1.2)
- GoogleSignIn (7.1.0):
- AppAuth (< 2.0, >= 1.7.3)
- GTMAppAuth (< 5.0, >= 4.1.1)
- GTMSessionFetcher/Core (~> 3.3)
- GoogleUtilities (7.13.3):
- GoogleUtilities/AppDelegateSwizzler (= 7.13.3)
- GoogleUtilities/Environment (= 7.13.3)
@@ -132,10 +128,6 @@ PODS:
- GoogleUtilities/UserDefaults (7.13.3):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GTMAppAuth (4.1.1):
- AppAuth/Core (~> 1.7)
- GTMSessionFetcher/Core (< 4.0, >= 3.3)
- GTMSessionFetcher/Core (3.5.0)
- lottie-ios (4.5.0)
- lottie-react-native (7.2.2):
- DoubleConversion
@@ -1381,6 +1373,9 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-app-auth (8.0.3):
- AppAuth (>= 1.7.6)
- React-Core
- react-native-biometrics (3.0.1):
- React-Core
- react-native-cloud-storage (2.3.0):
@@ -1715,9 +1710,6 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- RNGoogleSignin (13.3.1):
- GoogleSignIn (~> 7.1)
- React-Core
- RNKeychain (10.0.0):
- DoubleConversion
- glog
@@ -1870,6 +1862,7 @@ DEPENDENCIES:
- React-logger (from `../../node_modules/react-native/ReactCommon/logger`)
- React-Mapbuffer (from `../../node_modules/react-native/ReactCommon`)
- React-microtasksnativemodule (from `../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
- react-native-app-auth (from `../../node_modules/react-native-app-auth`)
- react-native-biometrics (from `../../node_modules/react-native-biometrics`)
- react-native-cloud-storage (from `../../node_modules/react-native-cloud-storage`)
- react-native-get-random-values (from `../../node_modules/react-native-get-random-values`)
@@ -1909,7 +1902,6 @@ DEPENDENCIES:
- "RNFBApp (from `../../node_modules/@react-native-firebase/app`)"
- "RNFBMessaging (from `../../node_modules/@react-native-firebase/messaging`)"
- RNGestureHandler (from `../../node_modules/react-native-gesture-handler`)
- "RNGoogleSignin (from `../../node_modules/@react-native-google-signin/google-signin`)"
- RNKeychain (from `../../node_modules/react-native-keychain`)
- RNLocalize (from `../../node_modules/react-native-localize`)
- RNReactNativeHapticFeedback (from `../../node_modules/react-native-haptic-feedback`)
@@ -1935,10 +1927,7 @@ SPEC REPOS:
- FirebaseMessaging
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleSignIn
- GoogleUtilities
- GTMAppAuth
- GTMSessionFetcher
- lottie-ios
- Mixpanel-swift
- nanopb
@@ -2025,6 +2014,8 @@ EXTERNAL SOURCES:
:path: "../../node_modules/react-native/ReactCommon"
React-microtasksnativemodule:
:path: "../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
react-native-app-auth:
:path: "../../node_modules/react-native-app-auth"
react-native-biometrics:
:path: "../../node_modules/react-native-biometrics"
react-native-cloud-storage:
@@ -2103,8 +2094,6 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@react-native-firebase/messaging"
RNGestureHandler:
:path: "../../node_modules/react-native-gesture-handler"
RNGoogleSignin:
:path: "../../node_modules/@react-native-google-signin/google-signin"
RNKeychain:
:path: "../../node_modules/react-native-keychain"
RNLocalize:
@@ -2135,7 +2124,7 @@ CHECKOUT OPTIONS:
:git: https://github.com/vinodiOS/SwiftQRScanner
SPEC CHECKSUMS:
AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
boost: 4cb898d0bf20404aab1850c656dcea009429d6c1
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
FBLazyVector: 430e10366de01d1e3d57374500b1b150fe482e6d
@@ -2150,10 +2139,7 @@ SPEC CHECKSUMS:
glog: 69ef571f3de08433d766d614c73a9838a06bf7eb
GoogleAppMeasurement: f3abf08495ef2cba7829f15318c373b8d9226491
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleSignIn: d4281ab6cf21542b1cfaff85c191f230b399d2db
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
lottie-react-native: 3ffec00c889acded6057766c99adf8eaced7790c
Mixpanel-swift: e9bef28a9648faff384d5ba6f48ecc2787eb24c0
@@ -2192,6 +2178,7 @@ SPEC CHECKSUMS:
React-logger: 80d87daf2f98bf95ab668b79062c1e0c3f0c2f8a
React-Mapbuffer: b2642edd9be75d51ead8cda109c986665eae09cf
React-microtasksnativemodule: 7ebf131e1792a668004d2719a36da0ff8d19c43c
react-native-app-auth: eb42594042a25455119a8c57194b4fd25b9352f4
react-native-biometrics: 43ed5b828646a7862dbc7945556446be00798e7d
react-native-cloud-storage: 47e6649373f429d2f244c904347e9fc4d9ef4ca8
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
@@ -2231,7 +2218,6 @@ SPEC CHECKSUMS:
RNFBApp: 4097f75673f8b42a7cd1ba17e6ea85a94b45e4d1
RNFBMessaging: 92325b0d5619ac90ef023a23cfd16fd3b91d0a88
RNGestureHandler: 9c3877d98d4584891b69d16ebca855ac46507f4d
RNGoogleSignin: 576a84dd0407b912e7a0adf07492de9feb79e5d9
RNKeychain: 4990d9be2916c60f9ed4f8c484fcd7ced4828b86
RNLocalize: 15463c4d79c7da45230064b4adcf5e9bb984667e
RNReactNativeHapticFeedback: e19b9b2e2ecf5593de8c4ef1496e1e31ae227514

View File

@@ -199,35 +199,48 @@ jest.mock('@stablelib/utf8', () => ({
decode: jest.fn(),
}));
// Mock @react-native-google-signin/google-signin
jest.mock('@react-native-google-signin/google-signin', () => ({
GoogleSignin: {
configure: jest.fn(),
hasPlayServices: jest.fn().mockResolvedValue(true),
signIn: jest.fn().mockResolvedValue({
user: {
id: 'mock-user-id',
email: 'mock@example.com',
name: 'Mock User',
photo: 'mock-photo-url',
},
}),
signOut: jest.fn(),
revokeAccess: jest.fn(),
isSignedIn: jest.fn().mockResolvedValue(false),
getCurrentUser: jest.fn().mockResolvedValue(null),
getTokens: jest.fn().mockResolvedValue({
accessToken: 'mock-access-token',
idToken: 'mock-id-token',
}),
},
statusCodes: {
SIGN_IN_REQUIRED: 'SIGN_IN_REQUIRED',
IN_PROGRESS: 'IN_PROGRESS',
PLAY_SERVICES_NOT_AVAILABLE: 'PLAY_SERVICES_NOT_AVAILABLE',
},
// Mock react-native-app-auth
jest.mock('react-native-app-auth', () => ({
authorize: jest.fn().mockResolvedValue({ accessToken: 'mock-access-token' }),
}));
// Mock @robinbobin/react-native-google-drive-api-wrapper
jest.mock('@robinbobin/react-native-google-drive-api-wrapper', () => {
class MockUploader {
setData() {
return this;
}
setDataMimeType() {
return this;
}
setRequestBody() {
return this;
}
execute = jest.fn();
}
class MockFiles {
newMultipartUploader() {
return new MockUploader();
}
list = jest.fn().mockResolvedValue({ files: [] });
delete = jest.fn();
getText = jest.fn().mockResolvedValue('');
}
class GDrive {
accessToken = '';
files = new MockFiles();
}
return {
__esModule: true,
GDrive,
MIME_TYPES: { application: { json: 'application/json' } },
APP_DATA_FOLDER_ID: 'appDataFolder',
};
});
// Mock react-native-cloud-storage
jest.mock('react-native-cloud-storage', () => {
const mockCloudStorage = {

View File

@@ -33,17 +33,18 @@
"force-local-upload-test": "yarn force-local-upload-test:android && yarn force-local-upload-test:ios",
"force-local-upload-test:android": "yarn reinstall && FORCE_UPLOAD_LOCAL_DEV=true bundle exec fastlane android internal_test --verbose",
"force-local-upload-test:ios": "yarn reinstall && FORCE_UPLOAD_LOCAL_DEV=true bundle exec fastlane ios internal_test --verbose",
"format": "yarn nice",
"ia": "yarn install-app",
"install-app": "yarn install-app:setup && cd ios && bundle exec pod install && cd .. && yarn clean:xcode-env-local",
"install-app:deploy": "yarn install-app:setup && yarn clean:xcode-env-local",
"install-app:setup": "yarn install && yarn build:deps && cd ios && bundle install && cd ..",
"ios": "react-native run-ios",
"ios:fastlane-debug": "yarn reinstall && bundle exec fastlane --verbose ios internal_test",
"setup": "yarn clean:build && yarn install && yarn build:deps && cd ios && bundle install && bundle exec pod install --repo-update && cd .. && yarn clean:xcode-env-local",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"nice": "yarn lint:fix && yarn fmt:fix",
"reinstall": "yarn clean && yarn install && yarn install-app",
"setup": "yarn clean:build && yarn install && yarn build:deps && cd ios && bundle install && bundle exec pod install --repo-update && cd .. && yarn clean:xcode-env-local",
"start": "watchman watch-del-all && react-native start",
"sync-versions": "bundle exec fastlane ios sync_version && bundle exec fastlane android sync_version",
"tag:release": "node scripts/tag.js release",
@@ -62,9 +63,9 @@
"@react-native-community/netinfo": "^11.4.1",
"@react-native-firebase/app": "^19.0.1",
"@react-native-firebase/messaging": "^19.0.1",
"@react-native-google-signin/google-signin": "^13.1.0",
"@react-navigation/native": "^7.0.14",
"@react-navigation/native-stack": "^7.2.0",
"@robinbobin/react-native-google-drive-api-wrapper": "^2.2.3",
"@segment/analytics-react-native": "^2.21.0",
"@segment/sovran-react-native": "^1.1.3",
"@selfxyz/common": "workspace:^",
@@ -90,6 +91,7 @@
"poseidon-lite": "^0.2.0",
"react": "^18.3.1",
"react-native": "0.75.4",
"react-native-app-auth": "^8.0.3",
"react-native-biometrics": "^3.0.1",
"react-native-check-version": "^1.3.0",
"react-native-cloud-storage": "^2.2.2",

View File

@@ -1,39 +1,42 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import { GOOGLE_SIGNIN_WEB_CLIENT_ID } from '@env';
import { GOOGLE_SIGNIN_ANDROID_CLIENT_ID } from '@env';
import { GDrive } from '@robinbobin/react-native-google-drive-api-wrapper';
import {
GoogleSignin,
isErrorWithCode,
statusCodes,
} from '@react-native-google-signin/google-signin';
AuthConfiguration,
authorize,
AuthorizeResult,
} from 'react-native-app-auth';
GoogleSignin.configure({
const config: AuthConfiguration = {
// DEBUG: log config for Auth
// ensure this prints the correct values before calling authorize
clientId: GOOGLE_SIGNIN_ANDROID_CLIENT_ID,
redirectUrl: 'com.proofofpassportapp:/oauth2redirect',
scopes: ['https://www.googleapis.com/auth/drive.appdata'],
webClientId: GOOGLE_SIGNIN_WEB_CLIENT_ID,
offlineAccess: true,
});
serviceConfiguration: {
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenEndpoint: 'https://oauth2.googleapis.com/token',
},
additionalParameters: { access_type: 'offline', prompt: 'consent' as const },
};
export async function googleSignIn() {
export async function googleSignIn(): Promise<AuthorizeResult | null> {
try {
await GoogleSignin.hasPlayServices();
if ((await GoogleSignin.signInSilently()).type === 'success') {
return await GoogleSignin.getTokens();
}
if ((await GoogleSignin.signIn()).type === 'success') {
return await GoogleSignin.getTokens();
}
// user cancelled
return null;
return await authorize(config);
} catch (error) {
console.error(error);
if (isErrorWithCode(error)) {
switch (error.code) {
case statusCodes.IN_PROGRESS:
return null;
case statusCodes.PLAY_SERVICES_NOT_AVAILABLE:
throw new Error('GooglePlayServices not available');
}
}
throw error;
return null;
}
}
export async function createGDrive() {
const response = await googleSignIn();
if (!response) {
// user canceled
return null;
}
const gdrive = new GDrive();
gdrive.accessToken = response.accessToken;
return gdrive;
}

View File

@@ -1,24 +1,73 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import {
APP_DATA_FOLDER_ID,
MIME_TYPES,
} from '@robinbobin/react-native-google-drive-api-wrapper';
import { ethers } from 'ethers';
import { useMemo } from 'react';
import { Platform } from 'react-native';
import {
CloudStorage,
CloudStorageProvider,
CloudStorageScope,
} from 'react-native-cloud-storage';
import { CloudStorage, CloudStorageScope } from 'react-native-cloud-storage';
import { name } from '../../../package.json';
import { Mnemonic } from '../../types/mnemonic';
import { googleSignIn } from './google';
import { createGDrive } from './google';
const FOLDER = `/${name}`;
const ENCRYPTED_FILE_PATH = `/${FOLDER}/encrypted-private-key`;
CloudStorage.setProviderOptions({ scope: CloudStorageScope.AppData });
if (Platform.OS === 'ios') {
CloudStorage.setProviderOptions({ scope: CloudStorageScope.AppData });
}
const FILE_NAME = 'encrypted-private-key';
export const STORAGE_NAME = Platform.OS === 'ios' ? 'iCloud' : 'Google Drive';
/**
* Type guard function to validate that an object conforms to the Mnemonic interface
*/
function isMnemonic(obj: unknown): obj is Mnemonic {
if (!obj || typeof obj !== 'object') {
return false;
}
const candidate = obj as Record<string, unknown>;
return !!(
typeof candidate.phrase === 'string' &&
typeof candidate.password === 'string' &&
typeof candidate.entropy === 'string' &&
candidate.wordlist &&
typeof candidate.wordlist === 'object' &&
typeof (candidate.wordlist as Record<string, unknown>).locale === 'string'
);
}
/**
* Safely parses and validates a mnemonic string
*/
function parseMnemonic(mnemonicString: string): Mnemonic {
let parsed: unknown;
try {
parsed = JSON.parse(mnemonicString);
} catch (e) {
throw new Error('Invalid JSON format in mnemonic backup');
}
if (!isMnemonic(parsed)) {
throw new Error(
'Invalid mnemonic structure: missing required properties (phrase, password, wordlist, entropy)',
);
}
if (!parsed.phrase || !ethers.Mnemonic.isValidMnemonic(parsed.phrase)) {
throw new Error('Invalid mnemonic phrase: not a valid BIP39 mnemonic');
}
return parsed;
}
/**
* For some reason google drive api can be very ... brittle and abort randomly (network conditions)
* so retry a couple times for good measure.
@@ -59,68 +108,100 @@ export function useBackupMnemonic() {
);
}
async function addAccessTokenForGoogleDrive() {
if (CloudStorage.getProvider() === CloudStorageProvider.GoogleDrive) {
const response = await googleSignIn();
if (!response) {
// user canceled
return;
}
CloudStorage.setProviderOptions({
accessToken: response.accessToken,
});
}
}
async function upload(mnemonic: Mnemonic) {
export async function upload(mnemonic: Mnemonic) {
if (!mnemonic || !mnemonic.phrase) {
throw new Error(
'Mnemonic not set yet. Did the user see the recovery phrase?',
);
}
await addAccessTokenForGoogleDrive();
try {
await CloudStorage.mkdir(FOLDER);
} catch (e) {
console.error(e);
if (!(e as Error).message.includes('already')) {
throw e;
}
}
await withRetries(() =>
CloudStorage.writeFile(ENCRYPTED_FILE_PATH, JSON.stringify(mnemonic)),
);
}
async function download() {
await addAccessTokenForGoogleDrive();
if (await CloudStorage.exists(ENCRYPTED_FILE_PATH)) {
const mnemonicString = await withRetries(() =>
CloudStorage.readFile(ENCRYPTED_FILE_PATH),
);
if (Platform.OS === 'ios') {
try {
const mnemonic = JSON.parse(mnemonicString) as Mnemonic;
if (
!mnemonic.phrase ||
!ethers.Mnemonic.isValidMnemonic(mnemonic.phrase)
) {
throw new Error();
}
return mnemonic;
await CloudStorage.mkdir(FOLDER);
} catch (e) {
throw new Error(
`Malformed mnemonic, expected JSON structure, got ${mnemonicString}`,
);
console.error(e);
if (!(e as Error).message.includes('already')) {
throw e;
}
}
await withRetries(() =>
CloudStorage.writeFile(ENCRYPTED_FILE_PATH, JSON.stringify(mnemonic)),
);
} else {
const gdrive = await createGDrive();
if (!gdrive) {
throw new Error('User canceled Google sign-in');
}
await withRetries(() =>
gdrive.files
.newMultipartUploader()
.setData(JSON.stringify(mnemonic))
.setDataMimeType(MIME_TYPES.application.json)
.setRequestBody({ name: FILE_NAME, parents: [APP_DATA_FOLDER_ID] })
.execute(),
);
}
throw new Error(
'Couldnt find the encrypted backup, did you back it up previously?',
);
}
async function disableBackup() {
await addAccessTokenForGoogleDrive();
withRetries(() => CloudStorage.rmdir(FOLDER, { recursive: true }));
export async function download() {
if (Platform.OS === 'ios') {
if (await CloudStorage.exists(ENCRYPTED_FILE_PATH)) {
const mnemonicString = await withRetries(() =>
CloudStorage.readFile(ENCRYPTED_FILE_PATH),
);
try {
const mnemonic = parseMnemonic(mnemonicString);
return mnemonic;
} catch (e) {
throw new Error(
`Failed to parse mnemonic backup: ${(e as Error).message}`,
);
}
}
throw new Error(
'Couldnt find the encrypted backup, did you back it up previously?',
);
}
const gdrive = await createGDrive();
if (!gdrive) {
throw new Error('User canceled Google sign-in');
}
const { files } = await gdrive.files.list({
spaces: APP_DATA_FOLDER_ID,
q: `name = '${FILE_NAME}'`,
});
if (!files.length || !files[0].id) {
throw new Error(
'Couldnt find the encrypted backup, did you back it up previously?',
);
}
const mnemonicString = await withRetries(() =>
gdrive.files.getText(files[0].id as string),
);
try {
const mnemonic = parseMnemonic(mnemonicString);
return mnemonic;
} catch (e) {
throw new Error(`Failed to parse mnemonic backup: ${(e as Error).message}`);
}
}
export async function disableBackup() {
if (Platform.OS === 'ios') {
await withRetries(() => CloudStorage.rmdir(FOLDER, { recursive: true }));
return;
}
const gdrive = await createGDrive();
if (!gdrive) {
// User canceled Google sign-in; skip disabling backup gracefully.
return;
}
const { files } = await gdrive.files.list({
spaces: APP_DATA_FOLDER_ID,
q: `name = '${FILE_NAME}'`,
});
await Promise.all(
files.filter(f => f.id).map(f => gdrive.files.delete(f.id as string)),
);
}

View File

@@ -0,0 +1,402 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import { renderHook } from '@testing-library/react-native';
import { Platform } from 'react-native';
// Mock dependencies
jest.mock('react-native-cloud-storage', () => ({
CloudStorage: {
setProviderOptions: jest.fn(),
mkdir: jest.fn(),
writeFile: jest.fn(),
exists: jest.fn(),
readFile: jest.fn(),
rmdir: jest.fn(),
},
CloudStorageScope: {
AppData: 'AppData',
},
}));
jest.mock('@robinbobin/react-native-google-drive-api-wrapper', () => ({
GDrive: jest.fn(),
APP_DATA_FOLDER_ID: 'mock-app-data-folder',
MIME_TYPES: {
application: {
json: 'application/json',
},
},
}));
jest.mock('../../src/utils/cloudBackup/google', () => {
const originalModule = jest.requireActual(
'../../src/utils/cloudBackup/google',
);
return {
...originalModule,
createGDrive: jest.fn(),
};
});
jest.mock('ethers', () => ({
ethers: {
Mnemonic: {
isValidMnemonic: jest.fn(),
},
},
}));
// Import after mocks
import { GDrive } from '@robinbobin/react-native-google-drive-api-wrapper';
import { ethers } from 'ethers';
import { CloudStorage } from 'react-native-cloud-storage';
import { useBackupMnemonic } from '../../src/utils/cloudBackup';
import { createGDrive } from '../../src/utils/cloudBackup/google';
// Mock implementations
const mockGDriveInstance = {
accessToken: '',
files: {
newMultipartUploader: jest.fn().mockReturnValue({
setData: jest.fn().mockReturnThis(),
setDataMimeType: jest.fn().mockReturnThis(),
setRequestBody: jest.fn().mockReturnThis(),
execute: jest.fn(),
}),
list: jest.fn(),
getText: jest.fn(),
delete: jest.fn(),
},
};
const mockMnemonic = {
phrase:
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
password: '',
wordlist: { locale: 'en' },
entropy: '0x00000000000000000000000000000000',
};
describe('cloudBackup', () => {
let originalPlatform: any;
let consoleSpy: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
originalPlatform = Platform.OS;
// Suppress console.error during tests to avoid cluttering output
consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
(GDrive as jest.Mock).mockImplementation(() => mockGDriveInstance);
(ethers.Mnemonic.isValidMnemonic as jest.Mock).mockReturnValue(true);
});
afterEach(() => {
Platform.OS = originalPlatform;
consoleSpy.mockRestore();
});
describe('useBackupMnemonic hook', () => {
it('should return upload, download, and disableBackup functions', () => {
const { result } = renderHook(() => useBackupMnemonic());
expect(result.current).toHaveProperty('upload');
expect(result.current).toHaveProperty('download');
expect(result.current).toHaveProperty('disableBackup');
expect(typeof result.current.upload).toBe('function');
expect(typeof result.current.download).toBe('function');
expect(typeof result.current.disableBackup).toBe('function');
});
});
describe('upload function - iOS', () => {
beforeEach(() => {
Platform.OS = 'ios';
});
it('should upload mnemonic to iCloud successfully', async () => {
(CloudStorage.mkdir as jest.Mock).mockResolvedValue(undefined);
(CloudStorage.writeFile as jest.Mock).mockResolvedValue(undefined);
const { result } = renderHook(() => useBackupMnemonic());
await expect(
result.current.upload(mockMnemonic),
).resolves.toBeUndefined();
expect(CloudStorage.mkdir).toHaveBeenCalledWith('/@selfxyz/mobile-app');
expect(CloudStorage.writeFile).toHaveBeenCalledWith(
'//@selfxyz/mobile-app/encrypted-private-key',
JSON.stringify(mockMnemonic),
);
});
it('should handle folder already exists error gracefully', async () => {
const folderExistsError = new Error('folder already exists');
(CloudStorage.mkdir as jest.Mock).mockRejectedValue(folderExistsError);
(CloudStorage.writeFile as jest.Mock).mockResolvedValue(undefined);
const { result } = renderHook(() => useBackupMnemonic());
await expect(
result.current.upload(mockMnemonic),
).resolves.toBeUndefined();
expect(CloudStorage.writeFile).toHaveBeenCalledWith(
'//@selfxyz/mobile-app/encrypted-private-key',
JSON.stringify(mockMnemonic),
);
});
it('should throw error for empty mnemonic', async () => {
const { result } = renderHook(() => useBackupMnemonic());
await expect(
result.current.upload({
phrase: '',
password: '',
wordlist: { locale: 'en' },
entropy: '',
}),
).rejects.toThrow(
'Mnemonic not set yet. Did the user see the recovery phrase?',
);
});
it('should throw error for null mnemonic', async () => {
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.upload(null as any)).rejects.toThrow(
'Mnemonic not set yet. Did the user see the recovery phrase?',
);
});
it('should throw error when mkdir fails with non-existing folder error', async () => {
const permissionError = new Error('permission denied');
(CloudStorage.mkdir as jest.Mock).mockRejectedValue(permissionError);
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.upload(mockMnemonic)).rejects.toThrow(
'permission denied',
);
});
});
describe('upload function - Android', () => {
beforeEach(() => {
Platform.OS = 'android';
});
it('should upload mnemonic to Google Drive successfully', async () => {
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
mockGDriveInstance.files
.newMultipartUploader()
.execute.mockResolvedValue({});
const { result } = renderHook(() => useBackupMnemonic());
await expect(
result.current.upload(mockMnemonic),
).resolves.toBeUndefined();
expect(createGDrive).toHaveBeenCalled();
expect(
mockGDriveInstance.files.newMultipartUploader().setData,
).toHaveBeenCalledWith(JSON.stringify(mockMnemonic));
expect(
mockGDriveInstance.files.newMultipartUploader().execute,
).toHaveBeenCalled();
});
it('should throw error when user cancels Google sign-in', async () => {
(createGDrive as jest.Mock).mockResolvedValue(null);
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.upload(mockMnemonic)).rejects.toThrow(
'User canceled Google sign-in',
);
});
});
describe('download function - iOS', () => {
beforeEach(() => {
Platform.OS = 'ios';
});
it('should download and parse mnemonic from iCloud successfully', async () => {
(CloudStorage.exists as jest.Mock).mockResolvedValue(true);
(CloudStorage.readFile as jest.Mock).mockResolvedValue(
JSON.stringify(mockMnemonic),
);
const { result } = renderHook(() => useBackupMnemonic());
const downloaded = await result.current.download();
expect(CloudStorage.exists).toHaveBeenCalledWith(
'//@selfxyz/mobile-app/encrypted-private-key',
);
expect(CloudStorage.readFile).toHaveBeenCalledWith(
'//@selfxyz/mobile-app/encrypted-private-key',
);
expect(downloaded).toEqual(mockMnemonic);
expect(ethers.Mnemonic.isValidMnemonic).toHaveBeenCalledWith(
mockMnemonic.phrase,
);
});
it('should throw error when backup file does not exist', async () => {
(CloudStorage.exists as jest.Mock).mockResolvedValue(false);
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.download()).rejects.toThrow(
'Couldnt find the encrypted backup, did you back it up previously?',
);
});
it('should throw error for malformed mnemonic JSON', async () => {
(CloudStorage.exists as jest.Mock).mockResolvedValue(true);
(CloudStorage.readFile as jest.Mock).mockResolvedValue('invalid json');
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.download()).rejects.toThrow(
'Failed to parse mnemonic backup: Invalid JSON format in mnemonic backup',
);
});
it('should throw error for invalid mnemonic phrase', async () => {
const invalidMnemonic = { ...mockMnemonic, phrase: 'invalid phrase' };
(CloudStorage.exists as jest.Mock).mockResolvedValue(true);
(CloudStorage.readFile as jest.Mock).mockResolvedValue(
JSON.stringify(invalidMnemonic),
);
(ethers.Mnemonic.isValidMnemonic as jest.Mock).mockReturnValue(false);
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.download()).rejects.toThrow(
'Failed to parse mnemonic backup: Invalid mnemonic phrase: not a valid BIP39 mnemonic',
);
});
it('should throw error for missing mnemonic properties', async () => {
const incompleteMnemonic = { phrase: 'valid phrase', password: '' }; // missing wordlist and entropy
(CloudStorage.exists as jest.Mock).mockResolvedValue(true);
(CloudStorage.readFile as jest.Mock).mockResolvedValue(
JSON.stringify(incompleteMnemonic),
);
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.download()).rejects.toThrow(
'Failed to parse mnemonic backup: Invalid mnemonic structure: missing required properties (phrase, password, wordlist, entropy)',
);
});
});
describe('download function - Android', () => {
beforeEach(() => {
Platform.OS = 'android';
});
it('should download and parse mnemonic from Google Drive successfully', async () => {
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
mockGDriveInstance.files.list.mockResolvedValue({
files: [{ id: 'file-id', name: 'encrypted-private-key' }],
});
mockGDriveInstance.files.getText.mockResolvedValue(
JSON.stringify(mockMnemonic),
);
const { result } = renderHook(() => useBackupMnemonic());
const downloaded = await result.current.download();
expect(createGDrive).toHaveBeenCalled();
expect(mockGDriveInstance.files.list).toHaveBeenCalledWith({
spaces: 'mock-app-data-folder',
q: "name = 'encrypted-private-key'",
});
expect(mockGDriveInstance.files.getText).toHaveBeenCalledWith('file-id');
expect(downloaded).toEqual(mockMnemonic);
expect(ethers.Mnemonic.isValidMnemonic).toHaveBeenCalledWith(
mockMnemonic.phrase,
);
});
it('should throw error when user cancels Google sign-in', async () => {
(createGDrive as jest.Mock).mockResolvedValue(null);
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.download()).rejects.toThrow(
'User canceled Google sign-in',
);
});
it('should throw error when backup file does not exist', async () => {
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
mockGDriveInstance.files.list.mockResolvedValue({
files: [],
});
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.download()).rejects.toThrow(
'Couldnt find the encrypted backup, did you back it up previously?',
);
});
it('should throw error for malformed mnemonic JSON', async () => {
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
mockGDriveInstance.files.list.mockResolvedValue({
files: [{ id: 'file-id', name: 'encrypted-private-key' }],
});
mockGDriveInstance.files.getText.mockResolvedValue('invalid json');
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.download()).rejects.toThrow(
'Failed to parse mnemonic backup: Invalid JSON format in mnemonic backup',
);
});
it('should throw error for invalid mnemonic phrase', async () => {
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
mockGDriveInstance.files.list.mockResolvedValue({
files: [{ id: 'file-id', name: 'encrypted-private-key' }],
});
mockGDriveInstance.files.getText.mockResolvedValue(
JSON.stringify({ ...mockMnemonic, phrase: 'invalid phrase' }),
);
(ethers.Mnemonic.isValidMnemonic as jest.Mock).mockReturnValue(false);
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.download()).rejects.toThrow(
'Failed to parse mnemonic backup: Invalid mnemonic phrase: not a valid BIP39 mnemonic',
);
});
it('should throw error for missing mnemonic properties', async () => {
const incompleteMnemonic = { phrase: 'valid phrase', password: '' }; // missing wordlist and entropy
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
mockGDriveInstance.files.list.mockResolvedValue({
files: [{ id: 'file-id', name: 'encrypted-private-key' }],
});
mockGDriveInstance.files.getText.mockResolvedValue(
JSON.stringify(incompleteMnemonic),
);
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.download()).rejects.toThrow(
'Failed to parse mnemonic backup: Invalid mnemonic structure: missing required properties (phrase, password, wordlist, entropy)',
);
});
});
});

View File

@@ -10,6 +10,7 @@
],
"scripts": {
"build": "yarn workspaces foreach --topological-dev --parallel --exclude @selfxyz/contracts -i --all run build",
"format": "yarn workspaces foreach --parallel -i --all --exclude self-workspace-root run format",
"lint": "yarn workspaces foreach --parallel -i --all --exclude self-workspace-root run lint",
"types": "yarn workspaces foreach --topological-dev --parallel --exclude @selfxyz/contracts -i --all run types ",
"postinstall": "patch-package"

View File

@@ -3567,23 +3567,6 @@ __metadata:
languageName: node
linkType: hard
"@react-native-google-signin/google-signin@npm:^13.1.0":
version: 13.3.1
resolution: "@react-native-google-signin/google-signin@npm:13.3.1"
peerDependencies:
expo: ">=50.0.0"
react: "*"
react-dom: "*"
react-native: "*"
peerDependenciesMeta:
expo:
optional: true
react-dom:
optional: true
checksum: 10c0/39be6c3bb52be30a8b6c49376ea90c325c07f63c01938df9ba4b3d8966f69c7e1e8fe8ab000227939b899fce06efd3725f3fba56848cf9d25eb9507f8f994f8c
languageName: node
linkType: hard
"@react-native/assets-registry@npm:0.75.4":
version: 0.75.4
resolution: "@react-native/assets-registry@npm:0.75.4"
@@ -4019,6 +4002,27 @@ __metadata:
languageName: node
linkType: hard
"@robinbobin/mimetype-constants@npm:^2.0.1":
version: 2.0.1
resolution: "@robinbobin/mimetype-constants@npm:2.0.1"
dependencies:
radashi: "npm:^12.2.3"
checksum: 10c0/37aa993f596a32ba654baf99128752f86a616503a430d173d080701b041d5af24ea992f9d0429c26120c7f5b4d007e89f54735ce31850ae21dd2285646ab8ebc
languageName: node
linkType: hard
"@robinbobin/react-native-google-drive-api-wrapper@npm:^2.2.3":
version: 2.2.3
resolution: "@robinbobin/react-native-google-drive-api-wrapper@npm:2.2.3"
dependencies:
"@robinbobin/mimetype-constants": "npm:^2.0.1"
base64-js: "npm:^1.5.1"
radashi: "npm:^12.2.3"
utf8: "npm:^3.0.0"
checksum: 10c0/e6d26b3b72011ba2d4cc70d4a4f102ffefc1e52d4904b3ea3c9fbd97c2fc2d86d519270135cefb56e9f3de36e8a2c7471d276f63e09afe5666b4f10150b05a81
languageName: node
linkType: hard
"@rollup/rollup-android-arm-eabi@npm:4.44.0":
version: 4.44.0
resolution: "@rollup/rollup-android-arm-eabi@npm:4.44.0"
@@ -4466,7 +4470,6 @@ __metadata:
"@react-native-community/netinfo": "npm:^11.4.1"
"@react-native-firebase/app": "npm:^19.0.1"
"@react-native-firebase/messaging": "npm:^19.0.1"
"@react-native-google-signin/google-signin": "npm:^13.1.0"
"@react-native/babel-preset": "npm:0.75.4"
"@react-native/eslint-config": "npm:0.75.4"
"@react-native/gradle-plugin": "npm:^0.79.2"
@@ -4474,6 +4477,7 @@ __metadata:
"@react-native/typescript-config": "npm:0.75.4"
"@react-navigation/native": "npm:^7.0.14"
"@react-navigation/native-stack": "npm:^7.2.0"
"@robinbobin/react-native-google-drive-api-wrapper": "npm:^2.2.3"
"@segment/analytics-react-native": "npm:^2.21.0"
"@segment/sovran-react-native": "npm:^1.1.3"
"@selfxyz/common": "workspace:^"
@@ -4522,6 +4526,7 @@ __metadata:
prettier: "npm:^3.5.3"
react: "npm:^18.3.1"
react-native: "npm:0.75.4"
react-native-app-auth: "npm:^8.0.3"
react-native-biometrics: "npm:^3.0.1"
react-native-bundle-visualizer: "npm:^3.1.3"
react-native-check-version: "npm:^1.3.0"
@@ -15068,7 +15073,7 @@ __metadata:
languageName: node
linkType: hard
"invariant@npm:^2.2.4":
"invariant@npm:2.2.4, invariant@npm:^2.2.4":
version: 2.2.4
resolution: "invariant@npm:2.2.4"
dependencies:
@@ -19388,6 +19393,13 @@ __metadata:
languageName: node
linkType: hard
"radashi@npm:^12.2.3":
version: 12.6.0
resolution: "radashi@npm:12.6.0"
checksum: 10c0/50bc1906bf13e13997d95ce0af7a452734b0973e4b9bdb73837789cf78656c1d91fadba2e8bcfb22c48b9d068408e54f01d86735589b77b35c50b3b0cbc21de6
languageName: node
linkType: hard
"randombytes@npm:^2.1.0":
version: 2.1.0
resolution: "randombytes@npm:2.1.0"
@@ -19496,6 +19508,25 @@ __metadata:
languageName: node
linkType: hard
"react-native-app-auth@npm:^8.0.3":
version: 8.0.3
resolution: "react-native-app-auth@npm:8.0.3"
dependencies:
invariant: "npm:2.2.4"
react-native-base64: "npm:0.0.2"
peerDependencies:
react-native: ">=0.63.0"
checksum: 10c0/a3b7e5394c509ad05fb091484d5ff0a14bf6e09b0cfa14c5d81b1abcd92b3e7110f17dca08cb4337e93725e78eddab4611c95a67ab2c7b8bec36984d8ae99999
languageName: node
linkType: hard
"react-native-base64@npm:0.0.2":
version: 0.0.2
resolution: "react-native-base64@npm:0.0.2"
checksum: 10c0/655e5638c30bc0af1ac649ba053e849a268f12d9657124b1418e27019cbf0f4141e5a93086ee640b46e90e5bb1003d640f38d55504b3637fcd72b7894ac1a30c
languageName: node
linkType: hard
"react-native-biometrics@npm:^3.0.1":
version: 3.0.1
resolution: "react-native-biometrics@npm:3.0.1"
@@ -23007,7 +23038,7 @@ __metadata:
languageName: node
linkType: hard
"utf8@npm:3.0.0":
"utf8@npm:3.0.0, utf8@npm:^3.0.0":
version: 3.0.0
resolution: "utf8@npm:3.0.0"
checksum: 10c0/675d008bab65fc463ce718d5cae8fd4c063540f269e4f25afebce643098439d53e7164bb1f193e0c3852825c7e3e32fbd8641163d19a618dbb53f1f09acb0d5a