Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Android update screen #1945

Merged
merged 6 commits into from
Jun 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading