diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index fdb4970..3afe430 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -9,8 +9,10 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { - - + implementation project(':capacitor-browser') + implementation project(':capacitor-filesystem') + implementation project(':evva-capacitor-secure-storage-plugin') + implementation "androidx.webkit:webkit:1.4.0" } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6b53b21..9da5091 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,8 +7,8 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher" android:supportsRtl="true" - android:theme="@style/AppTheme"> - + android:theme="@style/AppTheme" + android:requestLegacyExternalStorage="true"> + + diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 9a5fa87..6835b8c 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -1,3 +1,12 @@ // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN include ':capacitor-android' project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') + +include ':capacitor-browser' +project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android') + +include ':capacitor-filesystem' +project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android') + +include ':evva-capacitor-secure-storage-plugin' +project(':evva-capacitor-secure-storage-plugin').projectDir = new File('../node_modules/@evva/capacitor-secure-storage-plugin/android') diff --git a/package-lock.json b/package-lock.json index b0edd16..720f615 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,11 +12,13 @@ "@capacitor/browser": "^6.0.3", "@capacitor/cli": "^6.1.2", "@capacitor/core": "^6.1.2", + "@capacitor/filesystem": "^6.0.1", "@chatscope/chat-ui-kit-react": "^2.0.3", "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.0", + "@evva/capacitor-secure-storage-plugin": "^3.0.1", "@mui/icons-material": "^5.16.4", "@mui/lab": "^5.0.0-alpha.173", "@mui/material": "^5.16.7", @@ -38,6 +40,8 @@ "bcryptjs": "2.4.3", "buffer": "6.0.3", "compressorjs": "^1.2.1", + "cordova-plugin-android-permissions": "^1.1.5", + "cordova-plugin-file": "^8.1.1", "dompurify": "^3.1.6", "emoji-picker-react": "^4.12.0", "file-saver": "^2.0.5", @@ -503,6 +507,14 @@ "tslib": "^2.1.0" } }, + "node_modules/@capacitor/filesystem": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-6.0.1.tgz", + "integrity": "sha512-eHhXm6tzBIQhErzFnfOE6eA1U+15DHc2212/COfzzGGRk/dyGympoVV3ct2YPVzvpTSxMEW3xFocORv/xD9gFg==", + "peerDependencies": { + "@capacitor/core": "^6.0.0" + } + }, "node_modules/@chatscope/chat-ui-kit-react": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@chatscope/chat-ui-kit-react/-/chat-ui-kit-react-2.0.3.tgz", @@ -1186,6 +1198,14 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@evva/capacitor-secure-storage-plugin": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@evva/capacitor-secure-storage-plugin/-/capacitor-secure-storage-plugin-3.0.1.tgz", + "integrity": "sha512-6qupLfI+wIzozSAAz668aSddUjwhbaXFAlHUw1T4waAQjkWC/tRh2bfcLAHYB+MtQuWOrVI8uq65lnYHMac5SA==", + "peerDependencies": { + "@capacitor/core": "^6.1.2" + } + }, "node_modules/@floating-ui/core": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", @@ -4411,6 +4431,38 @@ "toggle-selection": "^1.0.6" } }, + "node_modules/cordova-plugin-android-permissions": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/cordova-plugin-android-permissions/-/cordova-plugin-android-permissions-1.1.5.tgz", + "integrity": "sha512-oTTV9cCMBqXTCmU+nYRebsP2IQfrtdvl2vYXHjoJgv5NHCIDgY4KFg6kJTcwXQOiymeGXuw0+MTvJJOueAdleA==", + "engines": [ + { + "name": "cordova", + "version": ">=5.0.0" + } + ] + }, + "node_modules/cordova-plugin-file": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/cordova-plugin-file/-/cordova-plugin-file-8.1.1.tgz", + "integrity": "sha512-vrC9oC5rkKYbQDL5Y+K8l3z3dK5TAC88gwA9jScD5mZ0lwzPMGWcUF1Y8LXE0vtaRmPn/cKIdfRW+aB+QW8yKA==", + "engines": { + "cordovaDependencies": { + "5.0.0": { + "cordova-android": ">=6.3.0" + }, + "7.0.0": { + "cordova-android": ">=10.0.0" + }, + "8.0.0": { + "cordova-android": ">=12.0.0" + }, + "9.0.0": { + "cordova": ">100" + } + } + } + }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", diff --git a/package.json b/package.json index e33a359..e8da587 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,13 @@ "@capacitor/browser": "^6.0.3", "@capacitor/cli": "^6.1.2", "@capacitor/core": "^6.1.2", + "@capacitor/filesystem": "^6.0.1", "@chatscope/chat-ui-kit-react": "^2.0.3", "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.0", + "@evva/capacitor-secure-storage-plugin": "^3.0.1", "@mui/icons-material": "^5.16.4", "@mui/lab": "^5.0.0-alpha.173", "@mui/material": "^5.16.7", @@ -42,6 +44,8 @@ "bcryptjs": "2.4.3", "buffer": "6.0.3", "compressorjs": "^1.2.1", + "cordova-plugin-android-permissions": "^1.1.5", + "cordova-plugin-file": "^8.1.1", "dompurify": "^3.1.6", "emoji-picker-react": "^4.12.0", "file-saver": "^2.0.5", diff --git a/src/App.tsx b/src/App.tsx index ea98624..c0624fb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -331,6 +331,13 @@ function App() { show: showUnsavedChanges, message: messageUnsavedChanges, } = useModal(); + const { + isShow: isShowInfo, + onCancel: onCancelInfo, + onOk: onOkInfo, + show: showInfo, + message: messageInfo, + } = useModal(); const { onCancel: onCancelQortalRequest, @@ -848,6 +855,9 @@ function App() { walletToBeDownloaded.wallet, walletToBeDownloaded.qortAddress ); + await showInfo({ + message: `Your wallet file was saved to internal storage, in the document folder. Keep that file secure.`, + }) } catch (error: any) { setWalletToBeDownloadedError(error?.message); } finally { @@ -1520,6 +1530,7 @@ function App() { show, message, rootHeight, + showInfo }} > { - saveFileToDiskFunc(); + onClick={async () => { + await saveFileToDiskFunc(); returnToMain(); }} > @@ -2547,6 +2558,27 @@ function App() { )} + {isShowInfo && ( + + {"Important Info"} + + + {message.message} + + + + + + + + + )} {isShowUnsavedChanges && ( { export async function handleActiveGroupDataFromSocket({ groups, directs }) { try { + console.log('handleActiveGroupDataFromSocket3', groups, directs) window.postMessage({ action: "SET_GROUPS", payload: groups, @@ -3024,6 +3025,7 @@ function setupMessageListener() { publishOnQDNCase(request, event); break; case "handleActiveGroupDataFromSocket": + console.log('handleActiveGroupDataFromSocket2', event) handleActiveGroupDataFromSocketCase(request, event); break; case "getThreadActivity": diff --git a/src/components/Apps/useQortalMessageListener.tsx b/src/components/Apps/useQortalMessageListener.tsx index 0375f35..0468afe 100644 --- a/src/components/Apps/useQortalMessageListener.tsx +++ b/src/components/Apps/useQortalMessageListener.tsx @@ -1,10 +1,61 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import FileSaver from 'file-saver'; import { executeEvent } from '../../utils/events'; import { useSetRecoilState } from 'recoil'; import { navigationControllerAtom } from '../../atoms/global'; +import { Filesystem, Directory, Encoding } from '@capacitor/filesystem'; +import { Browser } from '@capacitor/browser'; + + +export const saveFileInChunks = async (blob: Blob, fileName: string, chunkSize = 1024 * 1024) => { + const base64Prefix = 'data:video/mp4;base64,'; + try { + let offset = 0; + let isFirstChunk = true; + const fullFileName = fileName + Date.now() + '.mp4' + // Read the blob in chunks + while (offset < blob.size) { + // Extract the current chunk + const chunk = blob.slice(offset, offset + chunkSize); + + // Convert the chunk to Base64 + const base64Chunk = await blobToBase64(chunk); + + // Write the chunk to the file with the prefix added on the first chunk + await Filesystem.writeFile({ + path: fullFileName, + data: isFirstChunk ? base64Prefix + base64Chunk : base64Chunk, + directory: Directory.Documents, + recursive: true, + append: !isFirstChunk // Append after the first chunk + }); + + // Update offset and flag + offset += chunkSize; + isFirstChunk = false; + } + + console.log("File saved successfully in chunks:", fileName); + } catch (error) { + console.error("Error saving file in chunks:", error); + } +}; + + +// Helper function to convert a Blob to a Base64 string +const blobToBase64 = (blob: Blob): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => { + const base64data = reader.result?.toString().split(",")[1]; + resolve(base64data || ""); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +}; + class Semaphore { constructor(count) { this.count = count @@ -166,34 +217,92 @@ const UIQortalRequests = [ } } + + + export const showSaveFilePicker = async (data) => { - let blob - let fileName + let blob; + let fileName; + try { - const {filename, mimeType, fileHandleOptions, fileId} = data - blob = await retrieveFileFromIndexedDB(fileId) - fileName = filename + const { filename, mimeType, fileId } = data; + + // Retrieve file from IndexedDB or any other source + blob = await retrieveFileFromIndexedDB(fileId); + fileName = filename; + + await saveFileInChunks(blob, fileName) + } catch (error) { + console.error("Error saving file:", error); + + } + }; - const fileHandle = await window.showSaveFilePicker({ - suggestedName: filename, - types: [ - { - description: mimeType, - ...fileHandleOptions - } - ] - }) - const writeFile = async (fileHandle, contents) => { - const writable = await fileHandle.createWritable() - await writable.write(contents) - await writable.close() - } - writeFile(fileHandle, blob).then(() => console.log("FILE SAVED")) - } catch (error) { - FileSaver.saveAs(blob, fileName) - } - } + declare var cordova: any; + + // try { + // const { filename, mimeType, fileId } = data; + + // // Request legacy storage permissions if applicable (for Android 12 and below) + // // await requestLegacyPermissions(); + + // // Retrieve file from IndexedDB or another source + // const blob = await retrieveFileFromIndexedDB(fileId); + // const buffer = await blob.arrayBuffer(); + + // return new Promise((resolve, reject) => { + // window.resolveLocalFileSystemURL( + // cordova.file.externalRootDirectory, // Points to the root of public external storage + // (rootDirectoryEntry) => { + // rootDirectoryEntry.getDirectory( + // "Downloads", + // { create: true }, + // (downloadsDirectory) => { + // downloadsDirectory.getFile( + // filename, + // { create: true, exclusive: false }, + // (fileEntry) => { + // fileEntry.createWriter((fileWriter) => { + // fileWriter.onwriteend = () => { + // console.log("Video saved successfully in public Downloads:", fileEntry.nativeURL); + // resolve(fileEntry.nativeURL); + // }; + + // fileWriter.onerror = (error) => { + // console.error("Error writing video file:", error); + // reject(error); + // }; + + // const videoBlob = new Blob([buffer], { type: mimeType || "video/mp4" }); + // fileWriter.truncate(0); + // fileWriter.write(videoBlob); + // }); + // }, + // (error) => { + // console.error("Error accessing or creating file:", error); + // reject(error); + // } + // ); + // }, + // (error) => { + // console.error("Error accessing Downloads folder:", error); + // reject(error); + // } + // ); + // }, + // (error) => { + // console.error("Error accessing external storage:", error); + // reject(error); + // } + // ); + // }); + // } catch (error) { + // console.error("Error saving video file:", error); + // throw error; + // } + // }; + async function storeFilesInIndexedDB(obj) { // First delete any existing files in IndexedDB with '_qortalfile' in their ID await deleteQortalFilesFromIndexedDB(); @@ -353,7 +462,6 @@ isDOMContentLoaded: false ) { let data; try { - console.log('storeFilesInIndexedDB', structuredClone(event.data)) data = await storeFilesInIndexedDB(event.data); } catch (error) { console.error('Error storing files in IndexedDB:', error); diff --git a/src/components/Group/Group.tsx b/src/components/Group/Group.tsx index 9ac0de0..bfdb5d6 100644 --- a/src/components/Group/Group.tsx +++ b/src/components/Group/Group.tsx @@ -440,7 +440,7 @@ export const Group = ({ const [appsMode, setAppsMode] = useState('home') const [isOpenSideViewDirects, setIsOpenSideViewDirects] = useState(false) const [isOpenSideViewGroups, setIsOpenSideViewGroups] = useState(false) - + console.log('groups', groups) const toggleSideViewDirects = ()=> { if(isOpenSideViewGroups){ setIsOpenSideViewGroups(false) @@ -896,8 +896,10 @@ export const Group = ({ // Handler function for incoming messages const messageHandler = (event) => { const message = event.data; - + console.log('SET_GROUPS100', event) if (message?.action === "SET_GROUPS") { + console.log('SET_GROUPS200', event) + // Update the component state with the received 'sendqort' state setGroups(message.payload); getLatestRegularChat(message.payload); diff --git a/src/components/Group/WebsocketActive.tsx b/src/components/Group/WebsocketActive.tsx index ffadd4f..c60f1f7 100644 --- a/src/components/Group/WebsocketActive.tsx +++ b/src/components/Group/WebsocketActive.tsx @@ -72,7 +72,7 @@ export const WebSocketActive = ({ myAddress, setIsLoadingGroups }) => { const sortedDirects = (data?.direct || []).filter(item => item?.name !== 'extension-proxy' && item?.address !== 'QSMMGSgysEuqDCuLw3S4cHrQkBrh3vP3VH' ).sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); - + console.log('sortedGroups', sortedGroups) window.sendMessage("handleActiveGroupDataFromSocket", { groups: sortedGroups, diff --git a/src/utils/chromeStorage.ts b/src/utils/chromeStorage.ts index 71c8863..44d306f 100644 --- a/src/utils/chromeStorage.ts +++ b/src/utils/chromeStorage.ts @@ -1,54 +1,117 @@ +import { SecureStoragePlugin } from '@evva/capacitor-secure-storage-plugin'; +let inMemoryKey: CryptoKey | null = null; +let inMemoryIV: Uint8Array | null = null; -export const storeData = (key: string, payload: any): Promise => { - return new Promise((resolve, reject) => { - try { - localStorage.setItem(key, JSON.stringify(payload)); - resolve("Data saved successfully"); - } catch (error) { - reject(new Error("Error saving data")); - } - }); - }; - - export const getData = (key: string): Promise => { - return new Promise((resolve, reject) => { - try { - const data = localStorage.getItem(key); - if (data) { - resolve(JSON.parse(data) as T); - } else { - reject(new Error(`No data found for key: ${key}`)); - } - } catch (error) { - reject(new Error("Error retrieving data")); - } - }); - }; - - - export async function removeKeysAndLogout( - keys: string[], - event: MessageEvent, - request: any - ) { - try { - // Remove each key from localStorage - keys.forEach((key) => localStorage.removeItem(key)); - - - // Send a response back to indicate successful logout - event.source.postMessage( - { - requestId: request.requestId, - action: "logout", - payload: true, - type: "backgroundMessageResponse", - }, - event.origin - ); - } catch (error) { - console.error("Error removing keys:", error); - } +async function initializeKeyAndIV() { + if (!inMemoryKey) { + inMemoryKey = await generateKey(); // Generates the key in memory } - +} + +async function generateKey(): Promise { + return await crypto.subtle.generateKey( + { + name: "AES-GCM", + length: 256 + }, + true, + ["encrypt", "decrypt"] + ); +} + +async function encryptData(data: string, key: CryptoKey): Promise<{ iv: Uint8Array; encryptedData: ArrayBuffer }> { + const encoder = new TextEncoder(); + const encodedData = encoder.encode(data); + + // Generate a random IV each time you encrypt + const iv = crypto.getRandomValues(new Uint8Array(12)); + + const encryptedData = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: iv + }, + key, + encodedData + ); + + return { iv, encryptedData }; +} + +async function decryptData(encryptedData: ArrayBuffer, key: CryptoKey, iv: Uint8Array): Promise { + const decryptedData = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: iv + }, + key, + encryptedData + ); + + const decoder = new TextDecoder(); + return decoder.decode(decryptedData); +} + +// Encrypt data, then concatenate the IV and encrypted data for storage +export const storeData = async (key: string, payload: any): Promise => { + await initializeKeyAndIV(); + + if (inMemoryKey) { + const { iv, encryptedData } = await encryptData(JSON.stringify(payload), inMemoryKey); + + // Combine IV and encrypted data into a single string + const combinedData = new Uint8Array([...iv, ...new Uint8Array(encryptedData)]); + await SecureStoragePlugin.set({ key, value: btoa(String.fromCharCode(...combinedData)) }); + + return "Data saved successfully"; + } else { + throw new Error("Key is not initialized in memory"); + } +}; + +// Retrieve data, split the IV and encrypted data, then decrypt +export const getData = async (key: string): Promise => { + await initializeKeyAndIV(); + + if (!inMemoryKey) throw new Error("Encryption key is not initialized"); + + const storedDataBase64 = await SecureStoragePlugin.get({ key }); + if (storedDataBase64.value) { + const combinedData = atob(storedDataBase64.value).split("").map((c) => c.charCodeAt(0)); + const iv = new Uint8Array(combinedData.slice(0, 12)); // First 12 bytes are the IV + const encryptedData = new Uint8Array(combinedData.slice(12)).buffer; // The rest is encrypted data + + const decryptedData = await decryptData(encryptedData, inMemoryKey, iv); + return JSON.parse(decryptedData) as T; + } else { + throw new Error(`No data found for key: ${key}`); + } +}; + + +// Remove keys from storage and log out +export async function removeKeysAndLogout(keys: string[], event: MessageEvent, request: any) { + try { + for (const key of keys) { + try { + await SecureStoragePlugin.remove({ key }); + await SecureStoragePlugin.remove({ key: `${key}_iv` }); // Remove associated IV + } catch (error) { + console.warn(`Key not found: ${key}`); + } + } + + event.source.postMessage( + { + requestId: request.requestId, + action: "logout", + payload: true, + type: "backgroundMessageResponse", + }, + event.origin + ); + } catch (error) { + console.error("Error removing keys:", error); + } +} diff --git a/src/utils/generateWallet/generateWallet.ts b/src/utils/generateWallet/generateWallet.ts index 7182d99..5c95527 100644 --- a/src/utils/generateWallet/generateWallet.ts +++ b/src/utils/generateWallet/generateWallet.ts @@ -4,8 +4,7 @@ import { crypto, walletVersion } from '../../constants/decryptWallet'; import { doInitWorkers, kdf } from '../../deps/kdf'; import PhraseWallet from './phrase-wallet'; import * as WORDLISTS from './wordlists'; -import { saveAs } from 'file-saver'; - +import { Filesystem, Directory, Encoding } from '@capacitor/filesystem'; export function generateRandomSentence(template = 'adverb verb noun adjective noun adverb verb noun adjective noun adjective verbed adjective noun', maxWordLength = 0, capitalize = true) { const partsOfSpeechMap = { 'noun': 'nouns', @@ -84,19 +83,17 @@ export const createAccount = async()=> { } - export const saveFileToDisk = async (data, qortAddress) => { - try { - const dataString = JSON.stringify(data); - const blob = new Blob([dataString], { type: 'application/json' }); - const fileName = "qortal_backup_" + qortAddress + ".json"; + export const saveFileToDisk = async (data: any, qortAddress: string) => { - saveAs(blob, fileName); - } catch (error) { - - if (error.name === 'AbortError') { - return; - } - // This fallback will only be executed if the `showSaveFilePicker` method fails. - FileSaver.saveAs(blob, fileName); // Ensure FileSaver is properly imported or available in your environment. - } -} + const dataString = JSON.stringify(data); + const fileName = `qortal_backup_${qortAddress}.json`; + + // Write the file to the Filesystem + await Filesystem.writeFile({ + path: fileName, + data: dataString, + directory: Directory.Documents, // Save in the Documents folder + encoding: Encoding.UTF8, + }); + +}; \ No newline at end of file