chore: on android use notification to display file upload progress instead of toast (#2628) [skip e2e]

This commit is contained in:
Aman Harwara
2023-11-09 01:31:34 +05:30
committed by GitHub
parent 6a7c5277f8
commit 2d687d9786
12 changed files with 257 additions and 51 deletions

View File

@@ -507,6 +507,11 @@ PODS:
- React-Core - React-Core
- RNKeychain (8.1.2): - RNKeychain (8.1.2):
- React-Core - React-Core
- RNNotifee (7.8.0):
- React-Core
- RNNotifee/NotifeeCore (= 7.8.0)
- RNNotifee/NotifeeCore (7.8.0):
- React-Core
- RNPrivacySnapshot (1.0.0): - RNPrivacySnapshot (1.0.0):
- React-Core - React-Core
- RNShare (9.4.1): - RNShare (9.4.1):
@@ -593,6 +598,7 @@ DEPENDENCIES:
- RNFS (from `../node_modules/react-native-fs`) - RNFS (from `../node_modules/react-native-fs`)
- RNIap (from `../node_modules/react-native-iap`) - RNIap (from `../node_modules/react-native-iap`)
- RNKeychain (from `../node_modules/react-native-keychain`) - RNKeychain (from `../node_modules/react-native-keychain`)
- "RNNotifee (from `../node_modules/@notifee/react-native`)"
- RNPrivacySnapshot (from `../node_modules/react-native-privacy-snapshot`) - RNPrivacySnapshot (from `../node_modules/react-native-privacy-snapshot`)
- RNShare (from `../node_modules/react-native-share`) - RNShare (from `../node_modules/react-native-share`)
- RNStoreReview (from `../node_modules/react-native-store-review`) - RNStoreReview (from `../node_modules/react-native-store-review`)
@@ -716,6 +722,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-iap" :path: "../node_modules/react-native-iap"
RNKeychain: RNKeychain:
:path: "../node_modules/react-native-keychain" :path: "../node_modules/react-native-keychain"
RNNotifee:
:path: "../node_modules/@notifee/react-native"
RNPrivacySnapshot: RNPrivacySnapshot:
:path: "../node_modules/react-native-privacy-snapshot" :path: "../node_modules/react-native-privacy-snapshot"
RNShare: RNShare:
@@ -789,6 +797,7 @@ SPEC CHECKSUMS:
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
RNIap: c397f49db45af3b10dca64b2325f21bb8078ad21 RNIap: c397f49db45af3b10dca64b2325f21bb8078ad21
RNKeychain: a65256b6ca6ba6976132cc4124b238a5b13b3d9c RNKeychain: a65256b6ca6ba6976132cc4124b238a5b13b3d9c
RNNotifee: f3c01b391dd8e98e67f539f9a35a9cbcd3bae744
RNPrivacySnapshot: 8eaf571478a353f2e5184f5c803164f22428b023 RNPrivacySnapshot: 8eaf571478a353f2e5184f5c803164f22428b023
RNShare: 32e97adc8d8c97d4a26bcdd3c45516882184f8b6 RNShare: 32e97adc8d8c97d4a26bcdd3c45516882184f8b6
RNStoreReview: 923b1c888c13469925bf0256dc2c046eab557ce5 RNStoreReview: 923b1c888c13469925bf0256dc2c046eab557ce5

View File

@@ -72,6 +72,7 @@
"node": ">=16" "node": ">=16"
}, },
"dependencies": { "dependencies": {
"@notifee/react-native": "^7.8.0",
"react-native-store-review": "^0.4.1" "react-native-store-review": "^0.4.1"
} }
} }

View File

