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 (
+
+
+
+
+ {[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"