fix: on mobile open links from editor in external browser (#1860)

This commit is contained in:
Aman Harwara
2022-10-25 21:38:29 +05:30
committed by GitHub
parent ca7455a854
commit d9db73ea05
14 changed files with 99 additions and 62 deletions

View File

@@ -2,7 +2,7 @@
class WebProcessDeviceInterface { class WebProcessDeviceInterface {
constructor(messageSender) { constructor(messageSender) {
this.appVersion = '1.2.3' this.appVersion = '1.2.3'
this.environment = 4 this.environment = 3
this.databases = [] this.databases = []
this.messageSender = messageSender this.messageSender = messageSender
} }

View File

@@ -11,6 +11,7 @@ import {
RawKeychainValue, RawKeychainValue,
removeFromArray, removeFromArray,
TransferPayload, TransferPayload,
UuidString,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { Alert, Linking, PermissionsAndroid, Platform, StatusBar } from 'react-native' import { Alert, Linking, PermissionsAndroid, Platform, StatusBar } from 'react-native'
import FileViewer from 'react-native-file-viewer' import FileViewer from 'react-native-file-viewer'
@@ -79,6 +80,7 @@ export class MobileDevice implements MobileDeviceInterface {
private eventObservers: MobileDeviceEventHandler[] = [] private eventObservers: MobileDeviceEventHandler[] = []
public isDarkMode = false public isDarkMode = false
public statusBarBgColor: string | undefined public statusBarBgColor: string | undefined
private componentUrls: Map<UuidString, string> = new Map()
constructor( constructor(
private stateObserverService?: AppStateObserverService, private stateObserverService?: AppStateObserverService,
@@ -596,4 +598,16 @@ export class MobileDevice implements MobileDeviceInterface {
}, },
) )
} }
addComponentUrl(componentUuid: UuidString, componentUrl: string) {
this.componentUrls.set(componentUuid, componentUrl)
}
removeComponentUrl(componentUuid: UuidString) {
this.componentUrls.delete(componentUuid)
}
isUrlComponentUrl(url: string): boolean {
return Array.from(this.componentUrls.values()).includes(url)
}
} }

View File

