diff --git a/.yarn/cache/react-native-share-npm-7.6.4-d44b48b702-2276435473.zip b/.yarn/cache/react-native-share-npm-7.9.0-b553614939-8eb2f5b4be.zip similarity index 52% rename from .yarn/cache/react-native-share-npm-7.6.4-d44b48b702-2276435473.zip rename to .yarn/cache/react-native-share-npm-7.9.0-b553614939-8eb2f5b4be.zip index 1da2a1a47..41ee9e47c 100644 Binary files a/.yarn/cache/react-native-share-npm-7.6.4-d44b48b702-2276435473.zip and b/.yarn/cache/react-native-share-npm-7.9.0-b553614939-8eb2f5b4be.zip differ diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 92fe436c3..579a1eda3 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -69,7 +69,7 @@ "react-native-screens": "3.13.1", "react-native-search-bar": "standardnotes/react-native-search-bar#7d2139daf9b7663b570403f21f520deceba9bb09", "react-native-search-box": "standardnotes/react-native-search-box#8c46369912cba78dca718588aca9c16926953ff7", - "react-native-share": "^7.3.7", + "react-native-share": "^7.9.0", "react-native-sodium-jsi": "1.2.0", "react-native-static-server": "standardnotes/react-native-static-server#28ef0175dbee3db9aadfab57498497067556a836", "react-native-store-review": "^0.2.0", diff --git a/packages/mobile/src/AppStack.tsx b/packages/mobile/src/AppStack.tsx index 338f026f9..244f53c55 100644 --- a/packages/mobile/src/AppStack.tsx +++ b/packages/mobile/src/AppStack.tsx @@ -121,11 +121,7 @@ export const AppStackComponent = (props: ModalStackNavigationProp<'AppStack'>) = [application], ) - if (!application) { - return null - } - - const shouldOpenWebApp = application.getValue(AlwaysOpenWebAppOnLaunchKey, StorageValueModes.Nonwrapped) as boolean + const shouldOpenWebApp = application?.getValue(AlwaysOpenWebAppOnLaunchKey, StorageValueModes.Nonwrapped) as boolean if (IsMobileWeb || shouldOpenWebApp) { return ( diff --git a/packages/mobile/src/Lib/Interface.ts b/packages/mobile/src/Lib/Interface.ts index aa5b840de..7c1151283 100644 --- a/packages/mobile/src/Lib/Interface.ts +++ b/packages/mobile/src/Lib/Interface.ts @@ -7,16 +7,27 @@ import { LegacyRawKeychainValue, MobileDeviceInterface, NamespacedRootKeyInKeychain, + Platform as SNPlatform, RawKeychainValue, removeFromArray, TransferPayload, } from '@standardnotes/snjs' -import { Alert, Linking, Platform, StatusBar } from 'react-native' +import { Alert, Linking, PermissionsAndroid, Platform, StatusBar } from 'react-native' import FingerprintScanner from 'react-native-fingerprint-scanner' import FlagSecure from 'react-native-flag-secure-android' +import { + CachesDirectoryPath, + DocumentDirectoryPath, + DownloadDirectoryPath, + exists, + unlink, + writeFile, +} from 'react-native-fs' import { hide, show } from 'react-native-privacy-snapshot' +import Share from 'react-native-share' import { AppStateObserverService } from './../AppStateObserverService' import Keychain from './Keychain' +import { SNReactNativeCrypto } from './ReactNativeCrypto' import { IsMobileWeb } from './Utils' export type BiometricsType = 'Fingerprint' | 'Face ID' | 'Biometrics' | 'Touch ID' @@ -64,10 +75,14 @@ const showLoadFailForItemIds = (failedItemIds: string[]) => { export class MobileDevice implements MobileDeviceInterface { environment: Environment.Mobile = Environment.Mobile + platform: SNPlatform.Ios | SNPlatform.Android = Platform.OS === 'ios' ? SNPlatform.Ios : SNPlatform.Android private eventObservers: MobileDeviceEventHandler[] = [] public isDarkMode = false + private crypto: SNReactNativeCrypto - constructor(private stateObserverService?: AppStateObserverService) {} + constructor(private stateObserverService?: AppStateObserverService) { + this.crypto = new SNReactNativeCrypto() + } deinit() { this.stateObserverService?.deinit() @@ -455,4 +470,76 @@ export class MobileDevice implements MobileDeviceInterface { isDeviceDestroyed() { return false } + + async deleteFileAtPathIfExists(path: string) { + if (await exists(path)) { + await unlink(path) + } + } + + async shareBase64AsFile(base64: string, filename: string) { + let downloadedTempFilePath: string | undefined + try { + downloadedTempFilePath = await this.downloadBase64AsFile(base64, filename, true) + if (!downloadedTempFilePath) { + return + } + await Share.open({ + url: `file://${downloadedTempFilePath}`, + failOnCancel: false, + }) + } catch (error) { + this.consoleLog(`${error}`) + } finally { + if (downloadedTempFilePath) { + void this.deleteFileAtPathIfExists(downloadedTempFilePath) + } + } + } + + getFileDestinationPath(filename: string, saveInTempLocation: boolean): string { + let directory = DocumentDirectoryPath + + if (Platform.OS === 'android') { + directory = saveInTempLocation ? CachesDirectoryPath : DownloadDirectoryPath + } + + return `${directory}/${filename}` + } + + async hasStoragePermissionOnAndroid(): Promise { + if (Platform.OS !== 'android') { + return true + } + const grantedStatus = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE) + if (grantedStatus === PermissionsAndroid.RESULTS.GRANTED) { + return true + } + Alert.alert( + 'Storage permissions are required in order to download files. Please accept the permissions prompt and try again.', + ) + return false + } + + async downloadBase64AsFile( + base64: string, + filename: string, + saveInTempLocation = false, + ): Promise { + const isGrantedStoragePermissionOnAndroid = await this.hasStoragePermissionOnAndroid() + + if (!isGrantedStoragePermissionOnAndroid) { + return + } + + try { + const path = this.getFileDestinationPath(filename, saveInTempLocation) + void this.deleteFileAtPathIfExists(path) + const decodedContents = this.crypto.base64Decode(base64.replace(/data.*base64,/, '')) + await writeFile(path, decodedContents) + return path + } catch (error) { + this.consoleLog(`${error}`) + } + } } diff --git a/packages/mobile/src/MobileWebAppContainer.tsx b/packages/mobile/src/MobileWebAppContainer.tsx index 83af26b95..83d4a17a3 100644 --- a/packages/mobile/src/MobileWebAppContainer.tsx +++ b/packages/mobile/src/MobileWebAppContainer.tsx @@ -88,6 +88,7 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo constructor(messageSender) { this.appVersion = '${pjson.version} (${VersionInfo.buildVersion})' this.environment = 4 + this.platform = ${device.platform} this.databases = [] this.messageSender = messageSender } diff --git a/packages/services/src/Domain/Device/MobileDeviceInterface.ts b/packages/services/src/Domain/Device/MobileDeviceInterface.ts index 61f25205b..9f2d9826f 100644 --- a/packages/services/src/Domain/Device/MobileDeviceInterface.ts +++ b/packages/services/src/Domain/Device/MobileDeviceInterface.ts @@ -1,8 +1,9 @@ import { DeviceInterface } from './DeviceInterface' -import { Environment, RawKeychainValue } from '@standardnotes/models' +import { Environment, Platform, RawKeychainValue } from '@standardnotes/models' export interface MobileDeviceInterface extends DeviceInterface { environment: Environment.Mobile + platform: Platform.Ios | Platform.Android getRawKeychainValue(): Promise getDeviceBiometricsAvailability(): Promise @@ -12,4 +13,6 @@ export interface MobileDeviceInterface extends DeviceInterface { stopHidingMobileInterfaceFromScreenshots(): void consoleLog(...args: any[]): void handleThemeSchemeChange(isDark: boolean): void + shareBase64AsFile(base64: string, filename: string): Promise + downloadBase64AsFile(base64: string, filename: string, saveInTempLocation?: boolean): Promise } diff --git a/packages/ui-services/src/Archive/ArchiveManager.ts b/packages/ui-services/src/Archive/ArchiveManager.ts index 938df80af..329cb64c5 100644 --- a/packages/ui-services/src/Archive/ArchiveManager.ts +++ b/packages/ui-services/src/Archive/ArchiveManager.ts @@ -8,7 +8,7 @@ import { import { ContentType } from '@standardnotes/common' import { ApplicationInterface } from '@standardnotes/services' -function sanitizeFileName(name: string): string { +export function sanitizeFileName(name: string): string { return name.trim().replace(/[.\\/:"?*|<>]/g, '_') } @@ -52,13 +52,16 @@ export class ArchiveManager { }) if (encrypted) { - this.downloadData(blobData, `Standard Notes Encrypted Backup and Import File - ${this.formattedDate()}.txt`) + this.downloadData( + blobData, + `Standard Notes Encrypted Backup and Import File - ${this.formattedDateForExports()}.txt`, + ) } else { this.downloadZippedDecryptedItems(data).catch(console.error) } } - private formattedDate() { + formattedDateForExports() { const string = `${new Date()}` // Match up to the first parenthesis, i.e do not include '(Central Standard Time)' const matches = string.match(/^(.*?) \(/) @@ -108,7 +111,7 @@ export class ArchiveManager { await nextFile() } else { const finalBlob = await zipWriter.close() - this.downloadData(finalBlob, `Standard Notes Backup - ${this.formattedDate()}.zip`) + this.downloadData(finalBlob, `Standard Notes Backup - ${this.formattedDateForExports()}.zip`) } } @@ -143,7 +146,7 @@ export class ArchiveManager { async downloadDataAsZip(data: ZippableData) { const zipFileAsBlob = await this.zipData(data) - this.downloadData(zipFileAsBlob, `Standard Notes Export - ${this.formattedDate()}.zip`) + this.downloadData(zipFileAsBlob, `Standard Notes Export - ${this.formattedDateForExports()}.zip`) } private hrefForData(data: Blob) { diff --git a/packages/web/src/javascripts/Application/ApplicationGroup.ts b/packages/web/src/javascripts/Application/ApplicationGroup.ts index 27ec2daa3..a4d951ebb 100644 --- a/packages/web/src/javascripts/Application/ApplicationGroup.ts +++ b/packages/web/src/javascripts/Application/ApplicationGroup.ts @@ -20,7 +20,7 @@ const createApplication = ( device: WebOrDesktopDevice, webSocketUrl: string, ) => { - const platform = getPlatform() + const platform = getPlatform(device) const application = new WebApplication( deviceInterface, diff --git a/packages/web/src/javascripts/Components/Icon/Icon.tsx b/packages/web/src/javascripts/Components/Icon/Icon.tsx index 048838802..e12f77678 100644 --- a/packages/web/src/javascripts/Components/Icon/Icon.tsx +++ b/packages/web/src/javascripts/Components/Icon/Icon.tsx @@ -86,6 +86,7 @@ export const ICONS = { security: icons.SecurityIcon, server: icons.ServerIcon, settings: icons.SettingsIcon, + share: icons.ShareIcon, signIn: icons.SignInIcon, signOut: icons.SignOutIcon, spreadsheets: icons.SpreadsheetsIcon, diff --git a/packages/web/src/javascripts/Components/NotesContextMenu/NotesContextMenu.tsx b/packages/web/src/javascripts/Components/NotesContextMenu/NotesContextMenu.tsx index e107a4185..0f854e9b6 100644 --- a/packages/web/src/javascripts/Components/NotesContextMenu/NotesContextMenu.tsx +++ b/packages/web/src/javascripts/Components/NotesContextMenu/NotesContextMenu.tsx @@ -41,7 +41,7 @@ const NotesContextMenu = ({ side="right" togglePopover={closeMenu} > -
+
void @@ -95,7 +98,7 @@ const NoteAttributes: FunctionComponent<{ const format = editor?.package_info?.file_type || 'txt' return ( -
+
{typeof words === 'number' && (format === 'txt' || format === 'md') ? ( <>
@@ -220,18 +223,11 @@ const NotesOptions = ({ } }, [application]) - const getNoteFileName = useCallback( - (note: SNNote): string => { - const editor = application.componentManager.editorForNote(note) - const format = editor?.package_info?.file_type || 'txt' - return `${note.title}.${format}` - }, - [application.componentManager], - ) - const downloadSelectedItems = useCallback(async () => { if (notes.length === 1) { - application.getArchiveService().downloadData(new Blob([notes[0].text]), getNoteFileName(notes[0])) + const note = notes[0] + const blob = getNoteBlob(application, note) + application.getArchiveService().downloadData(blob, getNoteFileName(application, note)) return } @@ -243,8 +239,8 @@ const NotesOptions = ({ await application.getArchiveService().downloadDataAsZip( notes.map((note) => { return { - name: getNoteFileName(note), - content: new Blob([note.text]), + name: getNoteFileName(application, note), + content: getNoteBlob(application, note), } }), ) @@ -254,7 +250,7 @@ const NotesOptions = ({ message: `Exported ${notes.length} notes`, }) } - }, [application, getNoteFileName, notes]) + }, [application, notes]) const closeMenuAndToggleNotesList = useCallback(() => { toggleAppPane(AppPaneId.Items) @@ -358,11 +354,22 @@ const NotesOptions = ({ )} + {application.platform === Platform.Android && ( + + )} - + { + if (!application.isNativeMobileWeb() || application.platform !== Platform.Android) { + throw new Error('Function being used on non-android platform') + } + if (notes.length === 1) { + const note = notes[0] + const blob = getNoteBlob(application, note) + const base64 = await getBase64FromBlob(blob) + const { name, ext } = parseFileName(getNoteFileName(application, note)) + const filename = `${sanitizeFileName(name)}.${ext}` + const loadingToastId = addToast({ + type: ToastType.Loading, + message: `Exporting ${filename}..`, + }) + const downloaded = await application.mobileDevice.downloadBase64AsFile(base64, filename) + if (downloaded) { + dismissToast(loadingToastId) + addToast({ + type: ToastType.Success, + message: `Exported ${filename}`, + }) + } else { + addToast({ + type: ToastType.Error, + message: `Could not export ${filename}`, + }) + } + return + } + if (notes.length > 1) { + const zippedDataBlob = await application.getArchiveService().zipData( + notes.map((note) => { + return { + name: getNoteFileName(application, note), + content: getNoteBlob(application, note), + } + }), + ) + const zippedDataAsBase64 = await getBase64FromBlob(zippedDataBlob) + const filename = `Standard Notes Export - ${application.getArchiveService().formattedDateForExports()}.zip` + const loadingToastId = addToast({ + type: ToastType.Loading, + message: `Exporting ${filename}..`, + }) + const downloaded = await application.mobileDevice.downloadBase64AsFile(zippedDataAsBase64, filename) + if (downloaded) { + dismissToast(loadingToastId) + addToast({ + type: ToastType.Success, + message: `Exported ${filename}`, + }) + } else { + addToast({ + type: ToastType.Error, + message: `Could not export ${filename}`, + }) + } + } +} diff --git a/packages/web/src/javascripts/NativeMobileWeb/ShareSelectedItems.tsx b/packages/web/src/javascripts/NativeMobileWeb/ShareSelectedItems.tsx new file mode 100644 index 000000000..a12f64dc2 --- /dev/null +++ b/packages/web/src/javascripts/NativeMobileWeb/ShareSelectedItems.tsx @@ -0,0 +1,36 @@ +import { WebApplication } from '@/Application/Application' +import { getBase64FromBlob } from '@/Utils' +import { getNoteBlob, getNoteFileName } from '@/Utils/NoteExportUtils' +import { parseFileName } from '@standardnotes/filepicker' +import { SNNote } from '@standardnotes/snjs' +import { sanitizeFileName } from '@standardnotes/ui-services' + +export const shareSelectedItems = async (application: WebApplication, notes: SNNote[]) => { + if (!application.isNativeMobileWeb()) { + throw new Error('Share function being used outside mobile webview') + } + if (notes.length === 1) { + const note = notes[0] + const blob = getNoteBlob(application, note) + const base64 = await getBase64FromBlob(blob) + const { name, ext } = parseFileName(getNoteFileName(application, note)) + const filename = `${sanitizeFileName(name)}.${ext}` + application.mobileDevice.shareBase64AsFile(base64, filename) + return + } + if (notes.length > 1) { + const zippedDataBlob = await application.getArchiveService().zipData( + notes.map((note) => { + return { + name: getNoteFileName(application, note), + content: getNoteBlob(application, note), + } + }), + ) + const zippedDataAsBase64 = await getBase64FromBlob(zippedDataBlob) + application.mobileDevice.shareBase64AsFile( + zippedDataAsBase64, + `Standard Notes Export - ${application.getArchiveService().formattedDateForExports()}.zip`, + ) + } +} diff --git a/packages/web/src/javascripts/Utils/NoteExportUtils.ts b/packages/web/src/javascripts/Utils/NoteExportUtils.ts new file mode 100644 index 000000000..6e0cfce70 --- /dev/null +++ b/packages/web/src/javascripts/Utils/NoteExportUtils.ts @@ -0,0 +1,36 @@ +import { WebApplication } from '@/Application/Application' +import { SNNote } from '@standardnotes/snjs' + +export const getNoteFormat = (application: WebApplication, note: SNNote) => { + const editor = application.componentManager.editorForNote(note) + const format = editor?.package_info?.file_type || 'txt' + return format +} + +export const getNoteFileName = (application: WebApplication, note: SNNote): string => { + const format = getNoteFormat(application, note) + return `${note.title}.${format}` +} + +export const getNoteBlob = (application: WebApplication, note: SNNote) => { + const format = getNoteFormat(application, note) + let type: string + switch (format) { + case 'html': + type = 'text/html' + break + case 'json': + type = 'application/json' + break + case 'md': + type = 'text/markdown' + break + default: + type = 'text/plain' + break + } + const blob = new Blob([note.text], { + type, + }) + return blob +} diff --git a/packages/web/src/javascripts/Utils/Utils.ts b/packages/web/src/javascripts/Utils/Utils.ts index a5b3ee5c1..00e1d8d5b 100644 --- a/packages/web/src/javascripts/Utils/Utils.ts +++ b/packages/web/src/javascripts/Utils/Utils.ts @@ -1,4 +1,4 @@ -import { Platform, platformFromString } from '@standardnotes/snjs' +import { DeviceInterface, MobileDeviceInterface, Platform, platformFromString } from '@standardnotes/snjs' import { IsDesktopPlatform, IsWebPlatform } from '@/Constants/Version' import { EMAIL_REGEX } from '../Constants/Constants' import { MediaQueryBreakpoints } from '@/Hooks/useMediaQuery' @@ -31,7 +31,11 @@ export function getPlatformString() { } } -export function getPlatform(): Platform { +export function getPlatform(device: DeviceInterface | MobileDeviceInterface): Platform { + if ('platform' in device) { + return device.platform + } + return platformFromString(getPlatformString()) } @@ -204,3 +208,17 @@ export const disableIosTextFieldZoom = () => { } export const isMobileScreen = () => !window.matchMedia(MediaQueryBreakpoints.md).matches + +export const getBase64FromBlob = (blob: Blob) => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onloadend = () => { + if (reader.result) { + resolve(reader.result.toString()) + } else { + reject() + } + } + reader.readAsDataURL(blob) + }) +} diff --git a/yarn.lock b/yarn.lock index c1a940acf..3ac8905bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7195,7 +7195,7 @@ __metadata: react-native-screens: 3.13.1 react-native-search-bar: "standardnotes/react-native-search-bar#7d2139daf9b7663b570403f21f520deceba9bb09" react-native-search-box: "standardnotes/react-native-search-box#8c46369912cba78dca718588aca9c16926953ff7" - react-native-share: ^7.3.7 + react-native-share: ^7.9.0 react-native-sodium-jsi: 1.2.0 react-native-static-server: "standardnotes/react-native-static-server#28ef0175dbee3db9aadfab57498497067556a836" react-native-store-review: ^0.2.0 @@ -32971,10 +32971,10 @@ __metadata: languageName: node linkType: hard -"react-native-share@npm:^7.3.7": - version: 7.6.4 - resolution: "react-native-share@npm:7.6.4" - checksum: 22764354738587d9166653b09e014ddecf54562696b7f92353980b12be0f9adef58c5d9f927bd05a5e19f50956e6f14fbe2b7a3105675e5982823512945be315 +"react-native-share@npm:^7.9.0": + version: 7.9.0 + resolution: "react-native-share@npm:7.9.0" + checksum: 8eb2f5b4be8df11224f8f00b2761c2c3b4231590d945f57e8ed91899a455d7b5083bb62aa8a017ed8c15fbdfb95e85f9ee4060ac00c01897ef5c351e756c46af languageName: node linkType: hard