diff --git a/config.xml b/config.xml
index 946ace476..024114296 100644
--- a/config.xml
+++ b/config.xml
@@ -69,6 +69,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -314,6 +330,7 @@
+
diff --git a/hooks/android/ImmersiveFullscreen.java b/hooks/android/ImmersiveFullscreen.java
new file mode 100644
index 000000000..28d9f8a9a
--- /dev/null
+++ b/hooks/android/ImmersiveFullscreen.java
@@ -0,0 +1,221 @@
+package org.apache.cordova;
+
+import android.app.Activity;
+import android.content.pm.ActivityInfo;
+import android.os.Build;
+import android.view.View;
+import android.view.ViewTreeObserver;
+import android.view.Window;
+import android.view.WindowManager;
+import androidx.core.view.ViewCompat;
+import androidx.core.view.WindowCompat;
+import androidx.core.view.WindowInsetsCompat;
+import androidx.core.view.WindowInsetsControllerCompat;
+
+/** Owns window state only while Cordova is displaying a browser fullscreen view. */
+final class ImmersiveFullscreen
+ implements
+ ViewTreeObserver.OnWindowFocusChangeListener,
+ View.OnAttachStateChangeListener
+{
+
+ @SuppressWarnings("deprecation")
+ private static final int LEGACY_FULLSCREEN_FLAGS =
+ View.SYSTEM_UI_FLAG_FULLSCREEN |
+ View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
+ View.SYSTEM_UI_FLAG_IMMERSIVE |
+ View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
+
+ private final Activity activity;
+ private final Window window;
+ private final View decor;
+ private final WindowInsetsControllerCompat controller;
+ private final Runnable onExit;
+ private View fullscreenView;
+ private ViewTreeObserver focusObserver;
+ private boolean statusBarVisible;
+ private boolean navigationBarVisible;
+ private int previousBehavior;
+ private int previousCutoutMode;
+ private int previousLegacyFlags;
+ private boolean resumed;
+ private Integer requestedOrientation;
+ private int previousOrientation;
+ private boolean orientationApplied;
+
+ ImmersiveFullscreen(Activity activity, boolean resumed, Runnable onExit) {
+ this.activity = activity;
+ this.resumed = resumed;
+ this.onExit = onExit;
+ window = activity.getWindow();
+ decor = window.getDecorView();
+ controller = WindowCompat.getInsetsController(window, decor);
+ }
+
+ @SuppressWarnings("deprecation")
+ void enter(View view) {
+ if (fullscreenView != null) return;
+
+ WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(decor);
+ int flags = decor.getSystemUiVisibility();
+ statusBarVisible =
+ insets != null
+ ? insets.isVisible(WindowInsetsCompat.Type.statusBars())
+ : (flags & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0 &&
+ (window.getAttributes().flags &
+ WindowManager.LayoutParams.FLAG_FULLSCREEN) == 0;
+ navigationBarVisible =
+ insets != null
+ ? insets.isVisible(WindowInsetsCompat.Type.navigationBars())
+ : (flags & View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) == 0;
+ previousBehavior = controller.getSystemBarsBehavior();
+ previousLegacyFlags = flags & LEGACY_FULLSCREEN_FLAGS;
+ if (Build.VERSION.SDK_INT >= 28) {
+ WindowManager.LayoutParams attributes = window.getAttributes();
+ previousCutoutMode = attributes.layoutInDisplayCutoutMode;
+ attributes.layoutInDisplayCutoutMode =
+ Build.VERSION.SDK_INT >= 30
+ ? WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
+ : WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
+ window.setAttributes(attributes);
+ }
+
+ fullscreenView = view;
+ // The wrapper fills Cordova's root, with no status/navigation bar margins.
+ // Only the keyboard should shrink the usable area. Leave the root's inset
+ // listener alone so normal editor layout is unchanged on exit.
+ ViewCompat.setOnApplyWindowInsetsListener(view, (target, appliedInsets) -> {
+ int bottom = appliedInsets
+ .getInsets(WindowInsetsCompat.Type.ime())
+ .bottom;
+ target.setPadding(0, 0, 0, bottom);
+ return appliedInsets;
+ });
+ view.addOnAttachStateChangeListener(this);
+ focusObserver = decor.getViewTreeObserver();
+ focusObserver.addOnWindowFocusChangeListener(this);
+ reapply();
+ ViewCompat.requestApplyInsets(view);
+ }
+
+ void reapply() {
+ if (
+ !resumed ||
+ fullscreenView == null ||
+ !fullscreenView.isAttachedToWindow() ||
+ !decor.hasWindowFocus()
+ ) return;
+ controller.setSystemBarsBehavior(
+ WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ );
+ controller.hide(WindowInsetsCompat.Type.systemBars());
+ }
+
+ void lockOrientation(String orientation) {
+ final int requested;
+ if ("landscape".equals(orientation)) {
+ requested = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE;
+ } else if ("portrait".equals(orientation)) {
+ requested = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT;
+ } else {
+ throw new IllegalArgumentException(
+ "Orientation must be landscape or portrait."
+ );
+ }
+ if (
+ !resumed || fullscreenView == null || !fullscreenView.isAttachedToWindow()
+ ) {
+ throw new IllegalStateException(
+ "Orientation requires foreground fullscreen."
+ );
+ }
+
+ int previous = activity.getRequestedOrientation();
+ activity.setRequestedOrientation(requested);
+ // Only a successful first request owns the restoration state. Changing
+ // modes or resuming this session must not replace it with our own override.
+ if (requestedOrientation == null) previousOrientation = previous;
+ requestedOrientation = requested;
+ orientationApplied = true;
+ }
+
+ void unlockOrientation() {
+ restoreOrientation();
+ requestedOrientation = null;
+ }
+
+ void pause() {
+ resumed = false;
+ restoreOrientation();
+ }
+
+ void resume() {
+ resumed = true;
+ if (
+ fullscreenView != null &&
+ fullscreenView.isAttachedToWindow() &&
+ requestedOrientation != null
+ ) {
+ activity.setRequestedOrientation(requestedOrientation);
+ orientationApplied = true;
+ }
+ reapply();
+ }
+
+ private void restoreOrientation() {
+ if (!orientationApplied) return;
+ activity.setRequestedOrientation(previousOrientation);
+ orientationApplied = false;
+ }
+
+ @SuppressWarnings("deprecation")
+ void exit() {
+ if (fullscreenView == null) return;
+ View view = fullscreenView;
+ fullscreenView = null;
+ onExit.run();
+ unlockOrientation();
+ view.removeOnAttachStateChangeListener(this);
+ ViewCompat.setOnApplyWindowInsetsListener(view, null);
+ view.setPadding(0, 0, 0, 0);
+ if (
+ focusObserver.isAlive()
+ ) focusObserver.removeOnWindowFocusChangeListener(this);
+ focusObserver = null;
+
+ controller.setSystemBarsBehavior(previousBehavior);
+ restoreBar(WindowInsetsCompat.Type.statusBars(), statusBarVisible);
+ restoreBar(WindowInsetsCompat.Type.navigationBars(), navigationBarVisible);
+ if (Build.VERSION.SDK_INT < 30) {
+ // Restore only the bits we own; keep any theme changes made meanwhile.
+ decor.setSystemUiVisibility(
+ (decor.getSystemUiVisibility() & ~LEGACY_FULLSCREEN_FLAGS) |
+ previousLegacyFlags
+ );
+ }
+ if (Build.VERSION.SDK_INT >= 28) {
+ WindowManager.LayoutParams attributes = window.getAttributes();
+ attributes.layoutInDisplayCutoutMode = previousCutoutMode;
+ window.setAttributes(attributes);
+ }
+ ViewCompat.requestApplyInsets(decor);
+ }
+
+ private void restoreBar(int type, boolean visible) {
+ if (visible) controller.show(type);
+ else controller.hide(type);
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ if (hasFocus) reapply();
+ }
+
+ @Override
+ public void onViewAttachedToWindow(View view) {}
+
+ @Override
+ public void onViewDetachedFromWindow(View view) {
+ exit();
+ }
+}
diff --git a/hooks/immersive-fullscreen.js b/hooks/immersive-fullscreen.js
new file mode 100644
index 000000000..0d776d7d8
--- /dev/null
+++ b/hooks/immersive-fullscreen.js
@@ -0,0 +1,162 @@
+const fs = require("node:fs");
+const path = require("node:path");
+
+// Patch Cordova's existing custom-view lifecycle rather than replacing its
+// WebChromeClient (which also owns dialogs, permissions and file selection).
+module.exports = function prepareImmersiveFullscreen(context) {
+ if (!context.opts.platforms.includes("android")) return;
+
+ const root = context.opts.projectRoot;
+ const destination = path.join(
+ root,
+ "platforms/android/CordovaLib/src/org/apache/cordova",
+ );
+ const webViewPath = path.join(destination, "CordovaWebViewImpl.java");
+ const controller = fs.readFileSync(
+ path.join(root, "hooks/android/ImmersiveFullscreen.java"),
+ "utf8",
+ );
+ const original = fs.readFileSync(webViewPath, "utf8");
+ const newline = original.includes("\r\n") ? "\r\n" : "\n";
+
+ // Markers delimit our generated blocks so each prepare can replace them safely,
+ // including when only the controller changes.
+ let source = original
+ .replace(/\r\n/g, "\n")
+ .replace(
+ /^[ \t]*\/\/ ACODE_FULLSCREEN_BEGIN ([a-z]+)\n[\s\S]*?^[ \t]*\/\/ ACODE_FULLSCREEN_END \1\n/gm,
+ "",
+ );
+ if (/ACODE_FULLSCREEN_(BEGIN|END)/.test(source)) {
+ throw new Error(
+ "Acode fullscreen: incomplete generated patch; regenerate the Android platform.",
+ );
+ }
+
+ function insert(name, anchor, code, before = false) {
+ const matches = [...source.matchAll(new RegExp(anchor.source, "gm"))];
+ if (matches.length !== 1) {
+ throw new Error(
+ `Acode fullscreen: expected one Cordova ${name} anchor, found ${matches.length}. Update hooks/immersive-fullscreen.js for this Cordova version.`,
+ );
+ }
+ const indent = matches[0][1];
+ const block = [
+ `// ACODE_FULLSCREEN_BEGIN ${name}`,
+ ...code.trim().split("\n"),
+ `// ACODE_FULLSCREEN_END ${name}`,
+ ]
+ .map((line) => indent + line)
+ .join("\n");
+ source = source.replace(anchor, (match) =>
+ before ? `${block}\n${match}` : `${match}\n${block}`,
+ );
+ }
+
+ // System calls these setters on the UI thread; null releases the orientation override.
+ insert(
+ "field",
+ /^([ \t]*)private View mCustomView;[ \t]*$/m,
+ `
+private ImmersiveFullscreen immersiveFullscreen;
+private boolean fullscreenPaused;
+private boolean fullscreenBackHandlerEnabled;
+
+public void setFullscreenBackHandler(boolean enabled) {
+ if (enabled && (mCustomView == null || !mCustomView.isAttachedToWindow() || fullscreenPaused)) {
+ throw new IllegalStateException("Back handler requires foreground fullscreen.");
+ }
+ fullscreenBackHandlerEnabled = enabled;
+}
+
+public void setFullscreenOrientation(String orientation) {
+ if (orientation == null) {
+ if (immersiveFullscreen != null) immersiveFullscreen.unlockOrientation();
+ return;
+ }
+ if (immersiveFullscreen == null) {
+ throw new IllegalStateException("Orientation requires foreground fullscreen.");
+ }
+ immersiveFullscreen.lockOrientation(orientation);
+}
+`,
+ );
+ // CoreAndroid's message channel accepts only Cordova's built-in events.
+ insert(
+ "back",
+ /(?<=if \(isBackButton && mCustomView != null\) \{\n)^([ \t]*)hideCustomView\(\);\n[ \t]*return true;[ \t]*$/m,
+ `
+if (fullscreenBackHandlerEnabled) {
+ engine.evaluateJavascript("cordova.fireDocumentEvent('fullscreenbackbutton');", null);
+ return true;
+}
+`,
+ true,
+ );
+ insert(
+ "enter",
+ /^([ \t]*)parent\.bringToFront\(\);[ \t]*$/m,
+ `
+fullscreenBackHandlerEnabled = false;
+immersiveFullscreen = new ImmersiveFullscreen(cordova.getActivity(), !fullscreenPaused, () -> fullscreenBackHandlerEnabled = false);
+immersiveFullscreen.enter(wrapperView);
+`,
+ );
+ insert(
+ "exit",
+ /^([ \t]*)mCustomView\.setVisibility\(View\.GONE\);[ \t]*$/m,
+ `
+fullscreenBackHandlerEnabled = false;
+if (immersiveFullscreen != null) {
+ immersiveFullscreen.exit();
+ immersiveFullscreen = null;
+}
+`,
+ true,
+ );
+ insert(
+ "pause",
+ /^([ \t]*)pluginManager\.onPause\(keepRunning\);[ \t]*$/m,
+ `
+fullscreenPaused = true;
+if (immersiveFullscreen != null) immersiveFullscreen.pause();
+`,
+ true,
+ );
+ insert(
+ "resume",
+ /^([ \t]*)this\.pluginManager\.onResume\(keepRunning\);[ \t]*$/m,
+ `
+fullscreenPaused = false;
+if (immersiveFullscreen != null) immersiveFullscreen.resume();
+`,
+ true,
+ );
+ insert(
+ "reset",
+ /^([ \t]*)pluginManager\.onReset\(\);[ \t]*$/m,
+ `
+fullscreenBackHandlerEnabled = false;
+if (immersiveFullscreen != null) immersiveFullscreen.unlockOrientation();
+`,
+ true,
+ );
+ // Release fullscreen while the WebView and its callback are still alive.
+ insert(
+ "destroy",
+ /^([ \t]*)engine\.destroy\(\);[ \t]*$/m,
+ `
+fullscreenBackHandlerEnabled = false;
+hideCustomView();
+`,
+ true,
+ );
+
+ // Validate all anchors before writing either generated file.
+ fs.writeFileSync(
+ path.join(destination, "ImmersiveFullscreen.java"),
+ controller,
+ );
+ const patched = source.replace(/\n/g, newline);
+ if (patched !== original) fs.writeFileSync(webViewPath, patched);
+};
diff --git a/src/handlers/intent.js b/src/handlers/intent.js
index 78c0009ab..22c30ac56 100644
--- a/src/handlers/intent.js
+++ b/src/handlers/intent.js
@@ -1,16 +1,19 @@
import fsOperation from "fileSystem";
+import select from "dialogs/select";
import auth from "lib/auth";
import config from "lib/config";
+import { isInitialPluginLoadComplete } from "lib/loadPlugins";
import openFile from "lib/openFile";
import { BANNER_SUPPRESSION_REASON, setBannerSuppressed } from "lib/startAd";
import helpers from "utils/helpers";
const handlers = [];
/**
- * Queue to store intents that arrive before files are restored
- * @type {Array<{url: string, options: object}>}
+ * Batches wait for restored files and plugin handlers, then open sequentially.
+ * @type {Array<{uris: string[], invalid: boolean}>}
*/
const pendingIntents = [];
+let opening;
/**
*
@@ -19,15 +22,13 @@ const pendingIntents = [];
export default async function HandleIntent(intent = {}) {
const type = intent.action?.split(".").slice(-1)[0];
- if (["SEND", "VIEW", "EDIT"].includes(type)) {
+ if (["SEND", "SEND_MULTIPLE", "VIEW", "EDIT"].includes(type)) {
/**@type {string} */
const url =
intent.fileUri ||
intent.data ||
intent.extras?.["android.intent.extra.STREAM"];
- if (!url) return;
-
- if (url.startsWith("acode://")) {
+ if (typeof url === "string" && url.startsWith("acode://")) {
const path = url.replace("acode://", "");
const [module, action, value] = path.split("/");
@@ -75,21 +76,26 @@ export default async function HandleIntent(intent = {}) {
return;
}
- const options = {
- mode: "single",
- render: true,
- persistInSession: false,
- };
-
- if (sessionStorage.getItem("isfilesRestored") === "true") {
- await openFile(url, options);
- } else {
- // Store the intent for later processing when files are restored
- pendingIntents.push({
- url,
- options,
- });
- }
+ const incoming = intent.uris?.length
+ ? intent.uris
+ : Array.isArray(url)
+ ? url
+ : url == null
+ ? []
+ : [url];
+ if (!Array.isArray(incoming) || !incoming.length) return;
+ const uris = [
+ ...new Set(
+ incoming.filter(
+ (uri) => typeof uri === "string" && /^(content|file):\/\//i.test(uri),
+ ),
+ ),
+ ];
+ pendingIntents.push({
+ uris,
+ invalid: incoming.some((uri) => !uris.includes(uri)),
+ });
+ await processPendingIntents();
}
}
@@ -106,23 +112,73 @@ export function removeIntentHandler(handler) {
if (index > -1) handlers.splice(index, 1);
}
-/**
- * Process all pending intents that were queued before files were restored.
- * This function is called after isfilesRestored is set to true in main.js.
- * @returns {Promise}
- */
+/** Drain only after both startup phases, including a partially failed plugin load. */
export async function processPendingIntents() {
- if (sessionStorage.getItem("isfilesRestored") !== "true") return;
-
- // Process all pending intents
- while (pendingIntents.length > 0) {
- const pendingIntent = pendingIntents.shift();
- try {
- await openFile(pendingIntent.url, pendingIntent.options);
- } catch (error) {
- helpers.error(error);
+ if (
+ sessionStorage.getItem("isfilesRestored") !== "true" ||
+ !isInitialPluginLoadComplete()
+ )
+ return;
+ if (opening) return opening;
+ opening = (async () => {
+ while (pendingIntents.length) {
+ const { uris, invalid } = pendingIntents.shift();
+ const failures = invalid
+ ? [{ filename: strings["invalid shared file"] }]
+ : [];
+ for (const uri of uris) {
+ try {
+ await openFile(uri, {
+ mode: "single",
+ render: true,
+ persistInSession: false,
+ external: true,
+ });
+ } catch (error) {
+ console.error("Unable to open incoming file", error);
+ failures.push({
+ code: error?.code,
+ filename: error?.filename || uri,
+ });
+ }
+ }
+ if (failures.length)
+ await reportFailures(failures).catch(HandleIntent.onError);
}
- }
+ })().finally(() => {
+ opening = undefined;
+ });
+ return opening;
+}
+
+async function reportFailures(failures) {
+ const needsPlugin = failures.some(
+ (error) => error.code === "DOCUMENT_HANDLER_UNAVAILABLE",
+ );
+ const explanation = needsPlugin
+ ? strings["document plugin required"]
+ : strings["shared files unavailable"];
+ // The select dialog supports rich text. Build its message as text so
+ // provider filenames cannot introduce markup or links.
+ const message = document.createElement("p");
+ message.style.cssText =
+ "white-space:pre-wrap;overflow-wrap:anywhere;margin:0";
+ message.textContent = `${explanation}\n\n${failures.map((error) => error.filename).join("\n")}`;
+ const answer = await new Promise((resolve, reject) => {
+ select(
+ strings["unable to open file"],
+ [
+ { text: message.outerHTML, disabled: true },
+ ...(needsPlugin ? [{ value: "plugins", text: strings.plugins }] : []),
+ { value: "close", text: needsPlugin ? strings.cancel : strings.ok },
+ ],
+ {
+ default: needsPlugin ? "plugins" : "close",
+ onCancel: () => resolve(null),
+ },
+ ).then(resolve, reject);
+ });
+ if (answer === "plugins") acode.exec("open", "plugins");
}
class IntentEvent {
diff --git a/src/index.d.ts b/src/index.d.ts
index 12e76f127..75aa19d8b 100644
--- a/src/index.d.ts
+++ b/src/index.d.ts
@@ -10,6 +10,9 @@ declare const ANDROID_SDK_INT: number;
declare const DOES_SUPPORT_THEME: boolean;
declare const acode: {
webview: AcodeWebViewAPI;
+ require(module: "fullscreen"): AcodeFullscreenAPI;
+ require(module: "orientation"): AcodeOrientationAPI;
+ require(module: string): unknown;
[key: string]: unknown;
};
@@ -172,3 +175,20 @@ interface AcodeWebView {
interface AcodeWebViewAPI {
create(options?: WebViewOptions): Promise;
}
+
+interface AcodeFullscreenAPI {
+ /**
+ * Claim Back for the current browser fullscreen owner, or release with null.
+ * The callback must explicitly exit fullscreen when desired.
+ * Resolves when the native request is accepted.
+ */
+ setBackHandler(callback: (() => void | Promise) | null): Promise;
+}
+
+/** Fullscreen-scoped orientation requests resolve on native acceptance. */
+interface AcodeOrientationAPI {
+ /** Requires a foreground browser fullscreen session. */
+ lock(mode: "landscape" | "portrait"): Promise;
+ /** Restores the previous orientation policy, if overridden. */
+ unlock(): Promise;
+}
diff --git a/src/lang/ar-ye.json b/src/lang/ar-ye.json
index 6939fd6b6..67b21b3d7 100644
--- a/src/lang/ar-ye.json
+++ b/src/lang/ar-ye.json
@@ -91,6 +91,9 @@
"theme": "السمة",
"title-listfiles": "سرد الملفات",
"unable to delete file": "عذراً، فشل حذف الملف",
+ "document plugin required": "ثبّت Docs Workspace أو فعّله من قسم الإضافات، ثم أعد فتح المستند.",
+ "shared files unavailable": "تعذّر فتح هذه الملفات. قد تكون غير مدعومة أو لم يعد الوصول إليها ممكنًا. حاول مشاركتها مرة أخرى.",
+ "invalid shared file": "ملف مشارَك غير صالح",
"unable to open file": "عذراً، فشل فتح الملف",
"unable to open folder": "عذراً، فشل فتح المجلد",
"unable to save file": "عذراً، فشل حفظ الملف",
diff --git a/src/lang/az-az.json b/src/lang/az-az.json
index 743e28f05..6285bb588 100644
--- a/src/lang/az-az.json
+++ b/src/lang/az-az.json
@@ -101,6 +101,9 @@
"title-listfiles": "Faylları siyahıya al",
"ui zoom": "İnterfeysin miqyası",
"unable to delete file": "Faylı silmək mümkün olmadı",
+ "document plugin required": "Qoşmalar bölməsində Docs Workspace-i quraşdırın və ya aktivləşdirin, sonra sənədi yenidən açın.",
+ "shared files unavailable": "Bu faylları açmaq mümkün olmadı. Onlar dəstəklənməyə bilər və ya artıq əlçatan deyil. Onları yenidən paylaşmağa çalışın.",
+ "invalid shared file": "Paylaşılan fayl etibarsızdır",
"unable to open file": "Təəssüf ki, faylı açmaq mümkün olmadı",
"unable to open folder": "Təəssüf ki, qovluğu açmaq mümkün olmadı",
"unable to save file": "Təəssüf ki, faylı yadda saxlamaq mümkün olmadı",
diff --git a/src/lang/be-by.json b/src/lang/be-by.json
index 9561a723f..98601603c 100644
--- a/src/lang/be-by.json
+++ b/src/lang/be-by.json
@@ -90,6 +90,9 @@
"theme": "Тэма",
"title-listfiles": "Спіс файлаў",
"unable to delete file": "немагчыма выдаліць файл",
+ "document plugin required": "Усталюйце або ўключыце Docs Workspace у раздзеле «Убудовы», затым адкрыйце дакумент зноў.",
+ "shared files unavailable": "Не ўдалося адкрыць гэтыя файлы. Магчыма, яны не падтрымліваюцца або больш недаступныя. Паспрабуйце падзяліцца імі яшчэ раз.",
+ "invalid shared file": "Няправільны абагулены файл",
"unable to open file": "Выбачайце, файл немагчыма адкрыць",
"unable to open folder": "Выбачайце, каталог немагчыма адкрыць",
"unable to save file": "Выбачайце, файл немагчыма захаваць",
diff --git a/src/lang/bn-bd.json b/src/lang/bn-bd.json
index 23221edf8..c439ccecd 100644
--- a/src/lang/bn-bd.json
+++ b/src/lang/bn-bd.json
@@ -90,6 +90,9 @@
"theme": "থীম",
"title-listfiles": "ফাইল তালিকাভুক্ত করুন",
"unable to delete file": "ডিলেট করতে অসমর্থ",
+ "document plugin required": "প্লাগইন বিভাগ থেকে Docs Workspace ইনস্টল বা সক্রিয় করুন, তারপর নথিটি আবার খুলুন।",
+ "shared files unavailable": "এই ফাইলগুলো খোলা যায়নি। এগুলো সমর্থিত নাও হতে পারে অথবা আর অ্যাক্সেসযোগ্য নেই। আবার শেয়ার করার চেষ্টা করুন।",
+ "invalid shared file": "শেয়ার করা ফাইলটি অবৈধ",
"unable to open file": "দুঃখিত, ফাইলটি খুলতে ব্যার্থ",
"unable to open folder": "দুঃখিত, ফোল্ডার খুলতে ব্যার্থ",
"unable to save file": "দুঃখিত, ফাইলটি সংরক্ষণে ব্যার্থ",
diff --git a/src/lang/cs-cz.json b/src/lang/cs-cz.json
index 52596946d..f33b46844 100644
--- a/src/lang/cs-cz.json
+++ b/src/lang/cs-cz.json
@@ -90,6 +90,9 @@
"theme": "Motiv",
"title-listfiles": "Seznam souborů",
"unable to delete file": "nelze smazat soubor",
+ "document plugin required": "V sekci Pluginy nainstalujte nebo povolte Docs Workspace a poté dokument znovu otevřete.",
+ "shared files unavailable": "Tyto soubory se nepodařilo otevřít. Možná nejsou podporované nebo již nejsou přístupné. Zkuste je sdílet znovu.",
+ "invalid shared file": "Neplatný sdílený soubor",
"unable to open file": "Omlouváme se, soubor se nepodařilo otevřít",
"unable to open folder": "Omlouváme se, složku se nepodařilo otevřít",
"unable to save file": "Omlouváme se, soubor se nepodařilo uložit",
diff --git a/src/lang/de-de.json b/src/lang/de-de.json
index 5ef81148c..c19f4e6f5 100644
--- a/src/lang/de-de.json
+++ b/src/lang/de-de.json
@@ -90,6 +90,9 @@
"theme": "Design",
"title-listfiles": "Dateienliste",
"unable to delete file": "Löschen der Datei nicht möglich",
+ "document plugin required": "Installiere oder aktiviere Docs Workspace unter Plugins und öffne das Dokument anschließend erneut.",
+ "shared files unavailable": "Diese Dateien konnten nicht geöffnet werden. Sie werden möglicherweise nicht unterstützt oder sind nicht mehr zugänglich. Versuche, sie erneut zu teilen.",
+ "invalid shared file": "Ungültige geteilte Datei",
"unable to open file": "Datei konnte nicht geöffnet werden",
"unable to open folder": "Ordner konnte nicht geöffnet werden",
"unable to save file": "Datei konnte nicht gespeichert werden",
diff --git a/src/lang/en-us.json b/src/lang/en-us.json
index b3d998508..2869c6c2c 100644
--- a/src/lang/en-us.json
+++ b/src/lang/en-us.json
@@ -101,6 +101,9 @@
"title-listfiles": "List files",
"ui zoom": "UI zoom",
"unable to delete file": "unable to delete file",
+ "document plugin required": "Install or enable Docs Workspace in Plugins, then reopen the document.",
+ "shared files unavailable": "These files could not be opened. They may be unsupported or no longer accessible. Try sharing them again.",
+ "invalid shared file": "Invalid shared file",
"unable to open file": "Sorry, unable to open file",
"unable to open folder": "Sorry, unable to open folder",
"unable to save file": "Sorry, unable to save file",
diff --git a/src/lang/es-sv.json b/src/lang/es-sv.json
index 1942bb00a..ad78d40c9 100644
--- a/src/lang/es-sv.json
+++ b/src/lang/es-sv.json
@@ -90,6 +90,9 @@
"theme": "Tema",
"title-listfiles": "Listar archivos",
"unable to delete file": "no se puede eliminar el archivo",
+ "document plugin required": "Instala o activa Docs Workspace en Extensiones y vuelve a abrir el documento.",
+ "shared files unavailable": "No se pudieron abrir estos archivos. Puede que no sean compatibles o que ya no estén disponibles. Intenta compartirlos de nuevo.",
+ "invalid shared file": "Archivo compartido no válido",
"unable to open file": "Lo sentimos, no se puede abrir el archivo",
"unable to open folder": "Lo sentimos, no se puede abrir la carpeta",
"unable to save file": "Lo sentimos, no se puede guardar el archivo",
diff --git a/src/lang/fr-fr.json b/src/lang/fr-fr.json
index ec0b418df..5fc53c6f1 100644
--- a/src/lang/fr-fr.json
+++ b/src/lang/fr-fr.json
@@ -90,6 +90,9 @@
"theme": "Thème",
"title-listfiles": "Lister les fichiers",
"unable to delete file": "Impossible de supprimer le fichier",
+ "document plugin required": "Installez ou activez Docs Workspace dans Extensions, puis ouvrez à nouveau le document.",
+ "shared files unavailable": "Impossible d’ouvrir ces fichiers. Ils ne sont peut-être pas pris en charge ou ne sont plus accessibles. Essayez de les partager à nouveau.",
+ "invalid shared file": "Fichier partagé non valide",
"unable to open file": "Désolé, impossible d'ouvrir le fichier",
"unable to open folder": "Désolé, impossible d'ouvrir le dossier",
"unable to save file": "Désolé, impossible d'enregistrer le fichier",
diff --git a/src/lang/he-il.json b/src/lang/he-il.json
index 35b7a716c..358b04abc 100644
--- a/src/lang/he-il.json
+++ b/src/lang/he-il.json
@@ -90,6 +90,9 @@
"theme": "עיצוב",
"title-listfiles": "רשימת קבצים",
"unable to delete file": "לא ניתן למחוק קובץ",
+ "document plugin required": "התקינו או הפעילו את Docs Workspace בתוספים, ולאחר מכן פתחו שוב את המסמך.",
+ "shared files unavailable": "לא ניתן לפתוח את הקבצים האלה. ייתכן שהם אינם נתמכים או שאינם נגישים עוד. נסו לשתף אותם שוב.",
+ "invalid shared file": "קובץ משותף לא תקין",
"unable to open file": "מצטערים, לא הצלחנו לפתוח את הקובץ",
"unable to open folder": "מצטערים, לא הצלחנו לפתוח את התיקיה",
"unable to save file": "מצטערים, לא הצלחנו לשמור את הקובץ",
diff --git a/src/lang/hi-in.json b/src/lang/hi-in.json
index f16dae7b2..6aa97f71a 100644
--- a/src/lang/hi-in.json
+++ b/src/lang/hi-in.json
@@ -88,6 +88,9 @@
"theme": "थीम",
"title-listfiles": "फ़ाइलें सूचीबद्ध करें",
"unable to delete file": "फाइल डिलीट नहीं हो पा रहा है",
+ "document plugin required": "प्लगिन्स में Docs Workspace इंस्टॉल या चालू करें, फिर दस्तावेज़ दोबारा खोलें।",
+ "shared files unavailable": "ये फ़ाइलें खोली नहीं जा सकीं। हो सकता है कि ये समर्थित न हों या अब उपलब्ध न हों। इन्हें दोबारा साझा करने का प्रयास करें।",
+ "invalid shared file": "साझा की गई फ़ाइल अमान्य है",
"unable to open file": "क्षमा करें, फ़ाइल खोलने में असमर्थ",
"unable to open folder": "क्षमा करें, फ़ोल्डर खोलने में असमर्थ",
"unable to save file": "क्षमा करें, फ़ाइल सेव करने में असमर्थ",
diff --git a/src/lang/hu-hu.json b/src/lang/hu-hu.json
index 6db53dfca..f2865688a 100644
--- a/src/lang/hu-hu.json
+++ b/src/lang/hu-hu.json
@@ -90,6 +90,9 @@
"theme": "Téma",
"title-listfiles": "Fájlok listázása",
"unable to delete file": "Nem lehet törölni a fájlt",
+ "document plugin required": "Telepítse vagy engedélyezze a Docs Workspace bővítményt a Bővítmények között, majd nyissa meg újra a dokumentumot.",
+ "shared files unavailable": "Ezeket a fájlokat nem sikerült megnyitni. Lehet, hogy nem támogatottak, vagy már nem érhetők el. Próbálja meg újra megosztani őket.",
+ "invalid shared file": "Érvénytelen megosztott fájl",
"unable to open file": "Nem lehet megnyitni a fájlt",
"unable to open folder": "Nem lehet megnyitni a mappát",
"unable to save file": "Nem lehet menteni a fájlt",
diff --git a/src/lang/id-id.json b/src/lang/id-id.json
index f7dceb77f..e21a31590 100644
--- a/src/lang/id-id.json
+++ b/src/lang/id-id.json
@@ -90,6 +90,9 @@
"theme": "Tema",
"title-listfiles": "Daftar berkas",
"unable to delete file": "Tidak dapat menghapus berkas",
+ "document plugin required": "Pasang atau aktifkan Docs Workspace di Plugin, lalu buka kembali dokumen.",
+ "shared files unavailable": "Berkas-berkas ini tidak dapat dibuka. Formatnya mungkin tidak didukung atau berkas sudah tidak dapat diakses. Coba bagikan kembali.",
+ "invalid shared file": "Berkas yang dibagikan tidak valid",
"unable to open file": "Maaf, tidak dapat membuka berkas",
"unable to open folder": "Maaf, tidak dapat membuka folder",
"unable to save file": "Maaf, tidak dapat menyimpan berkas",
diff --git a/src/lang/index.d.ts b/src/lang/index.d.ts
index 7873fb8b6..59076bc1b 100644
--- a/src/lang/index.d.ts
+++ b/src/lang/index.d.ts
@@ -104,6 +104,9 @@ declare type LangStrings = {
"title-listfiles": string;
"ui zoom": string;
"unable to delete file": string;
+ "document plugin required": string;
+ "shared files unavailable": string;
+ "invalid shared file": string;
"unable to open file": string;
"unable to open folder": string;
"unable to save file": string;
diff --git a/src/lang/ir-fa.json b/src/lang/ir-fa.json
index 8a1db6e17..07f6c056b 100644
--- a/src/lang/ir-fa.json
+++ b/src/lang/ir-fa.json
@@ -90,6 +90,9 @@
"theme": "تم",
"title-listfiles": "List files",
"unable to delete file": "نمیتوانم فایل را حذف کنم",
+ "document plugin required": "Docs Workspace را در بخش Plugins نصب یا فعال کنید، سپس سند را دوباره باز کنید.",
+ "shared files unavailable": "این فایلها باز نشدند. ممکن است پشتیبانی نشوند یا دیگر در دسترس نباشند. دوباره آنها را به اشتراک بگذارید.",
+ "invalid shared file": "فایل اشتراکگذاریشده نامعتبر است",
"unable to open file": "متأسفم ، نمیتوانم فایل را باز کنم",
"unable to open folder": "متأسفم ، نمیتوانم پوشه را باز کنم",
"unable to save file": "متأسفم ، نمیتوانم فایل را ذخیره کنم",
diff --git a/src/lang/it-it.json b/src/lang/it-it.json
index 19ddbba94..efe7c90e3 100644
--- a/src/lang/it-it.json
+++ b/src/lang/it-it.json
@@ -90,6 +90,9 @@
"theme": "tema",
"title-listfiles": "List files",
"unable to delete file": "non è stato possibile eliminare il file",
+ "document plugin required": "Installa o abilita Docs Workspace in Plugins, quindi riapri il documento.",
+ "shared files unavailable": "Impossibile aprire questi file. Potrebbero non essere supportati o non essere più accessibili. Prova a condividerli di nuovo.",
+ "invalid shared file": "File condiviso non valido",
"unable to open file": "Ci spiace. non è stato possibile aprire il file",
"unable to open folder": "Ci spiace. non è stato possibile aprire la cartella",
"unable to save file": "Ci spiace. non è stato possibile salvare il file",
diff --git a/src/lang/ja-jp.json b/src/lang/ja-jp.json
index 03ec6d495..27fd7b603 100644
--- a/src/lang/ja-jp.json
+++ b/src/lang/ja-jp.json
@@ -90,6 +90,9 @@
"theme": "テーマ",
"title-listfiles": "ファイル一覧",
"unable to delete file": "ファイルを削除できません",
+ "document plugin required": "「プラグイン」で Docs Workspace をインストールまたは有効にしてから、ドキュメントを開き直してください。",
+ "shared files unavailable": "これらのファイルを開けませんでした。対応していない形式か、アクセスできなくなっている可能性があります。もう一度共有してみてください。",
+ "invalid shared file": "共有されたファイルが無効です",
"unable to open file": "ファイルを開けません",
"unable to open folder": "フォルダを開けません",
"unable to save file": "ファイルを保存できません",
diff --git a/src/lang/ko-kr.json b/src/lang/ko-kr.json
index fd76716b4..74156bbda 100644
--- a/src/lang/ko-kr.json
+++ b/src/lang/ko-kr.json
@@ -90,6 +90,9 @@
"theme": "배경",
"title-listfiles": "List files",
"unable to delete file": "파일을 삭제할 수 없습니다",
+ "document plugin required": "Plugins에서 Docs Workspace를 설치하거나 활성화한 다음 문서를 다시 여세요.",
+ "shared files unavailable": "이 파일들을 열 수 없습니다. 지원되지 않는 형식이거나 더 이상 접근할 수 없는 파일일 수 있습니다. 다시 공유해 보세요.",
+ "invalid shared file": "공유된 파일이 올바르지 않습니다",
"unable to open file": "파일을 열수가 없습니다",
"unable to open folder": "폴더를 열수가 없습니다",
"unable to save file": "파일을 저장할 수 없습니다.",
diff --git a/src/lang/ln-ln.json b/src/lang/ln-ln.json
index a0de1f1e1..63d4e9ce7 100644
--- a/src/lang/ln-ln.json
+++ b/src/lang/ln-ln.json
@@ -88,6 +88,9 @@
"text wrap": "Kokanga milongo ya makomi",
"theme": "Motema ya app",
"title-listfiles": "Tanga ba fichiers",
+ "document plugin required": "Tyá to fungolá Docs Workspace na Ba plugins, mpe fungolá lisusu mokanda.",
+ "shared files unavailable": "Ba fichiers oyo ekoki kofungwama te. Mbala mosusu lolenge na yango esungami te to ekoki lisusu kozwama te. Meká kokabola yango lisusu.",
+ "invalid shared file": "Fichier oyo ekabolami ezali malamu te",
"unable to open file": "Limbisa, ekoki te kofungola fichier",
"unable to open folder": "Limbisa, ekoki te kofungola dossier",
"unable to save file": "Limbisa, ekoki te kobomba fichier",
diff --git a/src/lang/ml-in.json b/src/lang/ml-in.json
index 4b1af01ee..04db79163 100644
--- a/src/lang/ml-in.json
+++ b/src/lang/ml-in.json
@@ -90,6 +90,9 @@
"theme": "പതിപാദം",
"title-listfiles": "ഫയലുകൾ ലിസ്റ്റ് ചെയ്യുക",
"unable to delete file": "ഫയൽ ഇല്ലാതാക്കാൻ കഴിയില്ല",
+ "document plugin required": "പ്ലഗിൻസ് വിഭാഗത്തിൽ Docs Workspace ഇൻസ്റ്റാൾ ചെയ്യുകയോ പ്രവർത്തനക്ഷമമാക്കുകയോ ചെയ്ത ശേഷം പ്രമാണം വീണ്ടും തുറക്കുക.",
+ "shared files unavailable": "ഈ ഫയലുകൾ തുറക്കാൻ കഴിഞ്ഞില്ല. ഇവ പിന്തുണയ്ക്കാത്തവയോ ഇനി ആക്സസ് ചെയ്യാൻ കഴിയാത്തവയോ ആയിരിക്കാം. വീണ്ടും പങ്കിടാൻ ശ്രമിക്കുക.",
+ "invalid shared file": "പങ്കിട്ട ഫയൽ അസാധുവാണ്",
"unable to open file": "ക്ഷമിക്കണം, ഫയൽ തുറക്കാൻ കഴിഞ്ഞില്ല",
"unable to open folder": "ക്ഷമിക്കണം, ഫോൾഡർ തുറക്കാൻ കഴിഞ്ഞില്ല",
"unable to save file": "ക്ഷമിക്കണം, ഫയൽ സംരക്ഷിക്കാൻ കഴിഞ്ഞില്ല",
diff --git a/src/lang/mm-unicode.json b/src/lang/mm-unicode.json
index 74364aff0..6411d5b9b 100644
--- a/src/lang/mm-unicode.json
+++ b/src/lang/mm-unicode.json
@@ -90,6 +90,9 @@
"theme": "theme",
"title-listfiles": "List files",
"unable to delete file": "ဖိုင်ဖျက်လို့မရပါ",
+ "document plugin required": "Plugins တွင် Docs Workspace ကို ထည့်သွင်းပါ သို့မဟုတ် ဖွင့်ထားပါ။ ထို့နောက် စာရွက်စာတမ်းကို ပြန်ဖွင့်ပါ။",
+ "shared files unavailable": "ဤဖိုင်များကို ဖွင့်၍မရပါ။ ပံ့ပိုးမထားသော ဖိုင်အမျိုးအစားများ ဖြစ်နိုင်သည် သို့မဟုတ် ဝင်ရောက်အသုံးပြု၍ မရတော့ခြင်း ဖြစ်နိုင်သည်။ ထပ်မံမျှဝေကြည့်ပါ။",
+ "invalid shared file": "မျှဝေထားသော ဖိုင် မမှန်ကန်ပါ",
"unable to open file": "ဝမ်းနည်းပါတယ်။ဖိုင်ဖွင့်မရပါ။",
"unable to open folder": "ဝမ်းနည်းပါတယ်။Folderဖွင့်မရပါ။",
"unable to save file": "ဝမ်းနည်းပါတယ်။ဖိုင်သိမ်းမရပါ။",
diff --git a/src/lang/mm-zawgyi.json b/src/lang/mm-zawgyi.json
index d1f72c1e8..dd8f456bf 100644
--- a/src/lang/mm-zawgyi.json
+++ b/src/lang/mm-zawgyi.json
@@ -90,6 +90,9 @@
"theme": "theme",
"title-listfiles": "List files",
"unable to delete file": "ဖိုင္ဖ်က္လို႔မရပါ",
+ "document plugin required": "Plugins တြင္ Docs Workspace ကို ထည့္သြင္းပါ သို႔မဟုတ္ ဖြင့္ထားပါ။ ထို႔ေနာက္ စာ႐ြက္စာတမ္းကို ျပန္ဖြင့္ပါ။",
+ "shared files unavailable": "ဤဖိုင္မ်ားကို ဖြင့္၍မရပါ။ ပံ့ပိုးမထားေသာ ဖိုင္အမ်ိဳးအစားမ်ား ျဖစ္ႏိုင္သည္ သို႔မဟုတ္ ဝင္ေရာက္အသုံးျပဳ၍ မရေတာ့ျခင္း ျဖစ္ႏိုင္သည္။ ထပ္မံမွ်ေဝၾကည့္ပါ။",
+ "invalid shared file": "မွ်ေဝထားေသာ ဖိုင္ မမွန္ကန္ပါ",
"unable to open file": "ဝမ္းနည္းပါတယ္။ဖိုင္ဖြင့္မရပါ။",
"unable to open folder": "ဝမ္းနည္းပါတယ္။Folderဖြင့္မရပါ။",
"unable to save file": "ဝမ္းနည္းပါတယ္။ဖိုင္သိမ္းမရပါ။",
diff --git a/src/lang/pl-pl.json b/src/lang/pl-pl.json
index e1729da06..d5b418a9e 100644
--- a/src/lang/pl-pl.json
+++ b/src/lang/pl-pl.json
@@ -90,6 +90,9 @@
"theme": "Motyw",
"title-listfiles": "Lista plików",
"unable to delete file": "nie można usunąć pliku",
+ "document plugin required": "Zainstaluj lub włącz Docs Workspace w sekcji Wtyczki, a następnie ponownie otwórz dokument.",
+ "shared files unavailable": "Nie udało się otworzyć tych plików. Mogą być nieobsługiwane lub już niedostępne. Spróbuj udostępnić je ponownie.",
+ "invalid shared file": "Nieprawidłowy udostępniony plik",
"unable to open file": "Nie udało się otworzyć pliku",
"unable to open folder": "Nie udało się otworzyć folderu",
"unable to save file": "Nie udało się zapisać pliku",
diff --git a/src/lang/pt-br.json b/src/lang/pt-br.json
index 6a7516d6c..aa6de4b0e 100644
--- a/src/lang/pt-br.json
+++ b/src/lang/pt-br.json
@@ -90,6 +90,9 @@
"theme": "Tema",
"title-listfiles": "Listar arquivos",
"unable to delete file": "não foi possível excluir o arquivo",
+ "document plugin required": "Instale ou ative o Docs Workspace em Plugins e abra o documento novamente.",
+ "shared files unavailable": "Não foi possível abrir estes arquivos. Eles podem não ser compatíveis ou não estar mais acessíveis. Tente compartilhá-los novamente.",
+ "invalid shared file": "Arquivo compartilhado inválido",
"unable to open file": "Desculpe, não foi possível abrir o arquivo",
"unable to open folder": "Desculpe, não foi possível abrir a pasta",
"unable to save file": "Desculpe, não foi possível salvar o arquivo",
diff --git a/src/lang/pu-in.json b/src/lang/pu-in.json
index 70ac968a6..05b389b8c 100644
--- a/src/lang/pu-in.json
+++ b/src/lang/pu-in.json
@@ -90,6 +90,9 @@
"theme": "ਥੀਮ",
"title-listfiles": "List files",
"unable to delete file": "ਫਾਈਲ ਨੂੰ ਮਿਟਾਉਣ ਵਿੱਚ ਅਸਮਰੱਥ",
+ "document plugin required": "ਪਲੱਗਇਨ ਵਿੱਚ Docs Workspace ਇੰਸਟਾਲ ਜਾਂ ਚਾਲੂ ਕਰੋ, ਫਿਰ ਦਸਤਾਵੇਜ਼ ਮੁੜ ਖੋਲ੍ਹੋ।",
+ "shared files unavailable": "ਇਹ ਫ਼ਾਈਲਾਂ ਖੋਲ੍ਹੀਆਂ ਨਹੀਂ ਜਾ ਸਕੀਆਂ। ਹੋ ਸਕਦਾ ਹੈ ਇਹ ਸਮਰਥਿਤ ਨਾ ਹੋਣ ਜਾਂ ਹੁਣ ਪਹੁੰਚਯੋਗ ਨਾ ਹੋਣ। ਇਨ੍ਹਾਂ ਨੂੰ ਮੁੜ ਸਾਂਝਾ ਕਰਨ ਦੀ ਕੋਸ਼ਿਸ਼ ਕਰੋ।",
+ "invalid shared file": "ਸਾਂਝੀ ਕੀਤੀ ਫ਼ਾਈਲ ਅਵੈਧ ਹੈ",
"unable to open file": "ਮਾਫ਼ ਕਰਨਾ, ਫ਼ਾਈਲ ਖੋਲ੍ਹਣ ਵਿੱਚ ਅਸਮਰੱਥ",
"unable to open folder": "ਮਾਫ਼ ਕਰਨਾ, ਫੋਲਡਰ ਖੋਲ੍ਹਣ ਵਿੱਚ ਅਸਮਰੱਥ",
"unable to save file": "ਮਾਫ਼ ਕਰਨਾ, ਫ਼ਾਈਲ ਨੂੰ ਸੁਰੱਖਿਅਤ ਕਰਨ ਵਿੱਚ ਅਸਮਰੱਥ",
diff --git a/src/lang/ru-ru.json b/src/lang/ru-ru.json
index 0d61d611b..79e83dddb 100644
--- a/src/lang/ru-ru.json
+++ b/src/lang/ru-ru.json
@@ -90,6 +90,9 @@
"theme": "Тема",
"title-listfiles": "Список файлов",
"unable to delete file": "Невозможно удалить файл",
+ "document plugin required": "Установите или включите Docs Workspace в разделе «Плагины», затем откройте документ снова.",
+ "shared files unavailable": "Не удалось открыть эти файлы. Возможно, они не поддерживаются или больше недоступны. Попробуйте поделиться ими ещё раз.",
+ "invalid shared file": "Недопустимый переданный файл",
"unable to open file": "Невозможно открыть файл",
"unable to open folder": "Невозможно открыть папку",
"unable to save file": "Невозможно сохранить файл",
diff --git a/src/lang/tl-ph.json b/src/lang/tl-ph.json
index bb7b2ac74..b5a5424fc 100644
--- a/src/lang/tl-ph.json
+++ b/src/lang/tl-ph.json
@@ -90,6 +90,9 @@
"theme": "Tema",
"title-listfiles": "Ilista ang mga file",
"unable to delete file": "hindi ma-delete ang file",
+ "document plugin required": "I-install o i-enable ang Docs Workspace sa Plugins, pagkatapos ay buksan muli ang dokumento.",
+ "shared files unavailable": "Hindi mabuksan ang mga file na ito. Maaaring hindi suportado ang mga ito o hindi na ma-access. Subukang ibahagi muli ang mga ito.",
+ "invalid shared file": "Hindi valid ang ibinahaging file",
"unable to open file": "Paumanhin, hindi ma-open ang file",
"unable to open folder": "Paumanhin, hindi ma-open ang folder",
"unable to save file": "Paumanhin, hindi ma-save ang file",
diff --git a/src/lang/tr-tr.json b/src/lang/tr-tr.json
index de2f19aae..0d723b7ff 100644
--- a/src/lang/tr-tr.json
+++ b/src/lang/tr-tr.json
@@ -90,6 +90,9 @@
"theme": "Tema",
"title-listfiles": "List files",
"unable to delete file": "Dosya silinemedi",
+ "document plugin required": "Plugins bölümünden Docs Workspace'i yükleyin veya etkinleştirin, ardından belgeyi yeniden açın.",
+ "shared files unavailable": "Bu dosyalar açılamadı. Desteklenmiyor veya artık erişilemiyor olabilirler. Yeniden paylaşmayı deneyin.",
+ "invalid shared file": "Paylaşılan dosya geçersiz",
"unable to open file": "Dosya açılamadı",
"unable to open folder": "Klasör açılamadı",
"unable to save file": "Dosya kaydedilemedi",
diff --git a/src/lang/uk-ua.json b/src/lang/uk-ua.json
index 43f8a786a..ab5da7210 100644
--- a/src/lang/uk-ua.json
+++ b/src/lang/uk-ua.json
@@ -90,6 +90,9 @@
"theme": "Тема",
"title-listfiles": "List files",
"unable to delete file": "не можливо видалити файл",
+ "document plugin required": "Установіть або ввімкніть Docs Workspace у розділі «Плагіни», а потім знову відкрийте документ.",
+ "shared files unavailable": "Не вдалося відкрити ці файли. Можливо, вони не підтримуються або більше недоступні. Спробуйте поділитися ними ще раз.",
+ "invalid shared file": "Недійсний надісланий файл",
"unable to open file": "Вибачте, не можливо відкрити файл",
"unable to open folder": "Вибачте, не можливо відкрити теку",
"unable to save file": "Вибачте, не можливо зберегти файл",
diff --git a/src/lang/uz-uz.json b/src/lang/uz-uz.json
index 8ecb20a89..986916b7e 100644
--- a/src/lang/uz-uz.json
+++ b/src/lang/uz-uz.json
@@ -90,6 +90,9 @@
"theme": "Mavzu",
"title-listfiles": "List files",
"unable to delete file": "faylni o'chirib bo'lmadi",
+ "document plugin required": "Plugins bo‘limida Docs Workspace’ni o‘rnating yoki yoqing, so‘ng hujjatni qayta oching.",
+ "shared files unavailable": "Bu fayllarni ochib bo‘lmadi. Ular qo‘llab-quvvatlanmasligi yoki endi ulardan foydalanib bo‘lmasligi mumkin. Ularni qayta ulashishga urinib ko‘ring.",
+ "invalid shared file": "Ulashilgan fayl yaroqsiz",
"unable to open file": "Kechirasiz,faylni ochib bo'lmadi",
"unable to open folder": "Kechirasiz,papkani ochib bo'lmadi",
"unable to save file": "Kechirasiz,faylni saqlab bo'lmadi",
diff --git a/src/lang/vi-vn.json b/src/lang/vi-vn.json
index ce61c46ac..64441eb46 100644
--- a/src/lang/vi-vn.json
+++ b/src/lang/vi-vn.json
@@ -90,6 +90,9 @@
"theme": "Chủ đề",
"title-listfiles": "Liệt kê tệp",
"unable to delete file": "không thể xóa tệp",
+ "document plugin required": "Cài đặt hoặc bật Docs Workspace trong Plugins, rồi mở lại tài liệu.",
+ "shared files unavailable": "Không thể mở các tệp này. Chúng có thể không được hỗ trợ hoặc không còn truy cập được. Hãy thử chia sẻ lại.",
+ "invalid shared file": "Tệp được chia sẻ không hợp lệ",
"unable to open file": "Xin lỗi, không thể mở tệp",
"unable to open folder": "Xin lỗi, không thể mở thư mục",
"unable to save file": "Xin lỗi, không thể lưu tệp",
diff --git a/src/lang/zh-cn.json b/src/lang/zh-cn.json
index d0b7a0604..d6c1737b6 100644
--- a/src/lang/zh-cn.json
+++ b/src/lang/zh-cn.json
@@ -90,6 +90,9 @@
"theme": "主题",
"title-listfiles": "列出文件",
"unable to delete file": "无法删除文件",
+ "document plugin required": "请在“插件”中安装或启用 Docs Workspace,然后重新打开文档。",
+ "shared files unavailable": "无法打开这些文件。它们可能不受支持或已无法访问。请尝试重新分享。",
+ "invalid shared file": "分享的文件无效",
"unable to open file": "无法打开文件",
"unable to open folder": "无法打开文件夹",
"unable to save file": "无法保存文件",
diff --git a/src/lang/zh-hant.json b/src/lang/zh-hant.json
index b8dc2ae7d..6ad71367e 100644
--- a/src/lang/zh-hant.json
+++ b/src/lang/zh-hant.json
@@ -90,6 +90,9 @@
"theme": "主題",
"title-listfiles": "列出文件",
"unable to delete file": "無法刪除文件",
+ "document plugin required": "請在「插件」中安裝或啟用 Docs Workspace,然後重新開啟文件。",
+ "shared files unavailable": "無法開啟這些文件。它們可能不受支援或已無法存取。請嘗試重新分享。",
+ "invalid shared file": "分享的文件無效",
"unable to open file": "無法打開文件",
"unable to open folder": "無法打開文件夾",
"unable to save file": "無法保存文件",
diff --git a/src/lang/zh-tw.json b/src/lang/zh-tw.json
index 679abc6d3..c814718a2 100644
--- a/src/lang/zh-tw.json
+++ b/src/lang/zh-tw.json
@@ -90,6 +90,9 @@
"theme": "主題",
"title-listfiles": "列出檔案",
"unable to delete file": "無法刪除檔案",
+ "document plugin required": "請在「外掛」中安裝或啟用 Docs Workspace,然後重新開啟文件。",
+ "shared files unavailable": "無法開啟這些檔案。它們可能不受支援或已無法存取。請嘗試重新分享。",
+ "invalid shared file": "分享的檔案無效",
"unable to open file": "無法開啟檔案",
"unable to open folder": "無法開啟資料夾",
"unable to save file": "無法儲存檔案",
diff --git a/src/lib/acode.js b/src/lib/acode.js
index f36c7401a..2870fc0a9 100644
--- a/src/lib/acode.js
+++ b/src/lib/acode.js
@@ -56,6 +56,7 @@ import fileIndex from "lib/fileIndex";
import files from "lib/fileList";
import fileTypeHandler from "lib/fileTypeHandler";
import fonts from "lib/fonts";
+import fullscreen from "lib/fullscreen";
import {
BROKEN_PLUGINS,
LOADED_PLUGINS,
@@ -64,6 +65,7 @@ import {
} from "lib/loadPlugins";
import notificationManager from "lib/notificationManager";
import openFolder, { addedFolder } from "lib/openFolder";
+import orientation from "lib/orientation";
import projects from "lib/projects";
import selectionMenu from "lib/selectionMenu";
import appSettings from "lib/settings";
@@ -436,6 +438,8 @@ class Acode {
this.define("sidebarApps", sidebarAppsModule);
this.define("terminal", terminalModule);
this.define("webview", webview);
+ this.define("orientation", orientation);
+ this.define("fullscreen", fullscreen);
this.define("codemirror", codemirrorModule);
this.define("codeHighlight", codeHighlightModule);
this.define("@codemirror/autocomplete", cmAutocomplete);
diff --git a/src/lib/fullscreen.js b/src/lib/fullscreen.js
new file mode 100644
index 000000000..f2985c1ea
--- /dev/null
+++ b/src/lib/fullscreen.js
@@ -0,0 +1,113 @@
+/** Opt-in Android Back delivery for the current browser fullscreen owner. */
+let owner = fullscreenElement();
+let generation = 0;
+let registration = null;
+let requested = false;
+let queue = Promise.resolve();
+
+function fullscreenElement() {
+ let element = document.fullscreenElement;
+ while (element?.shadowRoot?.fullscreenElement) {
+ element = element.shadowRoot.fullscreenElement;
+ }
+ return element;
+}
+
+function enqueue(operation) {
+ const result = queue.then(operation);
+ queue = result.catch(() => {});
+ return result;
+}
+
+function setNativeHandler(enabled) {
+ return new Promise((resolve, reject) => {
+ cordova.exec(
+ resolve,
+ (message) => reject(new Error(message)),
+ "System",
+ "set-fullscreen-back-handler",
+ [enabled],
+ );
+ });
+}
+
+function updateOwner() {
+ const next = fullscreenElement();
+ if (next === owner) return;
+ owner = next;
+ generation++;
+ registration = null;
+ if (requested) {
+ requested = false;
+ // Finish any in-flight enable before releasing it. New registrations queue
+ // behind this release; an old acknowledgement cannot reinstall a callback.
+ enqueue(() => setNativeHandler(false)).catch(() => {});
+ }
+}
+
+document.addEventListener("fullscreenchange", updateOwner);
+document.addEventListener("fullscreenbackbutton", () => {
+ updateOwner();
+ const current = registration;
+ const currentOwner = owner;
+ const currentGeneration = generation;
+ const exit = () => {
+ updateOwner();
+ if (
+ currentOwner &&
+ owner === currentOwner &&
+ generation === currentGeneration &&
+ registration === current
+ ) {
+ Promise.resolve(document.exitFullscreen()).catch(() => {});
+ }
+ };
+ if (!current) return exit();
+ try {
+ Promise.resolve(current.callback()).catch(exit);
+ } catch {
+ exit();
+ }
+});
+
+export default {
+ /**
+ * Claim Back while fullscreen, or release it with null. The callback must
+ * explicitly exit fullscreen when desired. Native acceptance is asynchronous.
+ * @param {(() => void | Promise) | null} callback
+ * @returns {Promise}
+ */
+ async setBackHandler(callback) {
+ if (callback !== null && typeof callback !== "function") {
+ throw new TypeError("Back handler must be a function or null.");
+ }
+ updateOwner();
+ if (callback && !owner) {
+ throw new Error("Back handler requires fullscreen.");
+ }
+ const requestGeneration = generation;
+ const next = callback ? { callback } : null;
+ requested = true;
+ await enqueue(async () => {
+ updateOwner();
+ if (requestGeneration !== generation) {
+ if (callback) throw new Error("Fullscreen session changed.");
+ return;
+ }
+ const previous = registration;
+ // Back can arrive after native acceptance but before its acknowledgement.
+ registration = next;
+ try {
+ await setNativeHandler(!!callback);
+ } catch (error) {
+ updateOwner();
+ if (requestGeneration === generation) registration = previous;
+ throw error;
+ }
+ updateOwner();
+ if (callback && requestGeneration !== generation) {
+ throw new Error("Fullscreen session changed.");
+ }
+ });
+ },
+};
diff --git a/src/lib/openFile.js b/src/lib/openFile.js
index e4b6dee1e..c84cee1fe 100644
--- a/src/lib/openFile.js
+++ b/src/lib/openFile.js
@@ -27,6 +27,7 @@ let loadingFileCount = 0;
* @property {string} paneId
* @property {boolean} persistInSession
* @property {AbortSignal} signal Discard an obsolete open before activating its file.
+ * @property {boolean} external Receive an Android file intent; report failures to the batch and require document handlers.
*/
/**
@@ -146,6 +147,15 @@ export default async function openFile(file, options = {}) {
// Check for registered file handlers
const customHandler = fileTypeHandler.getFileHandler(name);
+ const needsDocumentHandler =
+ options.external &&
+ /\.(pdf|docx|dotx|xlsx|xls|ods|pptx|ppsx|potx)$/i.test(name);
+ if (needsDocumentHandler && !customHandler) {
+ throw Object.assign(new Error("Document handler unavailable"), {
+ code: "DOCUMENT_HANDLER_UNAVAILABLE",
+ filename: name,
+ });
+ }
if (customHandler) {
try {
await customHandler.handleFile({
@@ -167,6 +177,14 @@ export default async function openFile(file, options = {}) {
} catch (error) {
if (signal?.aborted) return;
console.error(`File handler '${customHandler.id}' failed:`, error);
+ if (options.external) {
+ throw Object.assign(
+ new Error("Document handler failed", { cause: error }),
+ {
+ filename: name,
+ },
+ );
+ }
// Continue with default handling if custom handler fails
}
}
@@ -436,6 +454,8 @@ export default async function openFile(file, options = {}) {
// Else open a new file
// Checks for valid file
if (fileInfo.length * 0.000001 > settings.maxFileSize) {
+ if (options.external)
+ throw Object.assign(new Error("File too large"), { filename: name });
return alert(
strings.error.toUpperCase(),
strings["file too large"].replace(
@@ -445,6 +465,13 @@ export default async function openFile(file, options = {}) {
);
}
+ if (
+ options.external &&
+ (helpers.isBinary(name) ||
+ helpers.isBinary({ name, mime: fileInfo.mime || fileInfo.type }))
+ ) {
+ throw Object.assign(new Error("Unsupported file"), { filename: name });
+ }
if (helpers.isBinary(uri)) {
const confirmation = await confirm(strings.info, strings["binary file"]);
if (!confirmation || signal?.aborted) return;
@@ -479,6 +506,7 @@ export default async function openFile(file, options = {}) {
if (mode !== "single") recents.addFile(uri);
return;
} catch (error) {
+ if (options.external && !signal?.aborted) throw error;
if (!signal?.aborted) console.error(error);
} finally {
releaseTitleLoader?.();
diff --git a/src/lib/orientation.js b/src/lib/orientation.js
new file mode 100644
index 000000000..a83336ed5
--- /dev/null
+++ b/src/lib/orientation.js
@@ -0,0 +1,30 @@
+/** Temporary orientation requests for the main WebView's fullscreen session. */
+export default {
+ /**
+ * @param {"landscape" | "portrait"} mode
+ * @returns {Promise} Resolves when the native request is accepted.
+ */
+ async lock(mode) {
+ if (mode !== "landscape" && mode !== "portrait") {
+ throw new TypeError("Orientation must be landscape or portrait.");
+ }
+ await setOrientation(mode);
+ },
+
+ /** @returns {Promise} Restores the previous policy, if overridden. */
+ async unlock() {
+ await setOrientation(null);
+ },
+};
+
+function setOrientation(mode) {
+ return new Promise((resolve, reject) => {
+ cordova.exec(
+ resolve,
+ (message) => reject(new Error(message)),
+ "System",
+ "set-fullscreen-orientation",
+ [mode],
+ );
+ });
+}
diff --git a/src/main.js b/src/main.js
index aef107b04..cf91423da 100644
--- a/src/main.js
+++ b/src/main.js
@@ -386,6 +386,8 @@ async function onDeviceReady() {
window.log("error", "Failed to load plugins!");
window.log("error", error);
toast("Failed to load plugins!");
+ } finally {
+ void processPendingIntents().catch(intentHandler.onError);
}
applySettings.afterRender();
diff --git a/src/plugins/system/android/com/foxdebug/system/System.java b/src/plugins/system/android/com/foxdebug/system/System.java
index 8011ce98b..58e9f5333 100644
--- a/src/plugins/system/android/com/foxdebug/system/System.java
+++ b/src/plugins/system/android/com/foxdebug/system/System.java
@@ -66,6 +66,7 @@
import org.apache.cordova.CordovaInterface;
import org.apache.cordova.CordovaPlugin;
import org.apache.cordova.CordovaWebView;
+import org.apache.cordova.CordovaWebViewImpl;
import org.apache.cordova.PluginResult;
import org.json.JSONArray;
import org.json.JSONException;
@@ -180,6 +181,28 @@ public boolean execute(
case "get-configuration":
getConfiguration(callbackContext);
return true;
+ case "set-fullscreen-back-handler":
+ final boolean fullscreenBackHandler = args.getBoolean(0);
+ activity.runOnUiThread(() -> {
+ try {
+ ((CordovaWebViewImpl) webView).setFullscreenBackHandler(fullscreenBackHandler);
+ callbackContext.success();
+ } catch (RuntimeException error) {
+ callbackContext.error(error.getMessage());
+ }
+ });
+ return true;
+ case "set-fullscreen-orientation":
+ final String orientation = args.isNull(0) ? null : args.getString(0);
+ activity.runOnUiThread(() -> {
+ try {
+ ((CordovaWebViewImpl) webView).setFullscreenOrientation(orientation);
+ callbackContext.success();
+ } catch (RuntimeException error) {
+ callbackContext.error(error.getMessage());
+ }
+ });
+ return true;
case "http-stream-start":
httpStreamStart(args, callbackContext);
return true;
@@ -2015,13 +2038,57 @@ private JSONObject getIntentJson(Intent intent) {
json.put("data", intent.getDataString());
json.put("type", intent.getType());
json.put("package", intent.getPackage());
+ json.put("uris", getIntentUris(intent));
json.put("extras", getExtrasJson(intent.getExtras()));
- } catch (JSONException e) {
+ } catch (JSONException | RuntimeException e) {
e.printStackTrace();
}
return json;
}
+ private JSONArray getIntentUris(Intent intent) {
+ Set uris = new LinkedHashSet<>();
+ String action = intent.getAction();
+ if (Intent.ACTION_VIEW.equals(action) || Intent.ACTION_EDIT.equals(action)) {
+ if (intent.getData() != null) uris.add(intent.getData());
+ } else if (!Intent.ACTION_SEND.equals(action) && !Intent.ACTION_SEND_MULTIPLE.equals(action)) {
+ return new JSONArray();
+ }
+ try {
+ Object stream = intent.getExtras() == null ? null : intent.getExtras().get(Intent.EXTRA_STREAM);
+ if (stream instanceof Uri) uris.add((Uri) stream);
+ else if (stream instanceof ArrayList>) {
+ for (Object item : (ArrayList>) stream) {
+ if (item instanceof Uri) uris.add((Uri) item);
+ }
+ }
+ } catch (RuntimeException error) {
+ Log.w(TAG, "Unable to read shared streams", error);
+ }
+ ClipData clip = intent.getClipData();
+ if (clip != null) {
+ for (int i = 0; i < clip.getItemCount(); i++) {
+ Uri uri = clip.getItemAt(i).getUri();
+ if (uri != null) uris.add(uri);
+ }
+ }
+ JSONArray result = new JSONArray();
+ for (Uri uri : uris) {
+ if (!"content".equals(uri.getScheme()) && !"file".equals(uri.getScheme())) continue;
+ result.put(uri.toString());
+ int grants = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+ if ("content".equals(uri.getScheme()) && grants != 0 &&
+ (intent.getFlags() & Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) != 0) {
+ try {
+ context.getContentResolver().takePersistableUriPermission(uri, grants);
+ } catch (SecurityException | IllegalArgumentException ignored) {
+ // Temporary access remains valid when a provider cannot persist the grant.
+ }
+ }
+ }
+ return result;
+ }
+
private JSONObject getExtrasJson(Bundle extras) {
JSONObject json = new JSONObject();
if (extras != null) {
@@ -2042,7 +2109,7 @@ private JSONObject getExtrasJson(Bundle extras) {
json.put(key, (Boolean) value);
} else if (value instanceof Bundle) {
json.put(key, getExtrasJson((Bundle) value));
- } else {
+ } else if (value != null) {
json.put(key, value.toString());
}
} catch (JSONException e) {
diff --git a/src/plugins/system/system.d.ts b/src/plugins/system/system.d.ts
index 8100a2b99..ed15e281e 100644
--- a/src/plugins/system/system.d.ts
+++ b/src/plugins/system/system.d.ts
@@ -28,6 +28,7 @@ interface FileShortcut {
}
interface Intent {
+ uris?: string[];
action: string;
data: string;
type: string;
diff --git a/src/utils/binaryExtensions.js b/src/utils/binaryExtensions.js
index a255ed6db..2a3e69c22 100644
--- a/src/utils/binaryExtensions.js
+++ b/src/utils/binaryExtensions.js
@@ -243,6 +243,7 @@ const textExtensionSet = new Set([
"toml",
"ts",
"tsx",
+ "tsv",
"txt",
"vue",
"xml",
@@ -294,8 +295,11 @@ const binaryMimeTypes = new Set([
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.oasis.opendocument.text",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ "application/vnd.openxmlformats-officedocument.presentationml.slideshow",
+ "application/vnd.openxmlformats-officedocument.presentationml.template",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.template",
"application/wasm",
"application/x-7z-compressed",
"application/x-ace-compressed",
diff --git a/tests/unit/fileIntents.test.js b/tests/unit/fileIntents.test.js
new file mode 100644
index 000000000..1e156f290
--- /dev/null
+++ b/tests/unit/fileIntents.test.js
@@ -0,0 +1,179 @@
+// @vitest-environment happy-dom
+import { afterEach, expect, it, vi } from "vitest";
+import strings from "../../src/lang/en-us.json";
+import { loadSourceModule } from "../helpers/loadSourceModule";
+
+afterEach(() => vi.restoreAllMocks());
+
+function setup() {
+ let restored = false,
+ pluginsReady = false;
+ const open = vi.fn(async () => {});
+ const select = vi.fn(async () => "close");
+ const exec = vi.fn();
+ const reportError = vi.fn();
+ const handler = loadSourceModule(
+ "src/handlers/intent.js",
+ {
+ fileSystem: {},
+ "lib/auth": {},
+ "lib/config": {},
+ "lib/startAd": {},
+ "lib/openFile": open,
+ "lib/loadPlugins": { isInitialPluginLoadComplete: () => pluginsReady },
+ "dialogs/select": select,
+ "utils/helpers": { error: reportError },
+ },
+ {
+ document,
+ strings,
+ acode: { exec },
+ sessionStorage: { getItem: () => String(restored) },
+ },
+ );
+ return {
+ ...handler,
+ open,
+ select,
+ exec,
+ reportError,
+ ready(files = true, plugins = true) {
+ restored = files;
+ pluginsReady = plugins;
+ },
+ send(uris, action = "SEND_MULTIPLE") {
+ return handler.default({
+ action: `android.intent.action.${action}`,
+ uris,
+ });
+ },
+ };
+}
+
+it("queues cold-start and in-startup batches until both files and plugins are ready", async () => {
+ const f = setup();
+ await f.send(["content://docs/1"]);
+ f.ready(true, false);
+ await f.send(["content://docs/2"]);
+ await f.processPendingIntents();
+ expect(f.open).not.toHaveBeenCalled();
+ f.ready(false, true);
+ await f.processPendingIntents();
+ expect(f.open).not.toHaveBeenCalled();
+ f.ready();
+ await f.processPendingIntents();
+ expect(f.open.mock.calls.map(([uri]) => uri)).toEqual(["content://docs/1", "content://docs/2"]);
+ expect(f.open).toHaveBeenLastCalledWith("content://docs/2", {
+ mode: "single",
+ render: true,
+ persistInSession: false,
+ external: true,
+ });
+});
+
+it("serializes warm batches and deduplicates stream/ClipData URIs without losing order", async () => {
+ const f = setup();
+ f.ready();
+ let finish;
+ f.open.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ const first = f.send(["content://docs/1", "content://docs/1", "file:///second.pdf"]);
+ const second = f.send(["content://docs/3"], "SEND");
+ expect(f.open).toHaveBeenCalledTimes(1);
+ finish();
+ await Promise.all([first, second]);
+ expect(f.open.mock.calls.map(([uri]) => uri)).toEqual([
+ "content://docs/1",
+ "file:///second.pdf",
+ "content://docs/3",
+ ]);
+});
+
+it("accepts legacy single streams and VIEW/EDIT data and leaves deep links on their existing route", async () => {
+ const f = setup();
+ f.ready();
+ await f.default({
+ action: "android.intent.action.SEND",
+ extras: { "android.intent.extra.STREAM": "content://docs/one" },
+ });
+ for (const action of ["VIEW", "EDIT"])
+ await f.default({
+ action: `android.intent.action.${action}`,
+ data: "content://docs/opaque",
+ });
+ const link = vi.fn((event) => event.preventDefault());
+ f.addIntentHandler(link);
+ await f.default({
+ action: "android.intent.action.VIEW",
+ data: "acode://sample/open/id",
+ });
+ expect(link).toHaveBeenCalledWith(
+ expect.objectContaining({ module: "sample", action: "open", value: "id" }),
+ );
+ f.removeIntentHandler(link);
+ await f.default({
+ action: "android.intent.action.VIEW",
+ data: "acode://auth/callback",
+ });
+ await f.default({ action: "android.intent.action.MAIN" });
+ expect(f.open).toHaveBeenCalledTimes(3);
+ expect(f.select).not.toHaveBeenCalled();
+});
+
+it("continues after failures and offers Plugins once per batch, escaping provider names", async () => {
+ const f = setup();
+ f.ready();
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ f.open.mockRejectedValueOnce({
+ code: "DOCUMENT_HANDLER_UNAVAILABLE",
+ filename: "
.pdf",
+ });
+ f.open.mockRejectedValueOnce(new Error("Provider denied access"));
+ f.select.mockResolvedValueOnce("plugins");
+ await f.send([
+ "content://docs/1",
+ "content://docs/2",
+ "content://docs/3",
+ null,
+ 5,
+ "https://not-a-file.test",
+ ]);
+ expect(f.open).toHaveBeenCalledTimes(3);
+ expect(f.select).toHaveBeenCalledOnce();
+ const items = f.select.mock.calls[0][1];
+ expect(items[0].text).toContain("<img src=x>.pdf");
+ expect(items[0].text).toContain("content://docs/2");
+ expect(items.map((item) => item.value)).toEqual([undefined, "plugins", "close"]);
+ expect(f.exec).toHaveBeenCalledWith("open", "plugins");
+});
+
+it.each(["cancel", "reject"])(
+ "continues queued and later intents when the error dialog ends with %s",
+ async (outcome) => {
+ const f = setup();
+ f.ready();
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ f.open.mockRejectedValueOnce({
+ code: "DOCUMENT_HANDLER_UNAVAILABLE",
+ filename: "missing.docx",
+ });
+ f.select.mockImplementationOnce((_title, _items, options) => {
+ if (outcome === "reject") return Promise.reject(Error("Dialog failed"));
+ options.onCancel();
+ return new Promise(() => {});
+ });
+ await Promise.all([f.send(["content://docs/missing"]), f.send(["content://docs/queued"])]);
+ await f.send(["content://docs/next"]);
+ expect(f.open.mock.calls.map(([uri]) => uri)).toEqual([
+ "content://docs/missing",
+ "content://docs/queued",
+ "content://docs/next",
+ ]);
+ expect(f.reportError).toHaveBeenCalledTimes(outcome === "reject" ? 1 : 0);
+ expect(f.exec).not.toHaveBeenCalled();
+ },
+);
diff --git a/tests/unit/fullscreen.test.js b/tests/unit/fullscreen.test.js
new file mode 100644
index 000000000..c0f6ce643
--- /dev/null
+++ b/tests/unit/fullscreen.test.js
@@ -0,0 +1,215 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import orientation from "../../src/lib/orientation";
+
+let api, doc, exec;
+const flush = async () => {
+ for (let i = 0; i < 12; i++) await Promise.resolve();
+};
+const change = (owner) => {
+ doc.fullscreenElement = owner;
+ doc.dispatchEvent(new Event("fullscreenchange"));
+};
+const back = () => doc.dispatchEvent(new Event("fullscreenbackbutton"));
+
+beforeEach(async () => {
+ vi.resetModules();
+ doc = new EventTarget();
+ doc.fullscreenElement = null;
+ doc.exitFullscreen = vi.fn(async () => change(null));
+ exec = vi.fn((resolve) => resolve());
+ vi.stubGlobal("document", doc);
+ vi.stubGlobal("cordova", { exec });
+ api = (await import("../../src/lib/fullscreen.js")).default;
+});
+afterEach(() => vi.unstubAllGlobals());
+
+it("validates orientation requests and waits for native lock/unlock acceptance", async () => {
+ await expect(orientation.lock("any")).rejects.toThrow(TypeError);
+ expect(exec).not.toHaveBeenCalled();
+ exec.mockImplementation(() => {});
+ for (const mode of ["landscape", "portrait", null]) {
+ const settled = vi.fn();
+ const request = (mode ? orientation.lock(mode) : orientation.unlock()).then(settled);
+ const [success, , service, action, args] = exec.mock.lastCall;
+ expect([service, action, args]).toEqual(["System", "set-fullscreen-orientation", [mode]]);
+ await Promise.resolve();
+ expect(settled).not.toHaveBeenCalled();
+ success();
+ await request;
+ }
+ exec.mockImplementation((_success, error) => error("Native failure"));
+ await expect(orientation.lock("landscape")).rejects.toThrow("Native failure");
+ await expect(orientation.unlock()).rejects.toThrow("Native failure");
+});
+
+describe("fullscreen Back ownership", () => {
+ it("rejects invalid callbacks and registration outside fullscreen without native changes", async () => {
+ for (const value of [undefined, false, "pause", {}]) {
+ await expect(api.setBackHandler(value)).rejects.toThrow(TypeError);
+ }
+ await expect(api.setBackHandler(() => {})).rejects.toThrow(/requires fullscreen/);
+ expect(exec).not.toHaveBeenCalled();
+ await expect(api.setBackHandler(null)).resolves.toBeUndefined();
+ await expect(api.setBackHandler(null)).resolves.toBeUndefined();
+ expect(exec.mock.calls.map((call) => call.slice(2))).toEqual([
+ ["System", "set-fullscreen-back-handler", [false]],
+ ["System", "set-fullscreen-back-handler", [false]],
+ ]);
+ });
+
+ it("delivers repeated Back until explicit release without changing browser fullscreen", async () => {
+ const owner = {};
+ const callback = vi.fn();
+ change(owner);
+ await api.setBackHandler(callback);
+ back();
+ back();
+ back();
+ expect(callback).toHaveBeenCalledTimes(3);
+ expect(doc.fullscreenElement).toBe(owner);
+ expect(doc.exitFullscreen).not.toHaveBeenCalled();
+ await api.setBackHandler(null);
+ back();
+ expect(callback).toHaveBeenCalledTimes(3);
+ expect(doc.exitFullscreen).toHaveBeenCalledOnce();
+ await flush();
+ });
+
+ it("serializes replacement and release so late failure cannot clear a newer callback", async () => {
+ change({});
+ const old = vi.fn(),
+ next = vi.fn();
+ await api.setBackHandler(old);
+ let reject;
+ exec.mockImplementationOnce((_resolve, failure) => {
+ reject = failure;
+ });
+ const failed = api.setBackHandler(() => {});
+ const failure = expect(failed).rejects.toThrow("native failure");
+ const replacement = api.setBackHandler(next);
+ await flush();
+ expect(exec).toHaveBeenCalledTimes(2);
+ reject("native failure");
+ await failure;
+ await replacement;
+ back();
+ expect(next).toHaveBeenCalledOnce();
+ expect(old).not.toHaveBeenCalled();
+ await api.setBackHandler(null);
+ back();
+ expect(next).toHaveBeenCalledOnce();
+ await flush();
+ });
+
+ it("restores the previous registration if a replacement is rejected by native", async () => {
+ change({});
+ const old = vi.fn();
+ await api.setBackHandler(old);
+ exec.mockImplementationOnce((_resolve, reject) => reject("backgrounded"));
+ await expect(api.setBackHandler(() => {})).rejects.toThrow("backgrounded");
+ back();
+ expect(old).toHaveBeenCalledOnce();
+ });
+
+ it.each(["throw", "reject"])(
+ "exits the original session when a callback fails: %s",
+ async (kind) => {
+ change({});
+ await api.setBackHandler(() => {
+ if (kind === "throw") throw new Error("pause failed");
+ return Promise.reject(new Error("pause failed"));
+ });
+ back();
+ await flush();
+ expect(doc.exitFullscreen).toHaveBeenCalledOnce();
+ },
+ );
+
+ it.each(["replace", "reenter"])("ignores an async callback failure after %s", async (kind) => {
+ const owner = {};
+ change(owner);
+ let reject;
+ await api.setBackHandler(
+ () =>
+ new Promise((_resolve, failure) => {
+ reject = failure;
+ }),
+ );
+ back();
+ if (kind === "reenter") {
+ change(null);
+ change(owner);
+ }
+ const next = vi.fn();
+ await api.setBackHandler(next);
+ reject(new Error("obsolete callback"));
+ await flush();
+ expect(doc.exitFullscreen).not.toHaveBeenCalled();
+ back();
+ expect(next).toHaveBeenCalledOnce();
+ });
+
+ it("clears callbacks on owner changes including nested shadow fullscreen", async () => {
+ const child = {};
+ const shadowRoot = { fullscreenElement: child };
+ change({ shadowRoot });
+ const callback = vi.fn();
+ await api.setBackHandler(callback);
+ shadowRoot.fullscreenElement = {};
+ doc.dispatchEvent(new Event("fullscreenchange"));
+ await flush();
+ expect(exec.mock.calls.at(-1)[4]).toEqual([false]);
+ back();
+ expect(callback).not.toHaveBeenCalled();
+ expect(doc.exitFullscreen).toHaveBeenCalledOnce();
+ });
+
+ it.each(["resolve", "reject"])(
+ "cleans up pending registration after exit and %s, before a new owner registers",
+ async (result) => {
+ change({});
+ const old = vi.fn(),
+ queued = vi.fn(),
+ next = vi.fn();
+ let complete;
+ exec.mockImplementationOnce((resolve, reject) => {
+ complete = result === "resolve" ? resolve : reject;
+ });
+ const pending = api.setBackHandler(old);
+ const failure = expect(pending).rejects.toThrow();
+ const obsolete = api.setBackHandler(queued);
+ const obsoleteFailure = expect(obsolete).rejects.toThrow(/session changed/);
+ await flush();
+ change(null);
+ change({});
+ const fresh = api.setBackHandler(next);
+ complete("old request");
+ await failure;
+ await obsoleteFailure;
+ await fresh;
+ expect(exec.mock.calls.map((call) => call[4][0])).toEqual([true, false, true]);
+ back();
+ expect(next).toHaveBeenCalledOnce();
+ expect(old).not.toHaveBeenCalled();
+ expect(queued).not.toHaveBeenCalled();
+ },
+ );
+
+ it("invalidates a queued release on exit so it cannot release the next session", async () => {
+ change({});
+ let complete;
+ exec.mockImplementationOnce((resolve) => {
+ complete = resolve;
+ });
+ const pending = api.setBackHandler(() => {});
+ const failure = expect(pending).rejects.toThrow(/session changed/);
+ const release = api.setBackHandler(null);
+ await flush();
+ change(null);
+ change({});
+ const next = api.setBackHandler(() => {});
+ complete();
+ await Promise.all([failure, release, next]);
+ expect(exec.mock.calls.map((call) => call[4][0])).toEqual([true, false, true]);
+ });
+});
diff --git a/tests/unit/immersiveFullscreenPrepare.test.js b/tests/unit/immersiveFullscreenPrepare.test.js
new file mode 100644
index 000000000..66b790da5
--- /dev/null
+++ b/tests/unit/immersiveFullscreenPrepare.test.js
@@ -0,0 +1,131 @@
+import fs from "node:fs";
+import { createRequire } from "node:module";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+
+const require = createRequire(import.meta.url);
+const prepare = require("../../hooks/immersive-fullscreen.js");
+const cordovaSource = fs.readFileSync(
+ new URL(
+ "../../node_modules/cordova-android/framework/src/org/apache/cordova/CordovaWebViewImpl.java",
+ import.meta.url,
+ ),
+ "utf8",
+);
+const controllerSource = fs.readFileSync(
+ new URL("../../hooks/android/ImmersiveFullscreen.java", import.meta.url),
+ "utf8",
+);
+const roots = [];
+
+afterEach(() => {
+ for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
+});
+
+function fixture(source = cordovaSource) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "acode-fullscreen-"));
+ roots.push(root);
+ const native = path.join(root, "platforms/android/CordovaLib/src/org/apache/cordova");
+ const tracked = path.join(root, "hooks/android/ImmersiveFullscreen.java");
+ const generated = path.join(native, "ImmersiveFullscreen.java");
+ const webview = path.join(native, "CordovaWebViewImpl.java");
+ fs.mkdirSync(native, { recursive: true });
+ fs.mkdirSync(path.dirname(tracked), { recursive: true });
+ fs.writeFileSync(tracked, controllerSource);
+ fs.writeFileSync(webview, source);
+ return {
+ tracked,
+ generated,
+ webview,
+ run: () => prepare({ opts: { projectRoot: root, platforms: ["android"] } }),
+ read: () => fs.readFileSync(webview, "utf8"),
+ };
+}
+
+describe("Android immersive fullscreen preparation", () => {
+ it("wires fullscreen and orientation into the Cordova lifecycle in order", () => {
+ const f = fixture();
+ f.run();
+ const patched = f.read();
+ expect(fs.readFileSync(f.generated, "utf8")).toBe(controllerSource);
+ for (const [method, before, after] of [
+ ["showCustomView", "parent.bringToFront()", "immersiveFullscreen.enter(wrapperView)"],
+ ["hideCustomView", "immersiveFullscreen.exit()", "mCustomViewCallback.onCustomViewHidden()"],
+ ["handlePause", "immersiveFullscreen.pause()", "pluginManager.onPause"],
+ ["handleResume", "immersiveFullscreen.resume()", "pluginManager.onResume"],
+ ["onPageStarted", "immersiveFullscreen.unlockOrientation()", "pluginManager.onReset"],
+ ["handleDestroy", "hideCustomView()", "engine.destroy()"],
+ ]) {
+ const start = patched.indexOf(`public void ${method}(`);
+ expect(start).toBeGreaterThan(-1);
+ const body = patched.slice(start).split("\n }", 1)[0];
+ expect(body.indexOf(before)).toBeGreaterThan(-1);
+ expect(body.indexOf(after)).toBeGreaterThan(body.indexOf(before));
+ }
+ const keyUp = patched.indexOf("event.getAction() == KeyEvent.ACTION_UP");
+ const backEvent = patched.indexOf("fullscreenbackbutton");
+ expect(backEvent).toBeGreaterThan(keyUp);
+ expect(backEvent).toBeLessThan(patched.indexOf("hideCustomView();", keyUp));
+ });
+
+ it("is repeatable and refreshes the controller without altering upstream code", () => {
+ const f = fixture();
+ f.run();
+ const first = f.read();
+ fs.appendFileSync(f.tracked, "\n// updated native controller\n");
+ f.run();
+ expect(f.read()).toBe(first);
+ expect(fs.readFileSync(f.generated, "utf8")).toBe(fs.readFileSync(f.tracked, "utf8"));
+ expect(
+ first.replace(
+ /^[ \t]*\/\/ ACODE_FULLSCREEN_BEGIN ([a-z]+)\n[\s\S]*?^[ \t]*\/\/ ACODE_FULLSCREEN_END \1\n/gm,
+ "",
+ ),
+ ).toBe(cordovaSource);
+ });
+
+ it("accepts the existing Java hook's formatting and retains CRLF", async () => {
+ const formatted = await require("prettier").format(cordovaSource, {
+ plugins: [require.resolve("prettier-plugin-java")],
+ parser: "java",
+ tabWidth: 2,
+ printWidth: Number.POSITIVE_INFINITY,
+ endOfLine: "crlf",
+ });
+ const f = fixture(formatted);
+ f.run();
+ const first = f.read();
+ expect(first).toContain("\r\n");
+ expect(first.replace(/\r\n/g, "")).not.toContain("\n");
+ f.run();
+ expect(f.read()).toBe(first);
+ });
+
+ it.each([
+ ["missing", cordovaSource.replace("parent.bringToFront();", "")],
+ ["ambiguous", `${cordovaSource}\nparent.bringToFront();\n`],
+ ...[
+ "if (isBackButton && mCustomView != null)",
+ "pluginManager.onPause(keepRunning);",
+ "this.pluginManager.onResume(keepRunning);",
+ "pluginManager.onReset();",
+ ].map((anchor) => [anchor, cordovaSource.replaceAll(anchor, "// changed upstream")]),
+ ])("rejects incompatible anchors before writing: %s", (_kind, source) => {
+ const f = fixture(source);
+ expect(f.run).toThrow(/Acode fullscreen: expected one Cordova/);
+ expect(f.read()).toBe(source);
+ expect(fs.existsSync(f.generated)).toBe(false);
+ });
+
+ it("rejects a damaged prior patch without overwriting either generated file", () => {
+ const f = fixture();
+ f.run();
+ const damaged = f.read().replace("// ACODE_FULLSCREEN_END enter", "// missing end marker");
+ fs.writeFileSync(f.webview, damaged);
+ fs.writeFileSync(f.generated, "// existing controller");
+ expect(f.run).toThrow(/incomplete generated patch/);
+ expect(f.read()).toBe(damaged);
+ expect(fs.readFileSync(f.generated, "utf8")).toBe("// existing controller");
+ });
+});
diff --git a/tests/unit/openFileCancellation.test.js b/tests/unit/openFileCancellation.test.js
index 4f8cd26a3..231996249 100644
--- a/tests/unit/openFileCancellation.test.js
+++ b/tests/unit/openFileCancellation.test.js
@@ -1,6 +1,7 @@
import fs from "node:fs";
import ts from "typescript";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { isBinaryFile } from "../../src/utils/binaryExtensions";
// openFile contains app-specific JSX. Compile the actual module for this
// isolated test, supplying its Cordova/UI dependencies without booting the app.
@@ -72,7 +73,7 @@ describe("openFile cancellation", () => {
"palettes/changeEncoding": {},
"utils/encodings": { decode, detectEncoding },
"utils/helpers": {
- default: { getStatMtime: () => 0, isBinary: () => false },
+ default: { getStatMtime: () => 0, isBinary: isBinaryFile },
},
"./editorFile": { default: createEditor },
"./fileSessionPersistence": { promoteSessionPersistence: vi.fn() },
@@ -101,6 +102,142 @@ describe("openFile cancellation", () => {
expect(recents.addFile).toHaveBeenCalledWith("target");
});
+ it.each([
+ "pdf",
+ "docx",
+ "dotx",
+ "xlsx",
+ "xls",
+ "ods",
+ "pptx",
+ "ppsx",
+ "potx",
+ ])(
+ "requires a handler for external %s documents using the provider filename",
+ async (extension) => {
+ stat.mockResolvedValue({
+ name: `Document.${extension.toUpperCase()}`,
+ canWrite: false,
+ });
+ await expect(
+ openFile("content://provider/42", { external: true }),
+ ).rejects.toMatchObject({ code: "DOCUMENT_HANDLER_UNAVAILABLE" });
+ expect(readFile).not.toHaveBeenCalled();
+ expect(createEditor).not.toHaveBeenCalled();
+ expect(loaderVisible).toBe(false);
+ },
+ );
+
+ it("routes granted read-only documents and propagates handler failures instead of decoding bytes", async () => {
+ const handleFile = vi.fn(async () => {});
+ stat.mockResolvedValue({ name: "Shared.docx", canWrite: false });
+ handler.getFileHandler.mockReturnValue({ id: "docs", handleFile });
+ await openFile("content://provider/42", { external: true });
+ expect(handleFile).toHaveBeenCalledWith(
+ expect.objectContaining({ name: "Shared.docx", readOnly: true }),
+ );
+ handleFile.mockRejectedValueOnce(Error("Engine failed"));
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ await expect(
+ openFile("content://provider/42", { external: true }),
+ ).rejects.toMatchObject({ filename: "Shared.docx" });
+ expect(readFile).not.toHaveBeenCalled();
+ expect(createEditor).not.toHaveBeenCalled();
+ expect(loaderVisible).toBe(false);
+ });
+
+ it.each([
+ ["Quarterly report", "type", " Application/PDF; charset=binary "],
+ [
+ "Budget",
+ "mime",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ ],
+ [
+ "Letter",
+ "type",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.template",
+ ],
+ [
+ "Slides",
+ "mime",
+ "application/vnd.openxmlformats-officedocument.presentationml.slideshow",
+ ],
+ [
+ "Template",
+ "type",
+ "application/vnd.openxmlformats-officedocument.presentationml.template",
+ ],
+ ["archive.zip", "type", "text/plain"],
+ ])(
+ "rejects external binary %s before reading or decoding",
+ async (name, field, mime) => {
+ stat.mockResolvedValue({
+ name,
+ [field]: mime,
+ canWrite: true,
+ url: "content://provider/42",
+ });
+ await expect(
+ openFile("content://provider/42", { external: true }),
+ ).rejects.toMatchObject({
+ message: "Unsupported file",
+ filename: name,
+ });
+ expect(readFile).not.toHaveBeenCalled();
+ expect(decode).not.toHaveBeenCalled();
+ expect(createEditor).not.toHaveBeenCalled();
+ expect(loaderVisible).toBe(false);
+ },
+ );
+
+ it.each([
+ ["README", "text/plain"],
+ ["Makefile", undefined],
+ ["plain.txt", "application/octet-stream"],
+ ["plain.csv", "application/octet-stream"],
+ ["plain.tsv", "application/octet-stream"],
+ ])(
+ "retains the normal %s editor fallback for incoming files",
+ async (name, type) => {
+ stat.mockResolvedValue({ name, type, url: "content://provider/plain" });
+ await openFile("content://provider/plain", { external: true });
+ expect(createEditor).toHaveBeenCalledOnce();
+ expect(manager.activeFile.text).toBe("target text");
+ },
+ );
+
+ it("preserves handlers and internal opens for extensionless files with binary metadata", async () => {
+ stat.mockResolvedValue({
+ name: "Report",
+ type: "application/pdf",
+ canWrite: false,
+ });
+ const handleFile = vi.fn(async () => {});
+ handler.getFileHandler.mockReturnValueOnce({ id: "custom", handleFile });
+ await openFile("content://provider/report", { external: true });
+ expect(handleFile).toHaveBeenCalledWith(
+ expect.objectContaining({ name: "Report", readOnly: true }),
+ );
+ expect(readFile).not.toHaveBeenCalled();
+ expect(createEditor).not.toHaveBeenCalled();
+ await openFile("content://provider/report");
+ expect(createEditor).toHaveBeenCalledOnce();
+ });
+
+ it("reports expired grants and reuses already-open custom tabs without replacing their content", async () => {
+ stat.mockRejectedValueOnce(Error("Grant expired"));
+ await expect(
+ openFile("content://provider/42", { external: true }),
+ ).rejects.toThrow("Grant expired");
+ const existing = { makeActive: vi.fn() };
+ manager.getFile.mockReturnValueOnce(existing);
+ await openFile("content://provider/42", { external: true });
+ expect(existing.makeActive).toHaveBeenCalledOnce();
+ expect(stat).toHaveBeenCalledTimes(1);
+ expect(createEditor).not.toHaveBeenCalled();
+ });
+
it("does not activate an existing file with an already-aborted signal", async () => {
const file = { makeActive: vi.fn() };
manager.getFile.mockReturnValue(file);
diff --git a/tests/unit/sessionPersistence.test.js b/tests/unit/sessionPersistence.test.js
index e096148b6..823b1a249 100644
--- a/tests/unit/sessionPersistence.test.js
+++ b/tests/unit/sessionPersistence.test.js
@@ -25,12 +25,14 @@ vi.mock("cm/editorUtils", () => ({
})),
}));
vi.mock("dialogs/alert", () => ({ default: vi.fn() }));
+vi.mock("dialogs/select", () => ({ 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/loadPlugins", () => ({ isInitialPluginLoadComplete: () => true }));
vi.mock("lib/openFolder", () => ({
default: vi.fn(),
addedFolder: [],