diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index b85a13d9e1..62273f4615 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -15,6 +15,12 @@ + + + + + fba0f81f121c1a15f7195bb269428911 ITSAppUsesNonExemptEncryption - + + NSCameraUsageDescription + $(PRODUCT_NAME) uses camera for uploading images + NSPhotoLibraryAddUsageDescription + $(PRODUCT_NAME) uses photo library for uploading images + NSPhotoLibraryUsageDescription + $(PRODUCT_NAME) uses photo library for uploading images diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 4c166f43de..6304008749 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -37,6 +37,7 @@ "@ledgerhq/hw-app-eth": "^6.29.3", "@ledgerhq/react-native-hw-transport-ble": "=6.20.0", "@react-native-async-storage/async-storage": "^1.19.3", + "@react-native-camera-roll/camera-roll": "^7.6.1", "@react-native-community/blur": "^4.3.2", "@react-native-community/netinfo": "^11.2.1", "@react-navigation/bottom-tabs": "^6.5.12", @@ -92,6 +93,7 @@ "react-native-url-polyfill": "^2.0.0", "react-native-vision-camera": "^3.9.0", "react-native-webview": "^13.8.1", + "rn-fetch-blob": "0.13.0-beta.2", "semver": "^7.6.0", "setimmediate": "^1.0.5", "stream-browserify": "^3.0.0", diff --git a/apps/mobile/src/languages/en.json b/apps/mobile/src/languages/en.json index 29b3931aee..bd894aa2d6 100644 --- a/apps/mobile/src/languages/en.json +++ b/apps/mobile/src/languages/en.json @@ -721,10 +721,13 @@ "button.vote": "Vote", "button.stake": "Stake", "button.reject": "Reject", + "button.cancel": "Cancel", "text-button.select-all": "Select All", "text-button.view-all-proposals": "View All Proposals", "notification.transaction-success": "Transaction Success", "wallet-connect.information-text": "{appName} is requesting to connect to your Keplr account on {chainIds}", "hooks.confirm.cancel-button": "Cancel", - "hooks.confirm.yes-button": "Yes" + "hooks.confirm.yes-button": "Yes", + "save-image-modal.save-image-item": "Save Image", + "save-image-modal.save-success": "Image saved successfully" } diff --git a/apps/mobile/src/languages/ko.json b/apps/mobile/src/languages/ko.json index 19ceb234d3..0a2fc879fe 100644 --- a/apps/mobile/src/languages/ko.json +++ b/apps/mobile/src/languages/ko.json @@ -708,10 +708,13 @@ "button.vote": "투표하기", "button.stake": "스테이킹", "button.reject": "승인 안함", + "button.cancel": "취소", "text-button.select-all": "전체 선택", "text-button.view-all-proposals": "모든 체인의 프로포절 보기", "notification.transaction-success": "트랜잭션 성공", "wallet-connect.information-text": "{appName} 에서 당신의 {chainIds} 케플러 계정에 연결을 요청했습니다.", "hooks.confirm.cancel-button": "취소", - "hooks.confirm.yes-button": "예" + "hooks.confirm.yes-button": "예", + "save-image-modal.save-image-item": "이미지 저장", + "save-image-modal.save-success": "이미지가 저장되었습니다." } diff --git a/apps/mobile/src/screen/web/webpage.tsx b/apps/mobile/src/screen/web/webpage.tsx index 70e9675832..c07de8accc 100644 --- a/apps/mobile/src/screen/web/webpage.tsx +++ b/apps/mobile/src/screen/web/webpage.tsx @@ -9,7 +9,13 @@ import {observer} from 'mobx-react-lite'; import {WebViewStateContext} from './context'; import WebView, {WebViewMessageEvent} from 'react-native-webview'; import {RouteProp, useRoute} from '@react-navigation/native'; -import {BackHandler, Platform} from 'react-native'; +import { + BackHandler, + Linking, + PermissionsAndroid, + Platform, + Text, +} from 'react-native'; import RNFS from 'react-native-fs'; import EventEmitter from 'eventemitter3'; import {RNInjectedKeplr} from '../../injected/injected-provider'; @@ -31,8 +37,18 @@ import { URLTempAllowOnMobileMsg, } from '@keplr-wallet/background'; import {useConfirm} from '../../hooks/confirm'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useNotification} from '../../hooks/notification'; +import RNFetchBlob from 'rn-fetch-blob'; +import {CameraRoll} from '@react-native-camera-roll/camera-roll'; +import {Button} from '../../components/button'; +import {Columns} from '../../components/column'; +import {Box} from '../../components/box'; +import {useStyle} from '../../styles'; +import {registerModal} from '../../components/modal/v2'; +import {TouchableWithoutFeedback} from 'react-native-gesture-handler'; -const blocklistURL = 'https://blocklist.keplr.app'; +const blockListURL = 'https://blocklist.keplr.app'; export const useInjectedSourceCode = () => { const [code, setCode] = useState(); @@ -50,15 +66,146 @@ export const useInjectedSourceCode = () => { return code; }; +async function hasAndroidPermission() { + const getCheckPermissionPromise = async () => { + if (typeof Platform.Version === 'number' && Platform.Version >= 33) { + const [hasReadMediaImagesPermission, hasReadMediaVideoPermission] = + await Promise.all([ + PermissionsAndroid.check( + PermissionsAndroid.PERMISSIONS['READ_MEDIA_IMAGES'], + ), + PermissionsAndroid.check( + PermissionsAndroid.PERMISSIONS['READ_MEDIA_VIDEO'], + ), + ]); + return hasReadMediaImagesPermission && hasReadMediaVideoPermission; + } else { + return PermissionsAndroid.check( + PermissionsAndroid.PERMISSIONS['READ_EXTERNAL_STORAGE'], + ); + } + }; + + const hasPermission = await getCheckPermissionPromise(); + if (hasPermission) { + return true; + } + const getRequestPermissionPromise = async () => { + if (typeof Platform.Version === 'number' && Platform.Version >= 33) { + const statuses = await PermissionsAndroid.requestMultiple([ + PermissionsAndroid.PERMISSIONS['READ_MEDIA_IMAGES'], + PermissionsAndroid.PERMISSIONS['READ_MEDIA_VIDEO'], + ]); + return ( + statuses[PermissionsAndroid.PERMISSIONS['READ_MEDIA_IMAGES']] === + PermissionsAndroid.RESULTS['GRANTED'] && + statuses[PermissionsAndroid.PERMISSIONS['READ_MEDIA_VIDEO']] === + PermissionsAndroid.RESULTS['GRANTED'] + ); + } else { + const status = await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS['READ_EXTERNAL_STORAGE'], + ); + return status === PermissionsAndroid.RESULTS['GRANTED']; + } + }; + + return await getRequestPermissionPromise(); +} + +const imageLongPressScript = ` + let longPress = false; + let pressTimer = null; + let longTarget = null; + const longPressDuration = 1000; + + var cancel = function (e) { + if (pressTimer !== null) { + clearTimeout(pressTimer); + pressTimer = null; + } + this.classList.remove("longPress"); + }; + + var click = function (e) { + if (pressTimer !== null) { + clearTimeout(pressTimer); + pressTimer = null; + } + + this.classList.remove("longPress"); + + if (longPress) { + return false; + } + }; + + var start = function (e) { + if (e.type === "click" && e.button !== 0) { + return; + } + + longPress = false; + + this.classList.add("longPress"); + + if (pressTimer === null) { + pressTimer = setTimeout(function () { + var url = e.target.getAttribute("src"); + if ( + url && + url != "" && + url.startsWith("http") + ) { + if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) { + window.ReactNativeWebView.postMessage(JSON.stringify({message: "download-image", origin: url})); + } + } + + longPress = true; + }, longPressDuration); + } + + return false; + }; + + var el = document.querySelector("body"); + + var observer = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + if(mutation.target.tagName === "IMG") { + mutation.target.addEventListener("mousedown", start); + mutation.target.addEventListener("touchstart", start); + mutation.target.addEventListener("click", click); + mutation.target.addEventListener("mouseout", cancel); + mutation.target.addEventListener("touchend", cancel); + mutation.target.addEventListener("touchleave", cancel); + mutation.target.addEventListener("touchcancel", cancel); + } + }); + }); + + observer.observe(el, { + childList: true, + subtree: true, + attributes: true + }); +`; + export const WebpageScreen: FunctionComponent = observer(() => { const {chainStore} = useStore(); + const intl = useIntl(); const webviewRef = useRef(null); const route = useRoute>(); const insect = useSafeAreaInsets(); const [title, setTitle] = useState(''); const [canGoBack, setCanGoBack] = useState(false); const [canGoForward, setCanGoForward] = useState(false); + const [isSaveImageModalOpen, setIsSaveImageModalOpen] = useState(false); + const [imageData, setImageData] = useState(''); + const notification = useNotification(); + const confirm = useConfirm(); const sourceCode = useInjectedSourceCode(); @@ -106,6 +253,11 @@ export const WebpageScreen: FunctionComponent = observer(() => { console.log(e); } } + + if (data.message === 'download-image') { + setImageData(data.origin); + setIsSaveImageModalOpen(true); + } }, [eventEmitter, uri], ); @@ -159,7 +311,7 @@ export const WebpageScreen: FunctionComponent = observer(() => { const checkURLIsPhishing = (origin: string) => { try { - const _blocklistURL = new URL(blocklistURL); + const _blocklistURL = new URL(blockListURL); const url = new URL(origin); if (url.hostname === 'twitter.com' || url.hostname === 'x.com') { @@ -205,7 +357,7 @@ export const WebpageScreen: FunctionComponent = observer(() => { ) .then(r => { if (r) { - setUri(`${blocklistURL}?origin=${encodeURIComponent(origin)}`); + setUri(`${blockListURL}?origin=${encodeURIComponent(origin)}`); } }) .catch(e => { @@ -252,6 +404,7 @@ export const WebpageScreen: FunctionComponent = observer(() => { ref={webviewRef} applicationNameForUserAgent={`KeplrWalletMobile/${DeviceInfo.getVersion()}`} injectedJavaScriptBeforeContentLoaded={sourceCode} + injectedJavaScript={imageLongPressScript} onMessage={onMessage} onNavigationStateChange={(e: any) => { // Strangely, `onNavigationStateChange` is only invoked whenever page changed only in IOS. @@ -287,6 +440,132 @@ export const WebpageScreen: FunctionComponent = observer(() => { /> ) : null} {isExternal ? : null} + + { + try { + if (Platform.OS === 'android') { + //권한이 없으면 세팅 페이지로 이동 + if (!(await hasAndroidPermission())) { + await Linking.openSettings(); + return; + } + } + + //이미지의 content-type을 확인하여 jpeg, png만 저장 가능하도록 함 + const imageFetchResponse = await fetch(imageData); + if (imageFetchResponse.ok) { + const contentType = + imageFetchResponse.headers.get('content-type'); + + let imageExtension: string | undefined; + + if (contentType === 'image/jpeg') { + imageExtension = 'jpeg'; + } + + if (contentType === 'image/png') { + imageExtension = 'png'; + } + + if (!imageExtension) { + throw new Error('Invalid image extension'); + } + + //이미지를 저장할 경로를 설정 + const downloadDest = `${ + Platform.OS === 'ios' + ? RNFS.LibraryDirectoryPath + : RNFetchBlob.fs.dirs.DCIMDir + }/${Math.floor( + Math.random() * 10000, + )}${new Date().getTime()}.${imageExtension}`; + + /* iOS에서 이미지를 저장하려고 할 때 Error: The operation couldn’t be completed. (PHPhotosErrorDomain error -1.) 에러가 발생합니다. + 먼저 이미지를 다운받고 저장하면 에러가 발생하지 않습니다. 아래 이슈를 참고 했습니다. + https://github.com/react-native-cameraroll/react-native-cameraroll/issues/186 + */ + const downloadResponse = await RNFS.downloadFile({ + fromUrl: imageData, + toFile: downloadDest, + }).promise; + + if (downloadResponse.statusCode === 200) { + await CameraRoll.saveAsset(downloadDest, {type: 'photo'}); + } + } + + notification.show( + 'success', + intl.formatMessage({id: 'save-image-modal.save-success'}), + ); + } catch (e) { + console.log('error', e); + + /* iOS에서 Photo Permission 중에 Keep Add Only 옵션을 선택했을 때 + await CameraRoll.saveAsset() 함수를 실행하면 사진은 저장이 되는데 에러가 발생합니다. + ref: https://github.com/react-native-cameraroll/react-native-cameraroll/issues/617 + */ + if (e.message === 'Unknown error from a native module') { + notification.show( + 'success', + intl.formatMessage({id: 'save-image-modal.save-success'}), + ); + } + + // iOS에서 권한이 없을 때 설정 페이지로 이동 + if (e.message === 'Access to photo library was denied') { + await Linking.openSettings(); + } + } finally { + setIsSaveImageModalOpen(false); + } + }} + /> ); }); + +export const SaveImageModal = registerModal<{ + setIsOpen: (isOpen: boolean) => void; + saveImage: () => void; +}>( + observer(({setIsOpen, saveImage}) => { + const intl = useIntl(); + const style = useStyle(); + return ( + + + + + + + + + + + + + +