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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }),
+);
diff --git a/yarn.lock b/yarn.lock
index c1fa8674e6..c99a02316f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8600,6 +8600,7 @@ __metadata:
"@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-native/babel-preset": 0.73.21
@@ -8671,6 +8672,7 @@ __metadata:
react-native-vision-camera: ^3.9.0
react-native-webview: ^13.8.1
react-test-renderer: 18.2.0
+ rn-fetch-blob: 0.13.0-beta.2
semver: ^7.6.0
setimmediate: ^1.0.5
stream-browserify: ^3.0.0
@@ -11513,6 +11515,15 @@ __metadata:
languageName: node
linkType: hard
+"@react-native-camera-roll/camera-roll@npm:^7.6.1":
+ version: 7.6.1
+ resolution: "@react-native-camera-roll/camera-roll@npm:7.6.1"
+ peerDependencies:
+ react-native: ">=0.59"
+ checksum: e445217e94f3a49bba5cae197d25f142a830cd87c987b74136a45061b80db8113c8f5d7ff9a2ad4df73b57d0724a25e9dd03fdd42f441472d77451b6579ba346
+ languageName: node
+ linkType: hard
+
"@react-native-community/blur@npm:^4.3.2":
version: 4.3.2
resolution: "@react-native-community/blur@npm:4.3.2"
@@ -17347,7 +17358,7 @@ __metadata:
languageName: node
linkType: hard
-"base-64@npm:^0.1.0":
+"base-64@npm:0.1.0, base-64@npm:^0.1.0":
version: 0.1.0
resolution: "base-64@npm:0.1.0"
checksum: 5a42938f82372ab5392cbacc85a5a78115cbbd9dbef9f7540fa47d78763a3a8bd7d598475f0d92341f66285afd377509851a9bb5c67bbecb89686e9255d5b3eb
@@ -25131,6 +25142,20 @@ __metadata:
languageName: node
linkType: hard
+"glob@npm:7.0.6":
+ version: 7.0.6
+ resolution: "glob@npm:7.0.6"
+ dependencies:
+ fs.realpath: ^1.0.0
+ inflight: ^1.0.4
+ inherits: 2
+ minimatch: ^3.0.2
+ once: ^1.3.0
+ path-is-absolute: ^1.0.0
+ checksum: 6ad065f51982f9a76f7052984121c95bca376ea02060b21200ad62b400422b05f0dc331f72da89a73c21a2451cbe9bec16bb17dcf37a516dc51bbbb6efe462a1
+ languageName: node
+ linkType: hard
+
"glob@npm:7.1.6, glob@npm:^7.0.0, glob@npm:^7.1.1, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6":
version: 7.1.6
resolution: "glob@npm:7.1.6"
@@ -29694,6 +29719,13 @@ __metadata:
languageName: node
linkType: hard
+"lodash@npm:4.17.15":
+ version: 4.17.15
+ resolution: "lodash@npm:4.17.15"
+ checksum: bb689bc88c0645b7002a045cdbe32292ae51d5d2a6f6a5272cb5a5ace9b06700bb3d30c6be6ecfae9a70f9c943f60e90765033fc7ff706cf9219374eeda314ad
+ languageName: node
+ linkType: hard
+
"lodash@npm:4.17.21, lodash@npm:^4.17.11, lodash@npm:^4.17.12, lodash@npm:^4.17.13, lodash@npm:^4.17.15, lodash@npm:^4.17.19, lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:^4.17.4, lodash@npm:^4.2.1":
version: 4.17.21
resolution: "lodash@npm:4.17.21"
@@ -38271,6 +38303,17 @@ __metadata:
languageName: node
linkType: hard
+"rn-fetch-blob@npm:0.13.0-beta.2":
+ version: 0.13.0-beta.2
+ resolution: "rn-fetch-blob@npm:0.13.0-beta.2"
+ dependencies:
+ base-64: 0.1.0
+ glob: 7.0.6
+ lodash: 4.17.15
+ checksum: 3e58430c6330d615fed35e70dcc9e0db87db02373e56601726eee65c4054ff765222508ba4fa48893f380289b93b6b6e29f62544bf8ea0a31b03a437ab1da184
+ languageName: node
+ linkType: hard
+
"run-async@npm:^2.2.0, run-async@npm:^2.4.0":
version: 2.4.1
resolution: "run-async@npm:2.4.1"