Skip to content

Commit

Permalink
Merge pull request #1945 from oasisprotocol/ml/app-update-wall
Browse files Browse the repository at this point in the history
Add Android update screen
  • Loading branch information
lubej authored Jun 6, 2024
2 parents cd27b32 + 202c9d0 commit 5d3daa9
Show file tree
Hide file tree
Showing 19 changed files with 376 additions and 26 deletions.
1 change: 1 addition & 0 deletions .changelog/1945.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add Android update screen
1 change: 1 addition & 0 deletions android/app/capacitor.build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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')

}

Expand Down
3 changes: 3 additions & 0 deletions android/capacitor.settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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')
1 change: 1 addition & 0 deletions ios/App/Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/app/__tests__/__snapshots__/index.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

exports[`<App /> should render and match the snapshot 1`] = `
<FatalErrorHandler>
<IonicProvider>
<IonicNativePlatformProvider>
<ModalProvider>
<Helmet
defaultTitle="ROSE Wallet"
Expand Down Expand Up @@ -53,6 +53,6 @@ exports[`<App /> should render and match the snapshot 1`] = `
</PersistLoadingGate>
</Box>
</ModalProvider>
</IonicProvider>
</IonicNativePlatformProvider>
</FatalErrorHandler>
`;
21 changes: 0 additions & 21 deletions src/app/components/Ionic/IonicProvider.tsx

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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<PropsWithChildren> = ({ children }) => {
if (Capacitor.isNativePlatform()) {
return (
<IonicContextProvider>
<UpdateGate>{children}</UpdateGate>
</IonicContextProvider>
)
}

return children
}
176 changes: 176 additions & 0 deletions src/app/components/Ionic/components/UpdateGate/index.tsx
Original file line number Diff line number Diff line change
@@ -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<PropsWithChildren> = ({ 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 (
<Box direction="column" background="brand-blue" fill pad="large" style={{ minHeight: '100dvh' }}>
<Box alignSelf={isMobile ? 'start' : 'center'}>
<img alt="ROSE Wallet" src={walletWhiteLogotype} style={{ height: '35px' }} />
</Box>
{[UpdateAvailability.NOT_INITIALIZED, UpdateAvailability.LOADING].includes(updateAvailability) && (
<Box align="center" justify="center" flex="grow">
<Spinner />
</Box>
)}
{[
UpdateAvailability.UPDATE_AVAILABLE,
UpdateAvailability.UPDATE_IN_PROGRESS,
UpdateAvailability.UNKNOWN,
UpdateAvailability.ERROR,
].includes(updateAvailability) && (
<Box flex="grow">
<Box align="center" justify="center" flex="grow">
<Box margin={{ bottom: '70px', top: 'none' }} align="center">
<RefreshSpin color="white" size="44" />
<MuiWalletIcon color="white" size="84px" />
</Box>
<Paragraph
size="medium"
color="brand-light-blue"
alignSelf={isMobile ? 'start' : 'center'}
textAlign={isMobile ? 'start' : 'center'}
margin={{ bottom: 'small', top: 'none' }}
>
<Text weight="bolder">{updateStatusMap[updateAvailability]?.title}</Text>
</Paragraph>
<Paragraph
size="small"
color="brand-light-blue"
alignSelf={isMobile ? 'start' : 'center'}
textAlign={isMobile ? 'start' : 'center'}
margin="none"
>
{updateStatusMap[updateAvailability]?.desc}
</Paragraph>
</Box>
<Box align="center" justify="end" flex="shrink">
{updateAvailability === UpdateAvailability.UPDATE_AVAILABLE && (
<CTAButton
type="button"
onClick={handleNavigateToAppStore}
margin="medium"
pad={{ vertical: 'small', horizontal: 'large' }}
label={
<Text color="brand-blue" weight="bolder" size="medium">
{t('mobileUpdate.updateNow', 'Update now')}
</Text>
}
icon={<ShareRounded color="brand-blue" size="18px" />}
reverse
/>
)}
{updateAvailability !== UpdateAvailability.UPDATE_AVAILABLE && (
<CTAButton
type="button"
onClick={checkForUpdateAvailability}
margin="medium"
pad={{ vertical: 'small', horizontal: 'large' }}
label={
<Text color="brand-blue" weight="bolder" size="medium">
{t('mobileUpdate.retry', 'Retry')}
</Text>
}
/>
)}
{[UpdateAvailability.UNKNOWN, UpdateAvailability.ERROR].includes(updateAvailability) && (
<Button type="button" onClick={skipUpdate}>
<Text color="white" weight="bolder" size="small">
{t('mobileUpdate.later', 'Later')}
</Text>
</Button>
)}
</Box>
</Box>
)}
</Box>
)
}
39 changes: 39 additions & 0 deletions src/app/components/Ionic/hooks/useIonicRequiresUpdate.ts
Original file line number Diff line number Diff line change
@@ -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<SetStateAction<IonicProviderState>>,
) => {
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 }
}
24 changes: 24 additions & 0 deletions src/app/components/Ionic/providers/IonicContext.ts
Original file line number Diff line number Diff line change
@@ -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<IonicProviderContext>({} as IonicProviderContext)
26 changes: 26 additions & 0 deletions src/app/components/Ionic/providers/IonicProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<PropsWithChildren> = ({ children }) => {
const [state, setState] = useState<IonicProviderState>({ ...ionicProviderInitialState })

const { checkForUpdateAvailability, skipUpdate } = useIonicRequiresUpdate(state, setState)
useIonicAppStateChangeListener()
useIonicBackButtonListener()

const providerState: IonicProviderContext = {
state,
checkForUpdateAvailability,
skipUpdate,
}

return <IonicContext.Provider value={providerState}>{children}</IonicContext.Provider>
}
39 changes: 39 additions & 0 deletions src/app/components/Ionic/utils/capacitor-app-update.ts
Original file line number Diff line number Diff line change
@@ -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<UpdateAvailability> => {
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()
}
Loading

0 comments on commit 5d3daa9

Please sign in to comment.