From 698fc9ac0400425188fe643f0e2a08e89ba7ab84 Mon Sep 17 00:00:00 2001 From: Adithya Vardhan Date: Mon, 16 Sep 2024 20:09:58 +0530 Subject: [PATCH 1/7] fix: webview crypto polyfill issue (#123) --- app/_layout.tsx | 2 +- package.json | 2 +- polyfill/index.js | 183 ++++++++++++++++++++++++++++++++++++++++++++++ yarn.lock | 16 +--- 4 files changed, 186 insertions(+), 17 deletions(-) create mode 100644 polyfill/index.js diff --git a/app/_layout.tsx b/app/_layout.tsx index 2021613..0e054fe 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -13,7 +13,7 @@ import * as React from "react"; import { SafeAreaView } from "react-native"; import { NAV_THEME } from "~/lib/constants"; import { useColorScheme } from "~/lib/useColorScheme"; -import PolyfillCrypto from "react-native-webview-crypto"; +import PolyfillCrypto from "~/polyfill"; import { SWRConfig } from "swr"; import { swrConfiguration } from "lib/swr"; import Toast from "react-native-toast-message"; diff --git a/package.json b/package.json index ccd6748..810547b 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "react-native-toast-message": "^2.2.0", "react-native-url-polyfill": "^2.0.0", "react-native-webview": "13.8.6", - "react-native-webview-crypto": "^0.0.25", + "webview-crypto": "^0.1.13", "swr": "^2.2.5", "tailwind-merge": "^2.3.0", "text-encoding": "^0.7.0", diff --git a/polyfill/index.js b/polyfill/index.js new file mode 100644 index 0000000..cd56fc9 --- /dev/null +++ b/polyfill/index.js @@ -0,0 +1,183 @@ +const React = require("react"); +const { StyleSheet, View } = require("react-native"); +const { WebView } = require("react-native-webview"); +const { MainWorker, webViewWorkerString } = require("webview-crypto"); + +const styles = StyleSheet.create({ + hide: { + display: "none", + position: "absolute", + width: 0, + height: 0, + flexGrow: 0, + flexShrink: 1, + }, +}); + +const internalLibrary = ` +(function () { + function postMessage (message) { + if (window.ReactNativeWebView.postMessage === undefined) { + setTimeout(postMessage, 200, message) + } else { + window.ReactNativeWebView.postMessage(message) + } + } + var wvw = new WebViewWorker(postMessage) + // for Android + window.document.addEventListener('message', function (e) {wvw.onMainMessage(e.data);}) + // for iOS + window.addEventListener('message', function (e) {wvw.onMainMessage(e.data);}) +}()) +`; + +let resolveWorker; +let workerPromise = new Promise((resolve) => { + resolveWorker = resolve; +}); + +function sendToWorker(message) { + workerPromise.then((worker) => worker.onWebViewMessage(message)); +} + +const subtle = { + fake: true, + decrypt(...args) { + return workerPromise.then((worker) => worker.crypto.subtle.decrypt(...args)); + }, + deriveBits(...args) { + return workerPromise.then((worker) => + worker.crypto.subtle.deriveBits(...args) + ); + }, + deriveKey(...args) { + return workerPromise.then((worker) => + worker.crypto.subtle.deriveKey(...args) + ); + }, + digest(...args) { + return workerPromise.then((worker) => worker.crypto.subtle.digest(...args)); + }, + encrypt(...args) { + return workerPromise.then((worker) => worker.crypto.subtle.encrypt(...args)); + }, + exportKey(...args) { + return workerPromise.then((worker) => + worker.crypto.subtle.exportKey(...args) + ); + }, + generateKey(...args) { + return workerPromise.then((worker) => + worker.crypto.subtle.generateKey(...args) + ); + }, + importKey(...args) { + return workerPromise.then((worker) => + worker.crypto.subtle.importKey(...args) + ); + }, + sign(...args) { + return workerPromise.then((worker) => worker.crypto.subtle.sign(...args)); + }, + unwrapKey(...args) { + return workerPromise.then((worker) => + worker.crypto.subtle.unwrapKey(...args) + ); + }, + verify(...args) { + return workerPromise.then((worker) => worker.crypto.subtle.verify(...args)); + }, + wrapKey(...args) { + return workerPromise.then((worker) => worker.crypto.subtle.wrapKey(...args)); + }, +}; + +class PolyfillCrypto extends React.Component { + constructor(props) { + super(props); + this.props = props; + this.webViewRef = React.createRef(); + this.state = { + webViewKey: 0, + }; + } + + shouldComponentUpdate(nextProps, nextState) { + return nextState.webViewKey !== this.state.webViewKey; + } + + componentDidMount() { + const webView = this.webViewRef.current; + + resolveWorker( + new MainWorker(msg => { + webView.postMessage(msg); + }, this.props.debug) + ); + } + + componentDidUpdate(prevProps, prevState) { + if (prevState.webViewKey !== this.state.webViewKey) { + const webView = this.webViewRef.current; + resolveWorker( + new MainWorker( + (msg) => { + webView.postMessage(msg); + }, + this.props.debug + ) + ); + } + } + + componentWillUnmount() { + resolveWorker = undefined; + workerPromise = new Promise((resolve) => { + resolveWorker = resolve; + }); + } + + onContentProcessDidTerminate = (event) => { + const { nativeEvent } = event; + console.warn("Content process terminated, reloading", nativeEvent); + resolveWorker = undefined; + workerPromise = new Promise((resolve) => { + resolveWorker = resolve; + }); + this.setState((prevState) => ({ webViewKey: prevState.webViewKey + 1 })); + }; + + render() { + const code = `((function () {${webViewWorkerString};${internalLibrary}})())`; + const html = ``; + return ( + + + console.error(Object.keys(a), a.type, a.nativeEvent.description) + } + onMessage={(ev) => sendToWorker(ev.nativeEvent.data)} + ref={this.webViewRef} + originWhitelist={["*"]} + onContentProcessDidTerminate={this.onContentProcessDidTerminate} + source={{ html: html, baseUrl: "https://localhost" }} + /> + + ); + } +} + +if (typeof global.crypto !== "object") { + global.crypto = {}; +} + +global.crypto.fake = true; + +if (typeof global.crypto.subtle !== "object") { + global.crypto.subtle = subtle; +} + +Object.defineProperty(exports, "__esModule", { value: true }); +exports.default = PolyfillCrypto; diff --git a/yarn.lock b/yarn.lock index 26b0345..c213af6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3541,7 +3541,7 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== -encode-utf8@^1.0.2, encode-utf8@^1.0.3: +encode-utf8@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda" integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw== @@ -3911,11 +3911,6 @@ fast-base64-decode@^1.0.0: resolved "https://registry.yarnpkg.com/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz#b434a0dd7d92b12b43f26819300d2dafb83ee418" integrity sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q== -fast-base64-encode@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fast-base64-encode/-/fast-base64-encode-1.0.0.tgz#883945eb67e139dbf5a877bcca57a89e6824c7d4" - integrity sha512-z2XCzVK4fde2cuTEHu2QGkLD6BPtJNKJPn0Z7oINvmhq/quUuIIVPYKUdN0gYeZqOyurjJjBH/bUzK5gafyHvw== - fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -6547,15 +6542,6 @@ react-native-url-polyfill@^2.0.0: dependencies: whatwg-url-without-unicode "8.0.0-3" -react-native-webview-crypto@^0.0.25: - version "0.0.25" - resolved "https://registry.yarnpkg.com/react-native-webview-crypto/-/react-native-webview-crypto-0.0.25.tgz#c35506e1f092f7633db684f388f2b449667a05a2" - integrity sha512-H1kn5FFk0tBq5JDpkopyonAQTFEDAGoVJG+9Ip84jx4QmHmh5hxaJ5PkOXsMeNb2wHnwuvsg5p3krCOYNf20+A== - dependencies: - encode-utf8 "^1.0.2" - fast-base64-encode "^1.0.0" - webview-crypto "^0.1.13" - react-native-webview@13.8.6: version "13.8.6" resolved "https://registry.yarnpkg.com/react-native-webview/-/react-native-webview-13.8.6.tgz#5d4a62cb311d5ef8d910a8e112b3f1f2807bcd18" From 8f9a13674b81990a8c0db37d84da95c0e012a93e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Mon, 16 Sep 2024 16:50:25 +0200 Subject: [PATCH 2/7] chore: bump version --- app.json | 18 +++++++++++++----- package.json | 4 ++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/app.json b/app.json index 6dbed32..74d4a8e 100644 --- a/app.json +++ b/app.json @@ -2,8 +2,12 @@ "expo": { "name": "Alby Go", "slug": "alby-mobile", - "version": "1.4.2", - "scheme": ["bitcoin", "lightning", "alby"], + "version": "1.5.0", + "scheme": [ + "bitcoin", + "lightning", + "alby" + ], "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -12,7 +16,9 @@ "resizeMode": "cover", "backgroundColor": "#0F0C40" }, - "assetBundlePatterns": ["**/*"], + "assetBundlePatterns": [ + "**/*" + ], "plugins": [ [ "expo-camera", @@ -49,7 +55,9 @@ "foregroundImage": "./assets/adaptive-icon.png", "backgroundImage": "./assets/adaptive-icon-bg.png" }, - "permissions": ["android.permission.CAMERA"] + "permissions": [ + "android.permission.CAMERA" + ] }, "extra": { "eas": { @@ -58,4 +66,4 @@ }, "owner": "roland_alby" } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 810547b..0d53372 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "alby-go", - "version": "1.4.2", + "version": "1.5.0", "main": "expo-router/entry", "scripts": { "start": "expo start", @@ -66,4 +66,4 @@ "typescript": "~5.3.3" }, "private": true -} +} \ No newline at end of file From 4f05700aa32f5b1e4eb34212e51f2f52dd135b82 Mon Sep 17 00:00:00 2001 From: Adithya Vardhan Date: Tue, 17 Sep 2024 19:31:00 +0530 Subject: [PATCH 3/7] fix: allow deleting last wallet (#122) --- lib/state/appStore.ts | 23 ++++++++++++++++++----- pages/settings/wallets/EditWallet.tsx | 7 ++++++- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/lib/state/appStore.ts b/lib/state/appStore.ts index f5a827d..328b98a 100644 --- a/lib/state/appStore.ts +++ b/lib/state/appStore.ts @@ -99,7 +99,15 @@ export const useAppStore = create()((set, get) => { const removeCurrentWallet = () => { const wallets = [...get().wallets]; if (wallets.length <= 1) { - // cannot delete last wallet + // set to initial wallet status + secureStorage.removeItem(hasOnboardedKey); + secureStorage.setItem(selectedWalletIdKey, "0"); + secureStorage.setItem(getWalletKey(0), JSON.stringify({})); + set({ + nwcClient: undefined, + selectedWalletId: 0, + wallets: [{}], + }); return; } const selectedWalletId = get().selectedWalletId; @@ -191,17 +199,22 @@ export const useAppStore = create()((set, get) => { for (let i = 0; i < get().addressBookEntries.length; i++) { secureStorage.removeItem(getAddressBookEntryKey(i)); } - // clear selected wallet ID - secureStorage.removeItem(selectedWalletIdKey); + + // clear fiat currency + secureStorage.removeItem(fiatCurrencyKey); // clear onboarding status secureStorage.removeItem(hasOnboardedKey); + // set to initial wallet status + secureStorage.setItem(selectedWalletIdKey, "0"); + secureStorage.setItem(getWalletKey(0), JSON.stringify({})); + set({ nwcClient: undefined, fiatCurrency: undefined, - selectedWalletId: undefined, - wallets: [], + selectedWalletId: 0, + wallets: [{}], addressBookEntries: [], }); }, diff --git a/pages/settings/wallets/EditWallet.tsx b/pages/settings/wallets/EditWallet.tsx index 592945b..8018908 100644 --- a/pages/settings/wallets/EditWallet.tsx +++ b/pages/settings/wallets/EditWallet.tsx @@ -117,7 +117,12 @@ export function EditWallet() { { text: "Confirm", onPress: () => { - router.back(); + if (useAppStore.getState().wallets.length == 1) { + router.dismissAll(); + router.replace("/onboarding"); + } else { + router.back(); + } useAppStore.getState().removeCurrentWallet(); }, }, From bc4f0c28ba2d027bb0e945be94fa1de9b763956e Mon Sep 17 00:00:00 2001 From: Adithya Vardhan Date: Wed, 18 Sep 2024 18:14:35 +0530 Subject: [PATCH 4/7] chore: upgrade webview to get rid of polyfill (#130) * chore: add todo in crypto polyfill * chore: replace with webview crypto package --- app/_layout.tsx | 2 +- package.json | 4 +- polyfill/index.js | 183 ---------------------------------------------- yarn.lock | 16 +++- 4 files changed, 18 insertions(+), 187 deletions(-) delete mode 100644 polyfill/index.js diff --git a/app/_layout.tsx b/app/_layout.tsx index 0e054fe..2021613 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -13,7 +13,7 @@ import * as React from "react"; import { SafeAreaView } from "react-native"; import { NAV_THEME } from "~/lib/constants"; import { useColorScheme } from "~/lib/useColorScheme"; -import PolyfillCrypto from "~/polyfill"; +import PolyfillCrypto from "react-native-webview-crypto"; import { SWRConfig } from "swr"; import { swrConfiguration } from "lib/swr"; import Toast from "react-native-toast-message"; diff --git a/package.json b/package.json index 0d53372..b56fe95 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "react-native-toast-message": "^2.2.0", "react-native-url-polyfill": "^2.0.0", "react-native-webview": "13.8.6", - "webview-crypto": "^0.1.13", + "react-native-webview-crypto": "^0.0.26", "swr": "^2.2.5", "tailwind-merge": "^2.3.0", "text-encoding": "^0.7.0", @@ -66,4 +66,4 @@ "typescript": "~5.3.3" }, "private": true -} \ No newline at end of file +} diff --git a/polyfill/index.js b/polyfill/index.js deleted file mode 100644 index cd56fc9..0000000 --- a/polyfill/index.js +++ /dev/null @@ -1,183 +0,0 @@ -const React = require("react"); -const { StyleSheet, View } = require("react-native"); -const { WebView } = require("react-native-webview"); -const { MainWorker, webViewWorkerString } = require("webview-crypto"); - -const styles = StyleSheet.create({ - hide: { - display: "none", - position: "absolute", - width: 0, - height: 0, - flexGrow: 0, - flexShrink: 1, - }, -}); - -const internalLibrary = ` -(function () { - function postMessage (message) { - if (window.ReactNativeWebView.postMessage === undefined) { - setTimeout(postMessage, 200, message) - } else { - window.ReactNativeWebView.postMessage(message) - } - } - var wvw = new WebViewWorker(postMessage) - // for Android - window.document.addEventListener('message', function (e) {wvw.onMainMessage(e.data);}) - // for iOS - window.addEventListener('message', function (e) {wvw.onMainMessage(e.data);}) -}()) -`; - -let resolveWorker; -let workerPromise = new Promise((resolve) => { - resolveWorker = resolve; -}); - -function sendToWorker(message) { - workerPromise.then((worker) => worker.onWebViewMessage(message)); -} - -const subtle = { - fake: true, - decrypt(...args) { - return workerPromise.then((worker) => worker.crypto.subtle.decrypt(...args)); - }, - deriveBits(...args) { - return workerPromise.then((worker) => - worker.crypto.subtle.deriveBits(...args) - ); - }, - deriveKey(...args) { - return workerPromise.then((worker) => - worker.crypto.subtle.deriveKey(...args) - ); - }, - digest(...args) { - return workerPromise.then((worker) => worker.crypto.subtle.digest(...args)); - }, - encrypt(...args) { - return workerPromise.then((worker) => worker.crypto.subtle.encrypt(...args)); - }, - exportKey(...args) { - return workerPromise.then((worker) => - worker.crypto.subtle.exportKey(...args) - ); - }, - generateKey(...args) { - return workerPromise.then((worker) => - worker.crypto.subtle.generateKey(...args) - ); - }, - importKey(...args) { - return workerPromise.then((worker) => - worker.crypto.subtle.importKey(...args) - ); - }, - sign(...args) { - return workerPromise.then((worker) => worker.crypto.subtle.sign(...args)); - }, - unwrapKey(...args) { - return workerPromise.then((worker) => - worker.crypto.subtle.unwrapKey(...args) - ); - }, - verify(...args) { - return workerPromise.then((worker) => worker.crypto.subtle.verify(...args)); - }, - wrapKey(...args) { - return workerPromise.then((worker) => worker.crypto.subtle.wrapKey(...args)); - }, -}; - -class PolyfillCrypto extends React.Component { - constructor(props) { - super(props); - this.props = props; - this.webViewRef = React.createRef(); - this.state = { - webViewKey: 0, - }; - } - - shouldComponentUpdate(nextProps, nextState) { - return nextState.webViewKey !== this.state.webViewKey; - } - - componentDidMount() { - const webView = this.webViewRef.current; - - resolveWorker( - new MainWorker(msg => { - webView.postMessage(msg); - }, this.props.debug) - ); - } - - componentDidUpdate(prevProps, prevState) { - if (prevState.webViewKey !== this.state.webViewKey) { - const webView = this.webViewRef.current; - resolveWorker( - new MainWorker( - (msg) => { - webView.postMessage(msg); - }, - this.props.debug - ) - ); - } - } - - componentWillUnmount() { - resolveWorker = undefined; - workerPromise = new Promise((resolve) => { - resolveWorker = resolve; - }); - } - - onContentProcessDidTerminate = (event) => { - const { nativeEvent } = event; - console.warn("Content process terminated, reloading", nativeEvent); - resolveWorker = undefined; - workerPromise = new Promise((resolve) => { - resolveWorker = resolve; - }); - this.setState((prevState) => ({ webViewKey: prevState.webViewKey + 1 })); - }; - - render() { - const code = `((function () {${webViewWorkerString};${internalLibrary}})())`; - const html = ``; - return ( - - - console.error(Object.keys(a), a.type, a.nativeEvent.description) - } - onMessage={(ev) => sendToWorker(ev.nativeEvent.data)} - ref={this.webViewRef} - originWhitelist={["*"]} - onContentProcessDidTerminate={this.onContentProcessDidTerminate} - source={{ html: html, baseUrl: "https://localhost" }} - /> - - ); - } -} - -if (typeof global.crypto !== "object") { - global.crypto = {}; -} - -global.crypto.fake = true; - -if (typeof global.crypto.subtle !== "object") { - global.crypto.subtle = subtle; -} - -Object.defineProperty(exports, "__esModule", { value: true }); -exports.default = PolyfillCrypto; diff --git a/yarn.lock b/yarn.lock index c213af6..2813cc5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3541,7 +3541,7 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== -encode-utf8@^1.0.3: +encode-utf8@^1.0.2, encode-utf8@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda" integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw== @@ -3911,6 +3911,11 @@ fast-base64-decode@^1.0.0: resolved "https://registry.yarnpkg.com/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz#b434a0dd7d92b12b43f26819300d2dafb83ee418" integrity sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q== +fast-base64-encode@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fast-base64-encode/-/fast-base64-encode-1.0.0.tgz#883945eb67e139dbf5a877bcca57a89e6824c7d4" + integrity sha512-z2XCzVK4fde2cuTEHu2QGkLD6BPtJNKJPn0Z7oINvmhq/quUuIIVPYKUdN0gYeZqOyurjJjBH/bUzK5gafyHvw== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -6542,6 +6547,15 @@ react-native-url-polyfill@^2.0.0: dependencies: whatwg-url-without-unicode "8.0.0-3" +react-native-webview-crypto@^0.0.26: + version "0.0.26" + resolved "https://registry.yarnpkg.com/react-native-webview-crypto/-/react-native-webview-crypto-0.0.26.tgz#bce360876ed3367e677cb5cfa88564ca892ba72f" + integrity sha512-RshjQDik60LOhh7Q+SKIsXjnCgCIBZqZOB+v4MWa7l+0uAEyeZyMkWKL0xJRzWfTQ9vnLu4nHyn6qjEEi0uHnA== + dependencies: + encode-utf8 "^1.0.2" + fast-base64-encode "^1.0.0" + webview-crypto "^0.1.13" + react-native-webview@13.8.6: version "13.8.6" resolved "https://registry.yarnpkg.com/react-native-webview/-/react-native-webview-13.8.6.tgz#5d4a62cb311d5ef8d910a8e112b3f1f2807bcd18" From 77f42dc5e752735e4afd813cbc219482514a4c45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= <100827540+reneaaron@users.noreply.github.com> Date: Thu, 19 Sep 2024 11:00:16 +0200 Subject: [PATCH 5/7] fix: slide in receive screen from left (#131) --- pages/receive/Receive.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pages/receive/Receive.tsx b/pages/receive/Receive.tsx index 18c7f50..177fd2f 100644 --- a/pages/receive/Receive.tsx +++ b/pages/receive/Receive.tsx @@ -101,7 +101,7 @@ export function Receive() { polling && pollCount > 0 && receivedTransaction.payment_hash !== - prevTransaction?.payment_hash + prevTransaction?.payment_hash ) { if ( !invoiceRef.current || @@ -176,7 +176,10 @@ export function Receive() { return ( <> - + {!enterCustomAmount && !invoice && !lightningAddress && ( <> From 0d03586445dde3ee65ecf49d8d16f3bb3d5b04cf Mon Sep 17 00:00:00 2001 From: Adithya Vardhan Date: Thu, 19 Sep 2024 21:08:26 +0530 Subject: [PATCH 6/7] feat: add phone lock (pin, biometrics, etc) (#120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add biometrics * chore: add alert in security page * chore: remove switch colors from constants * fix: copy & component usage * chore: fix inactivity threshold and use key constant * chore: use boolean to string method * chore: do not store is biometric supported --------- Co-authored-by: René Aaron --- app.json | 6 +++ app/_layout.tsx | 29 +++++++++-- app/settings/security.js | 5 ++ app/unlock.js | 5 ++ components/Icons.tsx | 6 +++ components/ui/switch.tsx | 96 +++++++++++++++++++++++++++++++++++++ context/UserInactivity.tsx | 42 ++++++++++++++++ lib/constants.ts | 2 + lib/isBiometricSupported.ts | 7 +++ lib/state/appStore.ts | 25 ++++++++++ package.json | 2 + pages/Unlock.tsx | 56 ++++++++++++++++++++++ pages/settings/Security.tsx | 78 ++++++++++++++++++++++++++++++ pages/settings/Settings.tsx | 11 ++++- yarn.lock | 41 ++++++++++++++++ 15 files changed, 406 insertions(+), 5 deletions(-) create mode 100644 app/settings/security.js create mode 100644 app/unlock.js create mode 100644 components/ui/switch.tsx create mode 100644 context/UserInactivity.tsx create mode 100644 lib/isBiometricSupported.ts create mode 100644 pages/Unlock.tsx create mode 100644 pages/settings/Security.tsx diff --git a/app.json b/app.json index 74d4a8e..76e9b05 100644 --- a/app.json +++ b/app.json @@ -20,6 +20,12 @@ "**/*" ], "plugins": [ + [ + "expo-local-authentication", + { + "faceIDPermission": "Allow Alby Go to use Face ID." + } + ], [ "expo-camera", { diff --git a/app/_layout.tsx b/app/_layout.tsx index 2021613..7cdc74f 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -21,8 +21,11 @@ import { toastConfig } from "~/components/ToastConfig"; import * as Font from "expo-font"; import { useInfo } from "~/hooks/useInfo"; import { secureStorage } from "~/lib/secureStorage"; -import { hasOnboardedKey } from "~/lib/state/appStore"; +import { hasOnboardedKey, useAppStore } from "~/lib/state/appStore"; +import { usePathname } from "expo-router"; +import { UserInactivityProvider } from "~/context/UserInactivity"; import { PortalHost } from '@rn-primitives/portal'; +import { isBiometricSupported } from "~/lib/isBiometricSupported"; const LIGHT_THEME: Theme = { dark: false, @@ -49,6 +52,8 @@ export default function RootLayout() { const { isDarkColorScheme } = useColorScheme(); const [fontsLoaded, setFontsLoaded] = React.useState(false); const [checkedOnboarding, setCheckedOnboarding] = React.useState(false); + const isUnlocked = useAppStore((store) => store.unlocked); + const pathname = usePathname(); useConnectionChecker(); const rootNavigationState = useRootNavigationState(); @@ -64,7 +69,6 @@ export default function RootLayout() { }; async function loadFonts() { - await Font.loadAsync({ OpenRunde: require("./../assets/fonts/OpenRunde-Regular.otf"), "OpenRunde-Medium": require("./../assets/fonts/OpenRunde-Medium.otf"), @@ -75,12 +79,20 @@ export default function RootLayout() { setFontsLoaded(true); } + async function checkBiometricStatus() { + const isSupported = await isBiometricSupported() + if (!isSupported) { + useAppStore.getState().setSecurityEnabled(false); + } + } + React.useEffect(() => { const init = async () => { try { await Promise.all([ checkOnboardingStatus(), loadFonts(), + checkBiometricStatus(), ]); } finally { @@ -89,9 +101,16 @@ export default function RootLayout() { }; init(); - }, [hasNavigationState]); + React.useEffect(() => { + if (hasNavigationState && !isUnlocked) { + if (pathname !== "/unlock") { + router.push("/unlock"); + } + } + }, [isUnlocked, hasNavigationState]); + if (!fontsLoaded || !checkedOnboarding) { return null; } @@ -102,7 +121,9 @@ export default function RootLayout() { - + + + diff --git a/app/settings/security.js b/app/settings/security.js new file mode 100644 index 0000000..1dca538 --- /dev/null +++ b/app/settings/security.js @@ -0,0 +1,5 @@ +import { Security } from "../../pages/settings/Security"; + +export default function Page() { + return ; +} diff --git a/app/unlock.js b/app/unlock.js new file mode 100644 index 0000000..b17a77d --- /dev/null +++ b/app/unlock.js @@ -0,0 +1,5 @@ +import { Unlock } from "../pages/Unlock"; + +export default function Page() { + return ; +} diff --git a/components/Icons.tsx b/components/Icons.tsx index a40dbac..51d14a2 100644 --- a/components/Icons.tsx +++ b/components/Icons.tsx @@ -34,8 +34,10 @@ import { CameraOff, Palette, Egg, + Fingerprint, HelpCircle, CircleCheck, + TriangleAlert, } from "lucide-react-native"; import { cssInterop } from "nativewind"; @@ -85,8 +87,10 @@ interopIcon(Power); interopIcon(CameraOff); interopIcon(Palette); interopIcon(Egg); +interopIcon(Fingerprint); interopIcon(HelpCircle); interopIcon(CircleCheck); +interopIcon(TriangleAlert); export { AlertCircle, @@ -123,6 +127,8 @@ export { Power, Palette, Egg, + Fingerprint, HelpCircle, CircleCheck, + TriangleAlert, }; diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx new file mode 100644 index 0000000..951716d --- /dev/null +++ b/components/ui/switch.tsx @@ -0,0 +1,96 @@ +import * as SwitchPrimitives from '@rn-primitives/switch'; +import * as React from 'react'; +import { Platform } from 'react-native'; +import Animated, { + interpolateColor, + useAnimatedStyle, + useDerivedValue, + withTiming, +} from 'react-native-reanimated'; +import { useColorScheme } from '~/lib/useColorScheme'; +import { cn } from '~/lib/utils'; + +const SwitchWeb = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); + +SwitchWeb.displayName = 'SwitchWeb'; + +const RGB_COLORS = { + light: { + primary: 'rgb(255, 224, 112)', + input: 'rgb(228, 228, 231)', + }, + dark: { + primary: 'rgb(255, 224, 112)', + input: 'rgb(228, 228, 231)', + }, +} as const; + +const SwitchNative = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { colorScheme } = useColorScheme(); + const translateX = useDerivedValue(() => (props.checked ? 18 : 0)); + const animatedRootStyle = useAnimatedStyle(() => { + return { + backgroundColor: interpolateColor( + Number(props.checked), + [0, 1], + [RGB_COLORS[colorScheme].input, RGB_COLORS[colorScheme].primary] + ), + }; + }); + const animatedThumbStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: withTiming(translateX.value, { duration: 200 }) }], + })); + return ( + + + + + + + + ); +}); +SwitchNative.displayName = 'SwitchNative'; + +const Switch = Platform.select({ + web: SwitchWeb, + default: SwitchNative, +}); + +export { Switch }; diff --git a/context/UserInactivity.tsx b/context/UserInactivity.tsx new file mode 100644 index 0000000..2133fd7 --- /dev/null +++ b/context/UserInactivity.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { AppState, AppStateStatus, NativeEventSubscription } from 'react-native'; +import { secureStorage } from "~/lib/secureStorage"; +import { INACTIVITY_THRESHOLD } from "~/lib/constants"; +import { lastActiveTimeKey, useAppStore } from "~/lib/state/appStore"; + +export const UserInactivityProvider = ({ children }: any) => { + const [appState, setAppState] = React.useState(AppState.currentState); + const isSecurityEnabled = useAppStore((store) => store.isSecurityEnabled); + + const handleAppStateChange = async (nextState: AppStateStatus) => { + if (appState === "active" && nextState.match(/inactive|background/)) { + const now = Date.now(); + secureStorage.setItem(lastActiveTimeKey, now.toString()); + } else if (appState.match(/inactive|background/) && nextState === "active") { + const lastActiveTime = secureStorage.getItem(lastActiveTimeKey); + if (lastActiveTime) { + const timeElapsed = Date.now() - parseInt(lastActiveTime, 10); + if (timeElapsed >= INACTIVITY_THRESHOLD) { + useAppStore.getState().setUnlocked(false) + } + } + await secureStorage.removeItem(lastActiveTimeKey); + } + setAppState(nextState); + }; + + React.useEffect(() => { + let subscription: NativeEventSubscription + if (isSecurityEnabled) { + subscription = AppState.addEventListener("change", handleAppStateChange); + } + + return () => { + if (subscription) { + subscription.remove(); + } + }; + }, [appState, isSecurityEnabled]); + + return children; +} \ No newline at end of file diff --git a/lib/constants.ts b/lib/constants.ts index 46c2eae..5cae282 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -17,6 +17,8 @@ export const NAV_THEME = { }, }; +export const INACTIVITY_THRESHOLD = 5 * 60 * 1000; + export const CURSOR_COLOR = "hsl(47 100% 72%)"; export const TRANSACTIONS_PAGE_SIZE = 20; diff --git a/lib/isBiometricSupported.ts b/lib/isBiometricSupported.ts new file mode 100644 index 0000000..bcbefd4 --- /dev/null +++ b/lib/isBiometricSupported.ts @@ -0,0 +1,7 @@ +import * as LocalAuthentication from "expo-local-authentication"; + +export async function isBiometricSupported() { + const compatible = await LocalAuthentication.hasHardwareAsync(); + const securityLevel = await LocalAuthentication.getEnrolledLevelAsync(); + return compatible && securityLevel > 0 +} \ No newline at end of file diff --git a/lib/state/appStore.ts b/lib/state/appStore.ts index 328b98a..fa5f151 100644 --- a/lib/state/appStore.ts +++ b/lib/state/appStore.ts @@ -4,11 +4,14 @@ import { nwc } from "@getalby/sdk"; import { secureStorage } from "lib/secureStorage"; interface AppState { + readonly unlocked: boolean; readonly nwcClient: NWCClient | undefined; readonly fiatCurrency: string; readonly selectedWalletId: number; readonly wallets: Wallet[]; readonly addressBookEntries: AddressBookEntry[]; + readonly isSecurityEnabled: boolean; + setUnlocked: (unlocked: boolean) => void; setNWCClient: (nwcClient: NWCClient | undefined) => void; setNostrWalletConnectUrl(nostrWalletConnectUrl: string): void; removeNostrWalletConnectUrl(): void; @@ -16,6 +19,7 @@ interface AppState { removeCurrentWallet(): void; setFiatCurrency(fiatCurrency: string): void; setSelectedWalletId(walletId: number): void; + setSecurityEnabled(securityEnabled: boolean): void; addWallet(wallet: Wallet): void; addAddressBookEntry(entry: AddressBookEntry): void; reset(): void; @@ -26,7 +30,9 @@ const walletKeyPrefix = "wallet"; const addressBookEntryKeyPrefix = "addressBookEntry"; const selectedWalletIdKey = "selectedWalletId"; const fiatCurrencyKey = "fiatCurrency"; +export const isSecurityEnabledKey = "isSecurityEnabled"; export const hasOnboardedKey = "hasOnboarded"; +export const lastActiveTimeKey = "lastActiveTime"; type Wallet = { name?: string; @@ -132,15 +138,23 @@ export const useAppStore = create()((set, get) => { const initialSelectedWalletId = +( secureStorage.getItem(selectedWalletIdKey) || "0" ); + + const iSecurityEnabled = secureStorage.getItem(isSecurityEnabledKey) === "true"; + const initialWallets = loadWallets(); return { + unlocked: !iSecurityEnabled, addressBookEntries: loadAddressBookEntries(), wallets: initialWallets, nwcClient: getNWCClient(initialSelectedWalletId), fiatCurrency: secureStorage.getItem(fiatCurrencyKey) || "", + isSecurityEnabled: iSecurityEnabled, selectedWalletId: initialSelectedWalletId, updateCurrentWallet, removeCurrentWallet, + setUnlocked: (unlocked) => { + set({ unlocked }); + }, setNWCClient: (nwcClient) => set({ nwcClient }), removeNostrWalletConnectUrl: () => { updateCurrentWallet({ @@ -154,6 +168,13 @@ export const useAppStore = create()((set, get) => { nostrWalletConnectUrl, }); }, + setSecurityEnabled: (isEnabled) => { + secureStorage.setItem(isSecurityEnabledKey, isEnabled.toString()); + set({ + isSecurityEnabled: isEnabled, + ...(!isEnabled ? { unlocked: true } : {}), + }); + }, setFiatCurrency: (fiatCurrency) => { secureStorage.setItem(fiatCurrencyKey, fiatCurrency); set({ fiatCurrency }); @@ -203,6 +224,9 @@ export const useAppStore = create()((set, get) => { // clear fiat currency secureStorage.removeItem(fiatCurrencyKey); + // clear security enabled status + secureStorage.removeItem(isSecurityEnabledKey); + // clear onboarding status secureStorage.removeItem(hasOnboardedKey); @@ -216,6 +240,7 @@ export const useAppStore = create()((set, get) => { selectedWalletId: 0, wallets: [{}], addressBookEntries: [], + isSecurityEnabled: false, }); }, }; diff --git a/package.json b/package.json index b56fe95..d46ab98 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@react-native-async-storage/async-storage": "1.23.1", "@rn-primitives/dialog": "^1.0.3", "@rn-primitives/portal": "^1.0.3", + "@rn-primitives/switch": "^1.0.3", "bech32": "^2.0.0", "buffer": "^6.0.3", "class-variance-authority": "^0.7.0", @@ -34,6 +35,7 @@ "expo-font": "^12.0.9", "expo-linear-gradient": "~13.0.2", "expo-linking": "~6.3.1", + "expo-local-authentication": "~14.0.1", "expo-router": "^3.5.23", "expo-secure-store": "^13.0.2", "expo-status-bar": "~1.12.1", diff --git a/pages/Unlock.tsx b/pages/Unlock.tsx new file mode 100644 index 0000000..d2110b0 --- /dev/null +++ b/pages/Unlock.tsx @@ -0,0 +1,56 @@ +import { router, Stack } from "expo-router"; +import React from "react"; +import { View, Image } from "react-native"; +import * as LocalAuthentication from "expo-local-authentication"; + +import { Button } from "~/components/ui/button"; +import { Text } from "~/components/ui/text"; +import { useAppStore } from "~/lib/state/appStore"; + +export function Unlock() { + const [isUnlocking, setIsUnlocking] = React.useState(false); + + const handleUnlock = async () => { + try { + setIsUnlocking(true); + const biometricAuth = await LocalAuthentication.authenticateAsync({ + promptMessage: "Unlock Alby Go", + }); + if (biometricAuth.success) { + useAppStore.getState().setUnlocked(true); + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/"); + } + } + } finally { + setIsUnlocking(false); + } + }; + + React.useEffect(() => { + handleUnlock(); + }, []); + + return ( + + + + + Unlock to continue + + + + ); +} diff --git a/pages/settings/Security.tsx b/pages/settings/Security.tsx new file mode 100644 index 0000000..ea43809 --- /dev/null +++ b/pages/settings/Security.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { Text, View } from "react-native"; +import { TriangleAlert } from "~/components/Icons"; +import Loading from "~/components/Loading"; +import Screen from "~/components/Screen"; +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { Label } from "~/components/ui/label"; +import { Switch } from "~/components/ui/switch"; +import { isBiometricSupported } from "~/lib/isBiometricSupported"; +import { useAppStore } from "~/lib/state/appStore"; +import { cn } from "~/lib/utils"; + +export function Security() { + const [isSupported, setIsSupported] = React.useState(null); + const isEnabled = useAppStore((store) => store.isSecurityEnabled); + + React.useEffect(() => { + async function checkBiometricSupport() { + const supported = await isBiometricSupported(); + setIsSupported(supported); + } + checkBiometricSupport(); + }, []); + + return ( + + + {isSupported === null ? ( + + + + ) : ( + <> + {!isSupported && ( + + + + + Setup Device Security + + + To protect your wallet, please set up a phone lock in your device settings first. + + + + )} + + + + { + useAppStore.getState().setSecurityEnabled(!isEnabled); + }} + nativeID="security" + /> + + + + )} + + ); +} diff --git a/pages/settings/Settings.tsx b/pages/settings/Settings.tsx index ca78a08..67ca840 100644 --- a/pages/settings/Settings.tsx +++ b/pages/settings/Settings.tsx @@ -1,6 +1,6 @@ import { Link, router } from "expo-router"; import { Alert, TouchableOpacity, View } from "react-native"; -import { Bitcoin, Egg, Palette, Power, Wallet2 } from "~/components/Icons"; +import { Bitcoin, Egg, Fingerprint, Palette, Power, Wallet2 } from "~/components/Icons"; import { DEFAULT_CURRENCY, DEFAULT_WALLET_NAME } from "~/lib/constants"; import { useAppStore } from "~/lib/state/appStore"; @@ -43,6 +43,15 @@ export function Settings() { + + + + + Security + + + + Date: Wed, 25 Sep 2024 10:00:00 +0200 Subject: [PATCH 7/7] feat: improve handling for fixed amount LNURLs (#133) * feat: improve handling for fixed amount LNURLs * fix: invert readonly mode * fix: invert readonly mode * fix: add comment * fix: only allow comments if supported * fix: pass amount * fix: handle fixed amount lnurls in send screen * fix: remove obsolete amount --- components/DualCurrencyInput.tsx | 3 +++ pages/send/LNURLPay.tsx | 41 +++++++++++++++++++++----------- pages/send/PaymentSuccess.tsx | 2 +- pages/send/Send.tsx | 30 +++++++++++++++++------ 4 files changed, 54 insertions(+), 22 deletions(-) diff --git a/components/DualCurrencyInput.tsx b/components/DualCurrencyInput.tsx index b18958d..bebedc5 100644 --- a/components/DualCurrencyInput.tsx +++ b/components/DualCurrencyInput.tsx @@ -11,12 +11,14 @@ type DualCurrencyInputProps = { amount: string; setAmount(amount: string): void; autoFocus?: boolean; + readOnly?: boolean; }; export function DualCurrencyInput({ amount, setAmount, autoFocus = false, + readOnly = false, }: DualCurrencyInputProps) { const getFiatAmount = useGetFiatAmount(); const getSatsAmount = useGetSatsAmount(); @@ -58,6 +60,7 @@ export function DualCurrencyInput({ style={styles.amountInput} autoFocus={autoFocus} returnKeyType="done" + readOnly={readOnly} // aria-errormessage="inputError" /> diff --git a/pages/send/LNURLPay.tsx b/pages/send/LNURLPay.tsx index c3c3120..bd410a3 100644 --- a/pages/send/LNURLPay.tsx +++ b/pages/send/LNURLPay.tsx @@ -1,6 +1,6 @@ import Screen from "~/components/Screen"; import { router, useLocalSearchParams } from "expo-router"; -import React from "react"; +import React, { useEffect } from "react"; import { View } from "react-native"; import { Button } from "~/components/ui/button"; import { Text } from "~/components/ui/text"; @@ -21,6 +21,15 @@ export function LNURLPay() { const [isLoading, setLoading] = React.useState(false); const [amount, setAmount] = React.useState(""); const [comment, setComment] = React.useState(""); + const [isAmountReadOnly, setAmountReadOnly] = React.useState(false); + + useEffect(() => { + // Handle fixed amount LNURLs + if (lnurlDetails.minSendable === lnurlDetails.maxSendable) { + setAmount((lnurlDetails.minSendable / 1000).toString()); + setAmountReadOnly(true); + } + }, [lnurlDetails.minSendable, lnurlDetails.maxSendable]); async function requestInvoice() { setLoading(true); @@ -53,20 +62,24 @@ export function LNURLPay() { - - - Comment - - - + {lnurlDetails.commentAllowed && + + + Comment + + + + } To diff --git a/pages/send/PaymentSuccess.tsx b/pages/send/PaymentSuccess.tsx index d5eb4cc..bda1184 100644 --- a/pages/send/PaymentSuccess.tsx +++ b/pages/send/PaymentSuccess.tsx @@ -19,7 +19,7 @@ export function PaymentSuccess() { - + diff --git a/pages/send/Send.tsx b/pages/send/Send.tsx index 9f24320..923ad40 100644 --- a/pages/send/Send.tsx +++ b/pages/send/Send.tsx @@ -83,13 +83,29 @@ export function Send() { throw new Error("LNURL tag " + lnurlDetails.tag + " not supported"); } - router.replace({ - pathname: "/send/lnurl-pay", - params: { - lnurlDetailsJSON: JSON.stringify(lnurlDetails), - originalText, - }, - }); + // Handle fixed amount LNURLs + if (lnurlDetails.minSendable === lnurlDetails.maxSendable && !lnurlDetails.commentAllowed) { + try { + const callback = new URL(lnurlDetails.callback); + callback.searchParams.append("amount", (lnurlDetails.minSendable).toString()); + const lnurlPayInfo = await lnurl.getPayRequest(callback.toString()); + router.push({ + pathname: "/send/confirm", + params: { invoice: lnurlPayInfo.pr, originalText }, + }); + } catch (error) { + console.error(error); + errorToast(error); + } + } else { + router.replace({ + pathname: "/send/lnurl-pay", + params: { + lnurlDetailsJSON: JSON.stringify(lnurlDetails), + originalText, + }, + }); + } } else { // Check if this is a valid invoice new Invoice({