diff --git a/.changeset/curly-dryers-explain.md b/.changeset/curly-dryers-explain.md new file mode 100644 index 000000000000..e07f3c7baf38 --- /dev/null +++ b/.changeset/curly-dryers-explain.md @@ -0,0 +1,5 @@ +--- +"live-mobile": patch +--- + +Add custom image tool diff --git a/.changeset/green-ties-sit.md b/.changeset/green-ties-sit.md new file mode 100644 index 000000000000..bbf6d91622a0 --- /dev/null +++ b/.changeset/green-ties-sit.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/live-common": patch +--- + +Add "customImage" feature flag. diff --git a/apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/BluetoothHelperModule.java b/apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/BluetoothHelperModule.java index e7603656301a..c1152c89ba35 100644 --- a/apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/BluetoothHelperModule.java +++ b/apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/BluetoothHelperModule.java @@ -1,5 +1,7 @@ package com.ledger.live; +import static com.ledger.live.Constants.REQUEST_ENABLE_BT; + import android.bluetooth.BluetoothAdapter; import android.content.Intent; import android.app.Activity; @@ -18,8 +20,6 @@ public class BluetoothHelperModule extends ReactContextBaseJavaModule { - private static final int REQUEST_ENABLE_BT = 0; - private static final String E_ACTIVITY_DOES_NOT_EXIST = "E_ACTIVITY_DOES_NOT_EXIST"; private static final String E_BLE_CANCELLED = "E_BLE_CANCELLED"; diff --git a/apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/Constants.java b/apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/Constants.java new file mode 100644 index 000000000000..73ebc3372d88 --- /dev/null +++ b/apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/Constants.java @@ -0,0 +1,6 @@ +package com.ledger.live; + +public class Constants { + public static final int REQUEST_ENABLE_BT = 0; + public static final int REQUEST_IMAGE = 1; +} diff --git a/apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/ImagePickerModule.java b/apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/ImagePickerModule.java new file mode 100644 index 000000000000..e577f3e02e74 --- /dev/null +++ b/apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/ImagePickerModule.java @@ -0,0 +1,90 @@ +package com.ledger.live; + +import android.app.Activity; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; + +import androidx.annotation.NonNull; + +import com.facebook.react.bridge.ActivityEventListener; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.WritableMap; + +import java.util.List; + +public class ImagePickerModule extends ReactContextBaseJavaModule implements ActivityEventListener{ + private static String REACT_CLASS = "ImagePickerModule"; + private static String E_NULL_RESULT = "E_NULL_RESULT"; + + private Promise pickerPromise; + + public ImagePickerModule(ReactApplicationContext context) { + super(context); + context.addActivityEventListener(this); + } + + @NonNull + @Override + public String getName() { + return REACT_CLASS; + } + + @Override + public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) { + try { + if (requestCode == Constants.REQUEST_IMAGE) { + if (resultCode == Activity.RESULT_CANCELED){ + WritableMap map = Arguments.createMap(); + map.putBoolean("cancelled", true); + pickerPromise.resolve(map); + } else if (data == null) { + pickerPromise.reject(E_NULL_RESULT); + } else if (resultCode == Activity.RESULT_OK) { + if (pickerPromise == null) throw new Error(E_NULL_RESULT); + WritableMap map = Arguments.createMap(); + map.putString("uri", data.getData().toString()); + pickerPromise.resolve(map); + } else { + pickerPromise.reject(String.valueOf(resultCode)); + } + pickerPromise = null; + } + } catch (Exception e) { + if (pickerPromise != null) pickerPromise.reject(e); + } + } + + @Override + public void onNewIntent(Intent intent) {} + + private String getDefaultPictureAppPackageName() { + Intent intent = new Intent(Intent.ACTION_PICK); + intent.setType("image/*"); + List pkgAppsList = getReactApplicationContext().getPackageManager().queryIntentActivities(intent, PackageManager.GET_RESOLVED_FILTER); + try { + return pkgAppsList.get(0).activityInfo.processName; + } catch (Exception e) { + return null; + } + } + + @ReactMethod + public void pickImage(Promise promise) { + pickerPromise = promise; + try { + String defaultPictureApp = getDefaultPictureAppPackageName(); + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("image/*"); + intent.addCategory(Intent.CATEGORY_OPENABLE); + if (defaultPictureApp != null) intent.setPackage(defaultPictureApp); + getCurrentActivity().startActivityForResult(intent, Constants.REQUEST_IMAGE); + } catch(Exception e) { + promise.reject(e); + } + } +} diff --git a/apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/MainApplication.java b/apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/MainApplication.java index d636d89dc87b..ce14c16eb968 100644 --- a/apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/MainApplication.java +++ b/apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/MainApplication.java @@ -54,7 +54,7 @@ public boolean getUseDeveloperSupport() { protected List getPackages() { @SuppressWarnings("UnnecessaryLocalVariable") List packages = new PackageList(this).getPackages(); - packages.add(new BluetoothHelperPackage()); + packages.add(new NativeModulesPackage()); packages.add(new ReactVideoPackage()); packages.add(new BackgroundRunnerPackager()); return packages; diff --git a/apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/BluetoothHelperPackage.java b/apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/NativeModulesPackage.java similarity index 85% rename from apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/BluetoothHelperPackage.java rename to apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/NativeModulesPackage.java index 2504ddc765ef..93841f72dca9 100644 --- a/apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/BluetoothHelperPackage.java +++ b/apps/ledger-live-mobile/android/app/src/main/java/com/ledger/live/NativeModulesPackage.java @@ -10,7 +10,7 @@ import java.util.Collections; import java.util.List; -public class BluetoothHelperPackage implements ReactPackage { +public class NativeModulesPackage implements ReactPackage { @Override public List createViewManagers(ReactApplicationContext reactContext) { @@ -22,6 +22,7 @@ public List createNativeModules( ReactApplicationContext reactContext) { List modules = new ArrayList<>(); modules.add(new BluetoothHelperModule(reactContext)); + modules.add(new ImagePickerModule(reactContext)); return modules; } diff --git a/apps/ledger-live-mobile/android/build.gradle b/apps/ledger-live-mobile/android/build.gradle index 573ccc6ac9ff..816330dc7705 100644 --- a/apps/ledger-live-mobile/android/build.gradle +++ b/apps/ledger-live-mobile/android/build.gradle @@ -6,7 +6,7 @@ buildscript { minSdkVersion = 24 compileSdkVersion = 30 targetSdkVersion = 30 - kotlinVersion = "1.4.10" + kotlinVersion = "1.6.0" androidXCore = "1.6.0" if (System.properties['os.arch'] == "aarch64") { // For M1 Users we need to use the NDK 24 which added support for aarch64 @@ -57,6 +57,11 @@ allprojects { maven { url "$rootDir/../node_modules/expo-camera/android/maven" } + jcenter() { + content { + includeModule("com.theartofdev.edmodo", "android-image-cropper") // for expo-image-picker + } + } } configurations.all { resolutionStrategy { diff --git a/apps/ledger-live-mobile/ios/Podfile.lock b/apps/ledger-live-mobile/ios/Podfile.lock index 191e8b4b42f9..55751f59b9c6 100644 --- a/apps/ledger-live-mobile/ios/Podfile.lock +++ b/apps/ledger-live-mobile/ios/Podfile.lock @@ -13,9 +13,16 @@ PODS: - ZXingObjC/PDF417 - EXCamera (12.0.3): - ExpoModulesCore + - EXFileSystem (13.1.4): + - ExpoModulesCore - EXImageLoader (3.1.1): - ExpoModulesCore - React-Core + - EXImageManipulator (10.2.1): + - EXImageLoader + - ExpoModulesCore + - EXImagePicker (12.0.2): + - ExpoModulesCore - Expo (43.0.5): - ExpoModulesCore - ExpoModulesCore (0.6.5): @@ -421,6 +428,9 @@ PODS: - react-native-flipper-performance-plugin/FBDefines (= 0.2.1) - react-native-flipper-performance-plugin/FBDefines (0.2.1): - React-Core + - react-native-image-crop-tools (1.6.2): + - React + - TOCropViewController (= 2.5.3) - react-native-netinfo (6.2.1): - React-Core - react-native-performance (2.1.0): @@ -516,6 +526,8 @@ PODS: - React-jsi (= 0.68.2) - React-logger (= 0.68.2) - React-perflogger (= 0.68.2) + - rn-fetch-blob (0.12.0): + - React-Core - RNAnalytics (1.5.3): - Analytics - React-Core @@ -603,6 +615,7 @@ PODS: - Storyly (~> 1.24.0) - TcpSockets (4.0.0): - React + - TOCropViewController (2.5.3) - Yoga (1.14.0) - YogaKit (1.18.1): - Yoga (~> 1.14) @@ -617,7 +630,10 @@ DEPENDENCIES: - "DoubleConversion (from `../../../node_modules/.pnpm/react-native@0.68.2_qiqqwmyv63yhnuboofexv3s7x4/node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)" - "EXBarCodeScanner (from `../../../node_modules/.pnpm/expo-barcode-scanner@11.2.1_expo@43.0.5/node_modules/expo-barcode-scanner/ios`)" - "EXCamera (from `../../../node_modules/.pnpm/expo-camera@12.0.3_react@17.0.2/node_modules/expo-camera/ios`)" + - "EXFileSystem (from `../../../node_modules/.pnpm/expo-file-system@13.1.4_expo@43.0.5/node_modules/expo-file-system/ios`)" - "EXImageLoader (from `../../../node_modules/.pnpm/expo-image-loader@3.1.1_expo@43.0.5/node_modules/expo-image-loader/ios`)" + - "EXImageManipulator (from `../../../node_modules/.pnpm/expo-image-manipulator@10.2.1_expo@43.0.5/node_modules/expo-image-manipulator/ios`)" + - "EXImagePicker (from `../../../node_modules/.pnpm/expo-image-picker@12.0.2_expo@43.0.5/node_modules/expo-image-picker/ios`)" - "Expo (from `../../../node_modules/.pnpm/expo@43.0.5_@babel+core@7.17.10/node_modules/expo/ios`)" - "ExpoModulesCore (from `../../../node_modules/.pnpm/expo-modules-core@0.6.5/node_modules/expo-modules-core/ios`)" - "FBLazyVector (from `../../../node_modules/.pnpm/react-native@0.68.2_qiqqwmyv63yhnuboofexv3s7x4/node_modules/react-native/Libraries/FBLazyVector`)" @@ -672,6 +688,7 @@ DEPENDENCIES: - "react-native-fast-crypto (from `../../../node_modules/.pnpm/react-native-fast-crypto@2.2.0_jdmv3zyvsaug2f6l23zgrmwdli_react-native@0.68.2/node_modules/react-native-fast-crypto`)" - "react-native-fingerprint-scanner (from `../../../node_modules/.pnpm/github.com+hieuvp+react-native-fingerprint-scanner@f1d136f605412d58e4de9e7e155d6f818ba24731_react-native@0.68.2/node_modules/react-native-fingerprint-scanner`)" - "react-native-flipper-performance-plugin (from `../../../node_modules/.pnpm/react-native-flipper-performance-plugin@0.2.1/node_modules/react-native-flipper-performance-plugin`)" + - "react-native-image-crop-tools (from `../../../node_modules/.pnpm/react-native-image-crop-tools@1.6.2_ykxjy5s7xujdxmsgrwxo5mh3y4_zqxy7fpkavjkgz5xll7ed4r6rq/node_modules/react-native-image-crop-tools`)" - "react-native-netinfo (from `../../../node_modules/.pnpm/@react-native-community+netinfo@6.2.1_react-native@0.68.2/node_modules/@react-native-community/netinfo`)" - "react-native-performance (from `../../../node_modules/.pnpm/react-native-performance@2.1.0_react-native@0.68.2/node_modules/react-native-performance/ios`)" - "react-native-randombytes (from `../../../node_modules/.pnpm/react-native-randombytes@3.6.1/node_modules/react-native-randombytes`)" @@ -694,6 +711,7 @@ DEPENDENCIES: - "React-RCTVibration (from `../../../node_modules/.pnpm/react-native@0.68.2_qiqqwmyv63yhnuboofexv3s7x4/node_modules/react-native/Libraries/Vibration`)" - "React-runtimeexecutor (from `../../../node_modules/.pnpm/react-native@0.68.2_qiqqwmyv63yhnuboofexv3s7x4/node_modules/react-native/ReactCommon/runtimeexecutor`)" - "ReactCommon/turbomodule/core (from `../../../node_modules/.pnpm/react-native@0.68.2_qiqqwmyv63yhnuboofexv3s7x4/node_modules/react-native/ReactCommon`)" + - "rn-fetch-blob (from `../../../node_modules/.pnpm/rn-fetch-blob@0.12.0/node_modules/rn-fetch-blob`)" - "RNAnalytics (from `../../../node_modules/.pnpm/@segment+analytics-react-native@1.5.3/node_modules/@segment/analytics-react-native`)" - "RNCAsyncStorage (from `../../../node_modules/.pnpm/@react-native-async-storage+async-storage@1.17.4_react-native@0.68.2/node_modules/@react-native-async-storage/async-storage`)" - "RNCClipboard (from `../../../node_modules/.pnpm/@react-native-community+clipboard@1.5.1_zqxy7fpkavjkgz5xll7ed4r6rq/node_modules/@react-native-community/clipboard`)" @@ -753,6 +771,7 @@ SPEC REPOS: - Sentry - SocketRocket - Storyly + - TOCropViewController - YogaKit - ZXingObjC @@ -765,8 +784,14 @@ EXTERNAL SOURCES: :path: "../../../node_modules/.pnpm/expo-barcode-scanner@11.2.1_expo@43.0.5/node_modules/expo-barcode-scanner/ios" EXCamera: :path: "../../../node_modules/.pnpm/expo-camera@12.0.3_react@17.0.2/node_modules/expo-camera/ios" + EXFileSystem: + :path: "../../../node_modules/.pnpm/expo-file-system@13.1.4_expo@43.0.5/node_modules/expo-file-system/ios" EXImageLoader: :path: "../../../node_modules/.pnpm/expo-image-loader@3.1.1_expo@43.0.5/node_modules/expo-image-loader/ios" + EXImageManipulator: + :path: "../../../node_modules/.pnpm/expo-image-manipulator@10.2.1_expo@43.0.5/node_modules/expo-image-manipulator/ios" + EXImagePicker: + :path: "../../../node_modules/.pnpm/expo-image-picker@12.0.2_expo@43.0.5/node_modules/expo-image-picker/ios" Expo: :path: "../../../node_modules/.pnpm/expo@43.0.5_@babel+core@7.17.10/node_modules/expo/ios" ExpoModulesCore: @@ -823,6 +848,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/.pnpm/github.com+hieuvp+react-native-fingerprint-scanner@f1d136f605412d58e4de9e7e155d6f818ba24731_react-native@0.68.2/node_modules/react-native-fingerprint-scanner" react-native-flipper-performance-plugin: :path: "../../../node_modules/.pnpm/react-native-flipper-performance-plugin@0.2.1/node_modules/react-native-flipper-performance-plugin" + react-native-image-crop-tools: + :path: "../../../node_modules/.pnpm/react-native-image-crop-tools@1.6.2_ykxjy5s7xujdxmsgrwxo5mh3y4_zqxy7fpkavjkgz5xll7ed4r6rq/node_modules/react-native-image-crop-tools" react-native-netinfo: :path: "../../../node_modules/.pnpm/@react-native-community+netinfo@6.2.1_react-native@0.68.2/node_modules/@react-native-community/netinfo" react-native-performance: @@ -867,6 +894,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/.pnpm/react-native@0.68.2_qiqqwmyv63yhnuboofexv3s7x4/node_modules/react-native/ReactCommon/runtimeexecutor" ReactCommon: :path: "../../../node_modules/.pnpm/react-native@0.68.2_qiqqwmyv63yhnuboofexv3s7x4/node_modules/react-native/ReactCommon" + rn-fetch-blob: + :path: "../../../node_modules/.pnpm/rn-fetch-blob@0.12.0/node_modules/rn-fetch-blob" RNAnalytics: :path: "../../../node_modules/.pnpm/@segment+analytics-react-native@1.5.3/node_modules/@segment/analytics-react-native" RNCAsyncStorage: @@ -918,7 +947,10 @@ SPEC CHECKSUMS: DoubleConversion: 831926d9b8bf8166fd87886c4abab286c2422662 EXBarCodeScanner: e5ca0062d8ad1c4c1d2e386d6a308d5a32213020 EXCamera: 03d69135ceb6f5f18f37b63eddb63d7643b65c42 + EXFileSystem: 08a3033ac372b6346becf07839e1ccef26fb1058 EXImageLoader: 347b72c2ec2df65120ccec40ea65a4c4f24317ff + EXImageManipulator: 60d1bf3f1d7709453b1feb38adf8594b7f58710f + EXImagePicker: bf4d62532cc2bf217edbe4abbb0014e73e079eac Expo: d9588796cd19999da4d440d87bf7eb7ae4dbd608 ExpoModulesCore: 32c0ccb47f477d330ee93db72505380adf0de09a FBLazyVector: a7a655862f6b09625d11c772296b01cd5164b648 @@ -974,6 +1006,7 @@ SPEC CHECKSUMS: react-native-fast-crypto: 5943c42466b86ad70be60d3a5f64bd22251e5d9e react-native-fingerprint-scanner: ac6656f18c8e45a7459302b84da41a44ad96dbbe react-native-flipper-performance-plugin: ab280430bbdd17deffb7a30d94b604738cd04150 + react-native-image-crop-tools: 06b171ac13f1cd520e633a0d190c00f664b4cb79 react-native-netinfo: 3d3769f0d65de15c83a9bf1346f8be71de5a24bf react-native-performance: f4b6604a9d5a8a7407e34a82fab6c641d9a3ec12 react-native-randombytes: 421f1c7d48c0af8dbcd471b0324393ebf8fe7846 @@ -996,6 +1029,7 @@ SPEC CHECKSUMS: React-RCTVibration: 79040b92bfa9c3c2d2cb4f57e981164ec7ab9374 React-runtimeexecutor: b960b687d2dfef0d3761fbb187e01812ebab8b23 ReactCommon: 095366164a276d91ea704ce53cb03825c487a3f2 + rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba RNAnalytics: 6fc4977ed662251d8a0066422aed1d1a5f697caf RNCAsyncStorage: 9367a646dc24e3ab7b6874d79bc1bfd0832dce58 RNCClipboard: 41d8d918092ae8e676f18adada19104fa3e68495 @@ -1021,6 +1055,7 @@ SPEC CHECKSUMS: Storyly: 1ae05f53c33d2ce75e8fdcc57f2617c4304d433c storyly-react-native: fb35dd20cb36da37a851a785d12969c95fe3c888 TcpSockets: 4ef55305239923b343ed0a378b1fac188b1373b0 + TOCropViewController: 20a14b6a7a098308bf369e7c8d700dc983a974e6 Yoga: 99652481fcd320aefa4a7ef90095b95acd181952 YogaKit: f782866e155069a2cca2517aafea43200b01fd5a ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb diff --git a/apps/ledger-live-mobile/ios/ledgerlivemobile.xcodeproj/project.pbxproj b/apps/ledger-live-mobile/ios/ledgerlivemobile.xcodeproj/project.pbxproj index b9888eb74ea8..c4b23108c460 100644 --- a/apps/ledger-live-mobile/ios/ledgerlivemobile.xcodeproj/project.pbxproj +++ b/apps/ledger-live-mobile/ios/ledgerlivemobile.xcodeproj/project.pbxproj @@ -508,6 +508,7 @@ "${PODS_ROOT}/../../../../node_modules/.pnpm/react-native-vector-icons@8.1.0/node_modules/react-native-vector-icons/Fonts/SimpleLineIcons.ttf", "${PODS_ROOT}/../../../../node_modules/.pnpm/react-native-vector-icons@8.1.0/node_modules/react-native-vector-icons/Fonts/Zocial.ttf", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/TOCropViewController/TOCropViewControllerBundle.bundle", ); name = "[CP] Copy Pods Resources"; outputPaths = ( @@ -528,6 +529,7 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SimpleLineIcons.ttf", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Zocial.ttf", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/TOCropViewControllerBundle.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; diff --git a/apps/ledger-live-mobile/ios/ledgerlivemobile/Info.plist b/apps/ledger-live-mobile/ios/ledgerlivemobile/Info.plist index 746fa2521cb1..defe48d1c0d9 100644 --- a/apps/ledger-live-mobile/ios/ledgerlivemobile/Info.plist +++ b/apps/ledger-live-mobile/ios/ledgerlivemobile/Info.plist @@ -20,6 +20,12 @@ 3.3.0 CFBundleSignature ???? + NSCameraUsageDescription + The Ledger Live app needs access to your camera to scan QR codes + NSMicrophoneUsageDescription + Allow $(PRODUCT_NAME) to use the microphone + NSPhotoLibraryUsageDescription + Allow $(PRODUCT_NAME) to access your photos CFBundleURLTypes diff --git a/apps/ledger-live-mobile/package.json b/apps/ledger-live-mobile/package.json index 3df3ec7dcfc7..6766f2f96e62 100644 --- a/apps/ledger-live-mobile/package.json +++ b/apps/ledger-live-mobile/package.json @@ -109,7 +109,10 @@ "expo": "^43.0.1", "expo-barcode-scanner": "~11.2.0", "expo-camera": "~12.0.3", + "expo-file-system": "~13.1.3", "expo-image-loader": "~3.1.1", + "expo-image-manipulator": "~10.2.1", + "expo-image-picker": "12.0.2", "expo-modules-autolinking": "^0.5.5", "expo-modules-core": "^0.6.5", "fuse.js": "^6.4.6", @@ -141,6 +144,7 @@ "react-native-fast-image": "^8.5.11", "react-native-fingerprint-scanner": "git+https://github.com/hieuvp/react-native-fingerprint-scanner.git#f1d136f605412d58e4de9e7e155d6f818ba24731", "react-native-gesture-handler": "^2.5.0", + "react-native-image-crop-tools": "^1.6.2", "react-native-keychain": "^7.0.0", "react-native-level-fs": "^3.0.0", "react-native-localize": "^2.2.1", @@ -173,6 +177,7 @@ "redux-actions": "2.6.5", "redux-thunk": "2.3.0", "reselect": "4.0.0", + "rn-fetch-blob": "^0.12.0", "rn-snoopy": "^2.0.2", "rxjs": "^6.6.6", "rxjs-compat": "^6.6.6", diff --git a/apps/ledger-live-mobile/src/components/Alert.tsx b/apps/ledger-live-mobile/src/components/Alert.tsx index 4963ba63b5c6..e5f66c96c702 100644 --- a/apps/ledger-live-mobile/src/components/Alert.tsx +++ b/apps/ledger-live-mobile/src/components/Alert.tsx @@ -24,7 +24,7 @@ type IconType = React.ComponentType<{ size: number; color: string }>; type Props = { id?: string; type: AlertType; - children: React.ReactNode; + children?: React.ReactNode; title?: string; noIcon?: boolean; onLearnMore?: () => any; diff --git a/apps/ledger-live-mobile/src/components/CustomImage/BottomButtonsContainer.tsx b/apps/ledger-live-mobile/src/components/CustomImage/BottomButtonsContainer.tsx new file mode 100644 index 000000000000..ef5fab445a04 --- /dev/null +++ b/apps/ledger-live-mobile/src/components/CustomImage/BottomButtonsContainer.tsx @@ -0,0 +1,31 @@ +import { Flex } from "@ledgerhq/native-ui"; +import React, { ReactNode } from "react"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import styled from "styled-components/native"; + +type Props = { + children?: ReactNode | undefined; +}; + +const Container = styled(Flex).attrs({ + flexDirection: "column", + alignSelf: "stretch", + backgroundColor: "background.drawer", + borderTopLeftRadius: "16px", + borderTopRightRadius: "16px", +})``; +const Inner = styled(Flex).attrs({ + padding: 6, + pb: 8, +})``; + +const BottomButtonsContainer: React.FC = ({ children }) => { + const insets = useSafeAreaInsets(); + return ( + + {children} + + ); +}; + +export default BottomButtonsContainer; diff --git a/apps/ledger-live-mobile/src/components/CustomImage/ContrastChoice.tsx b/apps/ledger-live-mobile/src/components/CustomImage/ContrastChoice.tsx new file mode 100644 index 000000000000..86603649a42a --- /dev/null +++ b/apps/ledger-live-mobile/src/components/CustomImage/ContrastChoice.tsx @@ -0,0 +1,58 @@ +import { Box, Icons } from "@ledgerhq/native-ui"; +import React from "react"; +import styled from "styled-components/native"; + +type Props = { + color: string; + selected: boolean; +}; + +const Container = styled(Box).attrs((p: { selected: boolean }) => ({ + height: 64, + width: 64, + justifyContent: "center", + alignItems: "center", + borderWidth: p.selected ? 2 : 1, + borderColor: p.selected ? "primary.c80" : "neutral.c40", + borderRadius: "4px", +}))<{ selected: boolean }>``; + +const Round = styled(Box).attrs({ + height: 28, + width: 28, + borderRadius: 28, +})``; + +const PillBackground = styled(Box).attrs({ + height: "20px", + width: "20px", + backgroundColor: "background.drawer", +})``; + +const PillForeground = styled(Icons.CircledCheckSolidMedium).attrs({ + color: "primary.c80", + size: "20px", +})``; + +const CheckContainer = styled(Box).attrs({ + position: "absolute", + right: "-10px", + top: "-10px", +})``; + +const CheckPill: React.FC> = () => ( + + + + + +); + +const ContrastChoice: React.FC = ({ selected, color }) => ( + + + {selected && } + +); + +export default ContrastChoice; diff --git a/apps/ledger-live-mobile/src/components/CustomImage/CustomImageBottomModal.tsx b/apps/ledger-live-mobile/src/components/CustomImage/CustomImageBottomModal.tsx new file mode 100644 index 000000000000..0b54bb892069 --- /dev/null +++ b/apps/ledger-live-mobile/src/components/CustomImage/CustomImageBottomModal.tsx @@ -0,0 +1,64 @@ +import { useNavigation } from "@react-navigation/native"; +import React, { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Flex, InfiniteLoader, Text } from "@ledgerhq/native-ui"; +import { NavigatorName, ScreenName } from "../../const"; +import BottomModal, { Props as BottomModalProps } from "../BottomModal"; +import ModalChoice from "./ModalChoice"; +import { importImageFromPhoneGallery } from "./imageUtils"; + +type Props = { + isOpened?: boolean; + onClose: BottomModalProps["onClose"]; +}; + +const CustomImageBottomModal: React.FC = props => { + const [isLoading, setIsLoading] = useState(false); + const { onClose } = props; + const { t } = useTranslation(); + const navigation = useNavigation(); + const handleUploadFromPhone = useCallback(async () => { + try { + setIsLoading(true); + const importResult = await importImageFromPhoneGallery(); + if (importResult !== null) { + navigation.navigate(NavigatorName.CustomImage, { + screen: ScreenName.CustomImageStep1Crop, + params: importResult, + }); + } + } catch (error) { + console.error(error); + navigation.navigate(NavigatorName.CustomImage, { + screen: ScreenName.CustomImageErrorScreen, + params: { error }, + }); + } + setIsLoading(false); + onClose && onClose(); + }, [navigation, onClose, setIsLoading]); + + return ( + + + {t("customImage.drawer.title")} + + {isLoading ? ( + + + + ) : ( + <> + + + )} + + ); +}; + +export default CustomImageBottomModal; diff --git a/apps/ledger-live-mobile/src/components/CustomImage/ImageCropper.tsx b/apps/ledger-live-mobile/src/components/CustomImage/ImageCropper.tsx new file mode 100644 index 000000000000..4ec8360a1cf0 --- /dev/null +++ b/apps/ledger-live-mobile/src/components/CustomImage/ImageCropper.tsx @@ -0,0 +1,84 @@ +import React, { useCallback, useRef } from "react"; +import { Button, Flex } from "@ledgerhq/native-ui"; +import { CropView } from "react-native-image-crop-tools"; +import { StyleProp, ViewStyle } from "react-native"; +import { ImageDimensions, ImageFileUri } from "./types"; +import { ImageCropError } from "./errors"; + +export type CropResult = ImageDimensions & ImageFileUri; + +export type Props = ImageFileUri & { + aspectRatio: { width: number; height: number }; + onResult: (_: CropResult) => void; + onError: (_: Error) => void; + style?: StyleProp; + withButton?: boolean; +}; + +/** + * UI Component to crop an image. + * + * It + * - takes as "input" an image file URI, + * - displays a UI to crop that image + * - and on user action (crop confirmation) + * - outputs the result of the cropping as a file URI (which will be a new file) + * + * To trigger the crop confirmation, either leave the prop `withButton` to true + * in which case a button will be rendered an handle it, + * or pass a ref to this component and call `ref.current.saveImage(true, 100)` + */ +const ImageCropper = React.forwardRef((props, ref) => { + const { + style, + imageFileUri, // eslint-disable-line react/prop-types + aspectRatio, + onError, + onResult, + withButton = false, + } = props; + + const cropViewRef = useRef(null); + + const handleImageCrop = useCallback( + async res => { + const { height, width, uri: fileUri } = res; + if (!fileUri) { + onError(new ImageCropError()); + return; + } + onResult({ + width, + height, + imageFileUri: fileUri, + }); + }, + [onError, onResult], + ); + + const handleSave = useCallback(() => { + cropViewRef?.current?.saveImage(true, 100); + }, []); + + return ( + + + {withButton && ( + + )} + + ); +}); + +export default ImageCropper; diff --git a/apps/ledger-live-mobile/src/components/CustomImage/ImageProcessor.tsx b/apps/ledger-live-mobile/src/components/CustomImage/ImageProcessor.tsx new file mode 100644 index 000000000000..500b7f9c63df --- /dev/null +++ b/apps/ledger-live-mobile/src/components/CustomImage/ImageProcessor.tsx @@ -0,0 +1,167 @@ +import { Flex } from "@ledgerhq/native-ui"; +import React from "react"; +import { WebView } from "react-native-webview"; +import { + WebViewErrorEvent, + WebViewMessageEvent, +} from "react-native-webview/lib/WebViewTypes"; +import { ImageProcessingError } from "./errors"; +import { injectedCode } from "./injectedCode/imageProcessing"; +import { InjectedCodeDebugger } from "./InjectedCodeDebugger"; +import { ImageBase64Data, ImageDimensions } from "./types"; + +export type ProcessorPreviewResult = ImageBase64Data & ImageDimensions; + +export type ProcessorRawResult = { hexData: string } & ImageDimensions; + +export type Props = ImageBase64Data & { + onError: (_: Error) => void; + onPreviewResult: (_: ProcessorPreviewResult) => void; + onRawResult: (_: ProcessorRawResult) => void; + /** + * number >= 0 + * - 0: full black + * - 1: original contrast + * - >1: more contrasted than the original + * */ + contrast: number; + debug?: boolean; +}; + +/** + * Component to do some processing on an image (apply grayscale with 16 gray + * levels, apply some contrast, output a preview and a hex reprensentation of + * the result) + * + * It: + * - takes as an input an image base64 data & a contrast value as an input + * - displays nothing (except for a warning in __DEV__ if the injected code is + * not correctly injected) + * - outputs a preview of the result in base64 data uri + * - on user confirmation (call `ref.requestRawResult()`) it outputs the raw + * result which is a hex representation of the 16 levels of gray image. + * + * + * Under the hood, this is implemented with a webview and some code injected in + * it as it gives access to some web APIs (Canvas & Image) to do the processing. + * + * */ +export default class ImageProcessor extends React.Component { + /** + * NB: We are a class component here because we need to access some methods from + * the parent using a ref. + */ + + webViewRef: WebView | null = null; + + componentDidUpdate(prevProps: Props) { + if (prevProps.contrast !== this.props.contrast) this.setAndApplyContrast(); + if (prevProps.imageBase64DataUri !== this.props.imageBase64DataUri) + this.computeResult(); + } + + handleWebViewMessage = ({ nativeEvent: { data } }: WebViewMessageEvent) => { + const { onError, onPreviewResult, onRawResult } = this.props; + const { type, payload } = JSON.parse(data); + switch (type) { + case "LOG": + __DEV__ && console.log("WEBVIEWLOG:", payload); // eslint-disable-line no-console + break; + case "ERROR": + __DEV__ && console.error(payload); + onError(new ImageProcessingError()); + break; + case "BASE64_RESULT": + if (!payload.width || !payload.height || !payload.base64Data) { + onError(new ImageProcessingError()); + break; + } + onPreviewResult({ + width: payload.width, + height: payload.height, + imageBase64DataUri: payload.base64Data, + }); + break; + case "RAW_RESULT": + if ( + !payload.width || + !payload.height || + !payload.hexData || + payload.hexData.length !== payload.width * payload.height + ) { + onError(new ImageProcessingError()); + break; + } + onRawResult({ + width: payload.width, + height: payload.height, + hexData: payload.hexData, + }); + break; + default: + break; + } + }; + + injectJavaScript = (script: string) => { + this.webViewRef?.injectJavaScript(script); + }; + + processImage = () => { + const { imageBase64DataUri } = this.props; + this.injectJavaScript(`window.processImage("${imageBase64DataUri}");`); + }; + + setContrast = () => { + const { contrast } = this.props; + this.injectJavaScript(`window.setImageContrast(${contrast});`); + }; + + setAndApplyContrast = () => { + const { contrast } = this.props; + this.injectJavaScript(`window.setAndApplyImageContrast(${contrast})`); + }; + + requestRawResult = () => { + this.injectJavaScript("window.requestRawResult();"); + }; + + computeResult = () => { + this.setContrast(); + this.processImage(); + }; + + handleWebViewLoaded = () => { + this.computeResult(); + }; + + reloadWebView = () => { + this.webViewRef?.reload(); + }; + + handleWebViewError = ({ nativeEvent }: WebViewErrorEvent) => { + const { onError } = this.props; + console.error(nativeEvent); + onError(new ImageProcessingError()); + }; + + render() { + const { debug = false } = this.props; + return ( + <> + + + (this.webViewRef = c)} + injectedJavaScript={injectedCode} + androidLayerType="software" + androidHardwareAccelerationDisabled + onError={this.handleWebViewError} + onLoadEnd={this.handleWebViewLoaded} + onMessage={this.handleWebViewMessage} + /> + + + ); + } +} diff --git a/apps/ledger-live-mobile/src/components/CustomImage/InjectedCodeDebugger.tsx b/apps/ledger-live-mobile/src/components/CustomImage/InjectedCodeDebugger.tsx new file mode 100644 index 000000000000..cfa1aebfc640 --- /dev/null +++ b/apps/ledger-live-mobile/src/components/CustomImage/InjectedCodeDebugger.tsx @@ -0,0 +1,49 @@ +import React, { useCallback, useState } from "react"; +import { Alert, Switch, Text } from "@ledgerhq/native-ui"; +import { ScrollView } from "react-native"; + +/** + * Component to debug code that will be injected in a webview. + * The `injectedCode` prop is a string of that code and we want to detect & + * notify the dev in case the stringification has produced an unusable result. + * see https://github.com/facebook/hermes/issues/612. + */ +export function InjectedCodeDebugger({ + injectedCode, + debug, +}: { + injectedCode: string; + debug?: boolean; +}) { + const [sourceVisible, setSourceVisible] = useState(false); + const toggleShowSource = useCallback(() => { + setSourceVisible(!sourceVisible); + }, [setSourceVisible, sourceVisible]); + + // see https://github.com/facebook/hermes/issues/612 + const warningVisible = injectedCode?.trim() === "[bytecode]"; + + if (!__DEV__) return null; + return ( + <> + {debug && ( + + )} + {sourceVisible && ( + + {injectedCode} + + )} + {warningVisible && ( + + )} + + ); +} diff --git a/apps/ledger-live-mobile/src/components/CustomImage/ModalChoice.tsx b/apps/ledger-live-mobile/src/components/CustomImage/ModalChoice.tsx new file mode 100644 index 000000000000..95cf6eb3de6a --- /dev/null +++ b/apps/ledger-live-mobile/src/components/CustomImage/ModalChoice.tsx @@ -0,0 +1,57 @@ +import { Flex, Icon, Text } from "@ledgerhq/native-ui"; +import React from "react"; +import styled from "styled-components/native"; +import Touchable from "../Touchable"; + +type Props = { + onPress: (_?: any) => any; + iconName: string; + title: string; + event: string; + eventProperties?: any; +}; + +const StyledTouchable = styled(Touchable)` + margin-top: 16px; +`; + +const Container = styled(Flex).attrs({ + backgroundColor: "neutral.c30", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + px: 8, + py: 10, + borderRadius: 8, +})``; + +const ModalChoice: React.FC = props => { + const { iconName, title, onPress, event, eventProperties } = props; + return ( + + + + {title} + + + {iconName ? ( + + ) : null} + + + + ); +}; + +export default ModalChoice; diff --git a/apps/ledger-live-mobile/src/components/CustomImage/ResultDataTester.tsx b/apps/ledger-live-mobile/src/components/CustomImage/ResultDataTester.tsx new file mode 100644 index 000000000000..3382baa0c49b --- /dev/null +++ b/apps/ledger-live-mobile/src/components/CustomImage/ResultDataTester.tsx @@ -0,0 +1,117 @@ +import { Flex } from "@ledgerhq/native-ui"; +import React from "react"; +import { WebView } from "react-native-webview"; +import { + WebViewErrorEvent, + WebViewMessageEvent, +} from "react-native-webview/lib/WebViewTypes"; +import { ImageProcessingError } from "./errors"; +import { ProcessorPreviewResult, ProcessorRawResult } from "./ImageProcessor"; +import { injectedCode } from "./injectedCode/resultDataTesting"; +import { InjectedCodeDebugger } from "./InjectedCodeDebugger"; + +export type Props = ProcessorRawResult & { + onError: (_: Error) => void; + onPreviewResult: (_: ProcessorPreviewResult) => void; + debug?: boolean; +}; + +/** + * Component to do reconstruct a 16 levels of gray image from an hexadecimal + * representation of it. + * + * It: + * - takes as an input an image hex representation. + * - displays nothing (except for a warning in __DEV__ if the injected code is + * not correctly injected) + * - outputs a preview of the result in base64 data uri + * + * Under the hood, this is implemented with a webview and some code injected in + * it as it gives access to some web APIs (Canvas & Image) to do the processing. + * + * */ +export default class ResultDataTester extends React.Component { + webViewRef: WebView | null = null; + + componentDidUpdate(prevProps: Props) { + if (prevProps.hexData !== this.props.hexData) { + this.computeResult(); + } + } + + handleWebViewMessage = ({ nativeEvent: { data } }: WebViewMessageEvent) => { + const { onError, onPreviewResult } = this.props; + const { type, payload } = JSON.parse(data); + switch (type) { + case "LOG": + __DEV__ && console.log("WEBVIEWLOG:", payload); // eslint-disable-line no-console + break; + case "ERROR": + __DEV__ && console.error(payload); + onError(new ImageProcessingError()); + break; + case "BASE64_RESULT": + if (!payload.width || !payload.height || !payload.base64Data) { + onError(new ImageProcessingError()); + break; + } + onPreviewResult({ + width: payload.width, + height: payload.height, + imageBase64DataUri: payload.base64Data, + }); + break; + default: + break; + } + }; + + injectJavaScript = (script: string) => { + this.webViewRef?.injectJavaScript(script); + }; + + processImage = () => { + const { hexData, width, height } = this.props; + this.injectJavaScript( + `window.reconstructImage(${width}, ${height}, "${hexData}");`, + ); + }; + + computeResult = () => { + this.processImage(); + }; + + handleWebViewLoaded = () => { + this.computeResult(); + }; + + reloadWebView = () => { + this.webViewRef?.reload(); + }; + + handleWebViewError = ({ nativeEvent }: WebViewErrorEvent) => { + const { onError } = this.props; + console.error(nativeEvent); + onError(new ImageProcessingError()); + }; + + render() { + const { debug = false } = this.props; + return ( + <> + + + (this.webViewRef = c)} + injectedJavaScript={injectedCode} + androidLayerType="software" + androidHardwareAccelerationDisabled + onError={this.handleWebViewError} + onLoadEnd={this.handleWebViewLoaded} + onMessage={this.handleWebViewMessage} + /> + + + ); + } +} diff --git a/apps/ledger-live-mobile/src/components/CustomImage/errors.ts b/apps/ledger-live-mobile/src/components/CustomImage/errors.ts new file mode 100644 index 000000000000..00adcf10757e --- /dev/null +++ b/apps/ledger-live-mobile/src/components/CustomImage/errors.ts @@ -0,0 +1,26 @@ +import { createCustomErrorClass } from "@ledgerhq/errors"; + +export const ImageLoadFromGalleryError: any = createCustomErrorClass( + "ImageLoadFromGalleryError", +); + +export const ImageDownloadError: any = + createCustomErrorClass("ImageDownloadError"); + +export const ImageTooLargeError: any = + createCustomErrorClass("ImageTooLargeError"); + +export const ImageMetadataLoadingError: any = createCustomErrorClass( + "ImageMetadataLoadingError", +); + +export const ImageCropError: any = createCustomErrorClass("ImageCropError"); + +export const ImageResizeError: any = createCustomErrorClass("ImageResizeError"); + +export const ImagePreviewError: any = + createCustomErrorClass("ImagePreviewError"); + +export const ImageProcessingError: any = createCustomErrorClass( + "ImageProcessingError", +); diff --git a/apps/ledger-live-mobile/src/components/CustomImage/imageUtils.ts b/apps/ledger-live-mobile/src/components/CustomImage/imageUtils.ts new file mode 100644 index 000000000000..7130a13de163 --- /dev/null +++ b/apps/ledger-live-mobile/src/components/CustomImage/imageUtils.ts @@ -0,0 +1,160 @@ +import { Image, NativeModules, Platform } from "react-native"; +import RNFetchBlob from "rn-fetch-blob"; +import * as ImagePicker from "expo-image-picker"; +import { ImageDimensions, ImageFileUri, ImageUrl } from "./types"; +import { + ImageDownloadError, + ImageLoadFromGalleryError, + ImageMetadataLoadingError, + ImageTooLargeError, +} from "./errors"; + +/** + * Call this to prompt the user to pick an image from its phone. + * + * @returns (a promise) null if the user cancelled, otherwise an containing + * the chosen image file URI as well as the image dimensions + */ +export async function importImageFromPhoneGallery(): Promise { + try { + /** + * We have our own implementation for Android because expo-image-picker + * sometimes returns {cancelled: true} even when the user picks an image. + * More specifically, this happens if the user navigates to another app + * from the opened file picker app. + * */ + const pickImagePromise = + Platform.OS === "android" + ? NativeModules.ImagePickerModule.pickImage() + : ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: false, + quality: 1, + base64: false, + }); + const { uri, cancelled } = await pickImagePromise; + if (cancelled) return null; + if (uri) { + return { + imageFileUri: uri, + }; + } + throw new Error("uri is falsy"); + } catch (e) { + console.error(e); + throw new ImageLoadFromGalleryError(); + } +} + +type CancellablePromise = { + cancel: () => void; + resultPromise: Promise; +}; + +export function downloadImageToFile({ + imageUrl, +}: ImageUrl): CancellablePromise { + const downloadTask = RNFetchBlob.config({ fileCache: true }).fetch( + "GET", + imageUrl, + ); + return { + resultPromise: downloadTask + .then(res => ({ imageFileUri: "file://" + res.path() })) + .catch(e => { + if (e.message === "canceled") throw e; + throw new ImageDownloadError(); + }), + cancel: downloadTask.cancel, + }; +} + +export function downloadImageToFileWithDimensions( + source: ImageUrl, +): CancellablePromise> { + const { imageUrl } = source; + const { resultPromise, cancel } = downloadImageToFile({ + imageUrl, + }); + return { + resultPromise: Promise.all([loadImageSizeAsync(imageUrl), resultPromise]) + .then(([dims, { imageFileUri }]) => ({ + width: dims.width, + height: dims.height, + imageFileUri, + })) + .catch(e => { + cancel(); + throw e; + }), + cancel, + }; +} + +export async function loadImageSizeAsync( + url: string, +): Promise { + return new Promise((resolve, reject) => { + Image.getSize( + url, + (width, height) => { + resolve({ width, height }); + }, + error => { + console.error(error); + if (error?.message?.startsWith("Pool hard cap violation? ")) { + reject(new ImageTooLargeError()); + } else { + reject(new ImageMetadataLoadingError()); + } + }, + ); + }); +} + +/** + * @param imageDimensions dimensions of the image to resize + * @param boxDimensions dimensions of the constraint "box" to fit in + * @returns new dimensions with the aspect ratio of imageDimensions that fit in boxDimensions + */ +export function fitImageContain( + imageDimensions: ImageDimensions, + boxDimensions: ImageDimensions, +): ImageDimensions { + const { height: imageHeight, width: imageWidth } = imageDimensions; + const { height: boxHeight, width: boxWidth } = boxDimensions; + if ([boxHeight, boxWidth, imageHeight, imageWidth].some(val => val === 0)) + return boxDimensions; + if (imageHeight <= boxHeight && imageWidth <= boxWidth) + return { + width: imageWidth, + height: imageHeight, + }; + if (imageWidth / imageHeight >= boxWidth / boxHeight) { + /** + * Width of the box is the biggest constraint (image is wider than the box + * in terms of aspect ratio) + * + * Given this condition, do some basic math on the inequality and we have: + * + * (1). imageWidth / imageHeight >= boxWidth / boxHeight (the inequality in the "if" condition) + * (2). boxHeight / boxWidth >= imageHeight / imageWidth (carefully invert (1)) + * (3). boxHeight >= (imageHeight / imageWidth) * boxWidth (multiply (2) by boxWidth) + * + * -> (3) shows that the resulting height (below) is smaller than boxHeight. + * so the returned value "fits" in the box. + * + * */ + return { + width: boxWidth, + height: (imageHeight / imageWidth) * boxWidth, + }; + } + /** + * Height of the box is the biggest constraint + * */ + return { + width: (imageWidth / imageHeight) * boxHeight, + height: boxHeight, + }; +} diff --git a/apps/ledger-live-mobile/src/components/CustomImage/injectedCode/imageProcessing.ts b/apps/ledger-live-mobile/src/components/CustomImage/injectedCode/imageProcessing.ts new file mode 100644 index 000000000000..dccbbeb37722 --- /dev/null +++ b/apps/ledger-live-mobile/src/components/CustomImage/injectedCode/imageProcessing.ts @@ -0,0 +1,237 @@ +declare global { + interface Window { + ReactNativeWebView: any; + /* eslint-disable no-unused-vars */ + processImage: (imgBase64: string) => void; + setImageContrast: (val: number) => void; + setAndApplyImageContrast: (val: number) => void; + /* eslint-enable no-unused-vars */ + requestRawResult: () => void; + } +} + +type ComputationResult = { + base64Data: string; + width: number; + height: number; + hexRawResult: string; +}; + +/** + * This function is meant to be stringified and its body injected in the webview + * It allows us to have access to APIs that are available in browsers and not + * in React Native (here Canvas & Image). + * */ +function codeToInject() { + /** + * The following line is a hermes directive that allows + * Function.prototype.toString() to return clear stringified code that can + * thus be injected in a webview. + * + * ⚠️ IN DEBUG this doesn't work until you hot reload this file (just save the file and it will work) + * see https://github.com/facebook/hermes/issues/612 + * */ + + "show source"; + + function clampRGB(val: number) { + return Math.min(255, Math.max(val, 0)); + } + + function contrastRGB(rgbVal: number, contrastVal: number) { + return (rgbVal - 128) * contrastVal + 128; + } + + // simutaneously apply grayscale and contrast to the image + function applyFilter( + imageData: Uint8ClampedArray, + contrastAmount: number, + ): { imageDataResult: Uint8ClampedArray; hexRawResult: string } { + let hexRawResult = ""; + const filteredImageData = []; + + const numLevelsOfGray = 16; + const rgbStep = 255 / (numLevelsOfGray - 1); + + for (let i = 0; i < imageData.length; i += 4) { + /** gray rgb value for the pixel, in [0, 255] */ + const gray256 = + 0.299 * imageData[i] + + 0.587 * imageData[i + 1] + + 0.114 * imageData[i + 2]; + + /** gray rgb value after applying the contrast, in [0, 15] */ + const contrastedGray16 = Math.floor( + clampRGB(contrastRGB(gray256, contrastAmount)) / rgbStep, + ); + + /** gray rgb value after applying the contrast, in [0,255] */ + const contrastedGray256 = contrastedGray16 * rgbStep; + + const grayHex = contrastedGray16.toString(16); + + hexRawResult = hexRawResult.concat(grayHex); + // adding hexadecimal value of this pixel + + filteredImageData.push(contrastedGray256); + filteredImageData.push(contrastedGray256); + filteredImageData.push(contrastedGray256); + // push 3 bytes for color (all the same == gray) + + filteredImageData.push(255); + // push alpha = max = 255 + } + + return { + imageDataResult: Uint8ClampedArray.from(filteredImageData), + hexRawResult, + }; + } + + function createCanvas(image: HTMLImageElement): { + canvas: HTMLCanvasElement; + context: CanvasRenderingContext2D | null; + } { + const canvas = document.createElement("canvas"); + canvas.width = image.width; + canvas.height = image.height; + const context = canvas.getContext("2d"); + return { canvas, context }; + } + + function computeResult( + image: HTMLImageElement, + contrastAmount: number, + ): ComputationResult | null { + // 1. drawing image in a canvas & getting its data + const { canvas, context } = createCanvas(image); + if (!context) return null; + context.drawImage(image, 0, 0); + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + + // 2. applying filter to the image data + const { imageDataResult: grayData, hexRawResult } = applyFilter( + imageData.data, + contrastAmount, + ); + + // 3. putting the result in canvas + context.putImageData( + new ImageData(grayData, image.width, image.height), // eslint-disable-line no-undef + 0, + 0, + ); + + const grayScaleBase64 = canvas.toDataURL(); + return { + base64Data: grayScaleBase64, + width: image.width, + height: image.height, + hexRawResult, + }; + } + + /** + * + * + * + * + * INTERFACING WITH REACT NATIVE WEBVIEW + * + * - declaring some helpers to post messages to the WebView. + * - using a global temporary variable to store intermediary results + * - storing some functions as properties of the window object so they are + * still accessible after code minification. + * */ + + const postDataToWebView = (data: any) => { + window.ReactNativeWebView.postMessage(JSON.stringify(data)); + }; + + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars + const log = (data: any) => { + postDataToWebView({ type: "LOG", payload: data }); + }; + + const logError = (error: Error) => { + postDataToWebView({ type: "ERROR", payload: error.toString() }); + }; + + type Store = { + image: HTMLImageElement; + rawResult: string; + contrast: number; + }; + + let tmpStore: Store; + const initTmpStore = () => { + tmpStore = { + image: new Image(), // eslint-disable-line no-undef + rawResult: "", + contrast: 1, + }; + }; + initTmpStore(); + + const computeResultAndPostData = () => { + try { + const res = computeResult(tmpStore.image, tmpStore.contrast); + if (res === null) return; + const { base64Data, width, height, hexRawResult } = res; + tmpStore.rawResult = hexRawResult; + + postDataToWebView({ + type: "BASE64_RESULT", + payload: { + base64Data, + width, + height, + }, + }); + } catch (e) { + if (e instanceof Error) { + logError(e); + console.error(e); + } + } + }; + + window.processImage = imgBase64 => { + initTmpStore(); + tmpStore.image.onload = () => { + computeResultAndPostData(); + }; + tmpStore.image.src = imgBase64; + }; + + window.setImageContrast = val => { + tmpStore.contrast = val; + }; + + window.setAndApplyImageContrast = val => { + window.setImageContrast(val); + if (tmpStore.image) computeResultAndPostData(); + }; + + window.requestRawResult = () => { + /** + * stringifying and then parsing rawResult is a heavy operation that + * takes a lot of time so we should we should do this only once the user is + * satisfied with the preview. + */ + postDataToWebView({ + type: "RAW_RESULT", + payload: { + hexData: tmpStore.rawResult, + width: tmpStore.image.width, + height: tmpStore.image.height, + }, + }); + }; +} + +function getFunctionBody(str: string) { + return str.substring(str.indexOf("{") + 1, str.lastIndexOf("}")); +} + +export const injectedCode = getFunctionBody(codeToInject.toString()); diff --git a/apps/ledger-live-mobile/src/components/CustomImage/injectedCode/resultDataTesting.ts b/apps/ledger-live-mobile/src/components/CustomImage/injectedCode/resultDataTesting.ts new file mode 100644 index 000000000000..f6869ead92d7 --- /dev/null +++ b/apps/ledger-live-mobile/src/components/CustomImage/injectedCode/resultDataTesting.ts @@ -0,0 +1,87 @@ +declare global { + interface Window { + ReactNativeWebView: any; + /* eslint-disable-next-line no-unused-vars */ + reconstructImage: (width: number, height: number, hexData: string) => void; + } +} + +/** + * This function is meant to be stringified and its body injected in the webview + * It allows us to have access to APIs that are available in browsers and not + * in React Native (here Canvas & Image). + * */ +function codeToInject() { + /** + * The following line is a hermes directive that allows + * Function.prototype.toString() to return clear stringified code that can + * thus be injected in a webview. + * + * ⚠️ IN DEBUG this doesn't work until you hot reload this file (just save the file and it will work) + * see https://github.com/facebook/hermes/issues/612 + * */ + + "show source"; + + const postDataToWebView = (data: any) => { + window.ReactNativeWebView.postMessage(JSON.stringify(data)); + }; + + const logError = (error: Error) => { + postDataToWebView({ type: "ERROR", payload: error.toString() }); + }; + + /** + * store functions as a property of window so we can access them easily after minification + * */ + window.reconstructImage = (width, height, hexData) => { + try { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + + const context = canvas.getContext("2d"); + if (!context) return; + + const imageData: number[] = []; + + const numLevelsOfGray = 16; + const rgbStep = 255 / (numLevelsOfGray - 1); + hexData.split("").forEach(char => { + const numericVal16 = Number.parseInt(char, 16); + const numericVal256 = numericVal16 * rgbStep; + imageData.push(numericVal256); // R + imageData.push(numericVal256); // G + imageData.push(numericVal256); // B + imageData.push(255); + }); + + context.putImageData( + new ImageData(Uint8ClampedArray.from(imageData), width, height), // eslint-disable-line no-undef + 0, + 0, + ); + + const grayScaleBase64 = canvas.toDataURL(); + postDataToWebView({ + type: "BASE64_RESULT", + payload: { + base64Data: grayScaleBase64, + width, + height, + }, + }); + } catch (e) { + if (e instanceof Error) { + logError(e); + console.error(e); + } + } + }; +} + +function getFunctionBody(str: string) { + return str.substring(str.indexOf("{") + 1, str.lastIndexOf("}")); +} + +export const injectedCode = getFunctionBody(codeToInject.toString()); diff --git a/apps/ledger-live-mobile/src/components/CustomImage/types.ts b/apps/ledger-live-mobile/src/components/CustomImage/types.ts new file mode 100644 index 000000000000..7207ba764712 --- /dev/null +++ b/apps/ledger-live-mobile/src/components/CustomImage/types.ts @@ -0,0 +1,31 @@ +export type ImageDimensions = { + /** pixel height of the image */ + height: number; + /** pixel width of the image */ + width: number; +}; + +export type ImageBase64Data = { + /** + * Image data contained in a base 64 data URI scheme like that: + * "data:[],[;base64]," + * As defined in RFC 2397 https://datatracker.ietf.org/doc/html/rfc2397 + */ + imageBase64DataUri: string; +}; + +export type ImageFileUri = { + /** + * Image file URI locating an image file on the device. + * e.g "file://the_image_path + */ + imageFileUri: string; +}; + +export type ImageUrl = { + /** + * Image URL locating an image on the internet. + * e.g: "https://example.com/an_image.png" + */ + imageUrl: string; +}; diff --git a/apps/ledger-live-mobile/src/components/CustomImage/useResizedImage.tsx b/apps/ledger-live-mobile/src/components/CustomImage/useResizedImage.tsx new file mode 100644 index 000000000000..b3e9f497892c --- /dev/null +++ b/apps/ledger-live-mobile/src/components/CustomImage/useResizedImage.tsx @@ -0,0 +1,66 @@ +import { useEffect } from "react"; +import { manipulateAsync, SaveFormat } from "expo-image-manipulator"; +import { ImageBase64Data, ImageDimensions, ImageFileUri } from "./types"; +import { ImageResizeError } from "./errors"; + +export type ResizeResult = ImageBase64Data & ImageDimensions; + +export type Params = Partial & { + targetDimensions: ImageDimensions; + onError: (_: Error) => void; + onResult: (_: ResizeResult) => void; +}; + +/** + * Component to resize an image. + * + * It: + * - takes as an input an image file URI & target dimensions as an input + * - renders nothing + * - outputs the resulting image as a base64 data URI. + * + * */ + +function useResizedImage(params: Params) { + const { imageFileUri, targetDimensions, onError, onResult } = params; + useEffect(() => { + let dead = false; + if (!imageFileUri) + return () => { + dead = true; + }; + manipulateAsync( + imageFileUri, + [ + { + resize: { + width: targetDimensions.width, + height: targetDimensions.height, + }, + }, + ], + { base64: true, compress: 1, format: SaveFormat.PNG }, + ) + .then(({ base64, height, width }) => { + if (dead) return; + const fullBase64 = `data:image/png;base64, ${base64}`; + onResult({ imageBase64DataUri: fullBase64, height, width }); + }) + .catch(e => { + if (dead) return; + console.error(e); + onError(new ImageResizeError()); + }); + return () => { + dead = true; + }; + }, [ + imageFileUri, + targetDimensions?.height, + targetDimensions?.width, + onError, + onResult, + ]); +} + +export default useResizedImage; diff --git a/apps/ledger-live-mobile/src/components/Nft/NftLinksPanel.tsx b/apps/ledger-live-mobile/src/components/Nft/NftLinksPanel.tsx index 0e9d7c67df99..3a34a4a81160 100644 --- a/apps/ledger-live-mobile/src/components/Nft/NftLinksPanel.tsx +++ b/apps/ledger-live-mobile/src/components/Nft/NftLinksPanel.tsx @@ -1,8 +1,12 @@ -import React, { memo } from "react"; +import React, { memo, useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { useTheme } from "@react-navigation/native"; -import { NFTMetadata } from "@ledgerhq/types-live"; +import { useNavigation, useTheme } from "@react-navigation/native"; +import { NFTMediaSize, NFTMetadata } from "@ledgerhq/types-live"; import { View, StyleSheet, TouchableOpacity, Linking } from "react-native"; +import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; +import { Icons } from "@ledgerhq/native-ui"; +import { getMetadataMediaTypes } from "../../logic/nft"; +import { NavigatorName, ScreenName } from "../../const"; import ExternalLinkIcon from "../../icons/ExternalLink"; import OpenSeaIcon from "../../icons/OpenSea"; import RaribleIcon from "../../icons/Rarible"; @@ -15,6 +19,7 @@ type Props = { links: NFTMetadata["links"] | null; isOpen: boolean; onClose: () => void; + nftMetadata?: NFTMetadata; }; const NftLink = ({ @@ -26,8 +31,8 @@ const NftLink = ({ onPress, }: { style?: any; - leftIcon: React$Node; - rightIcon?: React$Node; + leftIcon: React.ReactNode; + rightIcon?: React.ReactNode; title: string; subtitle?: string; onPress?: () => any; @@ -46,9 +51,145 @@ const NftLink = ({ ); -const NftLinksPanel = ({ links, isOpen, onClose }: Props) => { +const NftLinksPanel = ({ links, isOpen, onClose, nftMetadata }: Props) => { const { colors } = useTheme(); const { t } = useTranslation(); + const navigation = useNavigation(); + const customImage = useFeature("customImage"); + + const mediaTypes = useMemo( + () => (nftMetadata ? getMetadataMediaTypes(nftMetadata) : null), + [nftMetadata], + ); + const mediaSizeForCustomImage = mediaTypes + ? (["big", "preview"] as NFTMediaSize[]).find( + size => mediaTypes[size] === "image", + ) + : null; + const customImageUri = + (mediaSizeForCustomImage && + nftMetadata?.medias?.[mediaSizeForCustomImage]?.uri) || + null; + + const showCustomImageButton = customImage?.enabled && !!customImageUri; + + const handleOpenOpenSea = useCallback(() => { + links?.opensea && Linking.openURL(links?.opensea); + }, [links?.opensea]); + + const handleOpenRarible = useCallback(() => { + links?.rarible && Linking.openURL(links?.rarible); + }, [links?.rarible]); + + const handleOpenExplorer = useCallback(() => { + links?.explorer && Linking.openURL(links?.explorer); + }, [links?.explorer]); + + const handlePressCustomImage = useCallback(() => { + if (!customImageUri) return; + navigation.navigate(NavigatorName.CustomImage, { + screen: ScreenName.CustomImageStep1Crop, + params: { + imageUrl: customImageUri, + }, + }); + onClose && onClose(); + }, [navigation, onClose, customImageUri]); + + const content = useMemo(() => { + const topSection = [ + ...(links?.opensea + ? [ + } + title={`${t("nft.viewerModal.viewOn")} OpenSea`} + rightIcon={} + onPress={handleOpenOpenSea} + />, + ] + : []), + ...(links?.rarible + ? [ + } + title={`${t("nft.viewerModal.viewOn")} Rarible`} + rightIcon={} + onPress={handleOpenRarible} + />, + ] + : []), + ]; + + const bottomSection = [ + ...(links?.explorer + ? [ + + + + } + title={t("nft.viewerModal.viewInExplorer")} + rightIcon={} + onPress={handleOpenExplorer} + />, + ] + : []), + ...(showCustomImageButton + ? [ + + + + } + onPress={handlePressCustomImage} + />, + ] + : []), + ]; + + const renderSection = (section: React.ReactNode[], keyPrefix: string) => + section.map((item, index, arr) => ( + + {item} + {index !== arr.length - 1 ? ( + + ) : null} + + )); + + return ( + <> + {renderSection(topSection, "top")} + {topSection.length > 0 && bottomSection.length > 0 ? ( + + ) : null} + {renderSection(bottomSection, "bottom")} + + ); + }, [ + links, + colors, + t, + showCustomImageButton, + handleOpenExplorer, + handleOpenOpenSea, + handleOpenRarible, + handlePressCustomImage, + ]); return ( { isOpened={isOpen} onClose={onClose} > - {!links?.opensea ? null : ( - } - title={`${t("nft.viewerModal.viewOn")} OpenSea`} - rightIcon={} - onPress={() => Linking.openURL(links.opensea)} - /> - )} - - {!links?.rarible ? null : ( - } - title={`${t("nft.viewerModal.viewOn")} Rarible`} - rightIcon={} - onPress={() => Linking.openURL(links.rarible)} - /> - )} - - {!links?.explorer ? null : ( - <> - - - - - - } - title={t("nft.viewerModal.viewInExplorer")} - rightIcon={} - onPress={() => Linking.openURL(links.explorer)} - /> - - )} + {content} ); }; @@ -131,6 +233,9 @@ const styles = StyleSheet.create({ sectionMargin: { marginBottom: 30, }, + sectionMarginTop: { + marginTop: 30, + }, icon: { marginRight: 16, }, @@ -149,7 +254,7 @@ const styles = StyleSheet.create({ hr: { borderBottomWidth: 1, borderBottomColor: "#DFDFDF", - marginVertical: 24, + marginBottom: 24, }, }); diff --git a/apps/ledger-live-mobile/src/components/Nft/NftViewer.tsx b/apps/ledger-live-mobile/src/components/Nft/NftViewer.tsx index cfb391003b62..9b84307fa0fb 100644 --- a/apps/ledger-live-mobile/src/components/Nft/NftViewer.tsx +++ b/apps/ledger-live-mobile/src/components/Nft/NftViewer.tsx @@ -390,6 +390,7 @@ const NftViewer = ({ route }: Props) => { + {Object.keys(families).map(name => { const { component, options } = families[name]; return ( diff --git a/apps/ledger-live-mobile/src/components/RootNavigator/CustomImageNavigator.tsx b/apps/ledger-live-mobile/src/components/RootNavigator/CustomImageNavigator.tsx new file mode 100644 index 000000000000..b3a95a77878b --- /dev/null +++ b/apps/ledger-live-mobile/src/components/RootNavigator/CustomImageNavigator.tsx @@ -0,0 +1,51 @@ +import React, { useMemo } from "react"; +import { createStackNavigator } from "@react-navigation/stack"; +import { useTheme } from "styled-components/native"; +import { useTranslation } from "react-i18next"; +import { ScreenName } from "../../const"; +import { getStackNavigatorConfig } from "../../navigation/navigatorConfig"; +import { ParamList } from "../../screens/CustomImage/types"; +import Step1Cropping from "../../screens/CustomImage/Step1Crop"; +import Step2Preview from "../../screens/CustomImage/Step2Preview"; +import Step3Transfer from "../../screens/CustomImage/Step3Transfer"; +import ErrorScreen from "../../screens/CustomImage/ErrorScreen"; + +const Empty = () => null; + +export type CustomImageParamList = ParamList; + +export default function CustomImageNavigator() { + const { colors } = useTheme(); + const stackNavigationConfig = useMemo( + () => getStackNavigatorConfig(colors, true), + [colors], + ); + const { t } = useTranslation(); + + return ( + + + + + + + ); +} + +const Stack = createStackNavigator(); diff --git a/apps/ledger-live-mobile/src/const/navigation.ts b/apps/ledger-live-mobile/src/const/navigation.ts index fc0fa3148d1f..43c99e66db90 100644 --- a/apps/ledger-live-mobile/src/const/navigation.ts +++ b/apps/ledger-live-mobile/src/const/navigation.ts @@ -384,6 +384,11 @@ export const ScreenName = { CardanoEditMemo: "CardanoEditMemo", // hedera HederaEditMemo: "HederaEditMemo", + + CustomImageStep1Crop: "CustomImageStep1Crop", + CustomImageStep2Preview: "CustomImageStep2Preview", + CustomImageStep3Transfer: "CustomImageStep3Transfer", + CustomImageErrorScreen: "CustomImageErrorScreen", }; export const NavigatorName = { // Stack @@ -461,4 +466,7 @@ export const NavigatorName = { // Root RootNavigator: "RootNavigator", Discover: "Discover", + + // Custom Image + CustomImage: "CustomImage", }; diff --git a/apps/ledger-live-mobile/src/locales/en/common.json b/apps/ledger-live-mobile/src/locales/en/common.json index 432a220cf8e7..96b229c38d70 100644 --- a/apps/ledger-live-mobile/src/locales/en/common.json +++ b/apps/ledger-live-mobile/src/locales/en/common.json @@ -19,6 +19,7 @@ "device": "Device", "cryptoAsset": "Crypto asset", "skip": "Skip", + "next": "Next", "noCryptoFound": "No crypto assets found", "needHelp": "Do you need help?", "edit": "Edit", @@ -686,6 +687,38 @@ }, "StellarSourceHasMultiSign": { "title": "Please disable multisign to make Stellar transactions" + }, + "ImageLoadFromGalleryError": { + "title": "Image loading from gallery failed", + "description": "Please try again with another image" + }, + "ImageDownloadError": { + "title": "Image download failed", + "description": "Please try again with another image" + }, + "ImageMetadataLoadingError": { + "title": "Image metadata loading failed", + "description": "Please try again with another image" + }, + "ImageTooLargeError": { + "title": "Image too large", + "description": "Please try with a smaller image" + }, + "ImageCropError": { + "title": "Cropping of the image failed", + "description": "Please try again with another image" + }, + "ImageResizeError": { + "title": "Resizing of the image failed", + "description": "Please try again with another image" + }, + "ImagePreviewError": { + "title": "Preview of the image failed", + "description": "Please try again with another image" + }, + "ImageProcessingError": { + "title": "Processing of the image failed", + "description": "Please try again with another image" } }, "bluetooth": { @@ -4698,5 +4731,17 @@ "currentAddress": { "messageIfVirtual": "Your {{name}} address cannot be confirmed on your Ledger device. Use at your own risk." } + }, + "customImage": { + "drawer": { + "title": "Choose an image", + "options": { + "uploadFromPhone": "Upload from my phone" + } + }, + "cropImage": "Crop image", + "chooseConstrast": "Choose contrast", + "uploadAnotherImage": "Upload another image", + "selectContrast": "Select your contrast" } } diff --git a/apps/ledger-live-mobile/src/logic/nft.ts b/apps/ledger-live-mobile/src/logic/nft.ts index 45c8906a2536..29a89d4ed81f 100644 --- a/apps/ledger-live-mobile/src/logic/nft.ts +++ b/apps/ledger-live-mobile/src/logic/nft.ts @@ -17,3 +17,17 @@ export const getMetadataMediaType = ( mimeTypesMap[type].includes(mediaType), ); }; + +export const getMetadataMediaTypes = ( + metadata: NFTMetadata, +): Record => { + const sizes: NFTMediaSize[] = ["preview", "big", "original"]; + const sizeToTypeMap = sizes.map(size => [ + size, + getMetadataMediaType(metadata, size), + ]); + return Object.fromEntries(sizeToTypeMap) as Record< + NFTMediaSize, + keyof typeof mimeTypesMap | undefined + >; +}; diff --git a/apps/ledger-live-mobile/src/screens/CustomImage/ErrorScreen.tsx b/apps/ledger-live-mobile/src/screens/CustomImage/ErrorScreen.tsx new file mode 100644 index 000000000000..8c54d4fd76c9 --- /dev/null +++ b/apps/ledger-live-mobile/src/screens/CustomImage/ErrorScreen.tsx @@ -0,0 +1,58 @@ +import { Flex, Icons, Text } from "@ledgerhq/native-ui"; +import { StackScreenProps } from "@react-navigation/stack"; +import React, { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { SafeAreaView } from "react-native-safe-area-context"; +import styled from "styled-components/native"; +import Button from "../../components/Button"; +import CustomImageBottomModal from "../../components/CustomImage/CustomImageBottomModal"; +import TranslatedError from "../../components/TranslatedError"; +import { ParamList } from "./types"; + +const Container = styled(SafeAreaView).attrs({ + edges: ["left", "right", "bottom"], +})` + flex: 1; +`; + +const ErrorScreen: React.FC< + StackScreenProps +> = ({ route }) => { + const [isModalOpened, setIsModalOpened] = useState(false); + const { params } = route; + const { error } = params; + + const { t } = useTranslation(); + + const closeModal = useCallback(() => { + setIsModalOpened(false); + }, [setIsModalOpened]); + + const openModal = useCallback(() => { + setIsModalOpened(true); + }, [setIsModalOpened]); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export default ErrorScreen; diff --git a/apps/ledger-live-mobile/src/screens/CustomImage/Step1Crop.tsx b/apps/ledger-live-mobile/src/screens/CustomImage/Step1Crop.tsx new file mode 100644 index 000000000000..54c1e75913a3 --- /dev/null +++ b/apps/ledger-live-mobile/src/screens/CustomImage/Step1Crop.tsx @@ -0,0 +1,168 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Flex, Icons, InfiniteLoader } from "@ledgerhq/native-ui"; +import { CropView } from "react-native-image-crop-tools"; +import { useTranslation } from "react-i18next"; +import { StackScreenProps } from "@react-navigation/stack"; +import ImageCropper, { + Props as ImageCropperProps, + CropResult, +} from "../../components/CustomImage/ImageCropper"; +import { + ImageDimensions, + ImageFileUri, +} from "../../components/CustomImage/types"; +import { downloadImageToFile } from "../../components/CustomImage/imageUtils"; +import { cropAspectRatio } from "./shared"; +import Button from "../../components/Button"; +import { ScreenName } from "../../const"; +import BottomContainer from "../../components/CustomImage/BottomButtonsContainer"; +import Touchable from "../../components/Touchable"; +import { ParamList } from "./types"; + +/** + * UI component that loads the input image (from the route params) & + * displays it in a cropping UI with a confirm button at the bottom. + * Then on confirmation it navigates to the preview step with the cropped image + * file URI as a param. + */ +const Step1Cropping: React.FC< + StackScreenProps +> = ({ navigation, route }) => { + const cropperRef = useRef(null); + const [imageToCrop, setImageToCrop] = useState(null); + const [rotated, setRotated] = useState(false); + + const { t } = useTranslation(); + + const { params } = route; + + const handleError = useCallback( + (error: Error) => { + console.error(error); + navigation.navigate( + ScreenName.CustomImageErrorScreen as "CustomImageErrorScreen", + { error }, + ); + }, + [navigation], + ); + + /** LOAD SOURCE IMAGE FROM PARAMS */ + useEffect(() => { + let dead = false; + if ("imageFileUri" in params) { + setImageToCrop({ + imageFileUri: params.imageFileUri, + }); + } else { + const { resultPromise, cancel } = downloadImageToFile(params); + resultPromise + .then(res => { + if (!dead) setImageToCrop(res); + }) + .catch(e => { + if (!dead) handleError(e); + }); + return () => { + dead = true; + cancel(); + }; + } + return () => { + dead = true; + }; + }, [params, setImageToCrop, handleError]); + + /** CROP IMAGE HANDLING */ + const handleCropResult: ImageCropperProps["onResult"] = useCallback( + (res: CropResult) => { + navigation.navigate( + ScreenName.CustomImageStep2Preview as "CustomImageStep2Preview", + res, + ); + }, + [navigation], + ); + + const handlePressNext = useCallback(() => { + cropperRef?.current?.saveImage(undefined, 100); + }, [cropperRef]); + + const handlePressRotateLeft = useCallback(() => { + if (cropperRef?.current) { + cropperRef.current.rotateImage(false); + setRotated(!rotated); + } + }, [cropperRef, rotated, setRotated]); + + const [containerDimensions, setContainerDimensions] = + useState(null); + const onContainerLayout = useCallback( + ({ nativeEvent: { layout } }) => { + if (containerDimensions !== null) return; + setContainerDimensions({ height: layout.height, width: layout.width }); + }, + [containerDimensions], + ); + + return ( + + + + {containerDimensions && imageToCrop ? ( + + ) : ( + + )} + + {imageToCrop ? ( + + + + + + + + + + + + + ) : null} + + + ); +}; + +export default Step1Cropping; diff --git a/apps/ledger-live-mobile/src/screens/CustomImage/Step2Preview.tsx b/apps/ledger-live-mobile/src/screens/CustomImage/Step2Preview.tsx new file mode 100644 index 000000000000..6c8e353a372f --- /dev/null +++ b/apps/ledger-live-mobile/src/screens/CustomImage/Step2Preview.tsx @@ -0,0 +1,218 @@ +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components/native"; +import { Button, Flex, InfiniteLoader, Text } from "@ledgerhq/native-ui"; +import { + Dimensions, + ImageErrorEventData, + NativeSyntheticEvent, + Pressable, +} from "react-native"; +import { StackScreenProps } from "@react-navigation/stack"; +import useResizedImage, { + Params as ImageResizerParams, + ResizeResult, +} from "../../components/CustomImage/useResizedImage"; +import ImageProcessor, { + Props as ImageProcessorProps, + ProcessorPreviewResult, + ProcessorRawResult, +} from "../../components/CustomImage/ImageProcessor"; +import { cropAspectRatio } from "./shared"; +import { fitImageContain } from "../../components/CustomImage/imageUtils"; +import BottomButtonsContainer from "../../components/CustomImage/BottomButtonsContainer"; +import ContrastChoice from "../../components/CustomImage/ContrastChoice"; +import { ScreenName } from "../../const"; +import { ImagePreviewError } from "../../components/CustomImage/errors"; +import { ParamList } from "./types"; + +export const PreviewImage = styled.Image.attrs({ + resizeMode: "contain", +})` + align-self: center; + margin: 16px; + width: 200px; + height: 200px; +`; + +const boxToFitDimensions = { + height: (Dimensions.get("screen").height * 2) / 3, + width: (Dimensions.get("screen").width * 2) / 3, +}; + +const contrasts = [ + { val: 1, color: "neutral.c70" }, + { val: 1.5, color: "neutral.c50" }, + { val: 2, color: "neutral.c40" }, + { val: 3, color: "neutral.c30" }, +]; + +/** + * UI component that loads the input image (from the route params) & + * displays it in a preview UI with some contrast options & a confirm button at + * the bottom. + * Then on confirmation it navigates to the transfer step with the raw hex data + * of the image & the preview base 64 data URI of the image as params. + */ +const Step2Preview: React.FC< + StackScreenProps +> = ({ navigation, route }) => { + const imageProcessorRef = useRef(null); + const [resizedImage, setResizedImage] = useState(null); + const [contrast, setContrast] = useState(1); + const [processorPreviewImage, setProcessorPreviewImage] = + useState(null); + const [rawResultLoading, setRawResultLoading] = useState(false); + + const { t } = useTranslation(); + + const { params } = route; + + const croppedImage = params; + + const handleError = useCallback( + (error: Error) => { + console.error(error); + navigation.navigate( + ScreenName.CustomImageErrorScreen as "CustomImageErrorScreen", + { error }, + ); + }, + [navigation], + ); + + /** IMAGE RESIZING */ + + const handleResizeResult: ImageResizerParams["onResult"] = useCallback( + (res: ResizeResult) => { + setResizedImage(res); + }, + [setResizedImage], + ); + + useResizedImage({ + targetDimensions: cropAspectRatio, + imageFileUri: croppedImage?.imageFileUri, + onError: handleError, + onResult: handleResizeResult, + }); + + /** RESULT IMAGE HANDLING */ + + const handlePreviewResult: ImageProcessorProps["onPreviewResult"] = + useCallback( + data => { + setProcessorPreviewImage(data); + }, + [setProcessorPreviewImage], + ); + + const handleRawResult: ImageProcessorProps["onRawResult"] = useCallback( + (data: ProcessorRawResult) => { + if (!processorPreviewImage) { + /** + * this should not happen as the "request raw result" button is only + * visible once the preview is there + * */ + throw new ImagePreviewError(); + } + navigation.navigate( + ScreenName.CustomImageStep3Transfer as "CustomImageStep3Transfer", + { + rawData: data, + previewData: processorPreviewImage, + }, + ); + setRawResultLoading(false); + }, + [navigation, setRawResultLoading, processorPreviewImage], + ); + + const handlePreviewImageError = useCallback( + ({ nativeEvent }: NativeSyntheticEvent) => { + console.error(nativeEvent.error); + handleError(new ImagePreviewError()); + }, + [handleError], + ); + + const requestRawResult = useCallback(() => { + imageProcessorRef?.current?.requestRawResult(); + setRawResultLoading(true); + }, [imageProcessorRef, setRawResultLoading]); + + const previewDimensions = useMemo( + () => + fitImageContain( + { + width: processorPreviewImage?.width ?? 200, + height: processorPreviewImage?.height ?? 200, + }, + boxToFitDimensions, + ), + [processorPreviewImage?.height, processorPreviewImage?.width], + ); + + return ( + + {resizedImage?.imageBase64DataUri && ( + + )} + + {processorPreviewImage?.imageBase64DataUri ? ( + + ) : ( + + )} + + + + {t("customImage.selectContrast")} + + {resizedImage?.imageBase64DataUri && ( + + {contrasts.map(({ val, color }) => ( + setContrast(val)}> + + + ))} + + )} + + + + ); +}; + +export default Step2Preview; diff --git a/apps/ledger-live-mobile/src/screens/CustomImage/Step3Transfer.tsx b/apps/ledger-live-mobile/src/screens/CustomImage/Step3Transfer.tsx new file mode 100644 index 000000000000..fda4a11a087c --- /dev/null +++ b/apps/ledger-live-mobile/src/screens/CustomImage/Step3Transfer.tsx @@ -0,0 +1,145 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { Dimensions, ScrollView } from "react-native"; +import { Flex, InfiniteLoader, Text } from "@ledgerhq/native-ui"; +import { StackScreenProps } from "@react-navigation/stack"; +import { ProcessorPreviewResult } from "../../components/CustomImage/ImageProcessor"; +import ResultDataTester from "../../components/CustomImage/ResultDataTester"; +import { fitImageContain } from "../../components/CustomImage/imageUtils"; +import { PreviewImage } from "./Step2Preview"; +import Alert from "../../components/Alert"; +import { ScreenName } from "../../const"; +import { ParamList } from "./types"; + +const boxToFitDimensions = { + height: (Dimensions.get("screen").height * 2) / 3, + width: (Dimensions.get("screen").width * 2) / 3, +}; + +const infoMessage = `This is meant as a data validation. We want to validate \ +that the raw data above (that is eventually what will be transfered) allows to \ +reconstruct exactly the image previewed on the previous screen. + +We take this raw data and use it to rebuild the image from scratch, then \ +we try to match the binary value of the "previewed" image and the \ +"reconstructed" image.`; + +const successMessage = + "Raw data is valid. Image is 100% matching the preview displayed on the preview screen."; + +const errorMessage = `\ +Raw data is invalid. + +The reconstructed image is not matching the preview \ +displayed on the preview screen. + +This should NOT happen, it means that some data has been lost.`; + +/** + * UI component that reconstructs an image from the raw hex data received as a + * route param, and compares it to the preview base64 URI data received as a + * route param. + * + * This is meant as a data validation. We want to validate that the raw data + * (that is eventually what will be transfered) allows to reconstruct exactly + * the image previewed on the previous screen. + * + * We take this raw data and use it to rebuild the image from scratch, then + * we try to match the binary value of the "previewed" image and the "reconstructed" + * image. + */ +const Step3Transfer: React.FC< + StackScreenProps +> = ({ route, navigation }) => { + const [reconstructedPreviewResult, setReconstructedPreviewResult] = + useState(null); + + const { rawData, previewData } = route.params; + + const handlePreviewResult = useCallback((res: ProcessorPreviewResult) => { + setReconstructedPreviewResult(res); + }, []); + + const handleError = useCallback( + (error: Error) => { + console.error(error); + navigation.navigate( + ScreenName.CustomImageErrorScreen as "CustomImageErrorScreen", + { error }, + ); + }, + [navigation], + ); + + const previewDimensions = useMemo( + () => + fitImageContain( + { + width: reconstructedPreviewResult?.width ?? 200, + height: reconstructedPreviewResult?.height ?? 200, + }, + boxToFitDimensions, + ), + [reconstructedPreviewResult?.height, reconstructedPreviewResult?.width], + ); + + return ( + + + {rawData?.hexData && ( + <> + + Raw data (500 first characters): + + + width: {rawData?.width} + height: {rawData?.height} + {rawData?.hexData.slice(0, 500)} + + + )} + {rawData && ( + + )} + + + Image reconstructed from raw data: + + + {reconstructedPreviewResult?.imageBase64DataUri ? ( + + {reconstructedPreviewResult?.imageBase64DataUri === + previewData?.imageBase64DataUri ? ( + + ) : ( + + )} + + + ) : ( + + )} + + + + ); +}; + +export default Step3Transfer; diff --git a/apps/ledger-live-mobile/src/screens/CustomImage/shared.ts b/apps/ledger-live-mobile/src/screens/CustomImage/shared.ts new file mode 100644 index 000000000000..b17d98defb16 --- /dev/null +++ b/apps/ledger-live-mobile/src/screens/CustomImage/shared.ts @@ -0,0 +1,4 @@ +export const cropAspectRatio = { + width: 1080, + height: 1400, +}; diff --git a/apps/ledger-live-mobile/src/screens/CustomImage/types.ts b/apps/ledger-live-mobile/src/screens/CustomImage/types.ts new file mode 100644 index 000000000000..12181de2e768 --- /dev/null +++ b/apps/ledger-live-mobile/src/screens/CustomImage/types.ts @@ -0,0 +1,24 @@ +import { CropResult } from "../../components/CustomImage/ImageCropper"; +import { + ProcessorPreviewResult, + ProcessorRawResult, +} from "../../components/CustomImage/ImageProcessor"; +import { ImageFileUri, ImageUrl } from "../../components/CustomImage/types"; + +type Step1CroppingParams = ImageUrl | ImageFileUri; + +type Step2PreviewParams = CropResult; + +type Step3TransferParams = { + rawData: ProcessorRawResult; + previewData: ProcessorPreviewResult; +}; + +type ErrorScreenParams = { error: Error }; + +export type ParamList = { + CustomImageStep1Crop: Step1CroppingParams; + CustomImageStep2Preview: Step2PreviewParams; + CustomImageStep3Transfer: Step3TransferParams; + CustomImageErrorScreen: ErrorScreenParams; +}; diff --git a/apps/ledger-live-mobile/src/screens/Settings/Debug/OpenDebugCustomImage.tsx b/apps/ledger-live-mobile/src/screens/Settings/Debug/OpenDebugCustomImage.tsx new file mode 100644 index 000000000000..09ee2d8cf8f1 --- /dev/null +++ b/apps/ledger-live-mobile/src/screens/Settings/Debug/OpenDebugCustomImage.tsx @@ -0,0 +1,17 @@ +import React, { useCallback, useState } from "react"; +import { FeatureToggle } from "@ledgerhq/live-common/lib/featureFlags/index"; +import SettingsRow from "../../../components/SettingsRow"; +import CustomImageBottomModal from "../../../components/CustomImage/CustomImageBottomModal"; + +export default function OpenDebugCustomImage() { + const [modalOpened, setModalOpened] = useState(false); + const openModal = useCallback(() => setModalOpened(true), [setModalOpened]); + const closeModal = useCallback(() => setModalOpened(false), [setModalOpened]); + + return ( + + + + + ); +} diff --git a/apps/ledger-live-mobile/src/screens/Settings/Debug/index.tsx b/apps/ledger-live-mobile/src/screens/Settings/Debug/index.tsx index 86647611ad18..3bdf4ac96265 100644 --- a/apps/ledger-live-mobile/src/screens/Settings/Debug/index.tsx +++ b/apps/ledger-live-mobile/src/screens/Settings/Debug/index.tsx @@ -32,6 +32,7 @@ import GenerateMockAccount from "./GenerateMockAccountsSelect"; import OpenDebugEnv from "./OpenDebugEnv"; import HasOrderedNanoRow from "./HasOrderedNanoRow"; import OpenDebugBlePairingFlow from "./OpenDebugBlePairingFlow"; +import OpenDebugCustomImage from "./OpenDebugCustomImage"; // Type of DebugMocks screen route params export type DebugMocksParams = { @@ -66,6 +67,7 @@ export function DebugMocks() { + diff --git a/libs/ledger-live-common/src/featureFlags/defaultFeatures.ts b/libs/ledger-live-common/src/featureFlags/defaultFeatures.ts index 01c88f4d8d15..fa03a32d7e58 100644 --- a/libs/ledger-live-common/src/featureFlags/defaultFeatures.ts +++ b/libs/ledger-live-common/src/featureFlags/defaultFeatures.ts @@ -67,4 +67,13 @@ export const defaultFeatures: DefaultFeatures = { counterValue: { enabled: false, }, + llmUsbFirmwareUpdate: { + enabled: false, + }, + ptxSmartRouting: { + enabled: false, + }, + ptxSmartRoutingMobile: { + enabled: false, + }, }; diff --git a/libs/ledgerjs/packages/types-live/README.md b/libs/ledgerjs/packages/types-live/README.md index 624ecd06c52f..67afc7b08342 100644 --- a/libs/ledgerjs/packages/types-live/README.md +++ b/libs/ledgerjs/packages/types-live/README.md @@ -457,7 +457,7 @@ Type: [string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Globa Add others with union (e.g. "learn" | "market" | "foo") -Type: (`"learn"` | `"pushNotifications"` | `"llmUsbFirmwareUpdate"` | `"ratings"` | `"counterValue"` | `"buyDeviceFromLive"` | `"ptxSmartRouting"` | `"currencyOsmosis"` | `"currencyOsmosisMobile"` | `"ptxSmartRoutingMobile"` | [string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)) +Type: (`"learn"` | `"pushNotifications"` | `"llmUsbFirmwareUpdate"` | `"ratings"` | `"counterValue"` | `"buyDeviceFromLive"` | `"ptxSmartRouting"` | `"currencyOsmosis"` | `"currencyOsmosisMobile"` | `"ptxSmartRoutingMobile"` | `"customImage"`) ### Feature diff --git a/libs/ledgerjs/packages/types-live/src/feature.ts b/libs/ledgerjs/packages/types-live/src/feature.ts index 794a296ebbf5..e02c87c1a22e 100644 --- a/libs/ledgerjs/packages/types-live/src/feature.ts +++ b/libs/ledgerjs/packages/types-live/src/feature.ts @@ -10,7 +10,7 @@ export type FeatureId = | "currencyOsmosis" | "currencyOsmosisMobile" | "ptxSmartRoutingMobile" - | string; + | "customImage"; /** We use objects instead of direct booleans for potential future improvements like feature versioning etc */ @@ -31,4 +31,4 @@ export type Feature = { }; /** */ -export type DefaultFeatures = { [key in FeatureId]: Feature }; +export type DefaultFeatures = { [key in FeatureId]?: Feature }; diff --git a/package.json b/package.json index bb4b475f04ba..9225a73c3e4d 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,8 @@ }, "patchedDependencies": { "react-native-fast-crypto@2.2.0": "patches/react-native-fast-crypto@2.2.0.patch", - "react-native-video@5.2.0": "patches/react-native-video@5.2.0.patch" + "react-native-video@5.2.0": "patches/react-native-video@5.2.0.patch", + "react-native-image-crop-tools@1.6.2": "patches/react-native-image-crop-tools@1.6.2.patch" } } } diff --git a/patches/react-native-image-crop-tools@1.6.2.patch b/patches/react-native-image-crop-tools@1.6.2.patch new file mode 100644 index 000000000000..b4f0111e1000 --- /dev/null +++ b/patches/react-native-image-crop-tools@1.6.2.patch @@ -0,0 +1,69 @@ +diff --git a/android/src/main/java/com/parsempo/ImageCropTools/ImageCropViewManager.kt b/android/src/main/java/com/parsempo/ImageCropTools/ImageCropViewManager.kt +index be9b96b89df8c6d151e5199202e4c4cf16c04888..02c27023754f2b4969800394f619ddf2545498a4 100644 +--- a/android/src/main/java/com/parsempo/ImageCropTools/ImageCropViewManager.kt ++++ b/android/src/main/java/com/parsempo/ImageCropTools/ImageCropViewManager.kt +@@ -2,6 +2,7 @@ package com.parsempo.ImageCropTools + + import android.graphics.Bitmap + import android.net.Uri ++import com.canhub.cropper.CropImageView + import com.facebook.react.bridge.Arguments + import com.facebook.react.bridge.ReadableArray + import com.facebook.react.bridge.ReadableMap +@@ -9,7 +10,6 @@ import com.facebook.react.common.MapBuilder + import com.facebook.react.uimanager.SimpleViewManager + import com.facebook.react.uimanager.ThemedReactContext + import com.facebook.react.uimanager.annotations.ReactProp +-import com.canhub.cropper.CropImageView + import com.facebook.react.uimanager.events.RCTEventEmitter + import java.io.File + import java.util.* +@@ -21,6 +21,7 @@ class ImageCropViewManager: SimpleViewManager() { + const val SOURCE_URL_PROP = "sourceUrl" + const val KEEP_ASPECT_RATIO_PROP = "keepAspectRatio" + const val ASPECT_RATIO_PROP = "cropAspectRatio" ++ const val SCALE_TYPE_PROP = "scaleType" + const val SAVE_IMAGE_COMMAND = 1 + const val ROTATE_IMAGE_COMMAND = 2 + const val SAVE_IMAGE_COMMAND_NAME = "saveImage" +@@ -105,4 +106,11 @@ class ImageCropViewManager: SimpleViewManager() { + view.clearAspectRatio() + } + } ++ ++ @ReactProp(name = SCALE_TYPE_PROP) ++ fun setScaleType(view: CropImageView, scaleType: Int) { ++ try { ++ view.scaleType = CropImageView.ScaleType.values()[scaleType] ++ } catch (e: Exception) {} ++ } + } +diff --git a/dist/crop-view.component.d.ts b/dist/crop-view.component.d.ts +index b6099fb1b2fad259cc103b6f8e0c0eae6580a7d3..322e4e462e51fe8775bfcb3fcc71f9188ea345ff 100644 +--- a/dist/crop-view.component.d.ts ++++ b/dist/crop-view.component.d.ts +@@ -14,6 +14,7 @@ declare type Props = { + width: number; + height: number; + }; ++ scaleType?: number; + }; + declare class CropView extends React.PureComponent { + static defaultProps: { +diff --git a/dist/crop-view.component.js b/dist/crop-view.component.js +index 7c93dc5ec66293e607ac74945acb1db0e33d0f1f..13fadae3df73a77afeb67ee17b000022a0f4b20b 100644 +--- a/dist/crop-view.component.js ++++ b/dist/crop-view.component.js +@@ -13,10 +13,10 @@ class CropView extends React.PureComponent { + }; + } + render() { +- const { sourceUrl, style, onImageCrop, keepAspectRatio, aspectRatio } = this.props; ++ const { sourceUrl, style, onImageCrop, keepAspectRatio, aspectRatio, scaleType } = this.props; + return (React.createElement(RCTCropView, { ref: this.viewRef, sourceUrl: sourceUrl, style: style, onImageSaved: (event) => { + onImageCrop(event.nativeEvent); +- }, keepAspectRatio: keepAspectRatio, cropAspectRatio: aspectRatio })); ++ }, keepAspectRatio: keepAspectRatio, cropAspectRatio: aspectRatio, scaleType })); + } + } + CropView.defaultProps = { \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aaf8419678c8..2eca2d500394 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,9 @@ patchedDependencies: react-native-video@5.2.0: hash: lojvl6twgoj4rxeqeytmq4uduq path: patches/react-native-video@5.2.0.patch + react-native-image-crop-tools@1.6.2: + hash: ykxjy5s7xujdxmsgrwxo5mh3y4 + path: patches/react-native-image-crop-tools@1.6.2.patch importers: @@ -608,7 +611,10 @@ importers: expo: ^43.0.1 expo-barcode-scanner: ~11.2.0 expo-camera: ~12.0.3 + expo-file-system: ~13.1.3 expo-image-loader: ~3.1.1 + expo-image-manipulator: ~10.2.1 + expo-image-picker: 12.0.2 expo-modules-autolinking: ^0.5.5 expo-modules-core: ^0.6.5 flipper-plugin-rn-performance-android: ^0.1.0 @@ -654,6 +660,7 @@ importers: react-native-fingerprint-scanner: git+https://github.com/hieuvp/react-native-fingerprint-scanner.git#f1d136f605412d58e4de9e7e155d6f818ba24731 react-native-flipper-performance-plugin: ^0.2.1 react-native-gesture-handler: ^2.5.0 + react-native-image-crop-tools: ^1.6.2 react-native-keychain: ^7.0.0 react-native-level-fs: ^3.0.0 react-native-localize: ^2.2.1 @@ -686,6 +693,7 @@ importers: redux-actions: 2.6.5 redux-thunk: 2.3.0 reselect: 4.0.0 + rn-fetch-blob: ^0.12.0 rn-snoopy: ^2.0.2 rxjs: ^6.6.6 rxjs-compat: ^6.6.6 @@ -750,7 +758,10 @@ importers: expo: 43.0.5_@babel+core@7.17.10 expo-barcode-scanner: 11.2.1_expo@43.0.5 expo-camera: 12.0.3_react@17.0.2 + expo-file-system: 13.1.4_expo@43.0.5 expo-image-loader: 3.1.1_expo@43.0.5 + expo-image-manipulator: 10.2.1_expo@43.0.5 + expo-image-picker: 12.0.2_expo@43.0.5 expo-modules-autolinking: 0.5.5 expo-modules-core: 0.6.5 fuse.js: 6.6.2 @@ -782,6 +793,7 @@ importers: react-native-fast-image: 8.5.11_zqxy7fpkavjkgz5xll7ed4r6rq react-native-fingerprint-scanner: github.com/hieuvp/react-native-fingerprint-scanner/f1d136f605412d58e4de9e7e155d6f818ba24731_react-native@0.68.2 react-native-gesture-handler: 2.5.0_mk7ksnag22facomrynqrp7v6gy + react-native-image-crop-tools: 1.6.2_ykxjy5s7xujdxmsgrwxo5mh3y4_zqxy7fpkavjkgz5xll7ed4r6rq react-native-keychain: 7.0.0 react-native-level-fs: 3.0.1_asyncstorage-down@4.2.0 react-native-localize: 2.2.1_zqxy7fpkavjkgz5xll7ed4r6rq @@ -814,6 +826,7 @@ importers: redux-actions: 2.6.5 redux-thunk: 2.3.0_redux@4.2.0 reselect: 4.0.0 + rn-fetch-blob: 0.12.0 rn-snoopy: 2.0.2_eslint@7.32.0 rxjs: 6.6.7 rxjs-compat: 6.6.7 @@ -22775,7 +22788,7 @@ packages: dev: true /base-64/0.1.0: - resolution: {integrity: sha1-eAqZyE59YAJgNhURxId2E78k9rs=} + resolution: {integrity: sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==} dev: false /base-x/3.0.9: @@ -30483,6 +30496,18 @@ packages: - supports-color dev: false + /expo-file-system/13.1.4_expo@43.0.5: + resolution: {integrity: sha512-/C2FKCzrdWuEt4m8Pzl9J4MhKgfU0denVLbqoKjidv8DnsLQrscFNlLhXuiooqWwsxB2OWAtGEVnPGJBWVuNEQ==} + peerDependencies: + expo: '*' + dependencies: + '@expo/config-plugins': 4.1.4 + expo: 43.0.5_@babel+core@7.17.10 + uuid: 3.4.0 + transitivePeerDependencies: + - supports-color + dev: false + /expo-file-system/13.1.4_expo@44.0.6: resolution: {integrity: sha512-/C2FKCzrdWuEt4m8Pzl9J4MhKgfU0denVLbqoKjidv8DnsLQrscFNlLhXuiooqWwsxB2OWAtGEVnPGJBWVuNEQ==} peerDependencies: @@ -30530,6 +30555,27 @@ packages: expo: 43.0.5_@babel+core@7.17.10 dev: false + /expo-image-manipulator/10.2.1_expo@43.0.5: + resolution: {integrity: sha512-0klgPMn8fIUkbWpRVT0LVCtq0ozzm3gO60jZEcJPofJRQWDKuv3Rcf0+8pTqpn45J53eAGsuZ72/Yj0AJqaedQ==} + peerDependencies: + expo: '*' + dependencies: + expo: 43.0.5_@babel+core@7.17.10 + expo-image-loader: 3.1.1_expo@43.0.5 + dev: false + + /expo-image-picker/12.0.2_expo@43.0.5: + resolution: {integrity: sha512-rAoNGtofV5cg3UN+cdIGDVfbMvbutBX6uNV7jCIEw/WZxZlbW+R7HC8l5lGDFtoLVqcpkHE/6JpAXvESxVSAOA==} + peerDependencies: + expo: '*' + dependencies: + '@expo/config-plugins': 4.1.5 + expo: 43.0.5_@babel+core@7.17.10 + uuid: 7.0.2 + transitivePeerDependencies: + - supports-color + dev: false + /expo-keep-awake/10.0.2_expo@43.0.5: resolution: {integrity: sha512-Ro1lgyKldbFs4mxhWM+goX9sg0S2SRR8FiJJeOvaRzf8xNhrZfWA00Zpr+/3ocCoWQ3eEL+X9UF4PXXHf0KoOg==} peerDependencies: @@ -32485,6 +32531,17 @@ packages: path-is-absolute: 1.0.1 dev: true + /glob/7.0.6: + resolution: {integrity: sha512-f8c0rE8JiCxpa52kWPAOa3ZaYEnzofDzCQLCn3Vdk0Z5OVLq3BsRFJI4S4ykpeVW6QMGBUkMeUpoEgWnMTnw5Q==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: false + /glob/7.1.3: resolution: {integrity: sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==} dependencies: @@ -45688,7 +45745,7 @@ packages: dependencies: '@babel/parser': 7.17.10 flow-parser: 0.121.0 - glob: 7.2.0 + glob: 8.0.1 invariant: 2.2.4 jscodeshift: 0.13.1 nullthrows: 1.1.1 @@ -45839,6 +45896,17 @@ packages: resolution: {integrity: sha512-eIlgtsmDp1jLC24dRn43hB3kEcZVqx6DUQbR0N1ABXGnMEafm9I3V3dUUeD1vh+Dy5WqijSoEwLNUPLgu5zDMg==} dev: false + /react-native-image-crop-tools/1.6.2_ykxjy5s7xujdxmsgrwxo5mh3y4_zqxy7fpkavjkgz5xll7ed4r6rq: + resolution: {integrity: sha512-yndxSrHTCKPxxXlkCS62JQzlSlAK/K4pxyqu+IfacK/baykfXvSB7ps7WB74C0gFFnhfXSrgcktrjHMjJwXTyA==} + peerDependencies: + react: ^16.8.1 + react-native: '>=0.59.0-rc.0 <1.0.x' + dependencies: + react: 17.0.2 + react-native: 0.68.2_qiqqwmyv63yhnuboofexv3s7x4 + dev: false + patched: true + /react-native-keychain/7.0.0: resolution: {integrity: sha512-tH26sgW4OxB/llXmhO+DajFISEUoF1Ip2+WSDMIgCt8SP1xRE81m2qFzgIOc/7StYsUERxHhDPkxvq2H0/Goig==} dev: false @@ -47962,6 +48030,13 @@ packages: hasBin: true dev: false + /rn-fetch-blob/0.12.0: + resolution: {integrity: sha512-+QnR7AsJ14zqpVVUbzbtAjq0iI8c9tCg49tIoKO2ezjzRunN7YL6zFSFSWZm6d+mE/l9r+OeDM3jmb2tBb2WbA==} + dependencies: + base-64: 0.1.0 + glob: 7.0.6 + dev: false + /rn-range-slider/2.1.1: resolution: {integrity: sha512-tDfH8G4e/k+waGMMd+HaYTlpOqyh1dd3aWdDmqKo0WGh1BfHB+NXy0X2REyr1ZJDvsrq7WEeFZ+PBTdD6cisbw==} dev: false @@ -52473,6 +52548,11 @@ packages: deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. hasBin: true + /uuid/7.0.2: + resolution: {integrity: sha512-vy9V/+pKG+5ZTYKf+VcphF5Oc6EFiu3W8Nv3P3zIh0EqVI80ZxOzuPfe9EHjkFNvf8+xuTHVeei4Drydlx4zjw==} + hasBin: true + dev: false + /uuid/7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} hasBin: true