@@ -50,6 +50,7 @@ import { Database } from './Database/Database'
import { isLegacyIdentifier } from './Database/LegacyIdentifier' import { isLegacyIdentifier } from './Database/LegacyIdentifier'
import { LegacyKeyValueStore } from './Database/LegacyKeyValueStore' import { LegacyKeyValueStore } from './Database/LegacyKeyValueStore'
import Keychain from './Keychain' import Keychain from './Keychain'
import notifee, { AuthorizationStatus, Notification } from '@notifee/react-native'
export type BiometricsType = 'Fingerprint' | 'Face ID' | 'Biometrics' | 'Touch ID' export type BiometricsType = 'Fingerprint' | 'Face ID' | 'Biometrics' | 'Touch ID'
@@ -75,7 +76,40 @@ export class MobileDevice implements MobileDeviceInterface {
private stateObserverService?: AppStateObserverService, private stateObserverService?: AppStateObserverService,
private androidBackHandlerService?: AndroidBackHandlerService, private androidBackHandlerService?: AndroidBackHandlerService,
private colorSchemeService?: ColorSchemeObserverService, private colorSchemeService?: ColorSchemeObserverService,
) {} ) {
this.initializeNotifications().catch(console.error)
}
async initializeNotifications() {
if (Platform.OS !== 'android') {
return
}
await notifee.createChannel({
id: 'files',
name: 'File Upload/Download',
})
}
async canDisplayNotifications(): Promise<boolean> {
const settings = await notifee.requestPermission()
return settings.authorizationStatus >= AuthorizationStatus.AUTHORIZED
}
async displayNotification(options: Notification): Promise<string> {
return await notifee.displayNotification({
...options,
android: {
...options.android,
channelId: 'files',
},
})
}
async cancelNotification(notificationId: string): Promise<void> {
await notifee.cancelNotification(notificationId)
}
async removeRawStorageValuesForIdentifier(identifier: string): Promise<void> { async removeRawStorageValuesForIdentifier(identifier: string): Promise<void> {
await this.removeRawStorageValue(namespacedKey(identifier, RawStorageKey.SnjsVersion)) await this.removeRawStorageValue(namespacedKey(identifier, RawStorageKey.SnjsVersion))

View File

@@ -14,6 +14,7 @@ import { MobileDevice, MobileDeviceEvent } from './Lib/MobileDevice'
import { IsDev } from './Lib/Utils' import { IsDev } from './Lib/Utils'
import { ReceivedSharedItemsHandler } from './ReceivedSharedItemsHandler' import { ReceivedSharedItemsHandler } from './ReceivedSharedItemsHandler'
import { ReviewService } from './ReviewService' import { ReviewService } from './ReviewService'
import notifee, { EventType } from '@notifee/react-native'
const LoggingEnabled = IsDev const LoggingEnabled = IsDev
@@ -117,6 +118,34 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
} }
}, [webViewRef, stateService, device, androidBackHandlerService, colorSchemeService]) }, [webViewRef, stateService, device, androidBackHandlerService, colorSchemeService])
useEffect(() => {
return notifee.onForegroundEvent(({ type, detail }) => {
if (type !== EventType.ACTION_PRESS) {
return
}
const { notification, pressAction } = detail
if (!notification || !pressAction) {
return
}
if (pressAction.id !== 'open-file') {
return
}
webViewRef.current?.postMessage(
JSON.stringify({
reactNativeEvent: ReactNativeToWebEvent.OpenFilePreview,
messageType: 'event',
messageData: {
id: notification.id,
},
}),
)
})
}, [])
useEffect(() => { useEffect(() => {
const observer = device.addMobileDeviceEventReceiver((event) => { const observer = device.addMobileDeviceEventReceiver((event) => {
if (event === MobileDeviceEvent.RequestsWebViewReload) { if (event === MobileDeviceEvent.RequestsWebViewReload) {

View File

@@ -5,6 +5,8 @@ import { DeviceInterface } from './DeviceInterface'
import { AppleIAPReceipt } from '../Subscription/AppleIAPReceipt' import { AppleIAPReceipt } from '../Subscription/AppleIAPReceipt'
import { ApplicationEvent } from '../Event/ApplicationEvent' import { ApplicationEvent } from '../Event/ApplicationEvent'
import type { Notification } from '../../../../mobile/node_modules/@notifee/react-native/dist/index'
export interface MobileDeviceInterface extends DeviceInterface { export interface MobileDeviceInterface extends DeviceInterface {
environment: Environment.Mobile environment: Environment.Mobile
platform: Platform.Ios | Platform.Android platform: Platform.Ios | Platform.Android
@@ -34,4 +36,8 @@ export interface MobileDeviceInterface extends DeviceInterface {
purchaseSubscriptionIAP(plan: AppleIAPProductId): Promise<AppleIAPReceipt | undefined> purchaseSubscriptionIAP(plan: AppleIAPProductId): Promise<AppleIAPReceipt | undefined>
authenticateWithU2F(authenticationOptionsJSONString: string): Promise<Record<string, unknown> | null> authenticateWithU2F(authenticationOptionsJSONString: string): Promise<Record<string, unknown> | null>
notifyApplicationEvent(event: ApplicationEvent): void notifyApplicationEvent(event: ApplicationEvent): void
canDisplayNotifications(): Promise<boolean>
displayNotification(options: Notification): Promise<string>
cancelNotification(notificationId: string): Promise<void>
} }

View File

@@ -12,4 +12,5 @@ export enum ReactNativeToWebEvent {
ReceivedFile = 'ReceivedFile', ReceivedFile = 'ReceivedFile',
ReceivedLink = 'ReceivedLink', ReceivedLink = 'ReceivedLink',
ReceivedText = 'ReceivedText', ReceivedText = 'ReceivedText',
OpenFilePreview = 'OpenFilePreview',
} }

View File

@@ -24,6 +24,7 @@ export interface WebApplicationInterface extends ApplicationInterface {
handleReceivedFileEvent(file: { name: string; mimeType: string; data: string }): void handleReceivedFileEvent(file: { name: string; mimeType: string; data: string }): void
handleReceivedTextEvent(item: { text: string; title?: string }): Promise<void> handleReceivedTextEvent(item: { text: string; title?: string }): Promise<void>
handleReceivedLinkEvent(item: { link: string; title: string }): Promise<void> handleReceivedLinkEvent(item: { link: string; title: string }): Promise<void>
handleOpenFilePreviewEvent(item: { id: string }): void
isNativeMobileWeb(): boolean isNativeMobileWeb(): boolean
handleAndroidBackButtonPressed(): void handleAndroidBackButtonPressed(): void
addAndroidBackHandlerEventListener(listener: () => boolean): (() => void) | undefined addAndroidBackHandlerEventListener(listener: () => boolean): (() => void) | undefined

View File

@@ -22,6 +22,7 @@ import {
NoteContent, NoteContent,
SNNote, SNNote,
DesktopManagerInterface, DesktopManagerInterface,
FileItem,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { action, computed, makeObservable, observable } from 'mobx' import { action, computed, makeObservable, observable } from 'mobx'
import { startAuthentication, startRegistration } from '@simplewebauthn/browser' import { startAuthentication, startRegistration } from '@simplewebauthn/browser'
@@ -76,6 +77,7 @@ import { NoAccountWarningController } from '@/Controllers/NoAccountWarningContro
import { SearchOptionsController } from '@/Controllers/SearchOptionsController' import { SearchOptionsController } from '@/Controllers/SearchOptionsController'
import { PersistenceService } from '@/Controllers/Abstract/PersistenceService' import { PersistenceService } from '@/Controllers/Abstract/PersistenceService'
import { removeFromArray } from '@standardnotes/utils' import { removeFromArray } from '@standardnotes/utils'
import { FileItemActionType } from '@/Components/AttachedFilesPopover/PopoverFileItemAction'
export type WebEventObserver = (event: WebAppEvent, data?: unknown) => void export type WebEventObserver = (event: WebAppEvent, data?: unknown) => void
@@ -353,6 +355,21 @@ export class WebApplication extends SNApplication implements WebApplicationInter
this.notifyWebEvent(WebAppEvent.MobileKeyboardDidChangeFrame, frame) this.notifyWebEvent(WebAppEvent.MobileKeyboardDidChangeFrame, frame)
} }
handleOpenFilePreviewEvent({ id }: { id: string }): void {
const file = this.items.findItem<FileItem>(id)
if (!file) {
return
}
this.filesController
.handleFileAction({
type: FileItemActionType.PreviewFile,
payload: {
file,
},
})
.catch(console.error)
}
handleReceivedFileEvent(file: { name: string; mimeType: string; data: string }): void { handleReceivedFileEvent(file: { name: string; mimeType: string; data: string }): void {
const filesController = this.filesController const filesController = this.filesController
const blob = getBlobFromBase64(file.data, file.mimeType) const blob = getBlobFromBase64(file.data, file.mimeType)

View File

@@ -291,6 +291,11 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
private async downloadFile(file: FileItem): Promise<void> { private async downloadFile(file: FileItem): Promise<void> {
let downloadingToastId = '' let downloadingToastId = ''
let canShowProgressNotification = false
if (this.mobileDevice && this.platform === Platform.Android) {
canShowProgressNotification = await this.mobileDevice.canDisplayNotifications()
}
try { try {
const saver = StreamingFileSaver.available() ? new StreamingFileSaver(file.name) : new ClassicFileSaver() const saver = StreamingFileSaver.available() ? new StreamingFileSaver(file.name) : new ClassicFileSaver()
@@ -301,11 +306,21 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
await saver.selectFileToSaveTo() await saver.selectFileToSaveTo()
} }
downloadingToastId = addToast({ if (this.mobileDevice && canShowProgressNotification) {
type: ToastType.Progress, downloadingToastId = await this.mobileDevice.displayNotification({
message: `Downloading file "${file.name}" (0%)`, title: `Downloading file "${file.name}"`,
progress: 0, android: {
}) progress: { max: 100, current: 0, indeterminate: true },
onlyAlertOnce: true,
},
})
} else {
downloadingToastId = addToast({
type: ToastType.Progress,
message: `Downloading file "${file.name}" (0%)`,
progress: 0,
})
}
const decryptedBytesArray: Uint8Array[] = [] const decryptedBytesArray: Uint8Array[] = []
@@ -320,10 +335,23 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
const progressPercent = Math.floor(progress.percentComplete) const progressPercent = Math.floor(progress.percentComplete)
updateToast(downloadingToastId, { if (this.mobileDevice && canShowProgressNotification) {
message: fileProgressToHumanReadableString(progress, file.name, { showPercent: true }), this.mobileDevice
progress: progressPercent, .displayNotification({
}) id: downloadingToastId,
title: `Downloading file "${file.name}"`,
android: {
progress: { max: 100, current: progressPercent, indeterminate: false },
onlyAlertOnce: true,
},
})
.catch(console.error)
} else {
updateToast(downloadingToastId, {
message: fileProgressToHumanReadableString(progress, file.name, { showPercent: true }),
progress: progressPercent,
})
}
lastProgress = progress lastProgress = progress
}) })
@@ -339,7 +367,6 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
const blob = new Blob([finalBytes], { const blob = new Blob([finalBytes], {
type: file.mimeType, type: file.mimeType,
}) })
// await downloadOrShareBlobBasedOnPlatform(this, blob, file.name, false)
await downloadOrShareBlobBasedOnPlatform({ await downloadOrShareBlobBasedOnPlatform({
archiveService: this.archiveService, archiveService: this.archiveService,
platform: this.platform, platform: this.platform,
@@ -351,12 +378,18 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
}) })
} }
addToast({ if (this.mobileDevice && canShowProgressNotification) {
type: ToastType.Success, await this.mobileDevice.displayNotification({
message: `Successfully downloaded file${ title: `Successfully downloaded file "${file.name}"`,
lastProgress && lastProgress.source === 'local' ? ' from local backup' : '' })
}`, } else {
}) addToast({
type: ToastType.Success,
message: `Successfully downloaded file${
lastProgress && lastProgress.source === 'local' ? ' from local backup' : ''
}`,
})
}
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@@ -366,8 +399,12 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
}) })
} }
if (downloadingToastId.length > 0) { if (downloadingToastId) {
dismissToast(downloadingToastId) if (this.mobileDevice && canShowProgressNotification) {
this.mobileDevice.cancelNotification(downloadingToastId).catch(console.error)
} else {
dismissToast(downloadingToastId)
}
} }
} }
@@ -412,6 +449,11 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
const { showToast = true, note } = options const { showToast = true, note } = options
let toastId: string | undefined let toastId: string | undefined
let canShowProgressNotification = false
if (showToast && this.mobileDevice && this.platform === Platform.Android) {
canShowProgressNotification = await this.mobileDevice.canDisplayNotifications()
}
try { try {
const minimumChunkSize = this.files.minimumChunkSize() const minimumChunkSize = this.files.minimumChunkSize()
@@ -449,11 +491,21 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
const initialProgress = operation.getProgress().percentComplete const initialProgress = operation.getProgress().percentComplete
if (showToast) { if (showToast) {
toastId = addToast({ if (this.mobileDevice && canShowProgressNotification) {
type: ToastType.Progress, toastId = await this.mobileDevice.displayNotification({
message: `Uploading file "${fileToUpload.name}" (${initialProgress}%)`, title: `Uploading file "${fileToUpload.name}"`,
progress: initialProgress, android: {
}) progress: { max: 100, current: initialProgress, indeterminate: true },
onlyAlertOnce: true,
},
})
} else {
toastId = addToast({
type: ToastType.Progress,
message: `Uploading file "${fileToUpload.name}" (${initialProgress}%)`,
progress: initialProgress,
})
}
} }
const onChunk: OnChunkCallbackNoProgress = async ({ data, index, isLast }) => { const onChunk: OnChunkCallbackNoProgress = async ({ data, index, isLast }) => {
@@ -461,10 +513,21 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
const percentComplete = Math.round(operation.getProgress().percentComplete) const percentComplete = Math.round(operation.getProgress().percentComplete)
if (toastId) { if (toastId) {
updateToast(toastId, { if (this.mobileDevice && canShowProgressNotification) {
message: `Uploading file "${fileToUpload.name}" (${percentComplete}%)`, await this.mobileDevice.displayNotification({
progress: percentComplete, id: toastId,
}) title: `Uploading file "${fileToUpload.name}"`,
android: {
progress: { max: 100, current: percentComplete, indeterminate: false },
onlyAlertOnce: true,
},
})
} else {
updateToast(toastId, {
message: `Uploading file "${fileToUpload.name}" (${percentComplete}%)`,
progress: percentComplete,
})
}
} }
} }
@@ -480,32 +543,54 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
if (uploadedFile instanceof ClientDisplayableError) { if (uploadedFile instanceof ClientDisplayableError) {
addToast({ addToast({
type: ToastType.Error, type: ToastType.Error,
message: 'Unable to close upload session', message: uploadedFile.text,
}) })
throw new Error('Unable to close upload session') throw new Error(uploadedFile.text)
} }
if (toastId) { if (toastId) {
if (this.mobileDevice && canShowProgressNotification) {
this.mobileDevice.cancelNotification(toastId).catch(console.error)
}
dismissToast(toastId) dismissToast(toastId)
} }
if (showToast) { if (showToast) {
addToast({ if (this.mobileDevice && canShowProgressNotification) {
type: ToastType.Success, this.mobileDevice
message: `Uploaded file "${uploadedFile.name}"`, .displayNotification({
actions: [ id: uploadedFile.uuid,
{ title: `Uploaded file "${uploadedFile.name}"`,
label: 'Open', android: {
handler: (toastId) => { actions: [
void this.handleFileAction({ {
type: FileItemActionType.PreviewFile, title: 'Open',
payload: { file: uploadedFile }, pressAction: {
}) id: 'open-file',
dismissToast(toastId) },
},
],
}, },
}, })
], .catch(console.error)
autoClose: true, } else {
}) addToast({
type: ToastType.Success,
message: `Uploaded file "${uploadedFile.name}"`,
actions: [
{
label: 'Open',
handler: (toastId) => {
void this.handleFileAction({
type: FileItemActionType.PreviewFile,
payload: { file: uploadedFile },
})
dismissToast(toastId)
},
},
],
autoClose: true,
})
}
} }
return uploadedFile return uploadedFile
@@ -513,12 +598,23 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
console.error(error) console.error(error)
if (toastId) { if (toastId) {
if (this.mobileDevice && canShowProgressNotification) {
this.mobileDevice.cancelNotification(toastId).catch(console.error)
}
dismissToast(toastId) dismissToast(toastId)
} }
addToast({ if (this.mobileDevice && canShowProgressNotification) {
type: ToastType.Error, this.mobileDevice
message: 'There was an error while uploading the file', .displayNotification({
}) title: 'There was an error while uploading the file',
})
.catch(console.error)
} else {
addToast({
type: ToastType.Error,
message: 'There was an error while uploading the file',
})
}
} }
return undefined return undefined

View File

@@ -98,7 +98,9 @@ export class MobileWebReceiver {
case ReactNativeToWebEvent.ReceivedLink: case ReactNativeToWebEvent.ReceivedLink:
void this.application.handleReceivedLinkEvent(messageData as { link: string; title: string }) void this.application.handleReceivedLinkEvent(messageData as { link: string; title: string })
break break
case ReactNativeToWebEvent.OpenFilePreview:
void this.application.handleOpenFilePreviewEvent(messageData as { id: string })
break
default: default:
break break
} }

View File

@@ -3206,6 +3206,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@notifee/react-native@npm:^7.8.0":
version: 7.8.0
resolution: "@notifee/react-native@npm:7.8.0"
peerDependencies:
react-native: "*"
checksum: 800233c9505195046e918ee42a4b11d8e84cf1846ea4a0b52599e1a40181b2bbfcab09c64b445b5a7eed98c38556c97550bce32ed24a51ce05ccd8b65047548d
languageName: node
linkType: hard
"@npmcli/arborist@npm:^6.2.5": "@npmcli/arborist@npm:^6.2.5":
version: 6.3.0 version: 6.3.0
resolution: "@npmcli/arborist@npm:6.3.0" resolution: "@npmcli/arborist@npm:6.3.0"
@@ -4585,6 +4594,7 @@ __metadata:
"@babel/core": "*" "@babel/core": "*"
"@babel/preset-typescript": ^7.18.6 "@babel/preset-typescript": ^7.18.6
"@babel/runtime": ^7.20.1 "@babel/runtime": ^7.20.1
"@notifee/react-native": ^7.8.0
"@react-native-async-storage/async-storage": 1.19.3 "@react-native-async-storage/async-storage": 1.19.3
"@react-native/eslint-config": ^0.72.2 "@react-native/eslint-config": ^0.72.2
"@react-native/metro-config": ^0.72.11 "@react-native/metro-config": ^0.72.11