diff --git a/CodenameOne/src/com/codename1/ui/CN.java b/CodenameOne/src/com/codename1/ui/CN.java index f02c75e403d..799fbfa0460 100644 --- a/CodenameOne/src/com/codename1/ui/CN.java +++ b/CodenameOne/src/com/codename1/ui/CN.java @@ -664,6 +664,11 @@ public static Boolean canExecute(String url) { /// } /// ``` /// + /// On the JavaScript port this can open a new tab, navigate the current page + /// or show a confirmation `Sheet`, depending on the + /// `javascript.execute.target` property. See [Display#execute(String)] for + /// the details. + /// /// #### Parameters /// /// - `url`: the url to execute diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 83e61ccb8e3..91c37725119 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -4082,6 +4082,35 @@ public Boolean canExecute(String url) { /// `simulator-hooks.properties` format and the positional `itemN` / `labelN` /// conventions. /// + /// #### JavaScript port + /// + /// Browsers only let a page open a new window/tab from inside a live user + /// gesture, and Codename One dispatches events on its own EDT so by the time + /// your listener calls this method the browser no longer considers a gesture + /// to be in progress. The JavaScript port therefore resolves the + /// `javascript.execute.target` property to decide what to do: + /// + /// - `auto` (the default) opens a new tab when the page still has user + /// activation and otherwise navigates the page the app is running in. No + /// confirmation prompt is ever shown, but note that navigating the current + /// page unloads the app. + /// - `_blank` only ever opens a new tab. When the browser would block it the + /// port shows a confirmation `Sheet` whose OK button supplies the missing + /// gesture. This was the behavior before the property existed. + /// - `_self` always navigates the page the app is running in. + /// + /// Set it before the call, for example in your `init` method: + /// + /// ```java + /// Display.getInstance().setProperty("javascript.execute.target", "_self"); + /// ``` + /// + /// The property applies to any URL carrying a URI scheme the browser can + /// hand off, custom deep links like the `imdb:///find` example above + /// included. It is ignored on every other platform, and on all targets a + /// `javascript:` URL, a `data:` URL, a `file:` URL or a path into local + /// storage keeps its existing meaning. + /// /// #### Parameters /// /// - `url`: the url to execute diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index cb99941fd43..9b6e239c925 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -632,6 +632,106 @@ public static interface JSRunnable extends JSObject { public void addBacksideHook(JSRunnable r) { backSideHooks.push(r); } + + /** + * Hooks that only a gesture-backed drain may run. + * + *
{@link #backSideHooks} is also drained by the + * {@code platformHint.javascript.backsideHooksInterval} timer, which + * carries no transient activation. That is fine for media, but a + * {@code window.open()} drained from the polling timer is simply blocked -- + * so a popup queued there can lose the race to the gesture's own timeout + * and never open. Anything needing activation goes here instead, where only + * {@link #runBacksideHooksInTimeout(int)} reaches it.
+ */ + private final java.util.ArrayListDeliberately not the {@code noopener} window feature: that makes + * {@code window.open()} return null in every browser, which would destroy + * the only signal available for telling a blocked popup from a successful + * one. Clearing the property gets the same protection while keeping the + * handle. Wrapped, since the assignment can throw once the new window has + * navigated cross-origin.
+ */ + private static final String SEVER_OPENER = "try { cn1w.opener = null; } catch (e) {}"; + + /** + * Hands a URL to the page's main thread for opening. {@code window} and + * {@code window.open} do not exist inside the Web Worker the app runs in, so + * this routes through the eval-on-main host bridge (see the + * {@code eval_} binding in port.js) where the page's real window lives. + * + * @param url the URL to open + * @param sameWindow when true navigate the current page, when false open a + * new window/tab + * @param fallBackToSameWindow when opening a new window, navigate the + * current page instead if the browser blocked the popup. The check runs + * inside the page-side script because the popup verdict is only knowable + * there. + * @return true if the script reached the page, false if the main-thread + * bridge is unavailable and the caller should degrade + */ + private boolean openUrlOnMainThread(String url, boolean sameWindow, boolean fallBackToSameWindow) { + String literal = toJavaScriptStringLiteral(url); + // When the host bundle predates the __cn1_eval_on_main__ handler, + // port.js degrades to evaluating the script inside the worker. Opening + // fails loudly there (no window.open), but a same-window navigation + // would NOT: assigning to the worker's read-only location is a silent + // no-op in non-strict eval code, so execute() would appear to succeed + // and do nothing. Assert we are on the page so every variant fails + // detectably and the caller can degrade. + String guard = "if (typeof document === 'undefined') {" + + " throw new Error('cn1: not running on the page'); }"; + String script; + if (sameWindow) { + script = guard + "window.location.href = " + literal + ";"; + } else if (fallBackToSameWindow) { + // Only attempt the popup while the page still has transient user + // activation -- otherwise the browser blocks it and shows its + // "popup blocked" indicator before we navigate anyway. Browsers + // without navigator.userActivation just try and let !cn1w decide. + script = guard + + "var cn1a = window.navigator && window.navigator.userActivation;" + + "var cn1w = (!cn1a || cn1a.isActive) ? window.open(" + literal + ", '_blank') : null;" + + "if (!cn1w) { window.location.href = " + literal + "; } else { " + SEVER_OPENER + " }"; + } else { + // Report a blocked popup instead of returning as if it opened. The + // page-side null is the only evidence available -- there is no + // callback -- so turn it into a throw the host call propagates. + script = guard + "var cn1w = window.open(" + literal + ", '_blank');" + + "if (!cn1w) { throw new Error('cn1: popup blocked'); }" + + SEVER_OPENER; + } + try { + // Wrapped in a function: __cn1_eval_on_main__ runs this through + // indirect eval, so a bare "var cn1w" would land on the page's + // global object -- clobbering an identically named global of the + // embedding page, and throwing outright against a top-level let or + // const of that name, which would break the default popup path. + eval_("(function(){" + script + "})();"); + return true; + } catch (Throwable t) { + _log("Failed to open URL on the main thread: " + t); + return false; + } + } + + /** + * Longest URL the confirmation Sheet shows before it is ellipsized. Chosen + * to stay inside a narrow phone viewport at the default font. + */ + private static final int MAX_DISPLAY_URL_LENGTH = 40; + + /** Marker appended to a URL shortened for the confirmation Sheet. */ + private static final String DISPLAY_URL_ELLIPSIS = "..."; + + /** 0 unknown, 1 reachable, -1 not reachable. */ + private int mainThreadBridgeState; + + /** + * Whether scripts can reach the page at all, cached after one probe. + * + *A host bundle predating the {@code __cn1_eval_on_main__} handler leaves + * the worker with no page-side channel, so every open fails. Knowing that + * up front matters because the recovery for a blocked popup -- ask the user + * -- is worthless here: the Sheet would promise something no button on it + * can deliver.
+ */ + private boolean isMainThreadBridgeAvailable() { + if (mainThreadBridgeState == 0) { + try { + // A no-op that asserts page context, nothing else. + eval_("if (typeof document === 'undefined') {" + + " throw new Error('cn1: not running on the page'); }"); + mainThreadBridgeState = 1; + } catch (Throwable t) { + mainThreadBridgeState = -1; + } + } + return mainThreadBridgeState == 1; + } + + /** + * Tells the user a link cannot be opened, for the one case where no + * confirmation could help. Informational, so a single dismiss button. + */ + private void showCannotOpenMessage(String url) { + final Sheet sheet = new Sheet(null, "Open Link"); + SpanLabel message = new SpanLabel( + "This app cannot open links. Its web runtime is out of date."); + Button close = new Button("Close"); + close.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + sheet.back(); + } + }); + sheet.getContentPane().setLayout(BoxLayout.y()); + sheet.getContentPane().add(message); + Label detail = new Label(shortenUrlForDisplay(url)); + detail.setEndsWith3Points(true); + sheet.getContentPane().add(detail); + sheet.getContentPane().add(FlowLayout.encloseCenter(close)); + sheet.show(); + } + + + /** + * Hands a {@code data:} download to the bridge's structured handler. + * + *Three constraints apply and the current bridge can satisfy only two. + * The download must happen once; a blocked attempt should be retryable; and + * the payload must not be copied more than necessary. Registering fires the + * click eagerly, so re-firing the stash duplicates it. Clicking the anchor + * ourselves through eval-on-main avoids that and restores the retry, but + * escapes a multi-megabyte base64 string into JavaScript source, builds + * several payload-sized copies of it and makes the page parse it as code.
+ * + *So: register and return. One download, and the payload travels once as + * structured host-call data rather than as source text. The retry is what is + * given up, and recovering it needs a bridge-side change -- + * {@code __cn1_register_save_blob_dataurl__} would have to accept a + * no-eager-fire flag -- which lives in the translator, not here.
+ */ + private void downloadDataUrl(String dataUrl, String fileName) { + registerSaveBlobHandlerDataUrl(fileName, dataUrl); + } + + /** + * A raw URL has no spaces for {@code SpanLabel} to wrap on, so putting one + * in the confirmation Sheet blew the content pane's preferred width past the + * screen and pushed the buttons out of reach. Shorten it for display; the + * full URL is never needed to answer "open this link?". + */ + static String shortenUrlForDisplay(String url) { + if (url == null) { + return ""; + } + String rest; + // Special URLs also accept "\\" wherever they accept "/", so the + // authority ends there too -- otherwise https://evil.example\\Shared so "does this have a scheme" is answered once. A colon inside a + * path -- {@code /host:8443/p}, {@code ./user:pw@host} -- is not one, and + * treating it as one made those read as unparsed schemes rather than as the + * page-relative references they are.
+ */ + private static int schemeColon(String url) { + int colon = url.indexOf(':'); + if (colon < 1) { + return -1; + } + // scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ), per RFC 3986. + for (int i = 0; i < colon; i++) { + char c = url.charAt(i); + boolean alpha = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + boolean extra = i > 0 && ((c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.'); + if (!alpha && !extra) { + return -1; + } + } + return colon; + } + + /** + * The page's own scheme, without the trailing colon, or null if unreadable. + */ + private static String pageScheme() { + try { + String protocol = mainLocationPart("protocol"); + if (protocol == null) { + return null; + } + return protocol.endsWith(":") + ? protocol.substring(0, protocol.length() - 1) + : protocol; + } catch (Throwable t) { + return null; + } + } + + /** + * The WHATWG URL "special" schemes, which browsers parse with an authority + * even when the {@code //} is missing. + */ + private static boolean isSpecialScheme(String scheme) { + return "http".equalsIgnoreCase(scheme) + || "https".equalsIgnoreCase(scheme) + || "ws".equalsIgnoreCase(scheme) + || "wss".equalsIgnoreCase(scheme) + || "ftp".equalsIgnoreCase(scheme) + // Special for PARSING, which is all this governs. Routing still + // sends file: to storage -- see isExternalUrl() -- but when one + // does reach the Sheet, file:\\\\host/p carries a real authority + // and must be read as one. + || "file".equalsIgnoreCase(scheme); + } + + /** + * Trims a string with no authority to protect down to the display cap. + */ + private static String ellipsizeTail(String value) { + if (value.length() <= MAX_DISPLAY_URL_LENGTH) { + return value; + } + return value.substring(0, MAX_DISPLAY_URL_LENGTH - DISPLAY_URL_ELLIPSIS.length()) + + DISPLAY_URL_ELLIPSIS; + } + + /** + * Builds the "browser blocked this, please confirm" Sheet used by + * {@link #execute(String)}. The message, the (shortened) URL and the buttons + * each get their own row: the old single-row BorderLayout put the buttons + * east of a label whose preferred width was the whole URL, which pushed them + * off screen where they could not be tapped. + */ + private Sheet createConfirmationSheet(String title, String message, String detail, final Runnable onConfirm) { + final Sheet sheet = new Sheet(null, title); + SpanLabel messageLabel = new SpanLabel(message); + Button ok = new Button("OK"); + Button cancel = new Button("Cancel"); + ok.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + sheet.back(); + onConfirm.run(); + } + }); + cancel.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + sheet.back(); + } + }); + sheet.getContentPane().setLayout(BoxLayout.y()); + sheet.getContentPane().add(messageLabel); + if (detail != null && detail.length() > 0) { + // A plain Label truncates with an ellipsis when it does not fit, + // unlike SpanLabel which would just overflow on an unbreakable URL. + Label detailLabel = new Label(detail); + detailLabel.setEndsWith3Points(true); + sheet.getContentPane().add(detailLabel); + } + sheet.getContentPane().add(FlowLayout.encloseCenter(cancel, ok)); + return sheet; + } + + /** + * True when a backside-hook drain is pending because of an actual user + * gesture, rather than merely because the polling interval enabled by + * {@code platformHint.javascript.backsideHooksInterval} is running. + * + *{@link #isBacksideHookAvailable()} accepts either, which is right for + * media -- browsers relax autoplay once the page has engagement -- but not + * for a popup. The interval drains from {@code Window.setInterval}, which + * grants no transient activation, so a {@code window.open()} queued on it + * can be blocked with nothing shown to the user. The semaphore, in + * contrast, is only raised by + * {@code installBacksideHooksInUserInteraction()} off a real pointer or key + * event.
+ */ + private boolean isGestureBackedHookAvailable() { + return backsideHooksSemaphore > 0; + } + + /** + * Identifies the user interaction currently in play. Bumped by + * {@link #installBacksideHooksInUserInteraction()}, which runs once per + * pointer or key event. + */ + private int gestureGeneration; + + /** + * The {@link #gestureGeneration} that has already had a popup queued + * against it, or -1. + * + *One interaction authorizes ONE {@code window.open()}: the first + * consumes the transient activation, so anything else queued behind the + * same interaction is blocked. The count of pending drains cannot express + * this -- a single interaction schedules three (300ms, 1500ms, 5000ms), and + * a second interaction within five seconds raises the shared semaphore + * again before the first one's drains have run, so neither "the first drain + * ran" nor "the semaphore reached zero" marks a gesture boundary. Comparing + * generations does: a genuinely new interaction gets its own popup, while + * everything else in the same one goes to the Sheet and earns a gesture of + * its own.
+ */ + private int popupReservedForGeneration = -1; + + /** + * Applies the cleanup a browser performs before it parses a URL: strip + * leading and trailing C0 controls and spaces, then remove every tab and + * newline wherever they appear. + * + *Applied once at the top of {@link #execute(String)} so classification, + * the confirmation Sheet's display and the URL actually handed to the page + * all see the same string. Parsing the raw input separately in each place is + * what let " https:host@evil.example/" be classified and displayed off a + * scheme of {@code " https"} while the browser navigated to + * {@code evil.example}.
+ */ + static String normalizeUrlForParsing(String url) { + if (url == null) { + return null; + } + int start = 0; + int end = url.length(); + while (start < end && url.charAt(start) <= ' ') { + start++; + } + while (end > start && url.charAt(end - 1) <= ' ') { + end--; + } + StringBuilder sb = new StringBuilder(end - start); + for (int i = start; i < end; i++) { + char c = url.charAt(i); + if (c != '\t' && c != '\n' && c != '\r') { + sb.append(c); + } + } + return sb.toString(); + } + + /** + * True when the string opens with two path separators, which makes it + * scheme relative: the browser reads what follows as an authority. + * + *Any combination of {@code /} and {@code \\} counts, because a special + * base URL -- and an app served over http(s) always has one -- treats them + * alike. So {@code \\\\host/p}, {@code \\/host/p} and {@code /\\host/p} all + * navigate to {@code host} exactly as {@code //host/p} does.
+ * + *Shared by every place that has to agree on where an authority starts: + * classification, the storage gate and the confirmation display. Answering + * this question separately in each is what let earlier authority shapes be + * fixed in one and missed in another.
+ */ + private static boolean startsWithAuthorityMarker(String url) { + return url.length() >= 2 + && (url.charAt(0) == '/' || url.charAt(0) == '\\') + && (url.charAt(1) == '/' || url.charAt(1) == '\\'); + } + + /** + * True when {@code url} carries exactly {@code scheme}, compared without + * regard to case since URI schemes are case-insensitive. Guards the + * {@code javascript:} and {@code data:} branches of {@link #execute(String)}, + * which would otherwise let {@code JavaScript:} slip through to the + * external-link path. + */ + private static boolean hasScheme(String url, String scheme) { + return url.length() > scheme.length() + && url.charAt(scheme.length()) == ':' + && url.regionMatches(true, 0, scheme, 0, scheme.length()); + } + + /** + * True when the URL names something the browser itself can hand off, as + * opposed to a path into local storage that {@link #execute(String)} is + * expected to turn into a download. Only these get the + * {@code javascript.execute.target} treatment -- pointing the current page + * at a storage path would unload the app and land on nothing. + * + *Anything carrying a URI scheme qualifies, whatever its case. That + * deliberately includes custom deep links such as the {@code imdb:///find} + * example in {@code Display.execute}'s javadoc, which the browser passes to + * a registered handler, and it includes protocol-relative {@code //host/path} + * URLs. {@code file:} is the exception: like the bare paths that carry no + * scheme at all, it names local content.
+ */ + private static boolean isExternalUrl(String url) { + if (startsWithAuthorityMarker(url)) { + // Scheme relative -- the page's own scheme applies. + return true; + } + // RFC 3986 allows a single ALPHA scheme, so x:payload is a legitimate + // deep link. A colon inside a path is not a scheme -- schemeColon() + // holds that grammar for every caller. + int colon = schemeColon(url); + if (colon < 1) { + return false; + } + // equalsIgnoreCase rather than toLowerCase(): case folding a scheme + // through the default locale turns "FILE" into "fIle" under a Turkish + // locale and would classify a file: URL as external. + return !"file".equalsIgnoreCase(url.substring(0, colon)); + } + + /** + * Opens a URL, or asks the user to, honoring the + * {@code javascript.execute.target} property. See {@link #execute(String)}. + */ + private void openExternalUrl(final String url) { + String target = executeTarget(); + if (EXECUTE_TARGET_SELF.equals(target) + && openUrlOnMainThread(url, true, false)) { + return; + } + if (EXECUTE_TARGET_AUTO.equals(target) + && openUrlOnMainThread(url, false, true)) { + return; + } + // _blank, or _self / auto whose page-side script never ran because the + // host bundle predates the eval-on-main handler -- degrade rather than + // leave execute() with no effect at all. + // + // Only _blank actually asked for a new window. Under auto or _self the + // Sheet is a compatibility fallback the caller never requested, so + // promising a new window there would describe behavior they did not + // choose. + openInNewWindowWithConfirmation(url, EXECUTE_TARGET_BLANK.equals(target) + ? "Open this link in a new window?" + : "Open this link?"); + } + + /** + * Opens {@code url} in a new window/tab the gesture-aware way: a popup only + * survives inside a live user gesture, so ride a backside hook when a + * gesture-backed one is pending and otherwise ask the user, whose tap on the + * Sheet becomes the gesture. + * + *An interval-only hook is deliberately not enough here. Draining from a + * timer would let the browser block the popup with nothing shown, which is + * worse than the Sheet.
+ */ + private void openInNewWindowWithConfirmation(final String url, final String prompt) { + if (!isMainThreadBridgeAvailable()) { + // Guarded here rather than at each caller: every popup path funnels + // through this method, and putting it in openExternalUrl() alone + // left the local-path fallback and the download legs prompting for + // something no button could deliver. + _log("execute(): no page-side channel on this host bundle, cannot open " + url); + showCannotOpenMessage(url); + return; + } + if (isGestureBackedHookAvailable() && popupReservedForGeneration != gestureGeneration) { + // Tied to the interaction rather than to any drain of it -- see the + // field. Never cleared; the next interaction simply carries a + // different generation. + popupReservedForGeneration = gestureGeneration; + addGestureOnlyHook(new JSRunnable() { + @Override + public void run() { + // openUrlOnMainThread reports the reason on failure -- + // popup blocked or no page-side channel -- so do not + // second-guess it with a duplicate line here. + if (!openUrlOnMainThread(url, false, false)) { + // A browser setting, an extension or an activation that + // expired before the drain can still block the one + // reserved attempt. Fall back to asking rather than + // leaving the caller with a dead click. Hop to the EDT + // first -- this runs on a hook drain. + callSerially(new Runnable() { + @Override + public void run() { + showOpenConfirmation(url, prompt); + } + }); + } + } + }, new Runnable() { + @Override + public void run() { + // Superseded by a newer interaction. Ask rather than + // re-queue: queueing again would open this URL off a + // gesture the user made for something else. + showOpenConfirmation(url, prompt); + } + }); + return; + } + showOpenConfirmation(url, prompt); + } + + /** + * Asks the user whether to open {@code url}, and on OK queues the open + * against the tap that answered. Always shows the Sheet -- never queues a + * popup on its own -- so it is safe as the fallback for a hook that could + * not run. + */ + private void showOpenConfirmation(final String url, final String prompt) { + if (!isMainThreadBridgeAvailable()) { + // Reached directly by the superseded and blocked-popup fallbacks, + // which bypass the entry point above. + _log("execute(): no page-side channel on this host bundle, cannot open " + url); + showCannotOpenMessage(url); + return; + } + createConfirmationSheet("Open Link", prompt, + shortenUrlForDisplay(url), new Runnable() { + @Override + public void run() { + // This callback runs on the EDT, by which point the browser no + // longer counts the OK tap as an ongoing gesture -- opening + // from here would be discarded by a popup blocker, which is the + // very thing the Sheet exists to recover from. That tap did + // install a backside hook though (pointer handling calls + // installBacksideHooksInUserInteraction), so queue the open on + // it and let the drain perform it inside the gesture. On the + // gesture-only queue, or the polling interval could drain it + // first with no activation and block the link the user just + // confirmed. + addGestureOnlyHook(new JSRunnable() { + @Override + public void run() { + if (!openUrlOnMainThread(url, false, false)) { + // The user confirmed and it still did not open -- + // a browser setting, an extension, or an activation + // that expired before this drain. Do not stop at a + // log: they acted and deserve a way through. Offer + // the current tab, which needs no activation and so + // cannot be blocked in turn. Not a loop -- that + // path is a navigation, not another popup. + _log("execute(): confirmed open did not succeed for " + + url); + callSerially(new Runnable() { + @Override + public void run() { + showBlockedPopupFallback(url); + } + }); + } + } + }, new Runnable() { + @Override + public void run() { + // The confirming tap was superseded before its drain + // ran. Ask again rather than spend the newer gesture. + showOpenConfirmation(url, prompt); + } + }); + } + }).show(); + } + + /** + * Last resort after a confirmed open was blocked anyway: offer the current + * tab, which needs no activation and so cannot be blocked in turn. + */ + private void showBlockedPopupFallback(final String url) { + if (!isMainThreadBridgeAvailable()) { + _log("execute(): no page-side channel on this host bundle, cannot open " + url); + showCannotOpenMessage(url); + return; + } + createConfirmationSheet("Open Link", + "Your browser blocked the new window." + + " Open this link in the current tab instead?", + shortenUrlForDisplay(url), new Runnable() { + @Override + public void run() { + openUrlOnMainThread(url, true, false); + } + }).show(); + } + @Override - public void execute(String url) { - if (url.startsWith("javascript:")) { - String cmd = url.substring(url.indexOf(":")+1); + public void execute(String rawUrl) { + // Normalization exists to make classification, the Sheet's display and + // the string handed to the page agree on what the BROWSER will parse. + // It is therefore only applied to those: a javascript: payload and a + // storage path are consumed verbatim below, since stripping their tabs + // and newlines would change a program's meaning and a key's identity. + if (rawUrl == null) { + // Pre-existing behavior was an NPE off the first startsWith. There + // is nothing to open, so say so and return rather than crash the + // caller's EDT. + _log("execute(): ignoring null URL"); + return; + } + final String url = normalizeUrlForParsing(rawUrl); + if (hasScheme(url, "javascript")) { + // Payload taken from the raw string. Normalizing it would splice + // out newlines that terminate a // comment and tabs that separate + // tokens, silently changing what the caller asked to run. + String cmd = rawUrl.substring(rawUrl.indexOf(":")+1); eval_(cmd); return; } @@ -6690,120 +7644,149 @@ public void execute(String url) { String fileName = null; boolean useBlobHandler = false; Button nativeButton = new Button(); - if (!url.startsWith("http:") && - !url.startsWith("http:") && - !url.startsWith("mailto:") && - !url.startsWith("data:")) { - if (exists(url)) { - try { - Blob blob = openFileAsBlob(url); - char sep = getFileSystemSeparator(); - fileName = url; - if (fileName.indexOf(sep) >=0) { - fileName = fileName.substring(fileName.lastIndexOf(sep)+1); - } - registerSaveBlobHandler(fileName, blob); - useBlobHandler = true; - - } catch (Throwable ex) { - // openFileAsBlob failed -- fall through to the no-blob-handler - // branch below which opens the URL in a new window instead. + // Storage names are unrestricted, so NO shape can be ruled out in + // advance: report:2026.pdf, https:report.pdf and https://example.com/x + // are all legal keys, and three successive attempts to characterize + // "cannot be a key" each regressed one of them. Consult storage for + // everything and let the miss decide. The parent implementation did + // the same for every shape but http:, mailto: and data:. + // + // data: stays excluded, and it is the only exclusion that holds up: + // there the string is the download's payload rather than a name, and + // exists() would push a multi-megabyte base64 blob through the host + // bridge as a key, twice, for something that can never match. + // + // Looked up under the path exactly as given -- a key may legitimately + // open or close with a space, and nothing here is parsed by the + // browser. + if (!hasScheme(url, "data") && exists(rawUrl)) { + try { + Blob blob = openFileAsBlob(rawUrl); + char sep = getFileSystemSeparator(); + fileName = rawUrl; + if (fileName.indexOf(sep) >= 0) { + fileName = fileName.substring(fileName.lastIndexOf(sep) + 1); } + registerSaveBlobHandler(fileName, blob); + useBlobHandler = true; + } catch (Throwable ex) { + // openFileAsBlob failed -- fall through to the no-blob-handler + // branch below which opens the URL in a new window instead. } } - final boolean fuseBlobHandler = useBlobHandler; - String buttonText = null; //String icon = null; - final String furl = url; if (useBlobHandler) { //popover.setContents(""); buttonText = "Click to Download "+(fileName!=null?fileName:"File"); nativeButton.setText(buttonText); nativeButton.setMaterialIcon(FontImage.MATERIAL_SAVE); - } else if (url.startsWith("data:")) { - registerSaveBlobHandlerDataUrl(fileName == null ? "download":fileName, url); - //popover.setContents(""); - buttonText = "Click to Download "+(fileName!=null?fileName:"File"); - nativeButton.setText(buttonText); - nativeButton.setMaterialIcon(FontImage.MATERIAL_SAVE); - //icon = "save-file"; + } else if (hasScheme(url, "data")) { + // The RAW URL, not the normalized one: normalization strips tabs + // and newlines anywhere in the string, and this value is the + // download's payload, not something we are only classifying. Same + // rule as the javascript: payload and the storage key -- normalize + // what the browser parses, never what it carries. + downloadDataUrl(rawUrl, fileName == null ? "download" : fileName); + return; + } else if (isExternalUrl(url)) { + // Reached only by URLs the data: branch above did not claim. + // isExternalUrl() is true for data: as well -- it does carry a + // scheme -- so that branch MUST stay ahead of this one or data: + // URLs would navigate instead of downloading. + openExternalUrl(url); + return; } else { - //popover.setContents("Open URL in New Window"); - if (isBacksideHookAvailable()) { - addBacksideHook(new JSRunnable() { - public void run() { - window.open(furl, "New Window"); - } - }); - - } else { - final Sheet sheet = new Sheet(null, "Open Link"); - SpanLabel l = new SpanLabel("Open "+url+" in a new window?"); - Button ok = new Button("OK"); - Button cancel = new Button("Cancel"); - ok.addActionListener(new ActionListener() { - public void actionPerformed(ActionEvent evt) { - sheet.back(); - CN.execute(furl); - } - }); - cancel.addActionListener(new ActionListener() { - public void actionPerformed(ActionEvent evt) { - sheet.back(); - } - }); - sheet.getContentPane().setLayout(BoxLayout.y()); - sheet.getContentPane().add(BorderLayout.centerEastWest(l, FlowLayout.encloseIn(ok, cancel), null)); - sheet.show(); - - } + // A local/storage path we could not turn into a Blob: exists() said + // no, or openFileAsBlob() threw (see downloadBytesAsFile above on + // how readily the storage round trip fails on this port). Keep it + // off the external-link target policy -- under auto or _self that + // would point the page at a path the server does not serve and + // unload the app instead of downloading anything. Best effort in a + // new window, which is what this port did before -- and through the + // gesture-aware path, since opening straight from the EDT would let + // the browser block the popup with no confirmation shown. + _log("execute(): no downloadable content for " + url); + openInNewWindowWithConfirmation(url, "Open this link?"); return; - } - if (isBacksideHookAvailable()) { - addBacksideHook(new JSRunnable() { + // A final copy of useBlobHandler, needed because the anonymous classes + // below cannot capture it -- it is assigned in the branches above and + // so is not effectively final. Captured AFTER the chain: the data: + // branch registers a handler too, and capturing beforehand read false + // for that case and sent the download down the popup leg. + final boolean handlerRegistered = useBlobHandler; + final Runnable startDownload = new Runnable() { + @Override + public void run() { + if (handlerRegistered) { + fireSaveBlobHandler(); + } else { + _log("Opening URL in new window"); + openUrlOnMainThread(url, false, false); + } + } + }; + // Firing the registered blob handler is a download, which browsers gate + // on engagement rather than transient activation, so an interval-only + // hook is good enough for it. The no-blob-handler leg opens a popup + // instead, and that does need a real gesture -- draining it from a timer + // would let the browser block it with no Sheet shown. Pick the gate that + // matches the leg this run will actually take. + boolean hookAvailable = handlerRegistered + ? isBacksideHookAvailable() + : isGestureBackedHookAvailable(); + if (hookAvailable) { + JSRunnable hook = new JSRunnable() { + @Override public void run() { - if (fuseBlobHandler) { - fireSaveBlobHandler(); - } else { - _log("Opening URL in new window"); - window.open(furl, "New Window"); - } + startDownload.run(); } - }); + }; + if (handlerRegistered) { + addBacksideHook(hook); + } else { + // The no-blob leg opens a popup, so it needs the activation + // only a gesture-backed drain carries -- same reason its gate + // above is the stricter one. If a newer interaction supersedes + // it, ask rather than spend that interaction's activation. + addGestureOnlyHook(hook, new Runnable() { + @Override + public void run() { + showOpenConfirmation(url, "Open this link?"); + } + }); + } } else { - final Sheet sheet = new Sheet(null, "Download file"); String dlName = fileName == null ? "file" : fileName; - SpanLabel l = new SpanLabel("Download "+dlName+" now?"); - Button ok = new Button("OK"); - Button cancel = new Button("Cancel"); - ok.addActionListener(new ActionListener() { - public void actionPerformed(ActionEvent evt) { - sheet.back(); - addBacksideHook(new JSRunnable() { + createConfirmationSheet("Download file", "Download " + dlName + " now?", null, new Runnable() { + @Override + public void run() { + JSRunnable confirmed = new JSRunnable() { + @Override public void run() { - if (fuseBlobHandler) { - fireSaveBlobHandler(); - } else { - window.open(furl, "New Window"); - } + startDownload.run(); } - }); - } - }); - cancel.addActionListener(new ActionListener() { - public void actionPerformed(ActionEvent evt) { - sheet.back(); + }; + // Same split as the unconfirmed path above: the popup leg + // needs the activation the OK tap just granted, which the + // polling interval would not carry. + if (handlerRegistered) { + addBacksideHook(confirmed); + } else { + addGestureOnlyHook(confirmed, new Runnable() { + @Override + public void run() { + showOpenConfirmation(url, "Open this link?"); + } + }); + } } - }); - sheet.getContentPane().setLayout(BoxLayout.y()); - sheet.getContentPane().add(BorderLayout.centerEastWest(l, FlowLayout.encloseIn(ok, cancel), null)); - sheet.show(); + }).show(); } - + } diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WorkingWithJavascriptJava009Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WorkingWithJavascriptJava009Snippet.java new file mode 100644 index 00000000000..9c7881760ab --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WorkingWithJavascriptJava009Snippet.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class WorkingWithJavascriptJava009Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List