@@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Keyboard, Platform } from 'react-native' import { Keyboard, Platform } from 'react-native'
import VersionInfo from 'react-native-version-info' import VersionInfo from 'react-native-version-info'
import { WebView, WebViewMessageEvent } from 'react-native-webview' import { WebView, WebViewMessageEvent } from 'react-native-webview'
import { OnShouldStartLoadWithRequest } from 'react-native-webview/lib/WebViewTypes'
import pjson from '../package.json' import pjson from '../package.json'
import { AndroidBackHandlerService } from './AndroidBackHandlerService' import { AndroidBackHandlerService } from './AndroidBackHandlerService'
import { AppStateObserverService } from './AppStateObserverService' import { AppStateObserverService } from './AppStateObserverService'
@@ -23,6 +24,7 @@ export const MobileWebAppContainer = () => {
const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => void }) => { const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => void }) => {
const webViewRef = useRef<WebView>(null) const webViewRef = useRef<WebView>(null)
const sourceUri = (Platform.OS === 'android' ? 'file:///android_asset/' : '') + 'Web.bundle/src/index.html' const sourceUri = (Platform.OS === 'android' ? 'file:///android_asset/' : '') + 'Web.bundle/src/index.html'
const stateService = useMemo(() => new AppStateObserverService(), []) const stateService = useMemo(() => new AppStateObserverService(), [])
const androidBackHandlerService = useMemo(() => new AndroidBackHandlerService(), []) const androidBackHandlerService = useMemo(() => new AndroidBackHandlerService(), [])
@@ -99,7 +101,7 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
class WebProcessDeviceInterface { class WebProcessDeviceInterface {
constructor(messageSender) { constructor(messageSender) {
this.appVersion = '${pjson.version} (${VersionInfo.buildVersion})' this.appVersion = '${pjson.version} (${VersionInfo.buildVersion})'
this.environment = 4 this.environment = 3
this.platform = ${device.platform} this.platform = ${device.platform}
this.databases = [] this.databases = []
this.messageSender = messageSender this.messageSender = messageSender
@@ -195,6 +197,34 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
webViewRef.current?.postMessage(JSON.stringify({ messageId, returnValue, messageType: 'reply' })) webViewRef.current?.postMessage(JSON.stringify({ messageId, returnValue, messageType: 'reply' }))
} }
const onShouldStartLoadWithRequest: OnShouldStartLoadWithRequest = (request) => {
/**
* We want to handle link clicks within an editor by opening the browser
* instead of loading inline. On iOS, onShouldStartLoadWithRequest is
* called for all requests including the initial request to load the editor.
* On iOS, clicks in the editors have a navigationType of 'click', but on
* Android, this is not the case (no navigationType).
* However, on Android, this function is not called for the initial request.
* So that might be one way to determine if this request is a click or the
* actual editor load request. But I don't think it's safe to rely on this
* being the case in the future. So on Android, we'll handle url loads only
* if the url isn't equal to the editor url.
*/
const shouldStopRequest =
(Platform.OS === 'ios' && request.navigationType === 'click') ||
(Platform.OS === 'android' && request.url !== sourceUri)
const isComponentUrl = device.isUrlComponentUrl(request.url)
if (shouldStopRequest && !isComponentUrl) {
device.openUrl(request.url)
return false
}
return true
}
return ( return (
<WebView <WebView
ref={webViewRef} ref={webViewRef}
@@ -210,6 +240,7 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
onRenderProcessGone={() => { onRenderProcessGone={() => {
webViewRef.current?.reload() webViewRef.current?.reload()
}} }}
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
allowFileAccess={true} allowFileAccess={true}
allowUniversalAccessFromFileURLs={true} allowUniversalAccessFromFileURLs={true}
injectedJavaScriptBeforeContentLoaded={injectedJS} injectedJavaScriptBeforeContentLoaded={injectedJS}

View File

@@ -2,5 +2,4 @@ export enum Environment {
Web = 1, Web = 1,
Desktop = 2, Desktop = 2,
Mobile = 3, Mobile = 3,
NativeMobileWeb = 4,
} }

View File

@@ -17,4 +17,7 @@ export interface MobileDeviceInterface extends DeviceInterface {
downloadBase64AsFile(base64: string, filename: string, saveInTempLocation?: boolean): Promise<string | undefined> downloadBase64AsFile(base64: string, filename: string, saveInTempLocation?: boolean): Promise<string | undefined>
previewFile(base64: string, filename: string): Promise<boolean> previewFile(base64: string, filename: string): Promise<boolean>
exitApp(confirm?: boolean): void exitApp(confirm?: boolean): void
addComponentUrl(componentUuid: string, componentUrl: string): void
removeComponentUrl(componentUuid: string): void
isUrlComponentUrl(url: string): boolean
} }

View File

@@ -1,3 +1,7 @@
/**
* @jest-environment jsdom
*/
import { SNLog } from './../Log' import { SNLog } from './../Log'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common' import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { AlertService, DeviceInterface, namespacedKey, RawStorageKey } from '@standardnotes/services' import { AlertService, DeviceInterface, namespacedKey, RawStorageKey } from '@standardnotes/services'

View File

@@ -940,7 +940,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
} }
isNativeMobileWeb() { isNativeMobileWeb() {
return this.environment === Environment.NativeMobileWeb return this.environment === Environment.Mobile
} }
getDeinitMode(): DeinitMode { getDeinitMode(): DeinitMode {
@@ -1353,10 +1353,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
} }
private createComponentManager() { private createComponentManager() {
const MaybeSwappedComponentManager = this.getClass<typeof InternalServices.SNComponentManager>( this.componentManagerService = new InternalServices.SNComponentManager(
InternalServices.SNComponentManager,
)
this.componentManagerService = new MaybeSwappedComponentManager(
this.itemManager, this.itemManager,
this.syncService, this.syncService,
this.featuresService, this.featuresService,
@@ -1365,6 +1362,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.environment, this.environment,
this.platform, this.platform,
this.internalEventBus, this.internalEventBus,
this.deviceInterface,
) )
this.services.push(this.componentManagerService) this.services.push(this.componentManagerService)
} }
@@ -1647,13 +1645,4 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.statusService = new ExternalServices.StatusService(this.internalEventBus) this.statusService = new ExternalServices.StatusService(this.internalEventBus)
this.services.push(this.statusService) this.services.push(this.statusService)
} }
private getClass<T>(base: T) {
const swapClass = this.options.swapClasses?.find((candidate) => candidate.swap === base)
if (swapClass) {
return swapClass.with as T
} else {
return base
}
}
} }

View File

