From ac3d4cdbee645b31694b8ca098ba7d66accd3936 Mon Sep 17 00:00:00 2001 From: Ajit Kumar Date: Sun, 20 Sep 2026 18:17:53 +0530 Subject: [PATCH 1/3] feat: immersive fullscreen and orientation api --- config.xml | 17 ++ hooks/android/ImmersiveFullscreen.java | 221 ++++++++++++++++++ hooks/immersive-fullscreen.js | 154 ++++++++++++ src/handlers/intent.js | 128 +++++++--- src/lang/ar-ye.json | 3 + src/lang/az-az.json | 3 + src/lang/be-by.json | 3 + src/lang/bn-bd.json | 3 + src/lang/cs-cz.json | 3 + src/lang/de-de.json | 3 + src/lang/en-us.json | 3 + src/lang/es-sv.json | 3 + src/lang/fr-fr.json | 3 + src/lang/he-il.json | 3 + src/lang/hi-in.json | 3 + src/lang/hu-hu.json | 3 + src/lang/id-id.json | 3 + src/lang/index.d.ts | 3 + src/lang/ir-fa.json | 3 + src/lang/it-it.json | 3 + src/lang/ja-jp.json | 3 + src/lang/ko-kr.json | 3 + src/lang/ln-ln.json | 3 + src/lang/ml-in.json | 3 + src/lang/mm-unicode.json | 3 + src/lang/mm-zawgyi.json | 3 + src/lang/pl-pl.json | 3 + src/lang/pt-br.json | 3 + src/lang/pu-in.json | 3 + src/lang/ru-ru.json | 3 + src/lang/tl-ph.json | 3 + src/lang/tr-tr.json | 3 + src/lang/uk-ua.json | 3 + src/lang/uz-uz.json | 3 + src/lang/vi-vn.json | 3 + src/lang/zh-cn.json | 3 + src/lang/zh-hant.json | 3 + src/lang/zh-tw.json | 3 + src/lib/acode.js | 4 + src/lib/fullscreen.js | 113 +++++++++ src/lib/openFile.js | 28 +++ src/lib/orientation.js | 30 +++ src/main.js | 2 + .../android/com/foxdebug/system/System.java | 71 +++++- src/plugins/system/system.d.ts | 1 + src/utils/binaryExtensions.js | 4 + tests/unit/fileIntents.test.js | 179 ++++++++++++++ tests/unit/fullscreen.test.js | 215 +++++++++++++++++ tests/unit/immersiveFullscreenPrepare.test.js | 131 +++++++++++ tests/unit/openFileCancellation.test.js | 139 ++++++++++- tests/unit/sessionPersistence.test.js | 2 + 51 files changed, 1502 insertions(+), 39 deletions(-) create mode 100644 hooks/android/ImmersiveFullscreen.java create mode 100644 hooks/immersive-fullscreen.js create mode 100644 src/lib/fullscreen.js create mode 100644 src/lib/orientation.js create mode 100644 tests/unit/fileIntents.test.js create mode 100644 tests/unit/fullscreen.test.js create mode 100644 tests/unit/immersiveFullscreenPrepare.test.js diff --git a/config.xml b/config.xml index 946ace476a..0241142964 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 0000000000..28d9f8a9ab --- /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 0000000000..91557e3c38 --- /dev/null +++ b/hooks/immersive-fullscreen.js @@ -0,0 +1,154 @@ +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"; + + // Rebuild our blocks on every prepare, including when only the controller + // changes. No stale version flag and no duplicate fields or lifecycle calls. + 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, lines, 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}`, + ...lines, + `// ACODE_FULLSCREEN_END ${name}`, + ] + .map((line) => indent + line) + .join("\n"); + source = source.replace(anchor, (match) => + before ? `${block}\n${match}` : `${match}\n${block}`, + ); + } + + 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;", + "}", + "", + "// Called on the UI thread by Acode's System plugin. null releases the override.", + "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);", + "}", + ]); + insert( + "back", + /(?<=if \(isBackButton && mCustomView != null\) \{\n)^([ \t]*)hideCustomView\(\);\n[ \t]*return true;[ \t]*$/m, + [ + "if (fullscreenBackHandlerEnabled) {", + // CoreAndroid's message channel accepts only Cordova's built-in events. + ` 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, + ); + insert( + "destroy", + /^([ \t]*)engine\.destroy\(\);[ \t]*$/m, + [ + "// Release fullscreen while the WebView and its callback are still alive.", + "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 78c0009ab6..22c30ac568 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/lang/ar-ye.json b/src/lang/ar-ye.json index 6939fd6b68..67b21b3d7c 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 743e28f05d..6285bb588a 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 9561a723fc..98601603c2 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 23221edf83..c439ccecdc 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 52596946d4..f33b46844d 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 5ef81148c2..c19f4e6f56 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 b3d9985084..2869c6c2c2 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 1942bb00a4..ad78d40c95 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 ec0b418df2..5fc53c6f1d 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 35b7a716ce..358b04abc6 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 f16dae7b26..6aa97f71a1 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 6db53dfca5..f2865688a2 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 f7dceb77f1..e21a315901 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 7873fb8b6a..59076bc1b8 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 8a1db6e17f..07f6c056bc 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 19ddbba944..efe7c90e32 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 03ec6d4954..27fd7b6033 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 fd76716b43..74156bbda0 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 a0de1f1e17..63d4e9ce7e 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 4b1af01ee2..04db791635 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 74364aff07..6411d5b9b1 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 d1f72c1e89..dd8f456bf2 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 e1729da06e..d5b418a9e0 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 6a7516d6cd..aa6de4b0e2 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 70ac968a62..05b389b8c7 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 0d61d611bf..79e83dddba 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 bb7b2ac749..b5a5424fca 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 de2f19aaed..0d723b7ffd 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 43f8a786ab..ab5da72106 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 8ecb20a89b..986916b7e8 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 ce61c46ac2..64441eb461 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 d0b7a0604b..d6c1737b6d 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 b8dc2ae7df..6ad71367e6 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 679abc6d34..c814718a27 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 f36c7401ac..2870fc0a98 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 0000000000..f2985c1ea9 --- /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 e4b6dee1e0..c84cee1fec 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 0000000000..a83336ed56 --- /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 aef107b04a..cf91423da2 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 8011ce98b5..58e9f53335 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 8100a2b99f..ed15e281e1 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 a255ed6dbb..2a3e69c222 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 0000000000..1e156f290f --- /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 0000000000..c0f6ce6436 --- /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 0000000000..66b790da57 --- /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 4f8cd26a3b..231996249f 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 e096148b65..823b1a2494 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: [], From eef67bc96e92a18e3c2ce955d4192529145de145 Mon Sep 17 00:00:00 2001 From: Ajit Kumar Date: Sun, 20 Sep 2026 19:15:06 +0530 Subject: [PATCH 2/3] fix: typings --- src/index.d.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/index.d.ts b/src/index.d.ts index 12e76f127f..75aa19d8be 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; +} From c237e1f5e681cfedd0f4a820778c90db8a438b1c Mon Sep 17 00:00:00 2001 From: Ajit Kumar Date: Sun, 20 Sep 2026 22:01:18 +0530 Subject: [PATCH 3/3] chore: remove comments from generated files --- hooks/immersive-fullscreen.js | 136 ++++++++++++++++++---------------- 1 file changed, 72 insertions(+), 64 deletions(-) diff --git a/hooks/immersive-fullscreen.js b/hooks/immersive-fullscreen.js index 91557e3c38..0d776d7d83 100644 --- a/hooks/immersive-fullscreen.js +++ b/hooks/immersive-fullscreen.js @@ -19,8 +19,8 @@ module.exports = function prepareImmersiveFullscreen(context) { const original = fs.readFileSync(webViewPath, "utf8"); const newline = original.includes("\r\n") ? "\r\n" : "\n"; - // Rebuild our blocks on every prepare, including when only the controller - // changes. No stale version flag and no duplicate fields or lifecycle calls. + // 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( @@ -33,7 +33,7 @@ module.exports = function prepareImmersiveFullscreen(context) { ); } - function insert(name, anchor, lines, before = false) { + function insert(name, anchor, code, before = false) { const matches = [...source.matchAll(new RegExp(anchor.source, "gm"))]; if (matches.length !== 1) { throw new Error( @@ -43,7 +43,7 @@ module.exports = function prepareImmersiveFullscreen(context) { const indent = matches[0][1]; const block = [ `// ACODE_FULLSCREEN_BEGIN ${name}`, - ...lines, + ...code.trim().split("\n"), `// ACODE_FULLSCREEN_END ${name}`, ] .map((line) => indent + line) @@ -53,94 +53,102 @@ module.exports = function prepareImmersiveFullscreen(context) { ); } - 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;", - "}", - "", - "// Called on the UI thread by Acode's System plugin. null releases the override.", - "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);", - "}", - ]); + // 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) {", - // CoreAndroid's message channel accepts only Cordova's built-in events. - ` engine.evaluateJavascript("cordova.fireDocumentEvent('fullscreenbackbutton');", null);`, - " return true;", - "}", - ], + ` +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( + "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;", - "}", - ], + ` +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();", - ], + ` +fullscreenPaused = true; +if (immersiveFullscreen != null) immersiveFullscreen.pause(); +`, true, ); insert( "resume", /^([ \t]*)this\.pluginManager\.onResume\(keepRunning\);[ \t]*$/m, - [ - "fullscreenPaused = false;", - "if (immersiveFullscreen != null) immersiveFullscreen.resume();", - ], + ` +fullscreenPaused = false; +if (immersiveFullscreen != null) immersiveFullscreen.resume(); +`, true, ); insert( "reset", /^([ \t]*)pluginManager\.onReset\(\);[ \t]*$/m, - [ - "fullscreenBackHandlerEnabled = false;", - "if (immersiveFullscreen != null) immersiveFullscreen.unlockOrientation();", - ], + ` +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, - [ - "// Release fullscreen while the WebView and its callback are still alive.", - "fullscreenBackHandlerEnabled = false;", - "hideCustomView();", - ], + ` +fullscreenBackHandlerEnabled = false; +hideCustomView(); +`, true, );