diff --git a/.flowconfig b/.flowconfig index 4a2c3416b8..8477494a7a 100644 --- a/.flowconfig +++ b/.flowconfig @@ -35,6 +35,7 @@ node_modules/warning/.* ; Creates weird flow erros, ignoring for now .*/node_modules/@ledgerhq/react-native-hw-transport-ble/lib/BleTransport.js +.*/node_modules/@ledgerhq/live-common/src/families/tezos/bridge/js.ts [include] @@ -52,6 +53,7 @@ esproposal.nullish_coalescing=enable module.file_ext=.js module.file_ext=.tsx +module.file_ext=.ts module.file_ext=.json module.file_ext=.ios.js diff --git a/android/app/build.gradle b/android/app/build.gradle index 711a13a2f6..ec4516782c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -10,8 +10,6 @@ project.ext.envConfigFiles = [ apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle" apply from: "../../node_modules/react-native-vector-icons/fonts.gradle" -import com.android.build.OutputFile - /** * The react.gradle file registers a task for each build variant (e.g. bundleDebugJsAndAssets * and bundleReleaseJsAndAssets). @@ -257,3 +255,5 @@ task copyDownloadableDepsToLibs(type: Copy) { } apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project) +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 33c5fa42a4..782736f69e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + @@ -30,11 +31,11 @@ android:allowBackup="false" android:theme="@style/AppTheme" android:networkSecurityConfig="@xml/network_security_config"> + > { + return emptyList() + } + + override fun createNativeModules(reactContext: ReactApplicationContext): List { + val modules: MutableList = ArrayList() + modules.add(BackgroundRunner(reactContext)) + return modules + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/ledger/live/BackgroundService.kt b/android/app/src/main/java/com/ledger/live/BackgroundService.kt new file mode 100644 index 0000000000..cf41e9098b --- /dev/null +++ b/android/app/src/main/java/com/ledger/live/BackgroundService.kt @@ -0,0 +1,22 @@ +package com.ledger.live; + +import android.content.Intent +import com.facebook.react.HeadlessJsTaskService +import com.facebook.react.bridge.Arguments +import com.facebook.react.jstasks.HeadlessJsTaskConfig + +class BackgroundService : HeadlessJsTaskService() { + override fun getTaskConfig(intent: Intent): HeadlessJsTaskConfig? { + val extras = intent.extras + return if (extras != null) { + HeadlessJsTaskConfig( + "BackgroundRunnerService", + Arguments.fromBundle(extras), + 600000, // timeout for the task + true // optional: defines whether or not the task is allowed in foreground. Default is false + ) + } else { + return null; + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/ledger/live/MainActivity.java b/android/app/src/main/java/com/ledger/live/MainActivity.java index 790b32aef6..59649e15e2 100644 --- a/android/app/src/main/java/com/ledger/live/MainActivity.java +++ b/android/app/src/main/java/com/ledger/live/MainActivity.java @@ -1,14 +1,19 @@ package com.ledger.live; import expo.modules.ReactActivityDelegateWrapper; +import android.app.PendingIntent; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; +import android.content.Intent; import android.content.res.Configuration; import android.os.Bundle; import android.view.View; import android.view.WindowManager; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + import com.facebook.react.ReactActivity; import org.devio.rn.splashscreen.SplashScreen; @@ -45,7 +50,6 @@ protected void onCreate(Bundle savedInstanceState) { } } super.onCreate(null); - /** * Addresses an inconvenient side-effect of using `password-visible`, that * allowed styled texts to be pasted (receiver's address for instance) retaining diff --git a/android/app/src/main/java/com/ledger/live/MainApplication.java b/android/app/src/main/java/com/ledger/live/MainApplication.java index 7587bb7a74..315fc33f9d 100644 --- a/android/app/src/main/java/com/ledger/live/MainApplication.java +++ b/android/app/src/main/java/com/ledger/live/MainApplication.java @@ -1,10 +1,14 @@ package com.ledger.live; +import android.app.NotificationChannel; +import android.app.NotificationManager; import android.content.res.Configuration; import expo.modules.ApplicationLifecycleDispatcher; import expo.modules.ReactNativeHostWrapper; import android.app.Application; import android.content.Context; +import android.os.Build; + import com.facebook.react.PackageList; import com.facebook.react.ReactApplication; import co.airbitz.fastcrypto.RNFastCryptoPackage; @@ -22,6 +26,24 @@ import java.util.List; public class MainApplication extends Application implements ReactApplication { + public static String LO_NOTIFICATION_CHANNEL = "lo-llm"; + public static String HI_NOTIFICATION_CHANNEL = "hi-llm"; + public static int FW_UPDATE_NOTIFICATION_PROGRESS = 1; + public static int FW_UPDATE_NOTIFICATION_USER = 2; + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + String description = "Notification channel for background running tasks"; + NotificationChannel loChannel = new NotificationChannel(LO_NOTIFICATION_CHANNEL, LO_NOTIFICATION_CHANNEL, NotificationManager.IMPORTANCE_DEFAULT); + loChannel.setDescription(description); + NotificationChannel hiChannel = new NotificationChannel(HI_NOTIFICATION_CHANNEL, HI_NOTIFICATION_CHANNEL, NotificationManager.IMPORTANCE_HIGH); + hiChannel.setDescription(description); + + NotificationManager notificationManager = getSystemService(NotificationManager.class); + notificationManager.createNotificationChannel(loChannel); + notificationManager.createNotificationChannel(hiChannel); + } + } private final ReactNativeHost mReactNativeHost = new ReactNativeHostWrapper(this, new ReactNativeHost(this) { @@ -36,6 +58,7 @@ protected List getPackages() { List packages = new PackageList(this).getPackages(); packages.add(new BluetoothHelperPackage()); packages.add(new ReactVideoPackage()); + packages.add(new BackgroundRunnerPackager()); return packages; } @@ -61,6 +84,7 @@ public void onCreate() { SoLoader.init(this, /* native exopackage */ false); initializeFlipper(this, getReactNativeHost().getReactInstanceManager()); ApplicationLifecycleDispatcher.onApplicationCreate(this); + createNotificationChannel(); } /** diff --git a/android/app/src/main/res/drawable-hdpi-v11/ic_stat_group.png b/android/app/src/main/res/drawable-hdpi-v11/ic_stat_group.png new file mode 100644 index 0000000000..033c29baf4 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi-v11/ic_stat_group.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_stat_group.png b/android/app/src/main/res/drawable-hdpi/ic_stat_group.png new file mode 100644 index 0000000000..5f74d50ec6 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_stat_group.png differ diff --git a/android/app/src/main/res/drawable-mdpi-v11/ic_stat_group.png b/android/app/src/main/res/drawable-mdpi-v11/ic_stat_group.png new file mode 100644 index 0000000000..b7ecc47bd5 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi-v11/ic_stat_group.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_stat_group.png b/android/app/src/main/res/drawable-mdpi/ic_stat_group.png new file mode 100644 index 0000000000..6c6470055f Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_stat_group.png differ diff --git a/android/app/src/main/res/drawable-xhdpi-v11/ic_stat_group.png b/android/app/src/main/res/drawable-xhdpi-v11/ic_stat_group.png new file mode 100644 index 0000000000..c3888a0853 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi-v11/ic_stat_group.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_stat_group.png b/android/app/src/main/res/drawable-xhdpi/ic_stat_group.png new file mode 100644 index 0000000000..7433a79931 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_stat_group.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi-v11/ic_stat_group.png b/android/app/src/main/res/drawable-xxhdpi-v11/ic_stat_group.png new file mode 100644 index 0000000000..308d822861 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi-v11/ic_stat_group.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_stat_group.png b/android/app/src/main/res/drawable-xxhdpi/ic_stat_group.png new file mode 100644 index 0000000000..6a875c9dc2 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_stat_group.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi-v11/ic_stat_group.png b/android/app/src/main/res/drawable-xxxhdpi-v11/ic_stat_group.png new file mode 100644 index 0000000000..650f62bd87 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi-v11/ic_stat_group.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_stat_group.png b/android/app/src/main/res/drawable-xxxhdpi/ic_stat_group.png new file mode 100644 index 0000000000..d22c7f25e5 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_stat_group.png differ diff --git a/android/app/src/main/res/mipmap-ldpi/smallicon.png b/android/app/src/main/res/mipmap-ldpi/smallicon.png new file mode 100644 index 0000000000..d661c0e867 Binary files /dev/null and b/android/app/src/main/res/mipmap-ldpi/smallicon.png differ diff --git a/android/build.gradle b/android/build.gradle index d04621d0b7..0cd44daec8 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -19,6 +19,7 @@ buildscript { // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" + classpath "org.jetbrains.kotlin:kotlin-android-extensions:$kotlinVersion" classpath 'com.google.gms:google-services:4.3.10' } gradle.projectsEvaluated { diff --git a/index.js b/index.js index 7483dc9881..f83ecf5392 100644 --- a/index.js +++ b/index.js @@ -17,6 +17,7 @@ import * as Sentry from "@sentry/react-native"; import Config from "react-native-config"; import VersionNumber from "react-native-version-number"; +import BackgroundRunnerService from "./services/BackgroundRunnerService"; import App, { routingInstrumentation } from "./src"; import { getEnabled } from "./src/components/HookSentry"; import logReport from "./src/log-report"; @@ -107,3 +108,7 @@ logReport.logReportInit(); const AppWithSentry = Sentry.wrap(App); AppRegistry.registerComponent("ledgerlivemobile", () => AppWithSentry); +AppRegistry.registerHeadlessTask( + "BackgroundRunnerService", + () => BackgroundRunnerService, +); diff --git a/package.json b/package.json index 613279826f..5486761a86 100644 --- a/package.json +++ b/package.json @@ -70,14 +70,14 @@ "@formatjs/intl-locale": "^2.4.46", "@formatjs/intl-numberformat": "^7.4.2", "@formatjs/intl-pluralrules": "^4.3.2", - "@ledgerhq/devices": "6.24.1", + "@ledgerhq/devices": "6.27.1", "@ledgerhq/errors": "6.10.0", - "@ledgerhq/hw-transport": "6.24.1", + "@ledgerhq/hw-transport": "6.27.1", "@ledgerhq/hw-transport-http": "6.27.0", - "@ledgerhq/live-common": "22.0.2", + "@ledgerhq/live-common": "https://github.com/LedgerHQ/ledger-live-common.git#release/22.1.x", "@ledgerhq/logs": "6.10.0", "@ledgerhq/native-ui": "^0.7.16", - "@ledgerhq/react-native-hid": "6.24.1", + "@ledgerhq/react-native-hid": "6.27.1", "@ledgerhq/react-native-hw-transport-ble": "6.25.1", "@ledgerhq/react-native-passcode-auth": "^2.1.0", "@polkadot/reactnative-identicon": "0.87.5", @@ -189,6 +189,7 @@ "rn-snoopy": "^2.0.2", "rxjs": "^6.6.6", "rxjs-compat": "^6.6.6", + "semver": "^7.3.7", "stream-browserify": "^3.0.0", "string_decoder": "~1.3.0", "styled-components": "^5.3.3", @@ -210,6 +211,8 @@ "@types/react-native": "^0.65.21", "@types/react-native-video": "^5.0.13", "@types/react-test-renderer": "^17.0.1", + "@types/redux-actions": "^2.6.2", + "@types/semver": "^7.3.9", "babel-jest": "^26.6.3", "babel-plugin-module-resolver": "^4.1.0", "detox": "^18.2.1", diff --git a/services/BackgroundRunnerService.ts b/services/BackgroundRunnerService.ts new file mode 100644 index 0000000000..11e2fedd4e --- /dev/null +++ b/services/BackgroundRunnerService.ts @@ -0,0 +1,110 @@ +import { log } from "@ledgerhq/logs"; +import { withDevicePolling } from "@ledgerhq/live-common/lib/hw/deviceAccess"; +import getDeviceInfo from "@ledgerhq/live-common/lib/hw/getDeviceInfo"; +import { from } from "rxjs"; +import { timeout } from "rxjs/operators"; +import { NativeModules } from "react-native"; +import { hasFinalFirmware } from "@ledgerhq/live-common/lib/hw/hasFinalFirmware"; +import { FirmwareUpdateContext } from "@ledgerhq/live-common/lib/types/manager"; +import prepareFirmwareUpdate from "@ledgerhq/live-common/lib/hw/firmwareUpdate-prepare"; +import mainFirmwareUpdate from "@ledgerhq/live-common/lib/hw/firmwareUpdate-main"; + +import { addBackgroundEvent } from "../src/actions/appstate"; +import { store } from "../src/context/LedgerStore"; +import { BackgroundEvent } from "../src/reducers/appstate"; + +/** + * This task is not able to touch UI, but it will allow us to complete tasks + * even when the device goes to the background. We don't have access to hooks + * because we are not inside a component but we can read/write the store so we'll + * use that as the common-ground. + */ +const TAG = "headlessJS"; +const BackgroundRunnerService = async ({ + deviceId, + firmwareSerializedJson, +}: { + deviceId: string; + firmwareSerializedJson: string; +}) => { + const emitEvent = (e: BackgroundEvent) => + store.dispatch(addBackgroundEvent(e)); + const latestFirmware = JSON.parse(firmwareSerializedJson) as + | FirmwareUpdateContext + | null + | undefined; + + if (!latestFirmware) { + log(TAG, "no need to update"); + return 0; + } + + const onError = (error: any) => { + emitEvent({ type: "error", error }); + NativeModules.BackgroundRunner.stop(); + }; + + const onFirmwareUpdated = () => { + emitEvent({ type: "firmwareUpdated" }); + NativeModules.BackgroundRunner.stop(); + }; + + const waitForOnlineDevice = (maxWait: number) => { + return withDevicePolling(deviceId)( + transport => from(getDeviceInfo(transport)), + () => true, + ).pipe(timeout(maxWait)); + }; + + prepareFirmwareUpdate(deviceId, latestFirmware).subscribe({ + next: ({ progress, displayedOnDevice }) => { + if (displayedOnDevice) { + emitEvent({ type: "confirmUpdate" }); + } else { + emitEvent({ type: "downloadingUpdate", progress }); + } + }, + error: onError, + complete: () => { + // Depending on the update path, we might need to run the firmwareMain or simply wait until + // the device is online. + if ( + latestFirmware.shouldFlashMCU || + hasFinalFirmware(latestFirmware.final) + ) { + emitEvent({ type: "flashingMcu" }); + mainFirmwareUpdate(deviceId, latestFirmware).subscribe({ + next: ({ progress, installing }) => { + if (progress === 1 && installing === "flash-mcu") { + // this is the point where we lose communication with the device until the update + // is finished and the user has entered their PIN. Therefore the message here should + // be generic about waiting for the firmware to finish and then entering the pin + emitEvent({ type: "confirmPin" }); + } else { + emitEvent({ type: "flashingMcu", progress, installing }); + } + }, + error: onError, + complete: () => { + emitEvent({ type: "confirmPin" }); + waitForOnlineDevice(5 * 60 * 1000).subscribe({ + error: onError, + complete: onFirmwareUpdated, + }); + }, + }); + } else { + emitEvent({ type: "confirmPin" }); + // We're waiting forever condition that make getDeviceInfo work + waitForOnlineDevice(5 * 60 * 1000).subscribe({ + error: onError, + complete: onFirmwareUpdated, + }); + } + }, + }); + + return null; +}; + +export default BackgroundRunnerService; diff --git a/src/actions/appstate.js b/src/actions/appstate.js index 5f9d808554..d70f22e02a 100644 --- a/src/actions/appstate.js +++ b/src/actions/appstate.js @@ -21,3 +21,21 @@ export const setHasConnectedDevice = (hasConnectedDevice: boolean) => ( export const setModalLock = (modalLock: boolean) => (dispatch: *) => dispatch({ type: "SET_MODAL_LOCK", modalLock }); + +export const addBackgroundEvent = (event: *) => (dispatch: *) => + dispatch({ + type: "QUEUE_BACKGROUND_EVENT", + event, + }); + +export const dequeueBackgroundEvent = () => (dispatch: *) => + dispatch({ + type: "DEQUEUE_BACKGROUND_EVENT", + }); + +export const clearBackgroundEvents = () => (dispatch: *) => + dispatch({ + type: "CLEAR_BACKGROUND_EVENTS", + }); + +// TODO: migrate to TS diff --git a/src/analytics/Track.tsx b/src/analytics/Track.tsx new file mode 100644 index 0000000000..bd448c53a7 --- /dev/null +++ b/src/analytics/Track.tsx @@ -0,0 +1,40 @@ +import { PureComponent } from "react"; +import { track } from "./segment"; + +class Track extends PureComponent<{ + onMount?: boolean; + onUnmount?: boolean; + onUpdate?: boolean; + event: string; + mandatory?: boolean; +}> { + componentDidMount() { + if (this.props.onMount) this.track(); + } + + componentDidUpdate() { + if (this.props.onUpdate) this.track(); + } + + componentWillUnmount() { + if (this.props.onUnmount) this.track(); + } + + track = () => { + const { + event, + onMount, + onUnmount, + onUpdate, + mandatory, + ...properties + } = this.props; + track(event, properties, mandatory ?? null); + }; + + render() { + return null; + } +} + +export default Track; diff --git a/src/components/BottomModal.tsx b/src/components/BottomModal.tsx index 8259ea2fc9..51d9cf6bcf 100644 --- a/src/components/BottomModal.tsx +++ b/src/components/BottomModal.tsx @@ -14,6 +14,7 @@ export type Props = { children?: React.ReactNode; style?: StyleProp; preventBackdropClick?: boolean; + noCloseButton?: boolean; containerStyle?: StyleProp; }; @@ -25,6 +26,7 @@ const BottomModal = ({ preventBackdropClick, onModalHide, containerStyle, + noCloseButton, ...rest }: Props) => { const [open, setIsOpen] = useState(false); @@ -59,7 +61,7 @@ const BottomModal = ({ preventBackdropClick={modalLock || preventBackdropClick} isOpen={open} onClose={handleClose} - noCloseButton={modalLock} + noCloseButton={modalLock || noCloseButton} modalStyle={style} containerStyle={containerStyle} {...rest} diff --git a/src/components/DeviceAction/index.js b/src/components/DeviceAction/index.js index 11d573d3d7..66c7be0212 100644 --- a/src/components/DeviceAction/index.js +++ b/src/components/DeviceAction/index.js @@ -6,9 +6,10 @@ import type { Device, } from "@ledgerhq/live-common/lib/hw/actions/types"; import { DeviceNotOnboarded } from "@ledgerhq/live-common/lib/errors"; -import { TransportStatusError } from "@ledgerhq/errors"; +import { TransportStatusError, DisconnectedDevice } from "@ledgerhq/errors"; import { useTranslation } from "react-i18next"; import { useNavigation, useTheme } from "@react-navigation/native"; +import { track } from "../../analytics"; import { setLastSeenDeviceInfo } from "../../actions/settings"; import ValidateOnDevice from "../ValidateOnDevice"; import ValidateMessageOnDevice from "../ValidateMessageOnDevice"; @@ -26,6 +27,7 @@ import { renderConfirmSwap, renderConfirmSell, LoadingAppInstall, + AutoRepair, } from "./rendering"; import PreventNativeBack from "../PreventNativeBack"; import SkipLock from "../behaviour/SkipLock"; @@ -72,6 +74,9 @@ export default function DeviceAction({ requiresAppInstallation, inWrongDeviceForAccount, onRetry, + repairModalOpened, + onAutoRepair, + closeRepairModal, deviceSignatureRequested, deviceStreamingProgress, displayUpgradeWarning, @@ -111,6 +116,19 @@ export default function DeviceAction({ }); } + if (repairModalOpened && repairModalOpened.auto) { + return ( + + ); + } + if (requestQuitApp) { return renderRequestQuitApp({ t, @@ -205,6 +223,7 @@ export default function DeviceAction({ } if (!isLoading && error) { + track("DeviceActionError", error); onError && onError(error); // NB Until we find a better way, remap the error if it's 6d06 and we haven't fallen @@ -224,6 +243,16 @@ export default function DeviceAction({ }); } + if (error.message === "Invalid channel") { + return renderError({ + t, + navigation, + error: new DisconnectedDevice(), + colors, + theme, + }); + } + return renderError({ t, navigation, @@ -252,7 +281,7 @@ export default function DeviceAction({ } if (deviceInfo && deviceInfo.isBootloader) { - return renderBootloaderStep({ t, colors, theme }); + return renderBootloaderStep({ onAutoRepair, t }); } if (request && device && deviceSignatureRequested) { diff --git a/src/components/DeviceAction/rendering.tsx b/src/components/DeviceAction/rendering.tsx index 8cbd1ae949..04a718112e 100644 --- a/src/components/DeviceAction/rendering.tsx +++ b/src/components/DeviceAction/rendering.tsx @@ -1,10 +1,11 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { useDispatch } from "react-redux"; import styled from "styled-components/native"; import { WrongDeviceForAccount, UnexpectedBootloader } from "@ledgerhq/errors"; import { TokenCurrency } from "@ledgerhq/live-common/lib/types"; import { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; import { AppRequest } from "@ledgerhq/live-common/lib/hw/actions/app"; +import firmwareUpdateRepair from "@ledgerhq/live-common/lib/hw/firmwareUpdate-repair"; import { InfiniteLoader, Text, @@ -18,6 +19,7 @@ import { urls } from "../../config/urls"; import Alert from "../Alert"; import { lighten } from "../../colors"; import Button from "../Button"; +import FirmwareProgress from "../FirmwareProgress"; import { NavigatorName, ScreenName } from "../../const"; import Animation from "../Animation"; import getDeviceAnimation from "./getDeviceAnimation"; @@ -553,11 +555,76 @@ export function renderWarningOutdated({ ); } -export function renderBootloaderStep({ t, colors, theme }: RawProps) { - return renderError({ - t, - error: new UnexpectedBootloader(), - colors, - theme, - }); -} +export const renderBootloaderStep = ({ + onAutoRepair, + t, +}: RawProps & { onAutoRepair: () => void }) => ( + + {t("DeviceAction.deviceInBootloader.title")} + + {t("DeviceAction.deviceInBootloader.description")} + + + +); + +export const AutoRepair = ({ + onDone, + t, + device, + navigation, + colors, + theme, +}: RawProps & { + onDone: () => void; + device: Device; + navigation: StackNavigationProp; +}) => { + const [error, setError] = useState(null); + const [progress, setProgress] = useState(0); + + useEffect(() => { + const sub = firmwareUpdateRepair(device.deviceId, undefined).subscribe({ + next: ({ progress }) => { + setProgress(progress); + }, + error: err => { + setError(err); + }, + complete: () => { + onDone(); + navigation.replace(ScreenName.Manager, {}); + // we re-navigate to the manager to reset the selected device for the action + // if we don't do that, we get an "Invalid Channel" error once the device is back online + // since the manager still thinks it's connected to a bootloader device and not a normal one + }, + }); + + return () => sub.unsubscribe(); + }, [onDone, setProgress, device, navigation]); + + if (error) { + return renderError({ + t, + error, + colors, + theme, + }); + } + + return ( + + {t("FirmwareUpdate.preparingDevice")} + + {t("FirmwareUpdate.pleaseWaitUpdate")} + + ); +}; diff --git a/src/components/FirmwareProgress.tsx b/src/components/FirmwareProgress.tsx new file mode 100644 index 0000000000..ef651364c4 --- /dev/null +++ b/src/components/FirmwareProgress.tsx @@ -0,0 +1,24 @@ +import React, { memo } from "react"; +import { ProgressLoader, Text } from "@ledgerhq/native-ui"; + +type Props = { + progress?: number, +}; + +function FirmwareProgress({ progress }: Props) { + return ( + + + {progress && progress < 1 ? `${Math.round(progress * 100)}%`: ""} + + + ); +} + + +export default memo(FirmwareProgress); diff --git a/src/components/FirmwareUpdate/ConfirmPinStep.tsx b/src/components/FirmwareUpdate/ConfirmPinStep.tsx new file mode 100644 index 0000000000..2adb52bc72 --- /dev/null +++ b/src/components/FirmwareUpdate/ConfirmPinStep.tsx @@ -0,0 +1,63 @@ +import { Flex, Text, Log, NumberedList } from "@ledgerhq/native-ui"; +import React from "react"; +import Animation from "../Animation"; +import getDeviceAnimation from "../DeviceAction/getDeviceAnimation"; +import { useTranslation } from "react-i18next"; +import { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import { useTheme } from "styled-components/native"; +import Track from "../../analytics/Track"; + +type Props = { + device: Device; +}; + +const ConfirmPinStep = ({ device }: Props) => { + const { t } = useTranslation(); + const { theme } = useTheme(); + + return ( + + + + + + {device.deviceName} + + + + + {t("FirmwareUpdate.finishUpdate", { deviceName: device.deviceName })} + + + + + + + ); +}; + +export default ConfirmPinStep; diff --git a/src/components/FirmwareUpdate/ConfirmRecoveryStep.tsx b/src/components/FirmwareUpdate/ConfirmRecoveryStep.tsx new file mode 100644 index 0000000000..f29425a7dd --- /dev/null +++ b/src/components/FirmwareUpdate/ConfirmRecoveryStep.tsx @@ -0,0 +1,108 @@ +import { + Flex, + Text, + Link, + Icons, + Button, + Checkbox, + Alert, +} from "@ledgerhq/native-ui"; +import React, { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import { Linking, ScrollView } from "react-native"; +import { track } from "../../analytics"; +import Track from "../../analytics/Track"; +import { urls } from "../../config/urls"; +import SafeMarkdown from "../SafeMarkdown"; + +type Props = { + firmwareVersion: string; + firmwareNotes?: string | null; + onContinue: () => void; + device: Device; +}; + +const ConfirmRecoveryStep = ({ + firmwareVersion, + firmwareNotes, + onContinue, + device, +}: Props) => { + const { t } = useTranslation(); + const [ + confirmRecoveryPhraseBackup, + setConfirmRecoveryPhraseBackup, + ] = useState(false); + + const toggleConfirmRecoveryPhraseBackup = useCallback(() => { + track("FirmwareUpdateSeedDisclaimerChecked"); + setConfirmRecoveryPhraseBackup(!confirmRecoveryPhraseBackup); + }, [confirmRecoveryPhraseBackup]); + + const openRecoveryPhraseInfo = React.useCallback(async () => { + // Checking if the link is supported for links with custom URL scheme. + const supported = await Linking.canOpenURL(urls.recoveryPhraseInfo); + if (!supported) return; + + // Opening the link with some app, if the URL scheme is "http" the web link should be opened + // by some browser in the mobile + await Linking.openURL(urls.recoveryPhraseInfo); + }, [urls.recoveryPhraseInfo]); + + return ( + + + + + + {t("FirmwareUpdateReleaseNotes.introTitle", { + version: firmwareVersion, + deviceName: device.deviceName?.replace(/\u00a0/g, ' '), + })} + + + + + {t( + "onboarding.stepSetupDevice.recoveryPhraseSetup.infoModal.link", + )} + + + + + + + + {/** TODO: replace by divider component when we have one */} + + + + + + ); +}; + +export default ConfirmRecoveryStep; diff --git a/src/components/FirmwareUpdate/ConfirmUpdateStep.tsx b/src/components/FirmwareUpdate/ConfirmUpdateStep.tsx new file mode 100644 index 0000000000..05c7835e06 --- /dev/null +++ b/src/components/FirmwareUpdate/ConfirmUpdateStep.tsx @@ -0,0 +1,96 @@ +import { Flex, Text, Log } from "@ledgerhq/native-ui"; +import React from "react"; +import Animation from "../Animation"; +import getDeviceAnimation from "../DeviceAction/getDeviceAnimation"; +import manager from "@ledgerhq/live-common/lib/manager"; +import { useTranslation } from "react-i18next"; +import { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import { + DeviceInfo, + FirmwareUpdateContext, +} from "@ledgerhq/live-common/lib/types/manager"; +import { useTheme } from "styled-components/native"; +import Track from "../../analytics/Track"; + +type Props = { + device: Device; + deviceInfo: DeviceInfo; + latestFirmware?: FirmwareUpdateContext | null; +}; + +const ConfirmUpdateStep = ({ device, deviceInfo, latestFirmware }: Props) => { + const { t } = useTranslation(); + const { theme } = useTheme(); + + return ( + + + + + + {device.deviceName} + + + + {t("FirmwareUpdate.pleaseConfirmUpdate")} + + {latestFirmware?.osu?.hash ? ( + + {t("FirmwareUpdate.identifierTitle")} + + {manager + .formatHashName( + latestFirmware.osu.hash, + device.modelId, + deviceInfo, + ) + .map((hash, i) => ( + {hash} + ))} + + + ) : null} + + + {t("FirmwareUpdate.currentVersionNumber")} + + {deviceInfo.version} + + + + {t("FirmwareUpdate.newVersionNumber")} + + {latestFirmware?.final?.name} + + + ); +}; + +export default ConfirmUpdateStep; diff --git a/src/components/FirmwareUpdate/DownloadingUpdateStep.tsx b/src/components/FirmwareUpdate/DownloadingUpdateStep.tsx new file mode 100644 index 0000000000..fb034f4301 --- /dev/null +++ b/src/components/FirmwareUpdate/DownloadingUpdateStep.tsx @@ -0,0 +1,29 @@ +import { Flex, Text } from "@ledgerhq/native-ui"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import FirmwareProgress from "../FirmwareProgress"; +import Track from "../../analytics/Track"; + +type Props = { + progress?: number; +}; +const DownloadingUpdateStep = ({ progress }: Props) => { + const { t } = useTranslation(); + + return ( + + + + + {progress + ? t("FirmwareUpdate.steps.firmware") + : t("FirmwareUpdate.steps.preparing")} + + + {t("FirmwareUpdate.pleaseWaitDownload")} + + + ); +}; + +export default DownloadingUpdateStep; diff --git a/src/components/FirmwareUpdate/FirmwareUpdatedStep.tsx b/src/components/FirmwareUpdate/FirmwareUpdatedStep.tsx new file mode 100644 index 0000000000..34c4b33366 --- /dev/null +++ b/src/components/FirmwareUpdate/FirmwareUpdatedStep.tsx @@ -0,0 +1,27 @@ +import { Flex, Text, Icons, Log, Button } from "@ledgerhq/native-ui"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import Track from "../../analytics/Track"; + +type Props = { + onReinstallApps: () => void; +}; +const FirmwareUpdatedStep = ({ onReinstallApps }: Props) => { + const { t } = useTranslation(); + + return ( + + + + + {t("FirmwareUpdate.success")} + + {t("FirmwareUpdate.pleaseReinstallApps")} + + + ); +}; + +export default FirmwareUpdatedStep; diff --git a/src/components/FirmwareUpdate/FlashMcuStep.tsx b/src/components/FirmwareUpdate/FlashMcuStep.tsx new file mode 100644 index 0000000000..68aae1c8e0 --- /dev/null +++ b/src/components/FirmwareUpdate/FlashMcuStep.tsx @@ -0,0 +1,30 @@ +import { Flex, Text } from "@ledgerhq/native-ui"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import Track from "../../analytics/Track"; +import FirmwareProgress from "../FirmwareProgress"; + +type Props = { + progress?: number; + installing?: string | null; +}; +const FlashMcuStep = ({ progress, installing }: Props) => { + const { t } = useTranslation(); + + return ( + + + + + {progress && installing + ? t(`FirmwareUpdate.steps.${installing}`) + : t("FirmwareUpdate.steps.preparing")} + + + {t("FirmwareUpdate.pleaseWaitUpdate")} + + + ); +}; + +export default FlashMcuStep; diff --git a/src/components/FirmwareUpdate/index.tsx b/src/components/FirmwareUpdate/index.tsx new file mode 100644 index 0000000000..0c2594b55a --- /dev/null +++ b/src/components/FirmwareUpdate/index.tsx @@ -0,0 +1,265 @@ +import React, { useEffect, useCallback, useReducer } from "react"; +import { useTranslation } from "react-i18next"; +import { NativeModules } from "react-native"; +import { useSelector, useDispatch } from "react-redux"; +import { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import { Button, Icons } from "@ledgerhq/native-ui"; +import { + BackgroundEvent, + nextBackgroundEventSelector, +} from "../../reducers/appstate"; +import { + clearBackgroundEvents, + dequeueBackgroundEvent, +} from "../../actions/appstate"; +import BottomModal from "../BottomModal"; +import GenericErrorView from "../GenericErrorView"; +import { DeviceInfo } from "@ledgerhq/live-common/lib/types/manager"; +import useLatestFirmware from "../../hooks/useLatestFirmware"; +import ConfirmRecoveryStep from "./ConfirmRecoveryStep"; +import FlashMcuStep from "./FlashMcuStep"; +import FirmwareUpdatedStep from "./FirmwareUpdatedStep"; +import ConfirmPinStep from "./ConfirmPinStep"; +import ConfirmUpdateStep from "./ConfirmUpdateStep"; +import DownloadingUpdateStep from "./DownloadingUpdateStep"; +import { track } from "../../analytics"; +import { BluetoothNotSupportedError } from "@ledgerhq/live-common/lib/errors"; +import { + DisconnectedDevice, + DisconnectedDeviceDuringOperation, + WebsocketConnectionError, +} from "@ledgerhq/errors"; + +type Props = { + device: Device; + deviceInfo: DeviceInfo; + isOpen: boolean; + onClose: (restoreApps?: boolean) => void; + hasAppsToRestore: boolean; +}; + +type FwUpdateStep = + | "confirmRecoveryBackup" + | "downloadingUpdate" + | "error" + | "flashingMcu" + | "confirmPin" + | "confirmUpdate" + | "firmwareUpdated"; +type FwUpdateState = { + step: FwUpdateStep; + progress?: number; + error?: Error; + installing?: string | null; +}; + +export default function FirmwareUpdate({ + device, + deviceInfo, + onClose, + isOpen, + hasAppsToRestore, +}: Props) { + const nextBackgroundEvent = useSelector(nextBackgroundEventSelector); + const dispatch = useDispatch(); + const latestFirmware = useLatestFirmware(deviceInfo); + + const { t } = useTranslation(); + + // reducer for the firmware update state machine + const fwUpdateStateReducer = useCallback( + ( + state: FwUpdateState, + event: BackgroundEvent | { type: "reset"; wired: boolean }, + ): FwUpdateState => { + switch (event.type) { + case "confirmPin": + return { step: "confirmPin" }; + case "downloadingUpdate": + if (event.progress) { + NativeModules.BackgroundRunner.update( + Math.round(event.progress * 100), + t("FirmwareUpdate.Notifications.installing", { + progress: Math.round(event.progress * 100), + }), + ); + } + return { step: "downloadingUpdate", progress: event.progress }; + case "confirmUpdate": + NativeModules.BackgroundRunner.requireUserAction( + t("FirmwareUpdate.Notifications.confirmOnDevice"), + ); + return { step: "confirmUpdate" }; + case "flashingMcu": + return { + step: "flashingMcu", + progress: event.progress, + installing: event.installing, + }; + case "firmwareUpdated": + return { step: "firmwareUpdated" }; + case "error": + if (event.error.message === "Invalid channel") { + // this error comes from an uncaught exception on @ledgerhq/react-native-hid + // in this specific context, it almost always means the device was disconnected + // TODO: we should probably move this mapping to @ledgerhq/react-native-hid itself + // if this does mean a disconnected device in all contexts + event.error = new (DisconnectedDevice as ErrorConstructor)(); + } + return { step: "error", error: event.error }; + case "reset": + return { + step: event.wired ? "confirmRecoveryBackup" : "error", + progress: undefined, + error: event.wired + ? undefined + : new (BluetoothNotSupportedError as ErrorConstructor)(), + installing: undefined, + }; + default: + return { ...state }; + } + }, + [t], + ); + + const [state, dispatchEvent] = useReducer(fwUpdateStateReducer, { + step: device.wired ? "confirmRecoveryBackup" : "error", + progress: undefined, + error: device.wired + ? undefined + : new (BluetoothNotSupportedError as ErrorConstructor)(), + installing: undefined, + }); + + const { step, progress, error, installing } = state; + + const onReset = useCallback(() => { + dispatchEvent({ type: "reset", wired: device.wired }); + dispatch(clearBackgroundEvents()); + NativeModules.BackgroundRunner.stop(); + }, [dispatch]); + + // only allow closing of the modal when the update is not in an intermediate step + const canClose = + step === "confirmRecoveryBackup" || + step === "firmwareUpdated" || + step === "error" || + step === "confirmPin"; + + const onTryClose = useCallback( + (restoreApps: boolean) => { + if (canClose) { + onClose(restoreApps); + } + }, + [canClose], + ); + + const onCloseAndReinstall = useCallback(() => onTryClose(true), [onTryClose]); + const onCloseSilently = useCallback(() => onTryClose(false), [onTryClose]); + + useEffect(() => { + // reset the state whenever we re-open the modal + if (isOpen) { + onReset(); + } + }, [isOpen, onReset]); + + useEffect(() => { + if (!nextBackgroundEvent) return; + dispatchEvent(nextBackgroundEvent); + dispatch(dequeueBackgroundEvent()); + }, [nextBackgroundEvent, dispatch, dispatchEvent]); + + useEffect(() => { + if (step === "error") { + track("FirmwareUpdateError", error ?? null); + } + }, [step]); + + const launchUpdate = useCallback(() => { + if (latestFirmware) { + NativeModules.BackgroundRunner.start( + device.deviceId, + JSON.stringify(latestFirmware), + t("FirmwareUpdate.Notifications.preparingUpdate"), + ); + dispatchEvent({ type: "downloadingUpdate", progress: 0 }); + } + }, [latestFirmware]); + + const firmwareVersion = latestFirmware?.final?.name ?? ""; + + return ( + + {step === "confirmRecoveryBackup" && ( + + )} + {step === "flashingMcu" && ( + + )} + {step === "firmwareUpdated" && ( + + )} + {step === "error" && ( + <> + + {!( + error instanceof BluetoothNotSupportedError || + error instanceof WebsocketConnectionError + ) && + hasAppsToRestore && ( + + )} + + )} + {step === "confirmPin" && } + {step === "confirmUpdate" && ( + + )} + {step === "downloadingUpdate" && ( + + )} + + ); +} diff --git a/src/components/FirmwareUpdateBanner.tsx b/src/components/FirmwareUpdateBanner.tsx index 4c995914e2..1e38dfc68f 100644 --- a/src/components/FirmwareUpdateBanner.tsx +++ b/src/components/FirmwareUpdateBanner.tsx @@ -1,59 +1,58 @@ -import React, { useState, useEffect, useContext } from "react"; - -import { StyleSheet, TouchableOpacity } from "react-native"; -import manager from "@ledgerhq/live-common/lib/manager"; -import * as Animatable from "react-native-animatable"; -import { useTheme } from "@react-navigation/native"; -import { - DeviceModelInfo, - FirmwareUpdateContext, -} from "@ledgerhq/live-common/lib/types/manager"; +import React, { useState, useCallback } from "react"; +import { Platform } from "react-native"; +import { useNavigation, useRoute } from "@react-navigation/native"; +import { DeviceModelInfo } from "@ledgerhq/live-common/lib/types/manager"; import { useTranslation } from "react-i18next"; import { useSelector } from "react-redux"; -import { BottomDrawer, Notification } from "@ledgerhq/native-ui"; -import { - DownloadMedium, - NanoFirmwareUpdateMedium, -} from "@ledgerhq/native-ui/assets/icons"; -import ButtonUseTouchable from "../context/ButtonUseTouchable"; +import { ScreenName, NavigatorName } from "../const"; +import { Alert, BottomDrawer, Text } from "@ledgerhq/native-ui"; +import { DownloadMedium } from "@ledgerhq/native-ui/assets/icons"; import { lastSeenDeviceSelector, hasCompletedOnboardingSelector, + lastConnectedDeviceSelector, } from "../reducers/settings"; import { hasConnectedDeviceSelector } from "../reducers/appstate"; -import { BaseButton as Button } from "./Button"; +import Button from "./Button"; +import { useFeature } from "@ledgerhq/live-common/lib/featureFlags"; +import useLatestFirmware from "../hooks/useLatestFirmware"; +import { StackNavigationProp } from "@react-navigation/stack"; +import { isFirmwareUpdateVersionSupported } from "../logic/firmwareUpdate"; const FirmwareUpdateBanner = () => { const lastSeenDevice: DeviceModelInfo | null = useSelector( lastSeenDeviceSelector, ); + const lastConnectedDevice = useSelector(lastConnectedDeviceSelector); const hasConnectedDevice = useSelector(hasConnectedDeviceSelector); const hasCompletedOnboarding: boolean = useSelector( hasCompletedOnboardingSelector, ); + const [showDrawer, setShowDrawer] = useState(false); - const [showBanner, setShowBanner] = useState(false); - const [version, setVersion] = useState(""); - const { colors } = useTheme(); const { t } = useTranslation(); - const useTouchable = useContext(ButtonUseTouchable); - useEffect(() => { - async function getLatestFirmwareForDevice() { - const fw: - | FirmwareUpdateContext - | null - | undefined = await manager.getLatestFirmwareForDevice( - lastSeenDevice?.deviceInfo, - ); + const route = useRoute(); + const navigation = useNavigation>(); - setShowBanner(Boolean(fw)); - setVersion(fw?.final?.name ?? ""); + const onExperimentalFirmwareUpdate = useCallback(() => { + // if we're already in the manager page, only update the params + if (route.name === ScreenName.ManagerMain) { + navigation.setParams({ firmwareUpdate: true }); + } else { + navigation.navigate(NavigatorName.Manager, { + screen: ScreenName.Manager, + params: { firmwareUpdate: true }, + }); } - getLatestFirmwareForDevice(); - }, [lastSeenDevice, setShowBanner, setVersion]); + setShowDrawer(false); + }, [navigation]); + + const latestFirmware = useLatestFirmware(lastSeenDevice?.deviceInfo); + const showBanner = Boolean(latestFirmware); + const version = latestFirmware?.final?.name ?? ""; const onPress = () => { setShowDrawer(true); @@ -61,26 +60,40 @@ const FirmwareUpdateBanner = () => { const onCloseDrawer = () => { setShowDrawer(false); }; - const onDismissBanner = () => { - setShowBanner(false); - }; - return showBanner && hasConnectedDevice && hasCompletedOnboarding ? ( + const usbFwUpdateFeatureFlag = { enabled: true }; // TODO: do NOT merge this + const isUsbFwVersionUpdateSupported = + lastSeenDevice && + isFirmwareUpdateVersionSupported( + lastSeenDevice.deviceInfo, + lastSeenDevice.modelId, + ); + const usbFwUpdateActivated = + usbFwUpdateFeatureFlag?.enabled && + Platform.OS === "android" && + lastConnectedDevice?.wired && + isUsbFwVersionUpdateSupported; + + return showBanner && hasCompletedOnboarding && hasConnectedDevice ? ( <> - - - - - + + + {t("FirmwareUpdate.newVersion", { + version, + deviceName: lastConnectedDevice?.deviceName, + })} + +