diff --git a/.changelog/1945.feature.md b/.changelog/1945.feature.md new file mode 100644 index 0000000000..1f7438188b --- /dev/null +++ b/.changelog/1945.feature.md @@ -0,0 +1 @@ +Add Android update screen diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 6771f13b4d..5abdb54f69 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -11,6 +11,7 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { implementation project(':capacitor-community-bluetooth-le') implementation project(':capacitor-app') + implementation project(':capawesome-capacitor-app-update') } diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 61d0d1acc5..5ff434f8c9 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -7,3 +7,6 @@ project(':capacitor-community-bluetooth-le').projectDir = new File('../node_modu include ':capacitor-app' project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') + +include ':capawesome-capacitor-app-update' +project(':capawesome-capacitor-app-update').projectDir = new File('../node_modules/@capawesome/capacitor-app-update/android') diff --git a/ios/App/Podfile b/ios/App/Podfile index 7515e4d97f..e542648197 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -13,6 +13,7 @@ def capacitor_pods pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorCommunityBluetoothLe', :path => '../../node_modules/@capacitor-community/bluetooth-le' pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' + pod 'CapawesomeCapacitorAppUpdate', :path => '../../node_modules/@capawesome/capacitor-app-update' end target 'App' do diff --git a/package.json b/package.json index 93723bafee..63095d4113 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@capacitor/app": "6.0.0", "@capacitor/core": "6.0.0", "@capacitor/ios": "6.0.0", + "@capawesome/capacitor-app-update": "6.0.0", "@ethereumjs/util": "9.0.3", "@ledgerhq/hw-transport-webusb": "6.28.5", "@metamask/browser-passworder": "=3.0.0", diff --git a/src/app/__tests__/__snapshots__/index.test.tsx.snap b/src/app/__tests__/__snapshots__/index.test.tsx.snap index 08bbc9f1fa..58d0c26986 100644 --- a/src/app/__tests__/__snapshots__/index.test.tsx.snap +++ b/src/app/__tests__/__snapshots__/index.test.tsx.snap @@ -2,7 +2,7 @@ exports[` should render and match the snapshot 1`] = ` - + should render and match the snapshot 1`] = ` - + `; diff --git a/src/app/components/Ionic/IonicProvider.tsx b/src/app/components/Ionic/IonicProvider.tsx deleted file mode 100644 index 7e7b538230..0000000000 --- a/src/app/components/Ionic/IonicProvider.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { createContext, FC, PropsWithChildren } from 'react' -import { Capacitor } from '@capacitor/core' -import { useIonicBackButtonListener } from './hooks/useIonicBackButtonListener' -import { useIonicAppStateChangeListener } from './hooks/useIonicAppStateChangeListener' - -const IonicContext = createContext(undefined) - -const IonicContextProvider: FC = ({ children }) => { - useIonicBackButtonListener() - useIonicAppStateChangeListener() - - return {children} -} - -export const IonicProvider: FC = ({ children }) => { - if (Capacitor.isNativePlatform()) { - return {children} - } - - return children -} diff --git a/src/app/components/Ionic/components/IonicNativePlatformProvider/index.tsx b/src/app/components/Ionic/components/IonicNativePlatformProvider/index.tsx new file mode 100644 index 0000000000..e16f2539d8 --- /dev/null +++ b/src/app/components/Ionic/components/IonicNativePlatformProvider/index.tsx @@ -0,0 +1,16 @@ +import { FC, PropsWithChildren } from 'react' +import { Capacitor } from '@capacitor/core' +import { IonicContextProvider } from '../../providers/IonicProvider' +import { UpdateGate } from '../UpdateGate' + +export const IonicNativePlatformProvider: FC = ({ children }) => { + if (Capacitor.isNativePlatform()) { + return ( + + {children} + + ) + } + + return children +} diff --git a/src/app/components/Ionic/components/UpdateGate/index.tsx b/src/app/components/Ionic/components/UpdateGate/index.tsx new file mode 100644 index 0000000000..e0ba35634c --- /dev/null +++ b/src/app/components/Ionic/components/UpdateGate/index.tsx @@ -0,0 +1,176 @@ +import React, { FC, PropsWithChildren, useContext } from 'react' +import { IonicContext, UpdateAvailability } from '../../providers/IonicContext' +import { Box } from 'grommet/es6/components/Box' +import { Button } from 'grommet/es6/components/Button' +import { navigateToAppStore } from '../../utils/capacitor-app-update' +import { Paragraph } from 'grommet/es6/components/Paragraph' +import walletWhiteLogotype from '../../../../../../public/Rose Wallet White.svg' +import { Text } from 'grommet/es6/components/Text' +import { ShareRounded } from 'grommet-icons/es6/icons/ShareRounded' +import { Refresh } from 'grommet-icons/es6/icons/Refresh' +import styled, { keyframes } from 'styled-components' +import { normalizeColor } from 'grommet/es6/utils' +import { MuiWalletIcon } from '../../../../../styles/theme/icons/mui-icons/MuiWalletIcon' +import { Spinner } from 'grommet/es6/components/Spinner' +import { ResponsiveContext } from 'grommet/es6/contexts/ResponsiveContext' +import { useTranslation } from 'react-i18next' +import { TFunction } from 'i18next' + +const SpinKeyFrames = keyframes` + 0% { + transform: rotate(0deg) + } + + 100% { + transform: rotate(359deg) + } +` + +// TODO: Merge with Spinner.icon when grommet dependency is updated +const RefreshSpin = styled(Refresh)` + transform: rotate(0deg); + animation: ${SpinKeyFrames} 1s 0s infinite linear; +` + +const CTAButton = styled(Button)` + background-color: ${({ theme }) => normalizeColor('brand-light-blue', theme)}; + border-width: 0; + border-radius: 8px; +` + +const getUpdateStatusMap: (t: TFunction) => { + [key in UpdateAvailability]?: { title: string; desc: string } +} = t => ({ + [UpdateAvailability.UPDATE_AVAILABLE]: { + title: t('mobileUpdate.updateAvailableTitle', 'Update pending...'), + desc: t( + 'mobileUpdate.updateAvailableDescription', + 'A new update is available for your ROSE Wallet. We recommend updating to the latest version for bug fixes, enhanced security and new features.', + ), + }, + [UpdateAvailability.UPDATE_IN_PROGRESS]: { + title: t('mobileUpdate.updateInProgressTitle', 'Update in progress...'), + desc: t( + 'mobileUpdate.updateInProgressDescription', + 'Your ROSE Wallet is currently undergoing an update. Please check back at later time. Alternatively, you may choose to retry by clicking on the "{{retryButtonLabel}}" button.', + { retryButtonLabel: t('mobileUpdate.retry', 'Retry') }, + ), + }, + [UpdateAvailability.UNKNOWN]: { + title: t('mobileUpdate.unknownTitle', 'Unknown error'), + desc: t( + 'mobileUpdate.unknownOrErrorDescription', + 'Apologies for the inconvenience, an unexpected error has transpired. Please verify your internet connection and retry by clicking on the "{{retryButtonLabel}}" button.', + { retryButtonLabel: t('mobileUpdate.retry', 'Retry') }, + ), + }, + [UpdateAvailability.ERROR]: { + title: t('mobileUpdate.errorTitle', 'Unexpected error'), + desc: t( + 'mobileUpdate.unknownOrErrorDescription', + 'Apologies for the inconvenience, an unexpected error has transpired. Please verify your internet connection and retry by clicking on the "{{retryButtonLabel}}" button.', + { retryButtonLabel: t('mobileUpdate.retry', 'Retry') }, + ), + }, +}) + +export const UpdateGate: FC = ({ children }) => { + const { t } = useTranslation() + const isMobile = useContext(ResponsiveContext) === 'small' + const { + state: { updateAvailability }, + checkForUpdateAvailability, + skipUpdate, + } = useContext(IonicContext) + + if (updateAvailability === UpdateAvailability.UPDATE_NOT_AVAILABLE) return children + + const handleNavigateToAppStore = () => { + navigateToAppStore() + } + + const updateStatusMap = getUpdateStatusMap(t) + + return ( + + + ROSE Wallet + + {[UpdateAvailability.NOT_INITIALIZED, UpdateAvailability.LOADING].includes(updateAvailability) && ( + + + + )} + {[ + UpdateAvailability.UPDATE_AVAILABLE, + UpdateAvailability.UPDATE_IN_PROGRESS, + UpdateAvailability.UNKNOWN, + UpdateAvailability.ERROR, + ].includes(updateAvailability) && ( + + + + + + + + {updateStatusMap[updateAvailability]?.title} + + + {updateStatusMap[updateAvailability]?.desc} + + + + {updateAvailability === UpdateAvailability.UPDATE_AVAILABLE && ( + + {t('mobileUpdate.updateNow', 'Update now')} + + } + icon={} + reverse + /> + )} + {updateAvailability !== UpdateAvailability.UPDATE_AVAILABLE && ( + + {t('mobileUpdate.retry', 'Retry')} + + } + /> + )} + {[UpdateAvailability.UNKNOWN, UpdateAvailability.ERROR].includes(updateAvailability) && ( + + )} + + + )} + + ) +} diff --git a/src/app/components/Ionic/hooks/useIonicAppStateChangeListener.tsx b/src/app/components/Ionic/hooks/useIonicAppStateChangeListener.ts similarity index 100% rename from src/app/components/Ionic/hooks/useIonicAppStateChangeListener.tsx rename to src/app/components/Ionic/hooks/useIonicAppStateChangeListener.ts diff --git a/src/app/components/Ionic/hooks/useIonicRequiresUpdate.ts b/src/app/components/Ionic/hooks/useIonicRequiresUpdate.ts new file mode 100644 index 0000000000..de683b2389 --- /dev/null +++ b/src/app/components/Ionic/hooks/useIonicRequiresUpdate.ts @@ -0,0 +1,39 @@ +import { Dispatch, SetStateAction, useEffect } from 'react' +import { IonicProviderState, UpdateAvailability } from '../providers/IonicContext' +import { updateAvailable } from '../utils/capacitor-app-update' + +export const useIonicRequiresUpdate = ( + state: IonicProviderState, + setState: Dispatch>, +) => { + const checkForUpdateAvailability = async () => { + if (state.updateAvailability === UpdateAvailability.LOADING) { + return + } + + setState(prevState => ({ ...prevState, updateAvailability: UpdateAvailability.LOADING })) + + try { + const updateAvailability = await updateAvailable() + + setState(prevState => ({ ...prevState, updateAvailability })) + } catch (error) { + setState(prevState => ({ + ...prevState, + updateAvailability: UpdateAvailability.ERROR, + error: error as Error, + })) + } + } + + useEffect(() => { + checkForUpdateAvailability() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const skipUpdate = () => { + setState(prevState => ({ ...prevState, updateAvailability: UpdateAvailability.UPDATE_NOT_AVAILABLE })) + } + + return { checkForUpdateAvailability, skipUpdate } +} diff --git a/src/app/components/Ionic/providers/IonicContext.ts b/src/app/components/Ionic/providers/IonicContext.ts new file mode 100644 index 0000000000..9a76712628 --- /dev/null +++ b/src/app/components/Ionic/providers/IonicContext.ts @@ -0,0 +1,24 @@ +import { createContext } from 'react' + +export enum UpdateAvailability { + NOT_INITIALIZED, + LOADING, + UPDATE_AVAILABLE, + UPDATE_NOT_AVAILABLE, + UPDATE_IN_PROGRESS, + ERROR, + UNKNOWN, +} + +export interface IonicProviderState { + updateAvailability: UpdateAvailability + error: Error | null +} + +export interface IonicProviderContext { + readonly state: IonicProviderState + checkForUpdateAvailability: () => void + skipUpdate: () => void +} + +export const IonicContext = createContext({} as IonicProviderContext) diff --git a/src/app/components/Ionic/providers/IonicProvider.tsx b/src/app/components/Ionic/providers/IonicProvider.tsx new file mode 100644 index 0000000000..e9461f04b8 --- /dev/null +++ b/src/app/components/Ionic/providers/IonicProvider.tsx @@ -0,0 +1,26 @@ +import { FC, PropsWithChildren, useState } from 'react' +import { useIonicBackButtonListener } from '../hooks/useIonicBackButtonListener' +import { useIonicAppStateChangeListener } from '../hooks/useIonicAppStateChangeListener' +import { IonicContext, IonicProviderContext, IonicProviderState, UpdateAvailability } from './IonicContext' +import { useIonicRequiresUpdate } from '../hooks/useIonicRequiresUpdate' + +const ionicProviderInitialState: IonicProviderState = { + updateAvailability: UpdateAvailability.NOT_INITIALIZED, + error: null, +} + +export const IonicContextProvider: FC = ({ children }) => { + const [state, setState] = useState({ ...ionicProviderInitialState }) + + const { checkForUpdateAvailability, skipUpdate } = useIonicRequiresUpdate(state, setState) + useIonicAppStateChangeListener() + useIonicBackButtonListener() + + const providerState: IonicProviderContext = { + state, + checkForUpdateAvailability, + skipUpdate, + } + + return {children} +} diff --git a/src/app/components/Ionic/utils/capacitor-app-update.ts b/src/app/components/Ionic/utils/capacitor-app-update.ts new file mode 100644 index 0000000000..4b9ea68a3c --- /dev/null +++ b/src/app/components/Ionic/utils/capacitor-app-update.ts @@ -0,0 +1,39 @@ +import { + AppUpdate, + AppUpdateAvailability as IonicAppUpdateAvailability, +} from '@capawesome/capacitor-app-update' +import { Capacitor } from '@capacitor/core' +import { UpdateAvailability } from '../providers/IonicContext' + +// TODO: Skip on local builds +export const updateAvailable = async (): Promise => { + const result = await AppUpdate.getAppUpdateInfo() + const { updateAvailability, currentVersionCode, availableVersionCode } = result + + switch (updateAvailability) { + case IonicAppUpdateAvailability.UPDATE_IN_PROGRESS: + return UpdateAvailability.UPDATE_IN_PROGRESS + case IonicAppUpdateAvailability.UPDATE_NOT_AVAILABLE: + return UpdateAvailability.UPDATE_NOT_AVAILABLE + // Returns UNKNOWN when unable to determine with mobile app store if update is available or not + case IonicAppUpdateAvailability.UNKNOWN: + return UpdateAvailability.UNKNOWN + } + + // Example of version code -> "1", "2", ... + if ( + Capacitor.getPlatform() === 'android' && + parseInt(availableVersionCode ?? `${Number.MAX_SAFE_INTEGER}`, 10) > + parseInt(currentVersionCode ?? '0', 10) + ) { + return UpdateAvailability.UPDATE_AVAILABLE + } + + // TODO: Add for iOS + // Compare semVer between currentVersionName and availableVersionName + throw new Error('Unknown Capacitor platform!') +} + +export const navigateToAppStore = async () => { + await AppUpdate.openAppStore() +} diff --git a/src/app/index.tsx b/src/app/index.tsx index 0b9248f1d9..fa11b9b488 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -22,7 +22,7 @@ import { useRouteRedirects } from './useRouteRedirects' import { PersistLoadingGate } from 'app/components/Persist/PersistLoadingGate' import { UnlockGate } from 'app/components/Persist/UnlockGate' import { BuildBanner } from 'app/components/BuildBanner' -import { IonicProvider } from './components/Ionic/IonicProvider' +import { IonicNativePlatformProvider } from './components/Ionic/components/IonicNativePlatformProvider' export function App() { useRouteRedirects() @@ -31,7 +31,7 @@ export function App() { return ( - + - + ) } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 4e355aaf6f..794c22191b 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -215,6 +215,11 @@ "infoBox": { "valueCopied": "{{label}} copied." }, + "icons": { + "wallet": { + "label": "Wallet" + } + }, "language": "Language", "ledger": { "extension": { @@ -272,6 +277,18 @@ "requiredField": "This field is required", "title": "Important Wallet Update" }, + "mobileUpdate": { + "errorTitle": "Unexpected error", + "later": "Later", + "retry": "Retry", + "unknownOrErrorDescription": "Apologies for the inconvenience, an unexpected error has transpired. Please verify your internet connection and retry by clicking on the \"{{retryButtonLabel}}\" button.", + "unknownTitle": "Unknown error", + "updateAvailableDescription": "A new update is available for your ROSE Wallet. We recommend updating to the latest version for bug fixes, enhanced security and new features.", + "updateAvailableTitle": "Update pending...", + "updateInProgressDescription": "Your ROSE Wallet is currently undergoing an update. Please check back at later time. Alternatively, you may choose to retry by clicking on the \"{{retryButtonLabel}}\" button.", + "updateInProgressTitle": "Update in progress...", + "updateNow": "Update now" + }, "openWallet": { "bitpie": { "warning": "❗ BitPie wallet users: You cannot import the mnemonic phrase directly from your BitPie wallet. Check documentation for details." diff --git a/src/styles/theme/ThemeProvider.tsx b/src/styles/theme/ThemeProvider.tsx index ee56b36c40..64e8ce4a48 100644 --- a/src/styles/theme/ThemeProvider.tsx +++ b/src/styles/theme/ThemeProvider.tsx @@ -94,7 +94,9 @@ const grommetCustomTheme: ThemeType = { 'accent-1': 'focus', 'brand-background-light': '#e3e8ed', 'brand-white': '#f8f8f8', + white: '#ffffff', 'brand-blue': '#0500e2', + 'brand-light-blue': '#e8f5ff', 'brand-gray-medium': '#d5d6d7', 'brand-gray-extra-dark': '#06152b', 'status-ok': '#2ad5ab', diff --git a/src/styles/theme/icons/mui-icons/MuiWalletIcon.tsx b/src/styles/theme/icons/mui-icons/MuiWalletIcon.tsx new file mode 100644 index 0000000000..306694380b --- /dev/null +++ b/src/styles/theme/icons/mui-icons/MuiWalletIcon.tsx @@ -0,0 +1,20 @@ +/** + * Copyright 2024 https://github.com/google/material-design-icons + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Blank } from 'grommet-icons/es6/icons/Blank' +/* eslint-disable-next-line no-restricted-imports */ +import { IconProps } from 'grommet-icons/es6/icons' +import { useTranslation } from 'react-i18next' + +// https://github.com/google/material-design-icons/blob/6579dc142e9e2ec05df194c6541d3a951fe773e3/symbols/web/wallet/materialsymbolsoutlined/wallet_24px.svg +export const MuiWalletIcon = (props: IconProps) => { + const { t } = useTranslation() + + return ( + + + + ) +} diff --git a/yarn.lock b/yarn.lock index 12ff4eacc2..0aa1f1de42 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1192,6 +1192,11 @@ resolved "https://registry.yarnpkg.com/@capacitor/ios/-/ios-6.0.0.tgz#b04ce2967d732c73ebf29ec38b4b585255930e43" integrity sha512-7mAs3gjWiE5DPM4AGPduqFSDGXCPwwqQRMzbohVway7/cTWnHomHV8mIojMZE4GILeWO2fILbyu3C8q9pHg2vg== +"@capawesome/capacitor-app-update@6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@capawesome/capacitor-app-update/-/capacitor-app-update-6.0.0.tgz#cda175030417f5bfa3b2b7c8bbe22a531854aa40" + integrity sha512-T4k0dC3XoJDgtX1QOQx0Rr5eMi3NhJn93QawQ6ZYYMmssnqCoRPpo2rg3VKVKMUZgAmIUN5Rwons9T4QkjTOzA== + "@colors/colors@1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"