@@ -10,14 +10,6 @@ export interface ApplicationDisplayOptions {
} }
export interface ApplicationOptionalConfiguratioOptions { export interface ApplicationOptionalConfiguratioOptions {
/**
* Gives consumers the ability to provide their own custom
* subclass for a service. swapClasses should be an array of key/value pairs
* consisting of keys 'swap' and 'with'. 'swap' is the base class you wish to replace,
* and 'with' is the custom subclass to use.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
swapClasses?: { swap: any; with: any }[]
/** /**
* URL for WebSocket providing permissions and roles information. * URL for WebSocket providing permissions and roles information.
*/ */

View File

@@ -28,22 +28,11 @@ export function platformToString(platform: Platform) {
return map[platform] return map[platform]
} }
export function environmentFromString(string: string) {
const map: Record<string, Environment> = {
web: Environment.Web,
desktop: Environment.Desktop,
mobile: Environment.Mobile,
nativeMobileWeb: Environment.NativeMobileWeb,
}
return map[string]
}
export function environmentToString(environment: Environment) { export function environmentToString(environment: Environment) {
const map = { const map = {
[Environment.Web]: 'web', [Environment.Web]: 'web',
[Environment.Desktop]: 'desktop', [Environment.Desktop]: 'desktop',
[Environment.Mobile]: 'mobile', [Environment.Mobile]: 'native-mobile-web',
[Environment.NativeMobileWeb]: 'native-mobile-web',
} }
return map[environment] return map[environment]
} }

View File

@@ -14,7 +14,12 @@ import {
} from '@standardnotes/features' } from '@standardnotes/features'
import { ContentType } from '@standardnotes/common' import { ContentType } from '@standardnotes/common'
import { GenericItem, SNComponent, Environment, Platform } from '@standardnotes/models' import { GenericItem, SNComponent, Environment, Platform } from '@standardnotes/models'
import { DesktopManagerInterface, InternalEventBusInterface, AlertService } from '@standardnotes/services' import {
DesktopManagerInterface,
InternalEventBusInterface,
AlertService,
DeviceInterface,
} from '@standardnotes/services'
import { ItemManager } from '@Lib/Services/Items/ItemManager' import { ItemManager } from '@Lib/Services/Items/ItemManager'
import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService' import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService'
import { SNComponentManager } from './ComponentManager' import { SNComponentManager } from './ComponentManager'
@@ -27,6 +32,7 @@ describe('featuresService', () => {
let syncService: SNSyncService let syncService: SNSyncService
let prefsService: SNPreferencesService let prefsService: SNPreferencesService
let internalEventBus: InternalEventBusInterface let internalEventBus: InternalEventBusInterface
let device: DeviceInterface
const desktopExtHost = 'http://localhost:123' const desktopExtHost = 'http://localhost:123'
@@ -53,6 +59,7 @@ describe('featuresService', () => {
environment, environment,
platform, platform,
internalEventBus, internalEventBus,
device,
) )
manager.setDesktopManager(desktopManager) manager.setDesktopManager(desktopManager)
return manager return manager
@@ -81,6 +88,8 @@ describe('featuresService', () => {
internalEventBus = {} as jest.Mocked<InternalEventBusInterface> internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
internalEventBus.publish = jest.fn() internalEventBus.publish = jest.fn()
device = {} as jest.Mocked<DeviceInterface>
}) })
const nativeComponent = (identifier?: FeatureIdentifier, file_type?: FeatureDescription['file_type']) => { const nativeComponent = (identifier?: FeatureIdentifier, file_type?: FeatureDescription['file_type']) => {

View File

@@ -36,6 +36,8 @@ import {
DesktopManagerInterface, DesktopManagerInterface,
InternalEventBusInterface, InternalEventBusInterface,
AlertService, AlertService,
DeviceInterface,
isMobileDevice,
} from '@standardnotes/services' } from '@standardnotes/services'
const DESKTOP_URL_PREFIX = 'sn://' const DESKTOP_URL_PREFIX = 'sn://'
@@ -82,23 +84,21 @@ export class SNComponentManager
private environment: Environment, private environment: Environment,
private platform: Platform, private platform: Platform,
protected override internalEventBus: InternalEventBusInterface, protected override internalEventBus: InternalEventBusInterface,
private device: DeviceInterface,
) { ) {
super(internalEventBus) super(internalEventBus)
this.loggingEnabled = false this.loggingEnabled = false
this.addItemObserver() this.addItemObserver()
/* On mobile, events listeners are handled by a respective component */ window.addEventListener
if (environment !== Environment.Mobile) { ? window.addEventListener('focus', this.detectFocusChange, true)
window.addEventListener : window.attachEvent('onfocusout', this.detectFocusChange)
? window.addEventListener('focus', this.detectFocusChange, true) window.addEventListener
: window.attachEvent('onfocusout', this.detectFocusChange) ? window.addEventListener('blur', this.detectFocusChange, true)
window.addEventListener : window.attachEvent('onblur', this.detectFocusChange)
? window.addEventListener('blur', this.detectFocusChange, true)
: window.attachEvent('onblur', this.detectFocusChange)
window.addEventListener('message', this.onWindowMessage, true) window.addEventListener('message', this.onWindowMessage, true)
}
} }
get isDesktop(): boolean { get isDesktop(): boolean {
@@ -143,7 +143,7 @@ export class SNComponentManager
this.removeItemObserver?.() this.removeItemObserver?.()
;(this.removeItemObserver as unknown) = undefined ;(this.removeItemObserver as unknown) = undefined
if (window && !this.isMobile) { if (window) {
window.removeEventListener('focus', this.detectFocusChange, true) window.removeEventListener('focus', this.detectFocusChange, true)
window.removeEventListener('blur', this.detectFocusChange, true) window.removeEventListener('blur', this.detectFocusChange, true)
window.removeEventListener('message', this.onWindowMessage, true) window.removeEventListener('message', this.onWindowMessage, true)
@@ -221,9 +221,23 @@ export class SNComponentManager
addItemObserver(): void { addItemObserver(): void {
this.removeItemObserver = this.itemManager.addObserver<SNComponent>( this.removeItemObserver = this.itemManager.addObserver<SNComponent>(
[ContentType.Component, ContentType.Theme], [ContentType.Component, ContentType.Theme],
({ changed, inserted, source }) => { ({ changed, inserted, removed, source }) => {
const items = [...changed, ...inserted] const items = [...changed, ...inserted]
this.handleChangedComponents(items, source) this.handleChangedComponents(items, source)
const device = this.device
if (isMobileDevice(device) && 'addComponentUrl' in device) {
inserted.forEach((component) => {
const url = this.urlForComponent(component)
if (url) {
device.addComponentUrl(component.uuid, url)
}
})
removed.forEach((component) => {
device.removeComponentUrl(component.uuid)
})
}
}, },
) )
} }
@@ -271,9 +285,6 @@ export class SNComponentManager
} }
getActiveThemes(): SNTheme[] { getActiveThemes(): SNTheme[] {
if (this.environment === Environment.Mobile) {
throw Error('getActiveThemes must be handled separately by mobile')
}
return this.componentsForArea(ComponentArea.Themes).filter((theme) => { return this.componentsForArea(ComponentArea.Themes).filter((theme) => {
return theme.active return theme.active
}) as SNTheme[] }) as SNTheme[]
@@ -301,14 +312,10 @@ export class SNComponentManager
} }
} }
const isWeb = this.environment === Environment.Web const isMobile = this.environment === Environment.Mobile
const isMobileWebView = this.environment === Environment.NativeMobileWeb
if (nativeFeature) { if (nativeFeature) {
if (!isWeb && !isMobileWebView) {
throw Error('Mobile must override urlForComponent to handle native paths')
}
let baseUrlRequiredForThemesInsideEditors = window.location.origin let baseUrlRequiredForThemesInsideEditors = window.location.origin
if (isMobileWebView) { if (isMobile) {
baseUrlRequiredForThemesInsideEditors = window.location.href.split('/index.html')[0] baseUrlRequiredForThemesInsideEditors = window.location.href.split('/index.html')[0]
} }
return `${baseUrlRequiredForThemesInsideEditors}/components/assets/${component.identifier}/${nativeFeature.index_path}` return `${baseUrlRequiredForThemesInsideEditors}/components/assets/${component.identifier}/${nativeFeature.index_path}`

View File

@@ -92,7 +92,7 @@ export class DiskStorageService extends Services.AbstractService implements Serv
public setEncryptionPolicy(encryptionPolicy: Services.StorageEncryptionPolicy, persist = true): void { public setEncryptionPolicy(encryptionPolicy: Services.StorageEncryptionPolicy, persist = true): void {
if ( if (
encryptionPolicy === Services.StorageEncryptionPolicy.Disabled && encryptionPolicy === Services.StorageEncryptionPolicy.Disabled &&
![Environment.Mobile, Environment.NativeMobileWeb].includes(this.environment) ![Environment.Mobile].includes(this.environment)
) { ) {
throw Error('Disabling storage encryption is only available on mobile.') throw Error('Disabling storage encryption is only available on mobile.')
} }

View File

@@ -35,7 +35,7 @@ export class MobileWebReceiver {
this.handleNativeEvent(nativeEvent) this.handleNativeEvent(nativeEvent)
} }
} catch (error) { } catch (error) {
console.log('Error parsing message from React Native', error) console.log('[MobileWebReceiver] Error parsing message from React Native', error)
} }
} }

View File

@@ -8,7 +8,7 @@ export async function openSubscriptionDashboard(application: SNApplication) {
const url = `${window.dashboardUrl}?subscription_token=${token}` const url = `${window.dashboardUrl}?subscription_token=${token}`
if (application.deviceInterface.environment === Environment.NativeMobileWeb) { if (application.deviceInterface.environment === Environment.Mobile) {
application.deviceInterface.openUrl(url) application.deviceInterface.openUrl(url)
return return
} }