import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState, } from 'react' import { Linking, Platform } from 'react-native' import { WebView, WebViewProps } from 'react-native-webview' import type { ShouldStartLoadRequest, WebViewMessageEvent, } from 'react-native-webview/lib/WebViewTypes' import { appScheme } from './appScheme' import { trace } from './trace' import { startActivityAsync } from 'expo-intent-launcher' type DistributiveOmit = T extends any ? Omit : never export type PortOneController = { readonly webview: WebView | null readonly canGoBack: boolean } export type PortOneUIController = { readonly webview: WebView | null readonly canGoBack: boolean updateRequest(request: Request): void } type OverridableWebViewProps = Omit< WebViewProps, | 'request' | 'onError' | 'onComplete' | 'ref' | 'source' | 'injectedJavaScript' | 'injectedJavaScriptBeforeContentLoaded' | 'injectedJavaScriptForMainFrameOnly' | 'injectedJavaScriptBeforeContentLoadedForMainFrameOnly' | 'injectedJavaScriptObject' | 'onMessage' | 'onShouldStartLoadWithRequest' | 'onLoadProgress' | 'javaScriptEnabled' > export type SdkDelegateProps = { request: DistributiveOmit onError?: (error: Error) => void onComplete?: (response: Response) => void ref?: React.LegacyRef } & OverridableWebViewProps export type SdkUIDelegateProps = { request: DistributiveOmit onError?: (error: Error) => void onComplete?: (response: Response) => void ref?: React.LegacyRef } & OverridableWebViewProps export type SdkDelegate = React.FC< SdkDelegateProps > export type SdkUIDelegate = React.FC< SdkUIDelegateProps > function onMessage( message: WebViewMessageEvent, onError?: (error: Error) => void, onComplete?: (response: Response) => void ) { trace('onMessage', { data: message.nativeEvent.data }) const data = JSON.parse(message.nativeEvent.data) if ('response' in data) { onComplete?.(data.response) } else if ('error' in data) { onError?.(new Error(data.error.message)) } } function onShouldStartLoadWithRequest( request: ShouldStartLoadRequest, onComplete?: (response: Response) => void ) { trace('onShouldStartLoadWithRequest', { request }) const url = request.url const protocol = url.split(':', 2)[0]! switch (protocol) { case 'about': case 'http': case 'https': { const host = url.slice(protocol.length + 3).split('/', 2)[0] // Native Module로 일반화 필요 if (host === 'gcash.onelink.me' && Platform.OS === 'android') { marketIfFailWithDeepLink( 'gcash://com.mynt.gcash/app', url, `market://details?id=${appScheme.gcash.android}` ) return false } return true } case 'portone': { const [, search] = url.split('?', 2) const args = search?.split('&')?.flatMap((param) => { const [key, value] = param.split('=', 2) if (key != null && value != null) { return [[decodeURIComponent(key), decodeURIComponent(value)]] } else { return [] } }) ?? [] onComplete?.(Object.fromEntries(args) as Response) return false } case 'intent': { // https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/core/java/android/content/Intent.java#8200 const params = new Map() const hashIndex = url.lastIndexOf('#') if (hashIndex !== -1) { for (const param of url.slice(hashIndex + 8).split(';')) { if (param === 'end') break const [key, value] = param.split('=', 2) if (key != null && value != null) { params.set(decodeURIComponent(key), decodeURIComponent(value)) } } } const packageName = params.get('package') const scheme = params.get('scheme') const withoutIntent = url.slice( protocol.length + 1, hashIndex === -1 ? undefined : hashIndex ) const redirectUrl = scheme != null ? `${scheme}:${withoutIntent}` : withoutIntent const playUrl = `market://details?id=${packageName}` marketIfFail(redirectUrl, playUrl) return false } default: { if (protocol in appScheme) { const marketUrl = Platform.OS === 'android' ? `market://details?id=${appScheme[protocol]?.android}` : `itms-apps://apps.apple.com/app/${appScheme[protocol]?.ios}` marketIfFail(url, marketUrl) } else openURLSameTask(url).catch(() => {}) return false } } } function sdkDelegateHtml(method: string, requestObject: object): string { return ` ` } export function createSdkDelegate( method: string ): React.FC> { return forwardRef>( ( { request, onError, onComplete, allowsBackForwardNavigationGestures, originWhitelist, ...props }, ref ) => { const [canGoBack, setCanGoBack] = useState(false) const webview = useRef(null) useImperativeHandle(ref, () => ({ get webview() { return webview.current }, get canGoBack() { return canGoBack }, })) useEffect(() => { const linkHandler = Linking.addEventListener('url', ({ url }) => { trace('linkHandler', { url }) }) return () => linkHandler.remove() }, []) useEffect(() => { trace('request', { request }) }, [request]) const requestObject = { ...request, redirectUrl: 'portone://blank', } return ( onMessage(event, onError, onComplete)} onShouldStartLoadWithRequest={(event) => onShouldStartLoadWithRequest(event, onComplete) } onLoadProgress={(event) => setCanGoBack(event.nativeEvent.canGoBack)} allowsBackForwardNavigationGestures={ allowsBackForwardNavigationGestures ?? true } javaScriptEnabled /> ) } ) } function sdkUIDelegateHtml( method: string, uiType: string, requestObject: object, callbackPrefix: string ): string { return `
` } export function createSdkUIDelegate< Request extends { uiType: string }, Response, >( method: string, callbackPrefix: string ): React.FC< SdkUIDelegateProps> > { return forwardRef< PortOneUIController, SdkUIDelegateProps> >( ( { request, onError, onComplete, originWhitelist, allowsBackForwardNavigationGestures, ...props }, ref ) => { const [canGoBack, setCanGoBack] = useState(false) const webview = useRef(null) useImperativeHandle(ref, () => ({ get webview() { return webview.current }, get canGoBack() { return canGoBack }, updateRequest(newRequest: Request) { trace('updateRequest', { newRequest }) webview.current?.injectJavaScript( `PortOne.updateLoadPaymentUIRequest(${JSON.stringify(newRequest)});` ) }, })) useEffect(() => { const linkHandler = Linking.addEventListener('url', ({ url }) => { trace('linkHandler', { url }) }) return () => linkHandler.remove() }, []) useEffect(() => { trace('request', { request }) }, [request]) const requestObject = { ...request, redirectUrl: 'portone://blank', } return ( onMessage(event, onError, onComplete)} onShouldStartLoadWithRequest={(event) => onShouldStartLoadWithRequest(event, onComplete) } onLoadProgress={(event) => setCanGoBack(event.nativeEvent.canGoBack)} allowsBackForwardNavigationGestures={ allowsBackForwardNavigationGestures ?? true } javaScriptEnabled /> ) } ) } async function marketIfFail(link: string, market: string) { try { if (await Linking.canOpenURL(link)) return openURLSameTask(link) } catch { console.error( '앱을 열지 못했습니다. AndroidManifest.xml 혹은 LSApplicationQueriesSchemes에 외부 앱이 등록되었는지 확인해 주세요.' ) } trace('marketFallback', { link, market }) return openURLSameTask(market) } async function marketIfFailWithDeepLink( schemeUrl: string, deeplink: string, market: string ) { try { if (await Linking.canOpenURL(schemeUrl)) return Linking.openURL(deeplink) } catch { console.error( '앱을 열지 못했습니다. AndroidManifest.xml 혹은 LSApplicationQueriesSchemes에 외부 앱이 등록되었는지 확인해 주세요.' ) } trace('marketFallback', { deeplink, market }) return Linking.openURL(market) } async function openURLSameTask(url: string) { if (Platform.OS === 'android') { const colonIdx = url.indexOf(':') const schemeNormalizedUrl = colonIdx !== -1 ? `${url.substring(0, colonIdx).toLowerCase()}${url.substring(colonIdx)}` : url trace('openURLSameTask', { schemeNormalizedUrl }) return startActivityAsync('android.intent.action.VIEW', { data: schemeNormalizedUrl, }) } else { return Linking.openURL(url) } }