diff --git a/src/handlers/intent.js b/src/handlers/intent.js index fc5f5b9cb1..78c0009ab6 100644 --- a/src/handlers/intent.js +++ b/src/handlers/intent.js @@ -17,11 +17,14 @@ const pendingIntents = []; * @param {Intent} intent */ export default async function HandleIntent(intent = {}) { - const type = intent.action.split(".").slice(-1)[0]; + const type = intent.action?.split(".").slice(-1)[0]; if (["SEND", "VIEW", "EDIT"].includes(type)) { /**@type {string} */ - const url = intent.fileUri || intent.data; + const url = + intent.fileUri || + intent.data || + intent.extras?.["android.intent.extra.STREAM"]; if (!url) return; if (url.startsWith("acode://")) { @@ -72,19 +75,19 @@ export default async function HandleIntent(intent = {}) { return; } + const options = { + mode: "single", + render: true, + persistInSession: false, + }; + if (sessionStorage.getItem("isfilesRestored") === "true") { - await openFile(url, { - mode: "single", - render: true, - }); + await openFile(url, options); } else { // Store the intent for later processing when files are restored pendingIntents.push({ url, - options: { - mode: "single", - render: true, - }, + options, }); } } diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index 2aee9b500f..df7abed821 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -330,6 +330,7 @@ function maybeRecommendLanguageModeExtension(file, modeInfo) { * @property {string} [paneId] target editor pane id * @property {object} [pane] target editor pane * @property {boolean} [isPanePlaceholder] temporary empty tab for an empty pane + * @property {boolean} [persistInSession] restore the tab in a future app session * @property {boolean} [highlightStyles] adopt static CodeMirror highlight CSS into the custom tab shadow root */ @@ -500,6 +501,7 @@ export default class EditorFile { diskMtime = null; hasDiskConflict = false; isPanePlaceholder = false; + persistInSession = true; /** * @@ -513,6 +515,7 @@ export default class EditorFile { this.hideQuickTools = options?.hideQuickTools || false; this.paneId = options?.paneId || options?.pane?.id || null; this.isPanePlaceholder = !!options?.isPanePlaceholder; + this.persistInSession = options?.persistInSession !== false; // if options are passed if (options) { diff --git a/src/lib/fileSessionPersistence.js b/src/lib/fileSessionPersistence.js new file mode 100644 index 0000000000..e14c639402 --- /dev/null +++ b/src/lib/fileSessionPersistence.js @@ -0,0 +1,13 @@ +/** + * Promote an open file to durable session persistence when a later open source + * proves that its URI will remain accessible. Less durable sources, such as + * external intents, must not revoke an existing durable grant. + * + * @param {{persistInSession?: boolean}} file + * @param {boolean | undefined} requestedPersistence + */ +export function promoteSessionPersistence(file, requestedPersistence) { + if (requestedPersistence === true) { + file.persistInSession = true; + } +} diff --git a/src/lib/openFile.js b/src/lib/openFile.js index bbb5e5812f..020c028558 100644 --- a/src/lib/openFile.js +++ b/src/lib/openFile.js @@ -8,6 +8,7 @@ import { reopenWithNewEncoding } from "palettes/changeEncoding"; import { decode, detectEncoding } from "utils/encodings"; import helpers from "utils/helpers"; import EditorFile from "./editorFile"; +import { promoteSessionPersistence } from "./fileSessionPersistence"; import fileTypeHandler from "./fileTypeHandler"; import recents from "./recents"; import appSettings from "./settings"; @@ -22,6 +23,7 @@ import appSettings from "./settings"; * @property {string} mode * @property {string} uri * @property {string} paneId + * @property {boolean} persistInSession */ /** @@ -37,9 +39,19 @@ export default async function openFile(file, options = {}) { /**@type {EditorFile} */ const existingFile = editorManager.getFile(uri, "uri"); - const { cursorPos, render, onsave, text, mode, encoding, paneId } = options; + const { + cursorPos, + render, + onsave, + text, + mode, + encoding, + paneId, + persistInSession, + } = options; if (existingFile) { + promoteSessionPersistence(existingFile, persistInSession); // If file is already opened and new text is provided const incomingDoc = text != null ? Text.of(String(text).split("\n")) : null; @@ -120,6 +132,7 @@ export default async function openFile(file, options = {}) { savedMtime: helpers.getStatMtime(fileInfo), diskMtime: helpers.getStatMtime(fileInfo), paneId, + persistInSession, }); }; diff --git a/src/lib/saveState.js b/src/lib/saveState.js index 784304d475..7e0ea6790b 100644 --- a/src/lib/saveState.js +++ b/src/lib/saveState.js @@ -14,7 +14,7 @@ export default () => { files.forEach((file) => { if (file.type !== "editor") return; if (file.id === config.DEFAULT_FILE_SESSION) return; - if (file.SAFMode === "single") return; + if (file.persistInSession === false) return; // Selection per file: // - Active file uses live EditorView selection diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js index b26758b189..ef9c59ce28 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -60,6 +60,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { const IS_FOLDER_MODE = ["folder", "both"].includes(mode); const IS_FILE_MODE = ["file", "both"].includes(mode); + const SELECT_DOCUMENT_LABEL = "Select document"; const storedState = helpers.parseJSON(localStorage.fileBrowserState) || []; /**@type {Array} */ const state = []; @@ -106,6 +107,16 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { ); const $search = ; + const $selectDocument = ( + + ); const $lead = ; const $page = Page(strings["file browser"].capitalize(), { lead: $lead, @@ -178,8 +189,9 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { $content.addEventListener("click", handleClick); $content.addEventListener("contextmenu", handleContextMenu, true); $page.body = $content; + $page.header.append($search); + if (IS_FILE_MODE) $page.header.append($selectDocument); $page.header.append( - $search, $pasteToggler, $selectionModeToggler, $addMenuToggler, @@ -222,6 +234,12 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { }; $pasteToggler.onclick = pasteCopiedItems; + $selectDocument.onclick = selectDocument; + $selectDocument.onkeydown = (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + selectDocument(); + }; $fbMenu.onclick = function (e) { $fbMenu.hide(); @@ -653,6 +671,25 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { $page.hide(); } + function selectDocument() { + checkFiles.check = false; + sdcard.openDocumentFile( + (res) => { + res.url = res.uri; + resolve({ + type: "file", + ...res, + name: res.filename, + mode: "single", + }); + $page.hide(); + }, + (err) => { + helpers.error(err); + }, + ); + } + /** * @param {string} url */ @@ -872,6 +909,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { $addMenuToggler.style.display = "none"; $menuToggler.style.display = "none"; + $selectDocument.style.display = "none"; $selectionMenuToggler.style.display = ""; updatePasteToggler(); @@ -899,6 +937,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { $addMenuToggler.style.display = ""; $menuToggler.style.display = ""; + $selectDocument.style.display = ""; $selectionMenuToggler.style.display = "none"; updatePasteToggler(); @@ -998,7 +1037,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { else if (!$el.hasAttribute("disabled")) file(); break; case "openDoc": - openDoc(); + selectDocument(); break; } @@ -1304,25 +1343,6 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { localStorage.storageList = JSON.stringify(storageList); reload(); } - - function openDoc() { - checkFiles.check = false; - sdcard.openDocumentFile( - (res) => { - res.url = res.uri; - resolve({ - type: "file", - ...res, - name: res.filename, - mode: "single", - }); - $page.hide(); - }, - (err) => { - helpers.error(err); - }, - ); - } } function handleContextMenu(e) { @@ -1431,7 +1451,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { } if (IS_FILE_MODE) { - util.pushFolder(allStorages, "Select document", null, { + util.pushFolder(allStorages, SELECT_DOCUMENT_LABEL, null, { openDoc: true, notSelectable: true, }); diff --git a/src/pages/fileBrowser/index.js b/src/pages/fileBrowser/index.js index 63becfb329..bd46f597c8 100644 --- a/src/pages/fileBrowser/index.js +++ b/src/pages/fileBrowser/index.js @@ -31,7 +31,7 @@ function FileBrowser(mode, info, doesOpenLast, ...args) { } FileBrowser.openFile = (res) => { - const { url, name, mode } = res; + const { url, name, mode, persistedUriPermission } = res; const createOption = { uri: url, name, @@ -41,8 +41,11 @@ FileBrowser.openFile = (res) => { if (mode) { createOption.mode = mode; } + if (mode === "single") { + createOption.persistInSession = persistedUriPermission !== false; + } - openFile(url, createOption); + return openFile(url, createOption); }; FileBrowser.openFileError = (err) => { diff --git a/src/plugins/sdcard/index.d.ts b/src/plugins/sdcard/index.d.ts index f151eab349..fd6a6b7db8 100644 --- a/src/plugins/sdcard/index.d.ts +++ b/src/plugins/sdcard/index.d.ts @@ -35,6 +35,7 @@ interface DocumentFile { canWrite: boolean; filename: string; length: number; + persistedUriPermission: boolean; type: string; uri: string; } diff --git a/src/plugins/sdcard/src/android/SDcard.java b/src/plugins/sdcard/src/android/SDcard.java index 1b33e7edef..578d33dda3 100644 --- a/src/plugins/sdcard/src/android/SDcard.java +++ b/src/plugins/sdcard/src/android/SDcard.java @@ -266,6 +266,11 @@ public void openDocumentFile(String mimeType, CallbackContext callback) { intent.setAction(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType(mimeType); + intent.addFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION | + Intent.FLAG_GRANT_WRITE_URI_PERMISSION | + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + ); activityResultCallback = callback; cordova.startActivityForResult(this, intent, this.OPEN_DOCUMENT); } @@ -409,7 +414,10 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { return; } - takePermission(uri); + boolean persistedUriPermission = takePermission( + uri, + data.getFlags() + ); DocumentFile file = DocumentFile.fromSingleUri(context, uri); JSONObject res = new JSONObject(); @@ -418,6 +426,7 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { res.put("filename", file.getName()); res.put("canWrite", canWrite(file.getUri())); res.put("uri", uri.toString()); + res.put("persistedUriPermission", persistedUriPermission); activityResultCallback.success(res); } catch (JSONException e) { activityResultCallback.error(e.toString()); @@ -447,7 +456,7 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { return; } - takePermission(uri); + takePermission(uri, data.getFlags()); DocumentFile file = DocumentFile.fromTreeUri(context, uri); if (file != null && file.canWrite()) { activityResultCallback.success(uri.toString()); @@ -1152,13 +1161,22 @@ private DocumentFile getFile(String filePath) { return documentFile; } - private void takePermission(Uri uri) { - contentResolver = context.getContentResolver(); - contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_WRITE_URI_PERMISSION | - Intent.FLAG_GRANT_READ_URI_PERMISSION - ); + private boolean takePermission(Uri uri, int intentFlags) { + int permissionFlags = + intentFlags & + (Intent.FLAG_GRANT_WRITE_URI_PERMISSION | + Intent.FLAG_GRANT_READ_URI_PERMISSION); + + if (permissionFlags == 0) return false; + + try { + contentResolver = context.getContentResolver(); + contentResolver.takePersistableUriPermission(uri, permissionFlags); + return true; + } catch (SecurityException | IllegalArgumentException error) { + Log.w("SDcard", "Unable to persist URI permission for " + uri, error); + return false; + } } public boolean canWrite(Uri uri) { diff --git a/tests/unit/sessionPersistence.test.js b/tests/unit/sessionPersistence.test.js new file mode 100644 index 0000000000..938cc63a60 --- /dev/null +++ b/tests/unit/sessionPersistence.test.js @@ -0,0 +1,165 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const runtime = vi.hoisted(() => { + const settings = { + value: { + rememberFiles: true, + rememberFolders: false, + }, + }; + + return { + settings, + openFile: vi.fn(), + }; +}); + +vi.mock("cm/editorUtils", () => ({ + getAllFolds: vi.fn(() => []), + getScrollPosition: vi.fn(() => ({ scrollTop: 0, scrollLeft: 0 })), + getSelection: vi.fn(() => ({ + ranges: [{ from: 0, to: 0 }], + mainIndex: 0, + })), +})); +vi.mock("dialogs/alert", () => ({ default: vi.fn() })); +vi.mock("fileSystem", () => ({ default: vi.fn() })); +vi.mock("lib/auth", () => ({ default: {} })); +vi.mock("lib/config", () => ({ + default: { DEFAULT_FILE_SESSION: "default-session" }, +})); +vi.mock("lib/openFile", () => ({ default: runtime.openFile })); +vi.mock("lib/openFolder", () => ({ + default: vi.fn(), + addedFolder: [], +})); +vi.mock("lib/settings", () => ({ default: runtime.settings })); +vi.mock("lib/startAd", () => ({ + BANNER_SUPPRESSION_REASON: {}, + setBannerSuppressed: vi.fn(), +})); +vi.mock("utils/helpers", () => ({ + default: { error: vi.fn(), errorMessage: vi.fn() }, +})); + +import HandleIntent from "handlers/intent"; +import { promoteSessionPersistence } from "lib/fileSessionPersistence"; +import saveState from "lib/saveState"; +import FileBrowser from "pages/fileBrowser"; + +describe("file session persistence", () => { + beforeEach(() => { + runtime.openFile.mockReset(); + localStorage.clear(); + sessionStorage.clear(); + sessionStorage.setItem("isfilesRestored", "true"); + globalThis.editorManager = { + editor: {}, + files: [], + activeFile: null, + getFile(value, type = "id") { + return this.files.find((file) => file[type] === value); + }, + }; + runtime.openFile.mockImplementation(async (uri, options = {}) => { + const existingFile = globalThis.editorManager.getFile(uri, "uri"); + if (existingFile) { + promoteSessionPersistence(existingFile, options.persistInSession); + globalThis.editorManager.activeFile = existingFile; + return; + } + + const file = createOpenFile(uri, options); + globalThis.editorManager.files.push(file); + globalThis.editorManager.activeFile = file; + }); + }); + + it("persists only a picker document with durable URI access", async () => { + const selectedUri = "content://documents/document/selected"; + const unpersistableUri = "content://documents/document/no-grant"; + const intentUri = "content://external-app/shared/temporary"; + + await FileBrowser.openFile({ + type: "file", + url: selectedUri, + name: "selected.js", + mode: "single", + persistedUriPermission: true, + }); + await FileBrowser.openFile({ + type: "file", + url: unpersistableUri, + name: "no-grant.js", + mode: "single", + persistedUriPermission: false, + }); + await HandleIntent({ + action: "android.intent.action.VIEW", + data: intentUri, + }); + await HandleIntent({ + action: "android.intent.action.VIEW", + data: selectedUri, + }); + + saveState(); + + const restoredUris = JSON.parse(localStorage.files).map( + (file) => file.uri, + ); + expect(restoredUris).toEqual([selectedUri]); + }); + + it("promotes an intent tab when the same document is later selected", async () => { + const uri = "content://documents/document/shared"; + + await HandleIntent({ + action: "android.intent.action.VIEW", + data: uri, + }); + await FileBrowser.openFile({ + type: "file", + url: uri, + name: "shared.js", + mode: "single", + persistedUriPermission: true, + }); + + saveState(); + + expect(JSON.parse(localStorage.files).map((file) => file.uri)).toEqual([ + uri, + ]); + }); +}); + +function createOpenFile(uri, options) { + return { + id: `file-${globalThis.editorManager.files.length + 1}`, + uri, + type: "editor", + filename: options.name || uri.split("/").pop(), + pinned: false, + isUnsaved: false, + docVersion: 0, + savedVersion: 0, + cacheVersion: 0, + savedMtime: 1, + diskMtime: 1, + hasDiskConflict: false, + readOnly: false, + SAFMode: options.mode, + deletedFile: false, + session: { + selection: { ranges: [{ from: 0, to: 0 }], mainIndex: 0 }, + }, + lastScrollTop: 0, + lastScrollLeft: 0, + editable: true, + encoding: "UTF-8", + persistInSession: options.persistInSession, + }; +}