diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 3db0273961c..3e2c59d4cbf 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -80,6 +80,7 @@ import com.codename1.ui.CN; import com.codename1.ui.Command; import com.codename1.ui.ClipboardContent; +import com.codename1.ui.NativeDragOperation; import com.codename1.ui.Component; import com.codename1.ui.Container; import com.codename1.ui.Dialog; @@ -5566,6 +5567,117 @@ public void installNativeTheme() { throw new RuntimeException(); } + // ------------------------------------------------------------------------------------ + // Native (operating system) drag and drop. The payload is a ClipboardContent, the same one + // copyToClipboard publishes, because a drag is a copy the user aims with the pointer. + // + // A port implements the outbound half here and calls + // com.codename1.ui.NativeDragAndDrop for the inbound half. + // ------------------------------------------------------------------------------------ + + /// Returns true when this platform can hand a drag to the operating system. False here + /// leaves the framework with only its lightweight in-form drag and drop, which is what + /// every port had before. + /// + /// #### Returns + /// + /// true when native drag and drop is available + public boolean isNativeDragAndDropSupported() { + return false; + } + + /// Returns true when a drag started in this application can be dropped outside it -- on the + /// desktop, in a file manager, or in another application's window. Ports that can route a + /// drag between their own components but not out of the application return false while + /// still returning true from `#isNativeDragAndDropSupported()`. + /// + /// #### Returns + /// + /// true when a drag can leave the application + public boolean isNativeDragOutsideApplicationSupported() { + return isNativeDragAndDropSupported(); + } + + /// Hands the port the drag that would start if the press currently down turns into a drag. + /// + /// Ports whose platform owns the gesture -- where the operating system's own recognizer + /// decides a drag has begun and then asks what is being dragged -- answer from what they + /// were given here. Ports that start the session themselves can ignore this and use + /// `#startNativeDrag(com.codename1.ui.NativeDragOperation)`. + /// + /// Invoked on the event dispatch thread, once per press. A press that produces no drag is + /// followed by `#cancelNativeDrag()`. + /// + /// #### Parameters + /// + /// - `op`: the drag that is now possible + public void prepareNativeDrag(NativeDragOperation op) { + } + + /// Starts a native drag session now, because the pointer has moved far enough to be a drag. + /// Invoked on the event dispatch thread while the pointer is still down. + /// + /// A port that starts the session must eventually report the outcome through + /// `com.codename1.ui.NativeDragAndDrop#dragCompleted(int)`, or a source that offered a move + /// never learns whether to delete its copy. + /// + /// #### Parameters + /// + /// - `op`: what is being dragged + /// + /// #### Returns + /// + /// true when the operating system took the drag + public boolean startNativeDrag(NativeDragOperation op) { + return false; + } + + /// Discards whatever `#prepareNativeDrag(com.codename1.ui.NativeDragOperation)` staged, + /// because the press turned out to be a click. + public void cancelNativeDrag() { + } + + /// Notifies the port that the application has a component that can be dragged through the + /// operating system. + /// + /// A platform whose drag gesture is its own -- UIKit's is -- recognizes that gesture with a + /// recognizer installed on the surface, and installing one changes how every touch on that + /// surface is delivered. Doing it unconditionally would alter touch handling for every + /// application, including the overwhelming majority that never drags anything; a port that + /// needs a recognizer therefore installs it here, the first time an application says it + /// wants one. + /// + /// Called on the event dispatch thread, possibly many times; a port must make it idempotent. + public void nativeDragSourceRegistered() { + } + + /// Notifies the port that the application has a component that accepts drops from the + /// operating system. + /// + /// The counterpart of `#nativeDragSourceRegistered()`, for the same reason: a port attaches + /// whatever the platform needs in order to receive drops only for applications that asked + /// to receive them, so an application that never does keeps exactly the input handling it + /// had. + /// + /// Called on the event dispatch thread, possibly many times; a port must make it idempotent. + public void nativeDropTargetRegistered() { + } + + /// True when the port needs the drag image at the moment the operation is staged rather + /// than when the drag begins. + /// + /// A port that starts the session itself renders the image then, which costs nothing for a + /// press that turns out to be a click. A port whose platform owns the drag gesture is asked + /// for the preview from inside that platform's own callback, which is not a moment at which + /// a component can be rendered, so it has to have the image already. + /// + /// #### Returns + /// + /// true to render the drag image on every press over a native drag source + public boolean isNativeDragImageNeededOnPrepare() { + return false; + } + /// Performs a clipboard copy operation, if the native clipboard is supported by the implementation it would be used /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/ui/ClipboardContent.java b/CodenameOne/src/com/codename1/ui/ClipboardContent.java index c26f5a58092..cad251c2884 100644 --- a/CodenameOne/src/com/codename1/ui/ClipboardContent.java +++ b/CodenameOne/src/com/codename1/ui/ClipboardContent.java @@ -46,12 +46,40 @@ public class ClipboardContent { public static final String MIME_GIF = "image/gif"; /// A local file reference (a file path / URI `String`, or a `String[]` for several files). public static final String MIME_FILE = "application/x-file-list"; + /// A newline separated list of URIs, the format desktop browsers and file managers use when + /// a link or a file is dragged out of them. + public static final String MIME_URI_LIST = "text/uri-list"; private final List mimeTypes = new ArrayList(); private final List values = new ArrayList(); /// Adds or replaces a representation. Passing null removes the MIME type. public ClipboardContent setData(String mimeType, Object value) { + return put(mimeType, value); + } + + /// Declares a representation that is only built if something reads it, which is how a drag + /// offers a file it has not written yet or an image it has not encoded yet. + /// + /// The provider is asked at most once and its answer is then cached, so + /// `#getData(java.lang.String)` behaves exactly as it would for a value passed to + /// `#setData(java.lang.String, java.lang.Object)`. It may run on a native clipboard or drag + /// thread; see `ClipboardDataProvider`. Passing a null provider removes the MIME type. + /// + /// #### Parameters + /// + /// - `mimeType`: the MIME type this provider can produce + /// + /// - `provider`: the provider, or null to remove the representation + /// + /// #### Returns + /// + /// this instance, for chaining + public ClipboardContent setDataProvider(String mimeType, ClipboardDataProvider provider) { + return put(mimeType, provider == null ? null : new LazyValue(provider)); + } + + private ClipboardContent put(String mimeType, Object value) { String normalized = normalizeMimeType(mimeType); if (normalized.length() == 0) { throw new IllegalArgumentException("MIME type must not be empty"); @@ -74,9 +102,41 @@ public ClipboardContent setData(String mimeType, Object value) { } /// Returns the representation for a MIME type, or null when it isn't available. + /// + /// A representation registered through + /// `#setDataProvider(java.lang.String, com.codename1.ui.ClipboardDataProvider)` is produced + /// here, on the first call for that MIME type. public Object getData(String mimeType) { int index = mimeTypes.indexOf(normalizeMimeType(mimeType)); - return index < 0 ? null : values.get(index); + if (index < 0) { + return null; + } + Object value = values.get(index); + if (value instanceof LazyValue) { + return ((LazyValue) value).resolve(mimeTypes.get(index)); + } + return value; + } + + /// A provider plus the value it produced. The value is resolved once and remembered, so a + /// target that asks twice -- as a drop does when it queries and then reads -- does not run + /// the provider twice and does not, for instance, write the promised file twice. + private static final class LazyValue { + private final ClipboardDataProvider provider; + private Object resolved; + private boolean done; + + LazyValue(ClipboardDataProvider provider) { + this.provider = provider; + } + + synchronized Object resolve(String mimeType) { + if (!done) { + done = true; + resolved = provider.getClipboardData(mimeType); + } + return resolved; + } } /// Returns the binary (`byte[]`) representation for a MIME type -- e.g. the raw bytes of an image @@ -102,6 +162,43 @@ public String[] getMimeTypes() { return mimeTypes.toArray(new String[mimeTypes.size()]); } + /// Sets the `#MIME_FILE` representation from a list of file paths or `file:` URIs. + /// + /// Ports differ on whether a single file is carried as a `String` or a one element + /// `String[]`; this writes the form the rest of the framework expects, and + /// `#getFiles()` reads either. + /// + /// #### Parameters + /// + /// - `paths`: the file paths, or null to remove the representation + /// + /// #### Returns + /// + /// this instance, for chaining + public ClipboardContent setFiles(String[] paths) { + if (paths == null || paths.length == 0) { + return setData(MIME_FILE, null); + } + if (paths.length == 1) { + return setData(MIME_FILE, paths[0]); + } + return setData(MIME_FILE, paths.clone()); + } + + /// Returns the `#MIME_FILE` representation as a path array regardless of whether the + /// producer stored one `String` or a `String[]`, or null when no files are available. + public String[] getFiles() { + Object value = getData(MIME_FILE); + if (value instanceof String[]) { + String[] paths = (String[]) value; + return paths.length == 0 ? null : paths.clone(); + } + if (value instanceof String && ((String) value).length() > 0) { + return new String[] { (String) value }; + } + return null; + } + /// Returns the first available MIME type from the caller's preference list, or null. public String findPreferredMimeType(String[] preferredMimeTypes) { if (preferredMimeTypes == null) { diff --git a/CodenameOne/src/com/codename1/ui/ClipboardDataProvider.java b/CodenameOne/src/com/codename1/ui/ClipboardDataProvider.java new file mode 100644 index 00000000000..39678c24f49 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/ClipboardDataProvider.java @@ -0,0 +1,75 @@ +/* + * 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 Oracle 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.codename1.ui; + +/// Produces a clipboard or drag-and-drop representation on demand, so a payload that is +/// expensive to build is only built if something actually asks for it. +/// +/// This is what makes "drag a file out of the application" workable. A drag that offers +/// `ClipboardContent#MIME_FILE` has to name the file when the drag *starts*, but the drop may +/// never happen -- the user may let go over nothing -- and the target may prefer a different +/// representation entirely. Registering a provider with +/// `ClipboardContent#setDataProvider(java.lang.String, com.codename1.ui.ClipboardDataProvider)` +/// declares that the representation is available without paying for it up front; the bytes are +/// written, or the temporary file created, at the moment the receiving application reads that +/// MIME type. +/// +/// A provider is invoked at most once per `ClipboardContent` and MIME type -- the result is +/// cached -- and it may be invoked from a native drag or clipboard thread rather than the event +/// dispatch thread, so it must not touch the user interface. +/// +/// #### When it actually runs +/// +/// For a **drag**, on the desktop and on iOS the provider runs when a receiver reads that +/// representation, so a drag the user abandons costs nothing. Android is the exception: +/// `startDragAndDrop` takes a complete clip, and a clip carries text or a reference to a file +/// that already exists, so every provider runs as the drag begins. A drag out of an iOS +/// application resolves its *file list* at the same moment for a related reason -- the system +/// needs the number of items the drag carries, and for a file drag that is the number of +/// files. +/// +/// For a **copy**, it depends on what the platform's clipboard is. A desktop clipboard holds a +/// live handle back into this application, so nothing is read until a receiver pastes. The +/// iOS pasteboard and the Android clipboard are system stores that outlive the application: +/// whatever is copied has to survive this process being killed, and a promise that can only +/// be kept while the process is alive is not something to put on a clipboard. So copying +/// resolves every representation there, and it is right that it does -- a lazy pasteboard +/// entry would paste as nothing the moment the application went away. +/// +/// So a provider should be cheap enough to run once per drag or copy, and must not assume it +/// will only run when its data is wanted. +public interface ClipboardDataProvider { + /// Produces the value for one representation. + /// + /// #### Parameters + /// + /// - `mimeType`: the MIME type being requested, always one this provider was registered for + /// + /// #### Returns + /// + /// the value, normally a `String`, a `String[]` of file paths or a `byte[]`, or null when + /// the representation turned out to be unavailable + Object getClipboardData(String mimeType); +} diff --git a/CodenameOne/src/com/codename1/ui/Component.java b/CodenameOne/src/com/codename1/ui/Component.java index d059fa7655a..fcb89196ecd 100644 --- a/CodenameOne/src/com/codename1/ui/Component.java +++ b/CodenameOne/src/com/codename1/ui/Component.java @@ -411,6 +411,17 @@ static int rubberBandDecompress(int compressed, int dim) { private boolean draggable; private boolean dragAndDropInitialized; private boolean dropTarget; + /// Native (operating system) drag and drop state. Kept beside the lightweight drag and drop + /// fields above because the two are alternatives for the same gesture: a component that is a + /// native drag source hands the press to the platform, and the lightweight drag never runs. + private boolean nativeDragSource; + private boolean nativeDropTarget; + private NativeDragOperation nativeDragOperation; + private String[] acceptedDropMimeTypes; + private int acceptedDropActions = NativeDragOperation.ACTION_COPY + | NativeDragOperation.ACTION_MOVE | NativeDragOperation.ACTION_LINK; + private EventDispatcher nativeDropListeners; + private EventDispatcher nativeDragOverListeners; private Image dragImage; private Component dropTargetComponent; private int dragCallbacks = 0; @@ -5965,6 +5976,38 @@ public void pointerPressed(int x, int y) { void initDragAndDrop(int x, int y) { Component leadParent = LeadUtil.leadParentImpl(this); leadParent.dragAndDropInitialized = leadParent.isDragAndDropOperation(x, y); + // Native drag and drop is primed from the same place, so a native drag source is + // pressed, dragged and released through exactly the gesture a draggable component is. + NativeDragAndDrop.pressedOn(leadParent, x, y); + } + + /// Abandons a lightweight drag that has already started, without running the drop + /// machinery. Used when a native drag takes the gesture over: the port stops delivering + /// pointer drags at that point, so the lightweight drag would otherwise stay activated with + /// its image stranded where the gesture began. + void cancelLightweightDrag() { + Component leadParent = LeadUtil.leadParentImpl(this); + if (leadParent.dragActivated) { + if (leadParent.dragAndDropInitialized) { + // pointerDragged hides the source while the framework carries its image; the + // native session draws its own preview and never runs dragFinishedImpl, so + // without this the component the user dragged stays invisible for good. + leadParent.setVisible(true); + } + // The top level, not the form: pointerDragged records the dragged component + // on TopLevelSupport.rootOf(this), and a component dragged inside a window + // has no form at all -- so asking for one left that window still holding a + // component it would never be told had stopped being dragged. + Container p = TopLevelSupport.rootOf(leadParent); + if (p != null) { + p.setDraggedComponent(null); + p.repaint(); + } + } + leadParent.dragActivated = false; + leadParent.dragAndDropInitialized = false; + leadParent.dragImage = null; + leadParent.dropTargetComponent = null; } /// If this Component is focused, the pointer released event @@ -6450,6 +6493,320 @@ public void removeDragOverListener(ActionListener l) { } } + // ------------------------------------------------------------------------------------ + // Native (operating system) drag and drop. + // + // The listeners and callbacks above move a component around inside one form. The ones + // below hand the drag to the platform, so it can end on the desktop, in a file manager or + // in another application -- and so a drag from any of those can end here. The payload is a + // ClipboardContent either way, which is the point: whatever the component can already copy + // it can already drag, and whatever it can already paste it can already accept. + // + // See NativeDragAndDrop for the platform support matrix and for starting a drag by hand. + // ------------------------------------------------------------------------------------ + + /// Returns true when a drag starting on this component is handed to the operating system. + public boolean isNativeDragSource() { + return nativeDragSource; + } + + /// Makes a drag that starts on this component an operating system drag, which can be + /// dropped outside the application. + /// + /// Supply what is being dragged either by calling + /// `#setNativeDragOperation(com.codename1.ui.NativeDragOperation)`, or by overriding + /// `#createNativeDragOperation(int, int)` when the payload depends on where the press + /// landed -- which item of a list was grabbed, for instance. + /// + /// This is independent of `#setDraggable(boolean)`. Where the platform has no native drag + /// and drop this flag simply does nothing, so a component that should be draggable either + /// way sets both and the native session, when there is one, takes precedence. + /// + /// #### Parameters + /// + /// - `nativeDragSource`: true to hand drags on this component to the operating system + public void setNativeDragSource(boolean nativeDragSource) { + this.nativeDragSource = nativeDragSource; + if (nativeDragSource) { + // Tells the port that this application wants to drag. Ports whose platform needs a + // gesture recognizer on the surface install it here rather than at startup, so an + // application that never drags keeps exactly the touch handling it has today. + Display.impl.nativeDragSourceRegistered(); + } + } + + /// Returns the operation this component drags, or null when it supplies one per press by + /// overriding `#createNativeDragOperation(int, int)`. + public NativeDragOperation getNativeDragOperation() { + return nativeDragOperation; + } + + /// Sets what dragging this component puts on the operating system's drag, and makes the + /// component a native drag source. Passing null clears both. + /// + /// The operation is reusable: the same instance is offered for every drag of this + /// component, so it must not hold state from a previous session. Anything expensive in the + /// payload belongs behind + /// `ClipboardContent#setDataProvider(java.lang.String, com.codename1.ui.ClipboardDataProvider)`. + /// + /// #### Parameters + /// + /// - `nativeDragOperation`: what to drag, or null to stop being a drag source + public void setNativeDragOperation(NativeDragOperation nativeDragOperation) { + this.nativeDragOperation = nativeDragOperation; + setNativeDragSource(nativeDragOperation != null); + } + + /// Produces the operation for a drag starting at the given position, invoked on the event + /// dispatch thread as the press is dispatched. Override when the payload depends on where + /// the user grabbed the component; the default returns whatever + /// `#setNativeDragOperation(com.codename1.ui.NativeDragOperation)` was given. + /// + /// Returning null, or an operation allowing no actions, leaves the gesture alone -- which + /// is how a component refuses to be dragged from a particular spot. + /// + /// #### Parameters + /// + /// - `x`: the absolute x position of the press + /// + /// - `y`: the absolute y position of the press + /// + /// #### Returns + /// + /// the drag to start, or null for none + protected NativeDragOperation createNativeDragOperation(int x, int y) { + return nativeDragOperation; + } + + /// Returns true when this component accepts drops coming from the operating system. + public boolean isNativeDropTarget() { + return nativeDropTarget; + } + + /// Lets this component receive drops from the operating system: from another application, + /// from a file manager, from the desktop, or from elsewhere in this application. + /// + /// The deepest component under the pointer that is a native drop target and accepts the + /// content wins, so a target nested inside another target takes precedence -- and a target + /// that refuses a particular payload lets an ancestor have it. + /// + /// This is independent of `#setDropTarget(boolean)`, which governs the lightweight drag and + /// drop inside the form. + /// + /// #### Parameters + /// + /// - `nativeDropTarget`: true to accept operating system drops + public void setNativeDropTarget(boolean nativeDropTarget) { + this.nativeDropTarget = nativeDropTarget; + if (nativeDropTarget) { + // As with setNativeDragSource: the port attaches whatever the platform needs to + // receive drops only once an application says it wants them. + Display.impl.nativeDropTargetRegistered(); + } + } + + /// Returns the MIME types this component accepts, or null when it accepts anything. + public String[] getAcceptedDropMimeTypes() { + return acceptedDropMimeTypes == null ? null : acceptedDropMimeTypes.clone(); + } + + /// Restricts the drops this component accepts to drags offering at least one of these MIME + /// types, so a drag carrying anything else passes through to whatever is behind it. + /// + /// This filter is consulted on the platform's drag thread rather than the event dispatch + /// thread, which is what lets the answer be exact from the very first drag event; see + /// `NativeDragAndDrop#dragOver(int, int, int, com.codename1.ui.ClipboardContent, int)`. + /// + /// #### Parameters + /// + /// - `acceptedDropMimeTypes`: the MIME types, for instance + /// `ClipboardContent#MIME_FILE`, or null to accept anything + public void setAcceptedDropMimeTypes(String... acceptedDropMimeTypes) { + this.acceptedDropMimeTypes = acceptedDropMimeTypes == null || acceptedDropMimeTypes.length == 0 + ? null : acceptedDropMimeTypes.clone(); + } + + /// Returns the bit set of actions this component is willing to perform on a drop. + public int getAcceptedDropActions() { + return acceptedDropActions; + } + + /// Restricts what this component will do with a drop -- a target that can only copy should + /// not be offered a move, because the source deletes its data when a move completes. + /// + /// #### Parameters + /// + /// - `acceptedDropActions`: any combination of `NativeDragOperation#ACTION_COPY`, + /// `NativeDragOperation#ACTION_MOVE` and `NativeDragOperation#ACTION_LINK` + public void setAcceptedDropActions(int acceptedDropActions) { + this.acceptedDropActions = acceptedDropActions; + } + + /// Decides whether this component wants a drag carrying these representations at all. + /// + /// #### Threading + /// + /// Invoked on the platform's drag thread, not the event dispatch thread, because the + /// operating system needs the answer while the pointer is moving. Read the content and + /// this component's own configuration; do not touch the user interface, start animations + /// or block. Everything that needs the event dispatch thread belongs in + /// `#nativeDragEnter(com.codename1.ui.NativeDropEvent)` and its siblings. + /// + /// The default accepts anything unless + /// `#setAcceptedDropMimeTypes(java.lang.String...)` narrowed it. + /// + /// #### Parameters + /// + /// - `content`: the representations the drag is offering + /// + /// #### Returns + /// + /// true to be considered as the target + protected boolean canAcceptNativeDrop(ClipboardContent content) { + if (acceptedDropMimeTypes == null) { + return true; + } + if (content == null) { + return false; + } + return content.findPreferredMimeType(acceptedDropMimeTypes) != null; + } + + /// Callback invoked on the event dispatch thread when a native drag enters this component. + /// Use it to highlight the drop location, and `NativeDropEvent#accept(int)` or + /// `NativeDropEvent#reject()` to change what the cursor tells the user. + /// + /// #### Parameters + /// + /// - `ev`: the drag + protected void nativeDragEnter(NativeDropEvent ev) { + } + + /// Callback invoked on the event dispatch thread as a native drag moves over this + /// component. Delivered at most once at a time -- a new one is only queued after the + /// previous returned -- so a slow callback throttles itself instead of flooding the event + /// dispatch thread. + /// + /// #### Parameters + /// + /// - `ev`: the drag + protected void nativeDragOver(NativeDropEvent ev) { + } + + /// Callback invoked on the event dispatch thread when a native drag leaves this component + /// without dropping. Clear whatever `#nativeDragEnter(com.codename1.ui.NativeDropEvent)` + /// highlighted. The event carries no content. + /// + /// #### Parameters + /// + /// - `ev`: the drag + protected void nativeDragExit(NativeDropEvent ev) { + } + + /// Callback invoked on the event dispatch thread when a native drag is dropped on this + /// component. `NativeDropEvent#getContent()` is fully materialized by this point, so the + /// bytes and file paths can be read here or kept for later. + /// + /// #### Parameters + /// + /// - `ev`: the drop + protected void nativeDrop(NativeDropEvent ev) { + } + + /// Adds a listener invoked on the event dispatch thread when a native drag is dropped on + /// this component. The event is a `NativeDropEvent`. + /// + /// #### Parameters + /// + /// - `l`: the listener + public void addNativeDropListener(ActionListener l) { + if (nativeDropListeners == null) { + nativeDropListeners = new EventDispatcher(); + } + nativeDropListeners.addListener(l); + } + + /// Removes a listener added by + /// `#addNativeDropListener(com.codename1.ui.events.ActionListener)`. + /// + /// #### Parameters + /// + /// - `l`: the listener + public void removeNativeDropListener(ActionListener l) { + if (nativeDropListeners != null) { + nativeDropListeners.removeListener(l); + if (!nativeDropListeners.hasListeners()) { + nativeDropListeners = null; + } + } + } + + /// Adds a listener invoked on the event dispatch thread as a native drag enters, moves over + /// and leaves this component. The event is a `NativeDropEvent`; its + /// `com.codename1.ui.events.ActionEvent#getEventType()` says which of the three it is. + /// + /// #### Parameters + /// + /// - `l`: the listener + public void addNativeDragOverListener(ActionListener l) { + if (nativeDragOverListeners == null) { + nativeDragOverListeners = new EventDispatcher(); + } + nativeDragOverListeners.addListener(l); + } + + /// Removes a listener added by + /// `#addNativeDragOverListener(com.codename1.ui.events.ActionListener)`. + /// + /// #### Parameters + /// + /// - `l`: the listener + public void removeNativeDragOverListener(ActionListener l) { + if (nativeDragOverListeners != null) { + nativeDragOverListeners.removeListener(l); + if (!nativeDragOverListeners.hasListeners()) { + nativeDragOverListeners = null; + } + } + } + + /// Routes one native drag callback to the override and then to the listeners. Invoked on + /// the event dispatch thread by `NativeDragAndDrop`. + /// + /// #### Parameters + /// + /// - `ev`: the event + void dispatchNativeDropEvent(NativeDropEvent ev) { + switch (ev.getEventType()) { + case NativeDragEnter: + nativeDragEnter(ev); + fireNativeDragOver(ev); + break; + case NativeDragOver: + nativeDragOver(ev); + fireNativeDragOver(ev); + break; + case NativeDragExit: + nativeDragExit(ev); + fireNativeDragOver(ev); + break; + case NativeDrop: + nativeDrop(ev); + if (nativeDropListeners != null && nativeDropListeners.hasListeners()) { + nativeDropListeners.fireActionEvent(ev); + } + break; + default: + break; + } + } + + private void fireNativeDragOver(NativeDropEvent ev) { + if (nativeDragOverListeners != null && nativeDragOverListeners.hasListeners()) { + nativeDragOverListeners.fireActionEvent(ev); + } + } + /// Callback indicating that the drag has finished either via drop or by releasing the component /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/ui/Form.java b/CodenameOne/src/com/codename1/ui/Form.java index 81c67eefc02..e743fd1c450 100644 --- a/CodenameOne/src/com/codename1/ui/Form.java +++ b/CodenameOne/src/com/codename1/ui/Form.java @@ -4145,6 +4145,12 @@ public void pointerPressed(int x, int y) { if (cmp != null) { cmp = LeadUtil.leadParentImpl(cmp); + // Native drag and drop is primed here too. This branch does not call + // initDragAndDrop -- the lightweight drag has never worked in the title + // area -- so without this a Toolbar component given a native drag operation + // silently could not be dragged, while the same component in the content + // pane could. + NativeDragAndDrop.pressedOn(cmp, x, y); setPressedCmp(cmp); LeadUtil.pointerPressed(cmp, x, y); @@ -4261,6 +4267,20 @@ public void pointerDragged(int x, int y) { if (dragStopFlag) { pointerPressed(x, y); } + // A press that landed on a native drag source becomes an operating system drag here, + // as soon as it has moved far enough to be a drag rather than a click. From that point + // the platform owns the gesture, so nothing below runs for it. + // + // After the dragStopFlag recovery above, deliberately. A press that lands on a + // momentum-scrolling container is stopping the glide, and the press it was is only + // dispatched by that recovery -- so asking first handed the row to the operating system + // on the very first motion packet, and grabbing a moving list started an outbound drag + // instead of stopping it. Run afterwards, the recovery restages the press at this + // position, which leaves this packet below the drag threshold; a gesture that really + // does go on to drag still starts one on the next. + if (NativeDragAndDrop.pointerDragged(x, y)) { + return; + } autoRelease(x, y); boolean localPointerPressedAgainDuringDrag = pointerPressedAgainDuringDrag; pointerPressedAgainDuringDrag = false; @@ -4339,6 +4359,25 @@ public void pointerDragged(int[] x, int[] y) { if (dragStopFlag) { pointerPressed(x, y); } + // The same hook the scalar overload runs, and the one that matters: an ordinary + // one-finger drag reaches a Form through *this* method. CodenameOneImplementation wraps + // its coordinates into one-element arrays and Display dispatches them here, and this + // overload is a separate implementation rather than a call to the scalar one -- so with + // the hook only there, a gesture never started a native drag on any port that begins + // one itself, which is every port except the one whose operating system owns the + // gesture. Placed after the dragStopFlag recovery for the reason the scalar overload + // gives. + // + // One pointer only. A second finger makes this a pinch or a two-finger scroll, and + // handing that to the operating system as a drag is not what the user is doing -- + // and the press that staged one is spent, because the gesture has become + // something else. Merely skipping the hook left it staged, so lifting the second + // finger and moving on could still start the drag the first finger had prepared. + if (x.length > 1) { + NativeDragAndDrop.gestureCancelled(); + } else if (NativeDragAndDrop.pointerDragged(x[0], y[0])) { + return; + } autoRelease(x[0], y[0]); boolean localPointerPressedAgainDuringDrag = pointerPressedAgainDuringDrag; if (pointerDraggedListeners != null && pointerDraggedListeners.hasListeners()) { @@ -4582,6 +4621,9 @@ public void pointerReleased(int x, int y) { stylusCmp.fireStylusEvent(ActionEvent.Type.PointerReleased, x, y); } } + // A press that never became a drag releases the operation the press staged, so a + // later gesture somewhere else cannot start the drag this one declined to. + NativeDragAndDrop.pointerReleased(); try { Component origPressedCmp = pressedCmp; setRippleMotion(null); diff --git a/CodenameOne/src/com/codename1/ui/NativeDragAndDrop.java b/CodenameOne/src/com/codename1/ui/NativeDragAndDrop.java new file mode 100644 index 00000000000..c2cb3ea3ffe --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/NativeDragAndDrop.java @@ -0,0 +1,1141 @@ +/* + * 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 Oracle 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.codename1.ui; + +import com.codename1.io.Log; +import com.codename1.ui.events.ActionEvent; + +/// Drag and drop through the operating system rather than inside the application. +/// +/// Codename One has always had a lightweight drag and drop -- `Component#setDraggable(boolean)` +/// and `Component#setDropTarget(boolean)` -- which moves a rendered image around inside one +/// form. That never leaves the application, so it cannot drop a file on the desktop, cannot +/// carry text into another application's window, and cannot receive anything from one. +/// +/// This class is the other half: it hands the drag to the operating system's own drag machinery, +/// using the same `ClipboardContent` a copy publishes as the payload. That is the whole idea -- +/// a drag is a copy that the user aims with the pointer, so anything the application can already +/// put on the clipboard it can already drag out, and anything it can paste it can already accept +/// as a drop. +/// +/// #### Dragging out +/// +/// ```java +/// Label file = new Label("report.pdf"); +/// file.setNativeDragOperation(NativeDragOperation.createFileDrag( +/// new String[] { FileSystemStorage.getInstance().getAppHomePath() + "report.pdf" })); +/// ``` +/// +/// Dropping that on the desktop, on a mail composer or into a file manager copies the file, +/// because the receiving application asked for `ClipboardContent#MIME_FILE` and the drag +/// offered it. Offer several representations and every receiver takes the best one it +/// understands. +/// +/// #### Receiving a drop +/// +/// ```java +/// Container inbox = new Container(); +/// inbox.setNativeDropTarget(true); +/// inbox.addNativeDropListener(e -> { +/// NativeDropEvent drop = (NativeDropEvent)e; +/// String[] files = drop.getFiles(); +/// ... +/// }); +/// ``` +/// +/// #### Where it works +/// +/// Native drag and drop needs the platform to have it. Check `#isSupported()` before offering +/// the affordance, and `#isDragOutsideApplicationSupported()` before promising the user that a +/// drag can leave the application: a desktop can drop onto any other window, a tablet can drop +/// into another application beside it, and a phone in full screen has nowhere for a drag to go +/// even though drags within the application still work. Where nothing is supported the calls +/// here are harmless no-ops and the lightweight drag and drop is unaffected. +/// +/// #### Threading +/// +/// The gesture half runs on the event dispatch thread; the receiving half is called from +/// whatever thread the platform hands the port. All of the shared state below is therefore +/// guarded by one lock, and no callback into component or port code is ever made while holding +/// it -- the framework's own event dispatch thread blocks on the platform's UI thread to paint +/// on some ports, so a lock held across a callback is a deadlock waiting for the first drag. +public final class NativeDragAndDrop { + /// A press further than this from where it started is a drag rather than a click. Measured + /// in millimetres so it is a finger on a phone and a pointer on a desktop. + private static final float DRAG_THRESHOLD_MM = 1.5f; + + /// Guards every field below. Held only across field access, never across a call out. + private static final Object LOCK = new Object(); + + /// The operation prepared by the press that is currently down, waiting to see whether the + /// user drags, and the component it came from. + private static NativeDragOperation pending; + private static Component pendingSource; + + /// Where that press landed, which is both the drag threshold's origin and the point the + /// drag image is grabbed by. + private static int pressX; + private static int pressY; + + /// Set once this press has been offered to the port, so a platform that declined to start + /// the session is not asked again on every drag event of the same gesture. + private static boolean startOffered; + + /// The action mask the drag last advertised, so the drop event can report what the + /// *source* permits rather than the one action the target settled on. The port hands + /// drop() a single action, not a mask, and NativeDropEvent#getAllowedActions() is + /// documented as the source's -- a listener could not otherwise tell a move-only source + /// from one that offered both and chose a move. + private static int advertisedActions; + + /// The press the staged operation belongs to. + /// + /// A press is not its coordinates. Identifying it that way meant a gesture that ended + /// without a release -- Android cancels a touch outright, and nothing delivers a release + /// for it -- left an operation staged that a later press at the very same pixel then + /// inherited, along with a source component that may not even be under the pointer any + /// more. Every press mints one of these already, for the same reason. + private static Object pressToken; + + /// The session the operating system is currently running, or null. + private static NativeDragOperation active; + + /// The drop target the drag is currently over, and the action it last agreed to. See the + /// note on `#dragOver(int, int, int, com.codename1.ui.ClipboardContent, int)` about why the + /// answer given to the operating system is the previous callback's. + private static Component currentTarget; + private static int currentAction = NativeDragOperation.ACTION_NONE; + private static boolean overDispatchPending; + + /// Bumped whenever the target or the session changes. + /// + /// The callbacks below are queued onto the event dispatch thread, so one can still be + /// waiting when its drag leaves and another arrives over the same component. Identity alone + /// cannot tell those apart -- the component is the same one -- and the stale callback would + /// then answer for the new drag, handing it the old one's action or its refusal. + private static int targetGeneration; + + private NativeDragAndDrop() { + } + + /// Returns true when this platform can drag and drop through the operating system at all. + /// Where this is false every method here does nothing and reports failure, so no call site + /// needs to be conditional -- but an application that shows a "drag me" affordance should + /// hide it. + public static boolean isSupported() { + return Display.impl != null && Display.impl.isNativeDragAndDropSupported(); + } + + /// Returns true when a drag started here can be dropped outside the application: on the + /// desktop, in a file manager or in another application's window. + /// + /// This is narrower than `#isSupported()`. A platform can route drags between components, + /// and between this application's own windows, while still refusing to let one leave -- + /// which is the normal state of affairs on a phone. + public static boolean isDragOutsideApplicationSupported() { + return Display.impl != null && Display.impl.isNativeDragOutsideApplicationSupported(); + } + + /// Starts a native drag immediately, for an application that decides on its own that a drag + /// has begun -- from a long press, or a menu item -- rather than letting a component do it + /// through `Component#setNativeDragSource(boolean)`. + /// + /// Call this on the event dispatch thread while the pointer is still down; a drag the user + /// is not currently holding cannot be aimed and platforms reject it. + /// + /// #### Parameters + /// + /// - `source`: the component the drag comes from, used for the default drag image and + /// reported by `NativeDragOperation#getSource()`. May be null. + /// + /// - `op`: what is being dragged + /// + /// #### Returns + /// + /// true when the operating system took the drag; false when the platform has no native drag + /// and drop, refused to start a session, or is already running one + public static boolean startDrag(Component source, NativeDragOperation op) { + return startDrag(source, op, true); + } + + /// `renderPreview` is false for the gesture, which has already rendered one at the point the + /// press actually landed. Rendering again here would take a second snapshot of the same + /// component and replace that grab point with the component's centre, so the preview jumped + /// out from under the pointer the moment the drag began. A generated image is re-rendered + /// per gesture *by* the gesture; this entry point renders one only for a caller who has no + /// gesture to have done it. + private static boolean startDrag(Component source, NativeDragOperation op, + boolean renderPreview) { + if (op == null || !isSupported() + || op.getAllowedActions() == NativeDragOperation.ACTION_NONE) { + // Allowing nothing to be done with a drag is having no drag, and a press stages one + // on the same terms. Running it anyway would put a session in flight that no target + // could ever accept. + return false; + } + synchronized (LOCK) { + if (active != null) { + // One drag at a time, which is all any of these platforms runs. Installing the + // second operation before the port has answered would strand the first: a + // refusal clears the session entirely and a success attributes the first + // session's completion to the second operation, so the original source never + // learns what happened -- and a source waiting for ACTION_MOVE to delete its + // data would wait forever. + return false; + } + active = op; + currentTarget = null; + targetGeneration++; + currentAction = NativeDragOperation.ACTION_NONE; + } + op.setSource(source); + op.resetPerformedAction(); + if (renderPreview && needsGeneratedImage(op) && source != null) { + try { + // The same snapshot the gesture path renders, because this method documents the + // source as providing the default preview and without it the ports fall back to + // something worse: Android snapshots the whole Codename One surface, and JavaSE + // drags with no image at all. + // + // Centred, because a drag begun in code has no grab point to offset from. The + // gesture path uses where the finger actually went down; there is no such place + // here, and the centre is what a preview with no anchor should hang from. + op.setGeneratedDragImage(source.getDragImage(), + source.getWidth() / 2, source.getHeight() / 2); + } catch (Throwable err) { + Log.e(err); + } + } + boolean started = false; + try { + started = Display.impl.startNativeDrag(op); + } catch (Throwable err) { + // A port that cannot start a session must not take the application down with it; + // the gesture simply stays a lightweight one. + Log.e(err); + } + synchronized (LOCK) { + if (started) { + // Whatever the press staged is spent: the application has started a drag of + // its own and the gesture belongs to that. Leaving it staged had the release + // that follows cancel the port's staging underneath a session already + // running, and left a later press at the very same pixel looking like this + // one to isStagedFor -- so it skipped asking the component and dragged the + // stale payload. + pending = null; + pendingSource = null; + pressToken = null; + startOffered = false; + } + } + if (!started) { + synchronized (LOCK) { + if (active == op) { // NOPMD CompareObjectsWithEquals + active = null; + } + } + } + return started; + } + + /// Reports that the platform started a drag session on its own, for the operation the press + /// prepared. Ports whose operating system owns the drag gesture -- where a long press, not + /// the framework's own threshold, is what begins a drag -- call this instead of returning + /// true from `com.codename1.impl.CodenameOneImplementation#startNativeDrag(com.codename1.ui.NativeDragOperation)`. + /// + /// #### Returns + /// + /// the operation the session is carrying, or null when nothing was prepared -- in which case + /// the port should refuse to start a session + public static NativeDragOperation dragSessionStarted() { + NativeDragOperation op; + final Component source; + synchronized (LOCK) { + if (active != null) { + // A session is already running; see startDrag for why a second one must not + // displace it. The port refuses to start the drag on a null answer. + return null; + } + op = pending; + if (op == null || op.getAllowedActions() == NativeDragOperation.ACTION_NONE) { + // As in startDrag. Refusing *before* the operation is made active also keeps a + // session that can never complete from wedging every drag after it: a running + // drag is what stops the next one from starting, and nothing would report this + // one finished. A press does not stage such an operation in the first place, so + // this is only reachable if the source changed its mind mid-gesture. + return null; + } + source = pendingSource; + pending = null; + pendingSource = null; + active = op; + currentTarget = null; + targetGeneration++; + currentAction = NativeDragOperation.ACTION_NONE; + } + op.resetPerformedAction(); + if (source != null) { + // On the event dispatch thread, because it repaints. A component that is draggable + // as well as a native drag source would otherwise be left mid-drag with its image + // stranded, since the platform stops delivering pointer drags once it takes over. + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + cancelLightweightDrag(source); + } + }); + } + return op; + } + + /// Returns the drag this application is currently running through the operating system, or + /// null when it is not dragging. A drop target uses this to tell a drag it started itself + /// from one that arrived from elsewhere, which `NativeDropEvent#isLocal()` reports. + public static NativeDragOperation getActiveDrag() { + synchronized (LOCK) { + return active; + } + } + + // ------------------------------------------------------------------------------------ + // Gesture plumbing. Called by the top level containers as a press is dispatched, so that a + // native drag source behaves exactly like a lightweight draggable one from the user's side. + // ------------------------------------------------------------------------------------ + + /// Prepares a drag for the press that just landed, so the port has the payload in hand + /// before the operating system's own gesture recognizer asks for it. Every press either + /// installs a new pending operation or clears the previous one, which is what keeps a drag + /// source that was pressed and released from being dragged by a later gesture somewhere + /// else. + static void pressedOn(Component cmp, int x, int y) { + Object token = pressTokenOf(cmp); + if (isStagedFor(token, x, y)) { + return; + } + // Everything that can call out -- into the component for its payload and its drag + // image, and into the port -- happens outside the lock, and what this press staged is + // then installed in one go. Installing it unconditionally, rather than clearing first + // and filling in later, is also what keeps the two writes from reading as a botched + // lazy initialization of a static field. + NativeDragOperation op = null; + Component source = cmp; + if (cmp != null && isSupported()) { + // A disabled component is not a drag source, and neither is a disabled ancestor. + // A Form primes drag and drop before it applies its own isEnabled gate -- a Window + // applies it first -- so without this a disabled control could be dragged out of + // the application on the main surface and not in a window. + while (source != null && !(source.isNativeDragSource() && source.isEnabled())) { + source = source.getParent(); + } + if (source != null) { + try { + op = source.createNativeDragOperation(x, y); + } catch (Throwable err) { + Log.e(err); + } + } + } + if (op != null && op.getAllowedActions() == NativeDragOperation.ACTION_NONE) { + op = null; + } + if (op != null) { + op.setSource(source); + try { + if (needsGeneratedImage(op) && Display.impl.isNativeDragImageNeededOnPrepare()) { + // The platform asks for the preview from inside its own gesture callback, + // which is not a moment at which a component can be rendered. Rendering here + // costs a snapshot per press on a drag source, which is what the lightweight + // drag has always cost when one starts. + op.setGeneratedDragImage(source.getDragImage(), + x - source.getAbsoluteX(), y - source.getAbsoluteY()); + } + } catch (Throwable err) { + Log.e(err); + } + } + stage(op, source, token, x, y); + if (op != null) { + try { + Display.impl.prepareNativeDrag(op); + } catch (Throwable err) { + Log.e(err); + } + } + } + + /// True when this exact press has already staged an operation. + /// + /// A top level primes drag and drop on the component under the pointer and then again on + /// its nearest draggable ancestor, and the ancestor walk in `#pressedOn(Component, int, + /// int)` would not find a drag source that sits *between* the two -- so restaging would + /// throw away what the first call correctly staged. Every release clears the pending + /// operation, so a later press cannot land on a stale one even at the very same pixel. + private static boolean isStagedFor(Object token, int x, int y) { + synchronized (LOCK) { + if (pending == null) { + return false; + } + if (token != null || pressToken != null) { + return token == pressToken; // NOPMD CompareObjectsWithEquals + } + // No top level to mint a token: the position is all there is to go on. + return x == pressX && y == pressY; + } + } + + /// The object the top level minted for the press in progress, or null when there is no + /// top level to ask. + private static Object pressTokenOf(Component cmp) { + if (cmp == null) { + return null; + } + Container root = TopLevelSupport.rootOf(cmp); + return root == null ? null : root.getCurrentPointerPress(); + } + + /// Installs what a press staged, or clears it when the press staged nothing. Unconditional + /// rather than a clear followed by a fill, so that one press leaves one consistent state. + private static void stage(NativeDragOperation op, Component source, Object token, + int x, int y) { + synchronized (LOCK) { + pending = op; + pendingSource = op == null ? null : source; + pressToken = op == null ? null : token; + pressX = x; + pressY = y; + startOffered = false; + } + } + + /// Hands the gesture to the operating system once the pointer has moved far enough to be a + /// drag rather than a click. + /// + /// #### Parameters + /// + /// - `x`: the pointer position + /// + /// - `y`: the pointer position + /// + /// #### Returns + /// + /// true when the native drag has taken the gesture over and the framework should not also + /// treat it as a scroll or a lightweight drag + static boolean pointerDragged(int x, int y) { + int threshold = dragThreshold(); + NativeDragOperation op; + Component source; + int grabX; + int grabY; + synchronized (LOCK) { + if (pending == null) { + // A session already running owns the gesture. Ports differ on whether they keep + // delivering pointer drags during a native drag; swallowing them here means the + // ones that do cannot scroll the surface out from under the drag. + return active != null; + } + if (Math.abs(x - pressX) < threshold && Math.abs(y - pressY) < threshold) { + return false; + } + if (startOffered) { + // Already offered for this gesture and not taken, which is what a platform that + // starts the session on its own recognizer looks like. Leave the gesture alone + // until that recognizer fires; it announces itself through dragSessionStarted(). + return active != null; + } + startOffered = true; + op = pending; + source = pendingSource; + grabX = pressX; + grabY = pressY; + } + if (needsGeneratedImage(op) && source != null) { + try { + // Rendered afresh for this gesture, and recorded as generated rather than + // written in as though the application had supplied it. An application's own + // image is never touched -- it may have been positioned deliberately, and + // overwriting that offset would tear the image away from the pointer. + op.setGeneratedDragImage(source.getDragImage(), + grabX - source.getAbsoluteX(), grabY - source.getAbsoluteY()); + } catch (Throwable err) { + Log.e(err); + } + } + if (!startDrag(source, op, false)) { + // The port did not take it. Keep the prepared operation: on a platform whose own + // gesture recognizer owns dragging, the session begins later and this is what it + // will carry. Where there is no native drag and drop at all nothing was prepared + // in the first place, so there is nothing to keep. + return false; + } + synchronized (LOCK) { + if (pending == op) { // NOPMD CompareObjectsWithEquals + pending = null; + pendingSource = null; + pressToken = null; + } + } + cancelLightweightDrag(source); + return true; + } + + /// Abandons the lightweight drag this gesture started, whichever component it belongs to. + /// + /// A component can be both draggable and a native drag source, and from here the operating + /// system owns the gesture: the port stops delivering pointer drags, no lightweight drop + /// ever runs, and a drag left activated keeps its component hidden with its image stranded + /// where the gesture began. + /// + /// Not necessarily the source's own drag. A drag source is found by walking *up* from the + /// press, so a draggable child inside a native-drag-source ancestor stages the ancestor -- + /// and a motion too small to reach the native threshold starts the child's lightweight drag + /// first, which hides the child and records it on the top level. Cancelling the ancestor, + /// which never had a drag of its own, left that child invisible for good. + /// + /// #### Parameters + /// + /// - `source`: the component the native drag is running for, or null when there is none + private static void cancelLightweightDrag(Component source) { + if (source == null) { + return; + } + Container root = TopLevelSupport.rootOf(source); + Component dragged = root == null ? null : root.getDraggedComponent(); + if (dragged != null && dragged != source) { // NOPMD CompareObjectsWithEquals + dragged.cancelLightweightDrag(); + } + // The source as well, and whether or not it was the dragged one: a component can have + // activated a drag that never became the top level's -- grabbing a scroll does exactly + // that -- and those flags have to go, or the next gesture reads them as a drag already + // under way. Cancelling twice is harmless; the second call finds nothing activated. + source.cancelLightweightDrag(); + } + + /// Drops the operation prepared by a press that turned out to be a click. Called as the + /// pointer is released. + static void pointerReleased() { + gestureCancelled(); + } + + /// Abandons whatever a press staged, because the gesture it belonged to is over or has + /// turned into something else. + /// + /// A release is the ordinary way that happens and the framework calls this itself. A port + /// calls it for the ways that are not a release: a touch the platform cancels outright, + /// which delivers no release at all, and anything else that ends a gesture without one. + /// Leaving an operation staged past its gesture is what lets a later, unrelated movement + /// start a drag nobody asked for. + public static void gestureCancelled() { + boolean hadPending; + synchronized (LOCK) { + startOffered = false; + hadPending = pending != null; + pending = null; + pendingSource = null; + pressToken = null; + } + if (hadPending) { + try { + Display.impl.cancelNativeDrag(); + } catch (Throwable err) { + Log.e(err); + } + } + } + + /// True when this gesture should render its own preview: either the operation has no image + /// at all, or the one it has was rendered for an earlier drag of the same reusable + /// operation and is now out of date. + private static boolean needsGeneratedImage(NativeDragOperation op) { + return op.getDragImage() == null || op.isDragImageGenerated(); + } + + private static int dragThreshold() { + try { + return Math.max(4, Display.getInstance().convertToPixels(DRAG_THRESHOLD_MM)); + } catch (Throwable err) { + return 8; + } + } + + // ------------------------------------------------------------------------------------ + // The receiving side. Ports call these from whatever thread the operating system hands + // them, which is not the event dispatch thread. + // ------------------------------------------------------------------------------------ + + /// Reports that a native drag has entered one of the application's surfaces. + /// + /// #### Parameters + /// + /// - `windowId`: the id of the window the drag is over, or zero for the main surface + /// + /// - `x`: the pointer position within that surface + /// + /// - `y`: the pointer position within that surface + /// + /// - `content`: the representations the drag is offering + /// + /// - `allowedActions`: the actions the source permits + /// + /// #### Returns + /// + /// the action a drop would perform right now, or `NativeDragOperation#ACTION_NONE` when + /// nothing under the pointer will take it + public static int dragEnter(int windowId, int x, int y, ClipboardContent content, int allowedActions) { + boolean stillHovered; + synchronized (LOCK) { + stillHovered = currentTarget != null; + } + if (stillHovered) { + // The platform says this is an entry, so it is one, and nothing can still be hovered + // from before it. A session that ended without an exit -- a drop the target refused, + // a drag cancelled while inside the surface -- used to leave the previous target in + // place, and the next entry was then routed as a move over it: no enter callback + // ever arrived and the component stayed at the ended session's answer, which for a + // refusal is ACTION_NONE and is deliberately never recomputed. The ports clear this + // on their own end-of-session paths as well; this is the one place that cannot be + // reached by a platform forgetting to tell us. + dragExit(windowId); + } + return dragOver(windowId, x, y, content, allowedActions); + } + + /// Reports that a native drag has moved over one of the application's surfaces, and answers + /// whether it would be accepted here. + /// + /// #### Threading + /// + /// The operating system needs the answer synchronously, while the framework's callbacks + /// have to run on the event dispatch thread -- and blocking a native drag thread on the + /// event dispatch thread deadlocks, because on some ports the event dispatch thread is + /// itself waiting on that native thread to paint. So the target is resolved here, on the + /// calling thread, from state that does not change under it, while + /// `Component#nativeDragOver(com.codename1.ui.NativeDropEvent)` and the listeners are + /// dispatched asynchronously; the value returned is the one *they* produced for the + /// previous event on this same target. A target that changes its mind therefore shows the + /// user the new cursor one drag event late, which is a frame, and never blocks. + /// + /// A target that refuses a drop outright should say so through + /// `Component#canAcceptNativeDrop(com.codename1.ui.ClipboardContent)` or the accepted MIME + /// list instead, both of which are consulted here and are therefore exact from the first + /// event -- and from every event, including the drop itself. A `NativeDropEvent#reject()` + /// in a callback is a change of mind rather than a refusal: it is honoured from the next + /// event onward, and a drop landing before the callback has run reads what the target + /// declared. `#drop(int, int, int, com.codename1.ui.ClipboardContent, int)` says why that + /// cannot be closed without doing something worse. + /// + /// #### Parameters + /// + /// - `windowId`: the id of the window the drag is over, or zero for the main surface + /// + /// - `x`: the pointer position within that surface + /// + /// - `y`: the pointer position within that surface + /// + /// - `content`: the representations the drag is offering + /// + /// - `allowedActions`: the actions the source permits + /// + /// #### Returns + /// + /// the action a drop would perform right now, or `NativeDragOperation#ACTION_NONE` + public static int dragOver(int windowId, int x, int y, ClipboardContent content, int allowedActions) { + Component target = findTarget(windowId, x, y, content, allowedActions); + Component previous; + boolean changed; + boolean dispatchOver = false; + int answer; + synchronized (LOCK) { + advertisedActions = allowedActions; + previous = currentTarget; + changed = previous != target; // NOPMD CompareObjectsWithEquals + if (changed) { + currentTarget = target; + targetGeneration++; + // A pending dispatch belongs to the target that just went away, and its finally + // will no longer clear this -- so clearing it here is what keeps the new target + // able to dispatch at all. + overDispatchPending = false; + currentAction = target == null ? NativeDragOperation.ACTION_NONE + : preferredAction(allowedActions & target.getAcceptedDropActions()); + } else if (target != null) { + if (currentAction != NativeDragOperation.ACTION_NONE + && (currentAction & allowedActions) == 0) { + // The permitted set changed while the pointer stayed put, which is what the + // desktop modifier does. An action agreed under the old set is no longer on + // offer, so keeping it told the platform something it had just withdrawn. + // + // ACTION_NONE is excluded deliberately: it is a decision, not a stale value. + // Recomputing it here turned a target's refusal back into the default and + // handed it the drop -- the same defect the enter callback's own rejection + // suffered, arriving by a different route. + currentAction = preferredAction(allowedActions & target.getAcceptedDropActions()); + } + if (!overDispatchPending) { + overDispatchPending = true; + dispatchOver = true; + } + } + answer = target == null ? NativeDragOperation.ACTION_NONE : currentAction; + } + if (changed) { + dispatch(previous, ActionEvent.Type.NativeDragExit, content, x, y, allowedActions); + dispatch(target, ActionEvent.Type.NativeDragEnter, content, x, y, allowedActions); + } else if (dispatchOver) { + dispatch(target, ActionEvent.Type.NativeDragOver, content, x, y, allowedActions); + } + return answer; + } + + /// Reports that a native drag has left the application's surfaces without dropping. + /// + /// #### Parameters + /// + /// - `windowId`: the id of the window the drag left, or zero for the main surface + public static void dragExit(int windowId) { + Component previous; + synchronized (LOCK) { + previous = currentTarget; + currentTarget = null; + targetGeneration++; + currentAction = NativeDragOperation.ACTION_NONE; + advertisedActions = NativeDragOperation.ACTION_NONE; + } + dispatch(previous, ActionEvent.Type.NativeDragExit, null, 0, 0, NativeDragOperation.ACTION_NONE); + } + + /// Delivers a native drop. + /// + /// The content must be fully materialized before this is called: on most platforms the + /// native transfer object is only readable inside the drop callback, so a port that hands + /// over a lazy view of it delivers empty data by the time the event dispatch thread reads + /// it. + /// + /// #### Parameters + /// + /// - `windowId`: the id of the window dropped on, or zero for the main surface + /// + /// - `x`: the pointer position within that surface + /// + /// - `y`: the pointer position within that surface + /// + /// - `content`: the dropped representations + /// + /// - `action`: the action the operating system settled on + /// + /// #### Returns + /// + /// the action actually accepted, or `NativeDragOperation#ACTION_NONE` when nothing under + /// the pointer took the drop and the port should report the transfer as failed + public static int drop(int windowId, int x, int y, ClipboardContent content, int action) { + boolean local; + int advertised; + synchronized (LOCK) { + local = active != null; + advertised = advertisedActions; + } + return drop(windowId, x, y, content, action, advertised, local); + } + + /// Delivers a native drop whose origin the port knows. + /// + /// A port that assembles a drop asynchronously calls this one, because by the time the + /// assembly finishes the drag it belongs to may no longer be the one running: a drop that + /// arrived from another application, still loading when the user began a drag of their + /// own, would otherwise be reported to the target as local -- and a target that uses + /// `NativeDropEvent#isLocal()` to tell reordering from importing would treat foreign + /// content as an internal move. + /// + /// #### Parameters + /// + /// - `advertisedActions`: the mask *this* drag offered, or `NativeDragOperation#ACTION_NONE` + /// to use whatever the last drag event advertised. Carried for the same reason as the + /// locality beside it: a newer drag has since overwritten what the framework remembers, + /// and giving this drop that newer mask made its event report an action the source never + /// offered -- or, when the newer drag is narrower, report nothing accepted at all while + /// the platform had been told the drop succeeded. + /// + /// - `local`: true when the drag being dropped is one this application started + /// + /// #### Returns + /// + /// the action actually accepted, or `NativeDragOperation#ACTION_NONE` + public static int drop(int windowId, int x, int y, ClipboardContent content, int action, + int advertisedActions, boolean local) { + // Nothing materialized. Every representation the platform offered failed to be read -- + // a transferable that threw, a one-shot stream already spent -- and an empty payload is + // not a drop. A target that filters on a type refuses it anyway, but one that takes + // anything would have been handed nothing and both it and the source told the transfer + // had happened. The state below is still cleared, and the component that was hovering is + // still told the drag left it. + boolean carriesSomething = content != null && content.getMimeTypes().length > 0; + Component target = carriesSomething + ? findTarget(windowId, x, y, content, action) : null; + int accepted; + Component previous; + int advertised; + if (carriesSomething && target == null) { + // Nothing is at the release point any more. On a port that assembles a drop + // asynchronously the tree can be rebuilt while the item providers are still + // loading -- a form shown, a list replaced -- and the component that accepted + // this drag is then no longer where it was. It is still the component that + // accepted it, so it is offered the drop rather than the payload being dropped + // on the floor. + // + // Only when the position resolves to nothing at all. Where it resolves to some + // *other* component the position wins, because a release that lands somewhere + // else is a release somewhere else -- and this cannot tell that apart from a + // tree that changed underneath a slow load. Position is what a drop means + // everywhere else in here, and one heuristic guessing against it would make + // the two disagree. + target = stillWillingHoverTarget(content, action); + } + synchronized (LOCK) { + // What this drag advertised: the caller's answer where it has one, otherwise what + // the last drag event said. A drop arriving with neither -- which no real port + // does -- has only the port's one action to report. + advertised = advertisedActions; + if (advertised == NativeDragOperation.ACTION_NONE) { + advertised = NativeDragAndDrop.advertisedActions; + } + if (advertised == NativeDragOperation.ACTION_NONE) { + advertised = action; + } + previous = currentTarget; + if (target != null && target == currentTarget) { // NOPMD CompareObjectsWithEquals + // The target's own latest word, not a recomputation from the action the port + // supplied. That action is by construction one event behind -- it is what the + // last drag event answered -- so a target that rejected, or changed its mind, + // in a callback that has since run would have had that decision quietly + // discarded here, and a refusal turned back into a delivered drop on every + // port rather than only the one that was noticed. + // + // "Latest word" is as far as this can go, and deliberately so. A drop that + // arrives before the queued nativeDragEnter has run reads what the target + // *declared* rather than what that callback was about to say, and no + // rearrangement of this method fixes that: + // + // - Waiting for the callback deadlocks. This runs on the native drag thread, + // which the event dispatch thread blocks on to paint. + // - Refusing whenever a callback is outstanding refuses every ordinary drop + // that lands while the event dispatch thread is a frame behind. + // - Withholding delivery afterwards is worse than delivering. The platform has + // already been told the action; on ACTION_MOVE the source deletes its copy on + // that word, so a drop withheld after the fact destroys the data instead of + // misplacing it. + // + // What is exact is the declarative refusal: canAcceptNativeDrop and + // getAcceptedDropActions are consulted by findTarget on this thread, here as + // well as on every drag event, so a target refusing through either is never + // selected and never receives the drop, whatever the event dispatch thread is + // doing. That is what a target refusing outright must use -- reject() in a + // callback is a late change of mind, honoured from the next event onward. + accepted = currentAction; + } else { + // A different component from the one the callbacks were about: the pointer + // moved between the last drag event and the drop, so there is no decision of + // its own to honour and the declarative answer is the right one. + accepted = target == null ? NativeDragOperation.ACTION_NONE + : preferredAction(action & target.getAcceptedDropActions()); + } + currentTarget = null; + targetGeneration++; + overDispatchPending = false; + currentAction = accepted; + } + if (previous != null && previous != target) { // NOPMD CompareObjectsWithEquals + // A release that lands somewhere else -- a quick move and let go -- ends the drag + // for the component it was over, and that component has to be told. Clearing the + // target without it left the old hover highlight on for good: the drop goes to + // somebody else, and the port's own end-of-session cleanup then finds the target + // already cleared and has nothing left to deliver the exit to. + dispatch(previous, ActionEvent.Type.NativeDragExit, content, x, y, action, + NativeDragOperation.ACTION_NONE, Boolean.valueOf(local)); + } + if (accepted == NativeDragOperation.ACTION_NONE) { + return NativeDragOperation.ACTION_NONE; + } + // Queued after the exit above, so a component losing the drag hears about it before + // the one taking it hears about the drop. The event carries the source's whole mask + // and the action actually being performed -- different questions that used to get + // the same answer, so the drop reported the chosen action as though it were all the + // source had ever allowed. + dispatch(target, ActionEvent.Type.NativeDrop, content, x, y, advertised, accepted, + Boolean.valueOf(local)); + return accepted; + } + + /// The action a drop at this position would perform, without dispatching anything or + /// disturbing the drag in progress. + /// + /// A port whose platform commits to an action *before* it can read the transferred data -- + /// AWT does, because a drop has to be accepted before it becomes readable -- asks here + /// first, so that what it commits to is what `#drop(int, int, int, + /// com.codename1.ui.ClipboardContent, int)` will go on to report. Committing the platform's + /// own stale action instead told the source a copy had happened while the target was handed + /// a move. + /// + /// #### Parameters + /// + /// - `windowId`: the id of the window the drag is over, or zero for the main surface + /// + /// - `x`: the pointer position within that surface + /// + /// - `y`: the pointer position within that surface + /// + /// - `content`: the representations the drag is offering, which may still be a description + /// rather than the materialized payload + /// + /// - `action`: the action the platform is proposing + /// + /// #### Returns + /// + /// the action the drop would perform, or `NativeDragOperation#ACTION_NONE` + public static int plannedDropAction(int windowId, int x, int y, ClipboardContent content, + int action) { + Component target = findTarget(windowId, x, y, content, action); + synchronized (LOCK) { + if (target != null && target == currentTarget) { // NOPMD CompareObjectsWithEquals + return currentAction; + } + return target == null ? NativeDragOperation.ACTION_NONE + : preferredAction(action & target.getAcceptedDropActions()); + } + } + + /// Reports that the session started by `#startDrag(com.codename1.ui.Component, + /// com.codename1.ui.NativeDragOperation)` has finished, whatever the outcome, so that a + /// source offering `NativeDragOperation#ACTION_MOVE` learns whether to delete its copy. + /// + /// #### Parameters + /// + /// - `performedAction`: the action the receiver performed, or + /// `NativeDragOperation#ACTION_NONE` when the drag was cancelled or refused + public static void dragCompleted(final int performedAction) { + final NativeDragOperation op; + synchronized (LOCK) { + op = active; + active = null; + currentTarget = null; + targetGeneration++; + currentAction = NativeDragOperation.ACTION_NONE; + overDispatchPending = false; + advertisedActions = NativeDragOperation.ACTION_NONE; + } + if (op == null) { + return; + } + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + op.fireCompleted(performedAction); + } + }); + } + + // ------------------------------------------------------------------------------------ + + /// The component this drag was last over, when it is still part of a live surface and + /// still willing to take what has arrived -- otherwise null. + /// + /// Asked only when the position no longer names anything. A component detached from its + /// surface cannot be dropped on: it has no coordinates to speak of and nothing would + /// repaint. + private static Component stillWillingHoverTarget(ClipboardContent content, int actions) { + Component hovered; + synchronized (LOCK) { + hovered = currentTarget; + } + // Every test findTarget applies, including the one about pointer events: a + // component that opted out of being pointed at between the hover and the drop is + // not a target any more, and the walk has already skipped it -- so restoring it + // here was the one way it could still be dropped on. + if (hovered == null || TopLevelSupport.rootOf(hovered) == null + || !hovered.isNativeDropTarget() || hovered.isIgnorePointerEvents() + || !hovered.isEnabled() + || (actions & hovered.getAcceptedDropActions()) == 0) { + return null; + } + try { + return hovered.canAcceptNativeDrop(content) ? hovered : null; + } catch (Throwable err) { + Log.e(err); + return null; + } + } + + /// Resolves the deepest component under the pointer that is willing to take this content. + /// + /// Runs on the native drag thread and reads the component tree without mutating it, which + /// is the same thing the ports already do to route a native pointer press. + /// #### Parameters + /// + /// - `actions`: the actions in play, so a target that can perform none of them is passed + /// over rather than selected and then found to have nothing to offer. A move-only target + /// nested in a copy-capable one used to swallow a copy-only drag: it was chosen on the + /// content alone, answered with nothing, and the ancestor that would have taken the drop + /// was never reached. Refusing on the action is the same kind of refusal as refusing on + /// the MIME type, and the walk treats it the same way. + private static Component findTarget(int windowId, int x, int y, ClipboardContent content, + int actions) { + Container root = surfaceFor(windowId); + if (root == null) { + return null; + } + Component cmp; + try { + cmp = root.getComponentAt(x, y); + } catch (Throwable err) { + // The tree can be mutated on the event dispatch thread while this walks it. A drag + // event that lands mid-layout is not worth a crash; the next one resolves. + return null; + } + while (cmp != null) { + if (cmp.isNativeDropTarget() && !cmp.isIgnorePointerEvents() && cmp.isEnabled() + && (actions & cmp.getAcceptedDropActions()) != 0) { + try { + if (cmp.canAcceptNativeDrop(content)) { + return cmp; + } + } catch (Throwable err) { + Log.e(err); + } + } + cmp = cmp.getParent(); + } + return null; + } + + /// The container a window id names: the current form for the main surface, the window + /// itself otherwise. + private static Container surfaceFor(int windowId) { + if (windowId == 0) { + return Display.getInstance().getCurrent(); + } + try { + TopLevelContainer top = Desktop.getInstance().windowById(windowId); + return top == null ? null : top.asContainer(); + } catch (Throwable err) { + return null; + } + } + + /// Picks one action out of a bit set, preferring a copy because it is the one that cannot + /// destroy the source's data. + private static int preferredAction(int actions) { + if ((actions & NativeDragOperation.ACTION_COPY) != 0) { + return NativeDragOperation.ACTION_COPY; + } + if ((actions & NativeDragOperation.ACTION_MOVE) != 0) { + return NativeDragOperation.ACTION_MOVE; + } + if ((actions & NativeDragOperation.ACTION_LINK) != 0) { + return NativeDragOperation.ACTION_LINK; + } + return NativeDragOperation.ACTION_NONE; + } + + /// Queues one callback onto the event dispatch thread, where component code is allowed to + /// run, and folds whatever the target decided back into the answer the next drag event will + /// give the operating system. + private static void dispatch(final Component target, final ActionEvent.Type type, + final ClipboardContent content, final int x, final int y, final int allowedActions) { + dispatch(target, type, content, x, y, allowedActions, NativeDragOperation.ACTION_NONE, null); + } + + /// `knownLocal` is null when the caller has no better answer than the drag running now, + /// which is right for every event that happens while it is running. Only a drop assembled + /// after the fact knows better. + private static void dispatch(final Component target, final ActionEvent.Type type, + final ClipboardContent content, final int x, final int y, final int allowedActions, + final int performedAction, final Boolean knownLocal) { + if (target == null) { + if (type == ActionEvent.Type.NativeDragOver) { + synchronized (LOCK) { + overDispatchPending = false; + } + } + return; + } + final boolean local; + final int generation; + synchronized (LOCK) { + local = knownLocal == null ? active != null : knownLocal.booleanValue(); + generation = targetGeneration; + } + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + try { + NativeDropEvent ev = new NativeDropEvent(target, type, content, x, y, allowedActions, local); + if (type == ActionEvent.Type.NativeDrop + && performedAction != NativeDragOperation.ACTION_NONE) { + // The action the drop is performing, which is not what the event + // would default to: a source allowing both defaults to a copy, so a + // target handed a move read getAcceptedAction() as a copy. + ev.accept(performedAction); + } + if (type == ActionEvent.Type.NativeDragOver || type == ActionEvent.Type.NativeDragEnter) { + // Read as this runs, not when it was queued. A drag event can arrive + // before the enter callback ahead of it in the queue has run, and + // capturing the answer at queueing time meant this event then restored + // the default over a decision that callback had since made -- so a + // target rejecting only in nativeDragEnter had its rejection undone by + // the very next no-op nativeDragOver, and was handed the drop. + int startingAction; + synchronized (LOCK) { + startingAction = currentAction; + } + // The target starts from what the framework already agreed to, so a + // target that does not care keeps the answer stable instead of + // resetting it to the default on every event. + ev.accept(startingAction); + } + target.dispatchNativeDropEvent(ev); + if (type == ActionEvent.Type.NativeDragOver || type == ActionEvent.Type.NativeDragEnter) { + synchronized (LOCK) { + // The generation as well as the component: the same component can be + // the target of the drag that just left and of the one that just + // arrived, and this decision belongs to whichever queued it. + if (generation == targetGeneration + && currentTarget == target) { // NOPMD CompareObjectsWithEquals + currentAction = ev.getAcceptedAction(); + } + } + } + } catch (Throwable err) { + Log.e(err); + } finally { + if (type == ActionEvent.Type.NativeDragOver) { + synchronized (LOCK) { + if (generation == targetGeneration) { + overDispatchPending = false; + } + } + } + } + } + }); + } +} diff --git a/CodenameOne/src/com/codename1/ui/NativeDragOperation.java b/CodenameOne/src/com/codename1/ui/NativeDragOperation.java new file mode 100644 index 00000000000..8682061a8c1 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/NativeDragOperation.java @@ -0,0 +1,303 @@ +/* + * 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 Oracle 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.codename1.ui; + +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.util.EventDispatcher; + +/// Everything the operating system needs in order to drag something out of a Codename One +/// component: what is being dragged, what the receiver is allowed to do with it, and what the +/// user should see under the cursor while dragging. +/// +/// The payload is a `ClipboardContent`, the same object a copy publishes, so a component that +/// can already be copied can be made draggable by handing the very same content to +/// `Component#setNativeDragOperation(com.codename1.ui.NativeDragOperation)`. Offering several +/// representations is what lets one drag land correctly in unrelated applications: a text editor +/// takes `ClipboardContent#MIME_HTML`, a plain text field takes `ClipboardContent#MIME_TEXT` and +/// the desktop or a file manager takes `ClipboardContent#MIME_FILE`. +/// +/// Representations that are expensive to produce -- the file that only exists if the user +/// actually drops on the desktop -- should be registered with +/// `ClipboardContent#setDataProvider(java.lang.String, com.codename1.ui.ClipboardDataProvider)` +/// rather than built when the drag starts. +/// +/// #### Moving rather than copying +/// +/// `#ACTION_MOVE` means the receiver takes ownership and the source is expected to delete its +/// copy. The source only learns whether that happened once the operating system has finished +/// the transfer, which is why the outcome arrives asynchronously through +/// `#addCompletionListener(com.codename1.ui.events.ActionListener)` and not from the call that +/// started the drag. +public class NativeDragOperation { + /// No transfer, which is what a rejected or cancelled drag reports. + public static final int ACTION_NONE = 0; + + /// The receiver takes a copy and the source keeps its own. + public static final int ACTION_COPY = 1; + + /// The receiver takes ownership; the source should delete its copy when the drag completes + /// with this action. + public static final int ACTION_MOVE = 2; + + /// The receiver stores a reference rather than the data, the way a shortcut or an alias does. + public static final int ACTION_LINK = 4; + + private final ClipboardContent content; + private int allowedActions = ACTION_COPY; + private Image dragImage; + private int dragImageOffsetX; + private int dragImageOffsetY; + private String label; + private int performedAction = ACTION_NONE; + /// True when the image below is the one the framework rendered from the source component, + /// rather than one the application supplied. + private boolean dragImageGenerated; + private Component source; + private EventDispatcher completionListeners; + + /// Creates a drag carrying the given representations. + /// + /// #### Parameters + /// + /// - `content`: the payload, which must not be null + public NativeDragOperation(ClipboardContent content) { + if (content == null) { + throw new IllegalArgumentException("content must not be null"); + } + this.content = content; + } + + /// Creates a plain text drag, the shorthand for the common case. + /// + /// #### Parameters + /// + /// - `text`: the text being dragged + public NativeDragOperation(String text) { + this(new ClipboardContent().setData(ClipboardContent.MIME_TEXT, text == null ? "" : text)); + } + + /// Creates a drag carrying files, which is what a drop onto the desktop or a file manager + /// consumes. + /// + /// #### Parameters + /// + /// - `paths`: the file paths or `file:` URIs being dragged + /// + /// #### Returns + /// + /// the new operation + public static NativeDragOperation createFileDrag(String[] paths) { + return new NativeDragOperation(new ClipboardContent().setFiles(paths)); + } + + /// Returns the payload. + public ClipboardContent getContent() { + return content; + } + + /// Returns the bit set of actions the source is willing to allow, `#ACTION_COPY` by default. + public int getAllowedActions() { + return allowedActions; + } + + /// Sets the bit set of actions the source is willing to allow. The receiver chooses one of + /// them, usually influenced by the modifier keys the user is holding. + /// + /// Allowing none of them is allowing nothing to be done with the drag, so no drag begins at + /// all: there is nothing a receiver could accept. + /// + /// #### Parameters + /// + /// - `allowedActions`: any combination of `#ACTION_COPY`, `#ACTION_MOVE` and `#ACTION_LINK` + /// + /// #### Returns + /// + /// this instance, for chaining + public NativeDragOperation setAllowedActions(int allowedActions) { + this.allowedActions = allowedActions; + return this; + } + + /// Returns the image drawn under the cursor during the drag, or null to let the port draw + /// the component itself. + public Image getDragImage() { + return dragImage; + } + + /// Sets the image drawn under the cursor during the drag. When this is left null the port + /// renders the dragged component through `Component#getDragImage()`, so the user sees the + /// thing they grabbed. + /// + /// #### Parameters + /// + /// - `dragImage`: the image, or null for the default + /// + /// #### Returns + /// + /// this instance, for chaining + public NativeDragOperation setDragImage(Image dragImage) { + this.dragImage = dragImage; + this.dragImageGenerated = false; + return this; + } + + /// Installs the image the framework rendered from the source component, and the point of it + /// the press landed on. + /// + /// Kept apart from `#setDragImage(com.codename1.ui.Image)` because the operation is reused + /// for every drag of its component: writing a generated snapshot in as though the + /// application had supplied it meant every later drag showed the first one's picture, taken + /// before whatever the component has done since, grabbed at a point the user did not press. + void setGeneratedDragImage(Image image, int offsetX, int offsetY) { + this.dragImage = image; + this.dragImageOffsetX = offsetX; + this.dragImageOffsetY = offsetY; + this.dragImageGenerated = true; + } + + /// True when the current image was rendered by the framework, so a later gesture should + /// render a fresh one rather than reuse it. + boolean isDragImageGenerated() { + return dragImageGenerated; + } + + /// Returns the x offset of the cursor within the drag image. + public int getDragImageOffsetX() { + return dragImageOffsetX; + } + + /// Returns the y offset of the cursor within the drag image. + public int getDragImageOffsetY() { + return dragImageOffsetY; + } + + /// Places the cursor at a specific point of the drag image, so the image keeps the position + /// it had relative to the finger or pointer when the drag began. + /// + /// #### Parameters + /// + /// - `x`: the x offset within the image + /// + /// - `y`: the y offset within the image + /// + /// #### Returns + /// + /// this instance, for chaining + public NativeDragOperation setDragImageOffset(int x, int y) { + this.dragImageOffsetX = x; + this.dragImageOffsetY = y; + return this; + } + + /// Returns the human readable label some platforms show beside the drag image. + public String getLabel() { + return label; + } + + /// Sets the human readable label some platforms show beside the drag image, such as the + /// file name of a dragged document. Platforms that have no such affordance ignore it. + /// + /// #### Parameters + /// + /// - `label`: the label + /// + /// #### Returns + /// + /// this instance, for chaining + public NativeDragOperation setLabel(String label) { + this.label = label; + return this; + } + + /// Returns the component the drag started from, or null when the drag was started through + /// `NativeDragAndDrop#startDrag(com.codename1.ui.Component, com.codename1.ui.NativeDragOperation)` + /// without one. + public Component getSource() { + return source; + } + + void setSource(Component source) { + this.source = source; + } + + /// Returns the action the receiver actually performed, valid once the drag has completed. + /// Before that, and for a drag that was cancelled or rejected, this is `#ACTION_NONE`. + public int getPerformedAction() { + return performedAction; + } + + /// Adds a listener notified on the event dispatch thread once the operating system has + /// finished with this drag, whether it was dropped or abandoned. Read + /// `#getPerformedAction()` from the listener; a source offering `#ACTION_MOVE` deletes its + /// copy here and nowhere else, because until this fires nothing is known about whether the + /// receiver took it. + /// + /// #### Parameters + /// + /// - `l`: the listener + public void addCompletionListener(ActionListener l) { + if (completionListeners == null) { + completionListeners = new EventDispatcher(); + } + completionListeners.addListener(l); + } + + /// Removes a listener added by + /// `#addCompletionListener(com.codename1.ui.events.ActionListener)`. + /// + /// #### Parameters + /// + /// - `l`: the listener + public void removeCompletionListener(ActionListener l) { + if (completionListeners != null) { + completionListeners.removeListener(l); + } + } + + /// Clears the outcome of a previous drag, because this operation is being installed as the + /// active one again. + /// + /// The same instance is offered for every drag of the component that owns it, so without + /// this `#getPerformedAction()` would go on reporting the *previous* drag's result for the + /// whole of the new one, which contradicts its contract that the value before completion is + /// `#ACTION_NONE`. + void resetPerformedAction() { + performedAction = ACTION_NONE; + } + + /// Records the outcome and notifies the completion listeners. Invoked by the port, on the + /// event dispatch thread, when the native drag session ends. + /// + /// #### Parameters + /// + /// - `action`: the action the receiver performed, or `#ACTION_NONE` + void fireCompleted(int action) { + performedAction = action; + if (completionListeners != null && completionListeners.hasListeners()) { + completionListeners.fireActionEvent(new ActionEvent(this, ActionEvent.Type.NativeDragCompleted)); + } + } +} diff --git a/CodenameOne/src/com/codename1/ui/NativeDropEvent.java b/CodenameOne/src/com/codename1/ui/NativeDropEvent.java new file mode 100644 index 00000000000..fc6380a5bac --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/NativeDropEvent.java @@ -0,0 +1,188 @@ +/* + * 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 Oracle 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.codename1.ui; + +import com.codename1.ui.events.ActionEvent; + +/// One step of a native operating system drag passing over, or landing on, a component that was +/// marked as a native drop target with `Component#setNativeDropTarget(boolean)`. +/// +/// The payload is a `ClipboardContent` -- the same shape a paste produces -- so a component that +/// already knows how to paste knows how to accept a drop. Inspect +/// `ClipboardContent#getMimeTypes()` and take the richest representation you understand, exactly +/// as you would for a paste. +/// +/// The x and y inherited from `ActionEvent` are absolute screen coordinates within the surface +/// the drag is over, so `Component#getAbsoluteX()` and `Component#getAbsoluteY()` convert them +/// to component coordinates. +/// +/// #### Accepting +/// +/// A drag event tells the operating system whether the drop would be allowed and what would +/// happen. Call `#accept(int)` with one of the actions in `#getAllowedActions()` to show the +/// user the corresponding cursor, or `#reject()` to refuse. A target that never calls either +/// accepts `NativeDragOperation#ACTION_COPY` when the source allows it, which is what most +/// targets want. +public final class NativeDropEvent extends ActionEvent { + private final ClipboardContent content; + private final int allowedActions; + private final boolean local; + private final Component target; + private int acceptedAction; + + /// Creates a drop event. + /// + /// #### Parameters + /// + /// - `target`: the component the drag is over + /// + /// - `type`: the event type + /// + /// - `content`: the dragged payload + /// + /// - `x`: the absolute x position of the pointer + /// + /// - `y`: the absolute y position of the pointer + /// + /// - `allowedActions`: the actions the drag source permits + /// + /// - `local`: true when the drag started inside this application + NativeDropEvent(Component target, Type type, ClipboardContent content, int x, int y, + int allowedActions, boolean local) { + super(target, type, x, y); + this.target = target; + this.content = content; + this.allowedActions = allowedActions; + this.local = local; + this.acceptedAction = defaultAction(permittedActions()); + } + + /// Picks the action a target that expresses no preference gets: a copy when the source + /// allows one, otherwise whichever single action it does allow. + private static int defaultAction(int allowedActions) { + if ((allowedActions & NativeDragOperation.ACTION_COPY) != 0) { + return NativeDragOperation.ACTION_COPY; + } + if ((allowedActions & NativeDragOperation.ACTION_MOVE) != 0) { + return NativeDragOperation.ACTION_MOVE; + } + if ((allowedActions & NativeDragOperation.ACTION_LINK) != 0) { + return NativeDragOperation.ACTION_LINK; + } + return NativeDragOperation.ACTION_NONE; + } + + /// Returns the dragged payload. + public ClipboardContent getContent() { + return content; + } + + /// Returns the component the drag is over. + public Component getTarget() { + return target; + } + + /// Returns the bit set of actions that may be agreed to here and now, which is what + /// `#accept(int)` will take. + /// + /// The source's permissions are where it starts, narrowed by anything that has since + /// narrowed them. A desktop modifier is the usual one: a source offering a copy and a + /// move while the user holds the key for a move is offering a move *now*, and reporting + /// both would invite a target to accept the copy the user has just said they do not + /// want. That is the question this answers -- what may be accepted -- and it is the same + /// answer on the enter, the over and the drop, so a target reads one thing throughout a + /// drag rather than one thing while hovering and another at the end. + /// + /// It is not a way to ask what the source would have permitted in the absence of the + /// user's choice; nothing carries that, and a target that acted on it would be acting + /// against the choice. + public int getAllowedActions() { + return allowedActions; + } + + /// Returns true when the drag started inside this application rather than in another + /// application or on the desktop. A target that reorders its own items usually only wants + /// to handle local drags, and a target that imports foreign data usually only wants the + /// rest. + public boolean isLocal() { + return local; + } + + /// Returns the file paths carried by the drag, or null when it carries none. This is the + /// representation a drag out of a file manager or off the desktop arrives with. + public String[] getFiles() { + return content == null ? null : content.getFiles(); + } + + /// Returns the plain text carried by the drag, or null when it carries none. + public String getText() { + return content == null ? null : content.getText(ClipboardContent.MIME_TEXT); + } + + /// Returns the action this target has accepted, or `NativeDragOperation#ACTION_NONE` when + /// it has refused the drop. + public int getAcceptedAction() { + return acceptedAction; + } + + /// Accepts the drag, telling the operating system what dropping here would do. An action + /// the source does not allow is refused rather than silently substituted, because showing + /// the user a move cursor for a drag that can only copy is worse than showing no cursor. + /// + /// #### Parameters + /// + /// - `action`: one of `NativeDragOperation#ACTION_COPY`, `NativeDragOperation#ACTION_MOVE` + /// or `NativeDragOperation#ACTION_LINK` + public void accept(int action) { + // Exactly one, and one that is permitted. A listener passing the whole set back -- + // accept(getAllowedActions()) is the obvious thing to write -- used to have that set + // stored as though it were an action, and the ports then each read it their own way: + // the iOS mapping quietly chose the move out of copy-or-move. There is no such thing + // as agreeing to two actions, so a set is a refusal, as an unpermitted action is. + boolean single = action != NativeDragOperation.ACTION_NONE + && (action & (action - 1)) == 0; + acceptedAction = single && (action & permittedActions()) == action + ? action : NativeDragOperation.ACTION_NONE; + } + + /// What may actually be agreed to here: what the source offers, narrowed by what the + /// target said it accepts. + /// + /// Both halves, because a target that declared itself copy-only and then accepted a move + /// in a listener used to have that move honoured -- and a move is the source deleting its + /// copy. The declaration is what made this component eligible for the drag in the first + /// place, and it is what the framework's own default already respects; a listener widening + /// past it is a contradiction, and the safe reading of a contradiction is a refusal, which + /// is what accept() already does for an action the source never offered. + private int permittedActions() { + return target == null ? allowedActions : allowedActions & target.getAcceptedDropActions(); + } + + /// Refuses the drag, so the user sees a "no drop" cursor over this component and no drop + /// event is delivered. + public void reject() { + acceptedAction = NativeDragOperation.ACTION_NONE; + } +} diff --git a/CodenameOne/src/com/codename1/ui/Window.java b/CodenameOne/src/com/codename1/ui/Window.java index c7e104db000..e4f0b384850 100644 --- a/CodenameOne/src/com/codename1/ui/Window.java +++ b/CodenameOne/src/com/codename1/ui/Window.java @@ -3320,6 +3320,12 @@ public void pointerDragged(int x, int y) { stylusCmp.fireStylusEvent(ActionEvent.Type.PointerDrag, x, y); } } + // A press that landed on a native drag source becomes an operating system drag + // here, exactly as it does on a Form, once it has moved far enough to be a drag + // rather than a click. The platform owns the gesture from that point. + if (NativeDragAndDrop.pointerDragged(x, y)) { + return; + } // Read and cleared here, exactly as Form does: the flag describes the drag that // took a momentum scroll over, and leaving it set would tell every later drag in // the session that it too continued out of a glide. @@ -3352,6 +3358,21 @@ public void pointerDragged(int x, int y) { /// pressed child gets an ordinary one-finger drag and never its `pinch` callbacks. @Override public void pointerDragged(int[] x, int[] y) { + // The same hook the scalar overload runs, and the one an ordinary one-finger drag + // actually reaches: Display wraps a single pointer into one-element arrays and + // dispatches them here, and this overload is a separate implementation rather than a + // call to the scalar one. With the hook only there a gesture never began a native drag + // on any port that starts one itself. + // + // One pointer only: a second finger makes this a pinch or a two-finger scroll, which + // is not a drag to hand to the operating system -- and it spends the press that + // staged one, because the gesture has become something else. Merely skipping the + // hook left it staged for the next one-finger movement to start. + if (x.length > 1) { + NativeDragAndDrop.gestureCancelled(); + } else if (NativeDragAndDrop.pointerDragged(x[0], y[0])) { + return; + } // The same listener block the scalar overload runs. Adding it there only // meant a gesture stopped notifying window listeners the moment it became // multi touch, which is where pull to refresh loses its updates. @@ -3386,6 +3407,9 @@ public void pointerReleased(int x, int y) { stylusCmp.fireStylusEvent(ActionEvent.Type.PointerReleased, x, y); } } + // A press that never became a drag releases the operation the press staged, so a + // later gesture somewhere else cannot start the drag this one declined to. + NativeDragAndDrop.pointerReleased(); // The token identifying *this* gesture. A release handler may enter // invokeAndBlock, whose nested event loop can dispatch a fresh press in this // same window before the handler returns; tearing down unconditionally then @@ -4014,6 +4038,12 @@ void pushPointerInputScope(Container scope) { /// notification `#cancelPendingInput()` sends -- that one is for a window going /// away, this one is for input changing hands while the window stays put. private void cancelPointerGesture() { + // Whatever the press staged for the operating system goes with it. This is the + // window's own cancellation -- a press handler putting a dialog up, an overlay + // taking the pointer -- and it delivers no release, so nothing else clears it. The + // next motion packet reaches the native hook before the gestureCancelled test below + // it, and would have started a drag from the component now behind the dialog. + NativeDragAndDrop.gestureCancelled(); if (dragged != null && dragged.isDragAndDropInitialized()) { // No drop target: the user never completed the drag, something took the // pointer away. This still restores visibility and clears the drag flags. diff --git a/CodenameOne/src/com/codename1/ui/events/ActionEvent.java b/CodenameOne/src/com/codename1/ui/events/ActionEvent.java index 2bbe7cc3fce..1c1d4343394 100644 --- a/CodenameOne/src/com/codename1/ui/events/ActionEvent.java +++ b/CodenameOne/src/com/codename1/ui/events/ActionEvent.java @@ -569,5 +569,22 @@ public enum Type { OpenGallery, IsGalleryTypeSupported, + + /// Fired when a native operating system drag enters a component that is a native drop + /// target, see `com.codename1.ui.Component#addNativeDropListener(com.codename1.ui.events.ActionListener)` + NativeDragEnter, + + /// Fired repeatedly while a native operating system drag moves over a native drop target + NativeDragOver, + + /// Fired when a native operating system drag leaves a native drop target + NativeDragExit, + + /// Fired when a native operating system drag is dropped on a native drop target + NativeDrop, + + /// Fired on the source of a native drag once the operating system has finished the + /// transfer, telling the source which action -- if any -- was actually performed + NativeDragCompleted, } } diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 21660be5870..b6ad05b7a7a 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -78,6 +78,7 @@ import com.codename1.ui.Image; import com.codename1.ui.PeerComponent; import com.codename1.ui.ClipboardContent; +import com.codename1.ui.ClipboardDataProvider; import com.codename1.ui.events.ActionEvent; import com.codename1.impl.CodenameOneImplementation; import com.codename1.impl.VirtualKeyboardInterface; @@ -2192,6 +2193,9 @@ private void initSurface() { myView = new AndroidAsyncView(getActivity(), AndroidImplementation.this); } myView.getAndroidView().setVisibility(View.VISIBLE); + // Makes the surface an Android drop target, so a drag from another application -- + // or from elsewhere in this one -- reaches the components that asked for it. + AndroidNativeDragAndDrop.install(this, myView.getAndroidView()); if (hideOverlayWindowsRequested) { setHideOverlayWindows(true); @@ -10156,29 +10160,18 @@ public void run() { clipboard.setText(obj.toString()); } else { android.content.ClipboardManager clipboard = (android.content.ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); - android.content.ClipData clip = null; - if (sdk >= 16 && obj instanceof ClipboardContent - && ((ClipboardContent) obj).getText(ClipboardContent.MIME_HTML) != null) { - ClipboardContent rich = (ClipboardContent) obj; - clip = ClipData.newHtmlText("Codename One", - rich.getText(ClipboardContent.MIME_TEXT), - rich.getText(ClipboardContent.MIME_HTML)); - } else if (obj instanceof ClipboardContent - && ((ClipboardContent) obj).getText(ClipboardContent.MIME_TEXT) != null) { - clip = ClipData.newPlainText("Codename One", - ((ClipboardContent)obj).getText(ClipboardContent.MIME_TEXT)); - } else if (!(obj instanceof ClipboardContent)) { - clip = ClipData.newPlainText("Codename One", obj.toString()); - } + android.content.ClipData clip; if (obj instanceof ClipboardContent) { - try { - clip = enrichClipWithBinaryContent((ClipboardContent) obj, clip); - } catch (Throwable t) { - com.codename1.io.Log.e(t); + clip = clipDataFor((ClipboardContent) obj); + if (clip == null) { + // A copy of nothing is an empty clipboard, which is a thing the user + // asked for and can paste. A *drag* of nothing is not: there the null + // refuses to start, because a drag that carries nothing still lands + // somewhere and tells that receiver it succeeded. + clip = ClipData.newPlainText("Codename One", ""); } - } - if (clip == null) { - clip = ClipData.newPlainText("Codename One", ""); + } else { + clip = ClipData.newPlainText("Codename One", obj.toString()); } clipboard.setPrimaryClip(clip); } @@ -10186,15 +10179,178 @@ public void run() { }); } + /// Builds the Android clip that publishes a `ClipboardContent`, for a clipboard copy and + /// for a native drag alike -- both hand another application the same thing, so both go + /// through the same conversion, including the file provider URIs that let the receiving + /// application read generated image bytes. + /// + /// #### Parameters + /// + /// - `content`: the representations to publish + /// + /// #### Returns + /// + /// the clip, or null when the content produced no representation at all + ClipData clipDataFor(ClipboardContent content) { + beginStagingClip(); + int sdk = android.os.Build.VERSION.SDK_INT; + List mimeTypes = new ArrayList(); + List items = new ArrayList(); + String plain = content.getText(ClipboardContent.MIME_TEXT); + String html = content.getText(ClipboardContent.MIME_HTML); + // A clip carries one text payload. Where the content has no text/plain but does have + // some other text representation -- markdown, AsciiDoc, a URI list -- that one is the + // payload, since publishing an empty clip instead would lose it outright. + String primaryTextMime = plain != null ? ClipboardContent.MIME_TEXT : null; + // Not when there is HTML: that is already the payload, and an item built with null text + // coerces to the HTML's own text, whereas handing it the raw markup as the plain text + // would give every receiver the tags as well. + if (plain == null && html == null) { + String[] advertised = content.getMimeTypes(); + for (int iter = 0; iter < advertised.length && plain == null; iter++) { + if (ClipboardContent.MIME_FILE.equals(advertised[iter])) { + continue; + } + String value = content.getText(advertised[iter]); + if (value != null) { + plain = value; + primaryTextMime = advertised[iter]; + } + } + } + // The types are recorded here, but the text does not become an item of its own yet. A + // clip item is a dragged *object*, so a text item beside a file item is two things + // being dragged at once, and a receiver that imports everything takes the document + // *and* a stray piece of text instead of choosing the best form of one thing. Where + // the clip carries a URI, the text rides on it -- see attachCarriedText below. + boolean carriesHtml = sdk >= 16 && html != null; + if (carriesHtml) { + mimeTypes.add(ClipboardContent.MIME_TEXT); + mimeTypes.add(ClipboardContent.MIME_HTML); + } else if (plain != null) { + mimeTypes.add(ClipboardContent.MIME_TEXT); + if (primaryTextMime != null && !mimeTypes.contains(primaryTextMime)) { + mimeTypes.add(primaryTextMime); + } + } + try { + addBinaryContent(content, mimeTypes, items); + addPublishedUris(content, mimeTypes, items); + addRemainingRepresentations(content, plain, mimeTypes, items); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + if (carriesHtml || plain != null) { + attachCarriedText(items, plain, carriesHtml ? html : null); + } + if (items.isEmpty()) { + // Nothing was produced. Every representation this content offered is a provider that + // answered null or threw, which ClipboardDataProvider explicitly permits -- so there + // is no clip, and the callers decide what that means. Answering with empty text + // instead replaced the payload with a different one: a drag offering only + // application/pdf reported success and let another application accept blank text. + return null; + } + // Built from the union of the types, not by appending to a text clip. ClipData.addItem + // does not add the item's type to the description, so a clip assembled that way + // describes itself as text only -- and both a Codename One drop target filtering on + // MIME_FILE and an external receiver choosing a representation read the description. + ClipData clip = new ClipData("Codename One", + mimeTypes.toArray(new String[mimeTypes.size()]), items.get(0)); + for (int iter = 1; iter < items.size(); iter++) { + clip.addItem(items.get(iter)); + } + return clip; + } + + // ------------------------------------------------------------------------------------ + // Native drag and drop. See AndroidNativeDragAndDrop; the payload is the same ClipData a + // copy publishes, which is why a drag out of the application lands in another application + // exactly as a paste would. + // ------------------------------------------------------------------------------------ + + @Override + public boolean isNativeDragAndDropSupported() { + return AndroidNativeDragAndDrop.isSupported(); + } + + @Override + public boolean isNativeDragOutsideApplicationSupported() { + return AndroidNativeDragAndDrop.isOutsideApplicationSupported(); + } + + @Override + public boolean startNativeDrag(com.codename1.ui.NativeDragOperation op) { + return AndroidNativeDragAndDrop.startDrag(this, op); + } + + @Override + public void cancelNativeDrag() { + AndroidNativeDragAndDrop.cancelDrag(); + } + /** - * Enriches the given base ClipData (which may be null) with image bytes and/or file - * references carried by the ClipboardContent, exposing binary content as FileProvider - * content:// URIs. Returns the (possibly newly created) ClipData, or the original clip on - * failure. Never throws. + * Collects the image bytes and file references carried by the ClipboardContent as items and + * MIME types, exposing binary content as FileProvider content:// URIs. The caller assembles + * the ClipData from the union of everything collected here and the text types, because + * ClipData.addItem cannot widen a description that already exists. */ - private ClipData enrichClipWithBinaryContent(ClipboardContent content, ClipData clip) throws IOException { + private void addBinaryContent(ClipboardContent content, List mimeTypes, + List items) throws IOException { String authority = getContext().getPackageName() + ".provider"; + // The files first, then the byte-backed representations. Android's ClipData.Item holds + // exactly one Uri, so two representations that are both bytes cannot be one item -- the + // platform has no way to say "another reading of the same object" for them, only for + // the text and markup that attachCarriedText rides on the item below. Publishing them + // is still right: they are what the description advertises, and dropping them would + // refuse the very target that accepted the hover on one. What order fixes is which + // object a receiver reading only the first item takes -- the document, not its + // thumbnail. + // + // It is also what puts the carried text on the document rather than on the thumbnail. + + // File references: MIME_FILE may be a single String or a String[] + Object fileData = content.getData(ClipboardContent.MIME_FILE); + if (fileData != null) { + String[] paths; + if (fileData instanceof String[]) { + paths = (String[]) fileData; + } else { + paths = new String[]{ fileData.toString() }; + } + for (int i = 0; i < paths.length; i++) { + String pathOrUri = paths[i]; + if (pathOrUri == null || pathOrUri.length() == 0) { + continue; + } + // Each file on its own. A path outside the roots the file provider was + // configured with throws, and one throwing on the second of three used to + // abandon the third as well *and* skip every representation after the file + // loop -- so the clip went out holding one file, silently, and the drag + // reported success. + try { + Uri u; + if (pathOrUri.startsWith("content:")) { + u = Uri.parse(pathOrUri); + } else { + File file = pathOrUri.startsWith("file:") + ? new File(Uri.parse(pathOrUri).getPath()) + : new File(pathOrUri); + u = shareableUriFor(file, authority); + } + if (!mimeTypes.contains("text/uri-list")) { + mimeTypes.add("text/uri-list"); + } + items.add(new ClipData.Item(u)); + } catch (Throwable t) { + // Absent rather than advertised: nothing named it a type of its own, so + // no receiver is told the clip holds a file it does not. + com.codename1.io.Log.e(t); + } + } + } + // Image bytes: prefer PNG, then JPEG, then GIF String imageMime = null; byte[] imageBytes = null; @@ -10213,60 +10369,357 @@ private ClipData enrichClipWithBinaryContent(ClipboardContent content, ClipData imageExt = "gif"; } if (imageBytes != null) { - // AndroidGradleBuilder exposes cache/intent_files through the app's - // FileProvider. Keep generated clipboard payloads inside that root so - // FileProvider can safely create a content:// URI for paste targets. - File imageFile = new File(new File(getContext().getCacheDir(), "intent_files"), - "cn1-clip-image-" + System.currentTimeMillis() + "." + imageExt); - imageFile.getParentFile().mkdirs(); - OutputStream os = new FileOutputStream(imageFile); try { - os.write(imageBytes); - } finally { - os.close(); + Uri imageUri = writeAsProviderUri(imageBytes, imageExt, imageMime); + if (imageUri != null) { + if (!mimeTypes.contains(imageMime)) { + mimeTypes.add(imageMime); + } + items.add(new ClipData.Item(imageUri)); + } + } catch (Throwable t) { + // On its own, so a picture that cannot be written does not take the files + // and the other representations with it. + com.codename1.io.Log.e(t); } - Uri imageUri = FileProvider.getUriForFile(getContext(), authority, imageFile); - // Grant broadly so any paste target can read the content:// URI - getContext().grantUriPermission("android", imageUri, Intent.FLAG_GRANT_READ_URI_PERMISSION); - if (clip == null) { - clip = new ClipData("Codename One", new String[]{ imageMime }, new ClipData.Item(imageUri)); - } else { - clip.addItem(new ClipData.Item(imageUri)); + } + } + + /// Puts the URIs a text/uri-list names on the clip as URIs. + /// + /// A URI is what an Android receiver reads off `ClipData.Item#getUri()`, and a link has + /// nothing else to be read off. Left to the passes around this one a uri-list became + /// carried text, or -- where the clip had text already -- a content URI holding the list + /// as a document; either way a receiver that took the clip because it advertised + /// text/uri-list found no URI on it at all. + /// + /// One item per URI, because an item is a dragged object and a list of three links is + /// three of them. The clip's text still rides on the first, as it does on a file. + private static void addPublishedUris(ClipboardContent content, List mimeTypes, + List items) { + String list = content.getText(ClipboardContent.MIME_URI_LIST); + if (list == null) { + return; + } + for (int iter = 0; iter < items.size(); iter++) { + if (items.get(iter).getUri() != null) { + // The clip carries URIs already -- the files, which is what a list beside + // them names. Adding them again would drag every file twice, and they do not + // compare equal to the list's own entries either: what went onto the clip is + // a content URI this application minted for a path the source published. + // Naming the type is enough; the URIs the clip carries are what a reader + // builds the list back out of. + declareUriList(mimeTypes); + return; + } + } + boolean any = false; + String[] lines = list.split("\n"); + for (int iter = 0; iter < lines.length; iter++) { + String line = lines[iter].trim(); + // RFC 2483: a line opening with a hash is a comment, not a URI. + if (line.length() == 0 || line.charAt(0) == '#') { + continue; } + items.add(new ClipData.Item(Uri.parse(line))); + any = true; + } + if (any) { + declareUriList(mimeTypes); } + } - // File references: MIME_FILE may be a single String or a String[] - Object fileData = content.getData(ClipboardContent.MIME_FILE); - if (fileData != null) { - String[] paths; - if (fileData instanceof String[]) { - paths = (String[]) fileData; - } else { - paths = new String[]{ fileData.toString() }; + private static void declareUriList(List mimeTypes) { + if (!mimeTypes.contains(ClipboardContent.MIME_URI_LIST)) { + mimeTypes.add(ClipboardContent.MIME_URI_LIST); + } + } + + /// Puts the clip's text on the first item that carries a URI, or makes an item of it when + /// there is none. + /// + /// Android has no notion of "an alternative reading of this object": every item is another + /// thing being dragged. A file and its text fallback therefore have to be one item, or a + /// receiver importing the clip gets two objects where the source published one. The same + /// mistake on the iOS side made a receiver import a document and a stray piece of text. + private static void attachCarriedText(List items, String plain, String html) { + for (int iter = 0; iter < items.size(); iter++) { + Uri uri = items.get(iter).getUri(); + if (uri != null) { + items.set(iter, html != null + ? new ClipData.Item(plain, html, null, uri) + : new ClipData.Item(plain, null, uri)); + return; } - for (int i = 0; i < paths.length; i++) { - String pathOrUri = paths[i]; - if (pathOrUri == null || pathOrUri.length() == 0) { + } + // Nothing to ride on, so the text is the object. First, as it was before there was + // anything else in the clip at all. + items.add(0, html != null ? new ClipData.Item(plain, html) : new ClipData.Item(plain)); + } + + /// Adds the representations neither the text nor the binary pass above has taken. + /// + /// Byte-backed types -- a PDF, an archive, an application's own format -- become typed + /// content URIs, which is the only labelled way an Android clip carries bytes. Text types + /// are advertised only when their value *is* the text the clip already carries: a clip has + /// one text payload, so advertising a second, different reading of it would tell a receiver + /// the clip holds something it cannot then produce, and a Codename One target would accept + /// the hover and be refused at the drop. + private void addRemainingRepresentations(ClipboardContent content, String carriedText, + List mimeTypes, List items) throws IOException { + String[] advertised = content.getMimeTypes(); + for (int iter = 0; iter < advertised.length; iter++) { + String mime = advertised[iter]; + if (mimeTypes.contains(mime) || ClipboardContent.MIME_FILE.equals(mime)) { + continue; + } + // Each representation on its own: a provider that throws is one type absent, not + // every type after it. ClipboardDataProvider permits it to fail. + Object value; + try { + value = content.getData(mime); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + continue; + } + byte[] bytes = null; + if (value instanceof String) { + if (carriedText != null && carriedText.equals(value)) { + // The same text the clip already carries, so naming the type is enough. + mimeTypes.add(mime); continue; } - Uri u; - if (pathOrUri.startsWith("content:")) { - u = Uri.parse(pathOrUri); - } else { - File file = pathOrUri.startsWith("file:") - ? new File(Uri.parse(pathOrUri).getPath()) - : new File(pathOrUri); - u = FileProvider.getUriForFile(getContext(), authority, file); - getContext().grantUriPermission("android", u, Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - if (clip == null) { - clip = new ClipData("Codename One", new String[]{ "text/uri-list" }, new ClipData.Item(u)); - } else { - clip.addItem(new ClipData.Item(u)); + // A *different* reading -- Markdown source beside its plain rendering, say. + // A clip carries one text payload, so this one travels as a typed content URI + // the way binary does. Dropping it instead, which is what this did, lost a + // representation the application deliberately published. + bytes = ((String) value).getBytes("UTF-8"); + } else if (value instanceof byte[]) { + bytes = (byte[]) value; + } + if (bytes != null) { + try { + Uri uri = writeAsProviderUri(bytes, extensionForMime(mime), mime); + if (uri != null) { + mimeTypes.add(mime); + items.add(new ClipData.Item(uri)); + } + } catch (Throwable t) { + com.codename1.io.Log.e(t); } } } - return clip; + } + + /// A content URI another application can read for this file. + /// + /// The file provider is configured with a fixed set of roots -- the application's files + /// directory and cache/intent_files -- and getUriForFile throws for anything outside them. + /// Plenty of perfectly good paths are outside them: FileSystemStorage lists external + /// storage roots, and a file there used to throw, be logged, and be left out of the clip + /// entirely -- taking the whole drag with it when it was the only thing being dragged. + /// + /// So it is copied where the provider can reach, under its own name, which is what a + /// receiver sees. Not through writeAsProviderUri: that names and records what it mints as + /// transport for a representation's bytes, and this is a file the source published. + private static final long MAX_STAGED_SHARE_BYTES = 8L * 1024 * 1024; + private static final String SHARED_COPY_PREFIX = "cn1-shared-"; + + private Uri shareableUriFor(File file, String authority) throws IOException { + try { + Uri direct = FileProvider.getUriForFile(getContext(), authority, file); + getContext().grantUriPermission("android", direct, + Intent.FLAG_GRANT_READ_URI_PERMISSION); + return direct; + } catch (Throwable outsideTheRoots) { + com.codename1.io.Log.e(outsideTheRoots); + } + // The copy runs on the thread that started the drag, which is the event dispatch + // thread, and a drag has to begin while the finger is still down -- so this cannot be + // moved off it and cannot be allowed to take long. Android stops waiting for input after + // five seconds; a few megabytes is far below that on any storage, and a file bigger than + // this has no business being copied at all. It belongs under a provider root, which is + // where the roots above now put the external storage such files actually live on. + if (file.length() > MAX_STAGED_SHARE_BYTES) { + throw new IOException("refusing to copy " + file.length() + " bytes on the event " + + "dispatch thread to share " + file); + } + File dir = new File(getContext().getCacheDir(), "intent_files"); + dir.mkdirs(); + // Its own directory, so the copy keeps the original name without colliding with + // another file of the same name in the same drag. + File holder = File.createTempFile(SHARED_COPY_PREFIX, "", dir); + if (!holder.delete() || !holder.mkdirs()) { + throw new IOException("could not stage " + file + " for sharing"); + } + File copy = new File(holder, file.getName()); + InputStream in = new FileInputStream(file); + try { + OutputStream os = new FileOutputStream(copy); + try { + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) > 0) { + os.write(buffer, 0, read); + } + } finally { + os.close(); + } + } finally { + in.close(); + } + Uri staged = FileProvider.getUriForFile(getContext(), authority, copy); + getContext().grantUriPermission("android", staged, + Intent.FLAG_GRANT_READ_URI_PERMISSION); + // Remembered so it is cleaned up, but not as transport: this is a file the source + // published, and it has to read back as one. + rememberStagedClipFile(staged, copy, false); + return staged; + } + + /// Writes bytes somewhere the application's file provider can serve them from and returns + /// the content URI, which is how an Android clip carries anything that is not text. + /// + /// AndroidGradleBuilder exposes cache/intent_files through the app's FileProvider, so + /// generated payloads stay inside that root and FileProvider can safely name them. + /// + /// The name carries `mime` so the read back is an answer rather than a guess -- see + /// `#decodeMimeFromFileName(java.lang.String)`. + private Uri writeAsProviderUri(byte[] bytes, String extension, String mime) throws IOException { + if (bytes == null) { + return null; + } + // A zero length payload is still a payload: refusing it would leave the clip without a + // type it had advertised, and a target filtering on that type would accept the hover + // and be refused the drop. + File dir = new File(getContext().getCacheDir(), "intent_files"); + dir.mkdirs(); + // A name built from the clock and the payload's length collided: two representations of + // one payload that share an extension and a byte length are written within the same + // millisecond, and the second overwrote the first -- leaving both clip items pointing at + // the second one's bytes. createTempFile is the guarantee rather than a longer guess. + String encoded = encodeMimeForFileName(mime); + File file = File.createTempFile( + encoded == null ? CLIP_FILE_PREFIX : CLIP_FILE_PREFIX + encoded + "-", + "." + extension, dir); + OutputStream os = new FileOutputStream(file); + try { + os.write(bytes); + } finally { + os.close(); + } + Uri uri = FileProvider.getUriForFile(getContext(), + getContext().getPackageName() + ".provider", file); + // Grant broadly so any paste or drop target can read the content:// URI + getContext().grantUriPermission("android", uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + rememberStagedClipFile(uri, file, true); + return uri; + } + + /// The name every generated clip file starts with, and the alphabet + /// `#encodeMimeForFileName(java.lang.String)` writes the type in. + private static final String CLIP_FILE_PREFIX = "cn1-clip-"; + private static final String CLIP_MIME_HEX = "0123456789abcdef"; + + /// Writes a MIME type into something that is legal in a file name and reads back as itself. + /// + /// The extension cannot do this job. It is derived from the type and the derivation is + /// lossy -- `application/x-foo` and `application/x-foo+json` both reduce to `xfoo` -- so two + /// representations of one payload can produce URIs no reader can tell apart, and both are + /// then dropped rather than mispaired. Hex is unlovely for a file name nobody reads, and it + /// is exact: every byte of the type survives, and no character it produces means anything to + /// a file system, a URI or `#decodeMimeFromFileName(java.lang.String)`. + /// + /// Answers null for a type this cannot carry, and the file is then named without one. + private static String encodeMimeForFileName(String mime) { + if (mime == null || mime.length() == 0 || mime.length() > 60) { + return null; + } + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < mime.length(); iter++) { + int c = mime.charAt(iter); + if (c > 0xff) { + return null; + } + out.append(CLIP_MIME_HEX.charAt((c >> 4) & 0xf)).append(CLIP_MIME_HEX.charAt(c & 0xf)); + } + return out.toString(); + } + + /// The MIME type `#encodeMimeForFileName(java.lang.String)` wrote into this name, or null + /// when the name did not come from there -- a clip another application published, or one + /// whose type was too long to carry. + private static String decodeMimeFromFileName(String name) { + if (name == null || !name.startsWith(CLIP_FILE_PREFIX)) { + return null; + } + int end = name.indexOf('-', CLIP_FILE_PREFIX.length()); + if (end < 0) { + return null; + } + String hex = name.substring(CLIP_FILE_PREFIX.length(), end); + if (hex.length() == 0 || (hex.length() & 1) != 0) { + return null; + } + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < hex.length(); iter += 2) { + int hi = Character.digit(hex.charAt(iter), 16); + int lo = Character.digit(hex.charAt(iter + 1), 16); + if (hi < 0 || lo < 0) { + return null; + } + out.append((char) ((hi << 4) | lo)); + } + return out.toString().toLowerCase(); + } + + /// A file extension for a MIME type, used to name the temporary file a content URI is + /// served from. + /// + /// Android's own table first, because a FileProvider derives the URI's type from the + /// extension: a synthesized one it does not recognize makes ContentResolver.getType answer + /// application/octet-stream, and the type the clip advertised is then unrecoverable when + /// the clip is read back. + private static String extensionForMime(String mime) { + try { + String known = android.webkit.MimeTypeMap.getSingleton().getExtensionFromMimeType(mime); + if (known != null && known.length() > 0) { + return known; + } + } catch (Throwable t) { + // Fall through to the synthesized extension below. + } + int slash = mime.indexOf('/'); + String sub = slash < 0 ? mime : mime.substring(slash + 1); + int plus = sub.indexOf('+'); + if (plus > 0) { + sub = sub.substring(0, plus); + } + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < sub.length(); iter++) { + char c = sub.charAt(iter); + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { + out.append(c); + } + } + return out.length() == 0 ? "bin" : out.toString(); + } + + /// The MIME type to file an incoming image's bytes under: the framework's constant for the + /// three formats it names, and the type the content resolver reported for anything else. + /// + /// `#mimeForImageType(java.lang.String)` answers PNG for everything it does not recognize, + /// which for a WebP meant filing WebP bytes as a PNG -- undecodable by anything that + /// believed the label, and invisible to a target filtering on the type the drag advertised, + /// so the hover was accepted and the drop refused. + private static String imageMimeFor(String type) { + String lower = type.toLowerCase(); + if (lower.startsWith(ClipboardContent.MIME_PNG) + || lower.startsWith(ClipboardContent.MIME_JPEG) + || lower.startsWith(ClipboardContent.MIME_GIF)) { + return mimeForImageType(lower); + } + return lower; } /** @@ -10307,67 +10760,530 @@ public void run() { if (clip == null || clip.getItemCount() == 0) { return; } - ClipboardContent content = new ClipboardContent(); - String plain = null; - String html = null; - boolean hasImage = false; - List fileUris = new ArrayList(); - for (int i = 0; i < clip.getItemCount(); i++) { - ClipData.Item item = clip.getItemAt(i); + ClipboardContent content = contentFromClip(clip); + String plain = content.getText(ClipboardContent.MIME_TEXT); + // What the clip actually holds, not how many types it happens to name. + // Counting worked only because every clip used to acquire a text/plain of + // its own, empty or not: with that padding gone an image-only clip counted + // as one type, fell through to the plain-text answer, and a paste that had + // a perfectly good PNG in it returned null. + String[] types = content.getMimeTypes(); + boolean textOnly = types.length == 0 + || (types.length == 1 && ClipboardContent.MIME_TEXT.equals(types[0])); + if (!textOnly) { + response[0] = content; + } else { + response[0] = plain != null && plain.length() > 0 ? plain : null; + } + } + } + }); + return response[0]; + } + + /// Reads an Android `android.content.ClipData` into the framework's `ClipboardContent`. + /// + /// Shared by paste and by a native drop, because Android describes both the same way: a + /// list of items that are each text, HTML or a URI, and a URI is either an image to be read + /// or a file reference to be passed along. The plain text representation is always present, + /// even when empty, so a caller can tell "nothing but text" from "something richer" by the + /// number of MIME types. + /// + /// #### Parameters + /// + /// - `clip`: the clip data, which may be null + /// + /// #### Returns + /// + /// the content, never null + ClipboardContent contentFromClip(ClipData clip) { + return contentFromClip(clip, null); + } + + /// Reads a clip, and where a description is given also honours the MIME types it + /// advertises. + /// + /// A drag is filtered twice: once against the description while it hovers, and again + /// against the materialized content when it is dropped. If the second view is narrower than + /// the first, a target accepts the hover and is then refused the drop -- which is what + /// happened to a component filtering on `ClipboardContent#MIME_URI_LIST`, because a URI + /// item materializes as `MIME_FILE` alone. Nothing is invented here: an advertised type is + /// only filled from a value the clip actually produced. + /// + /// Paste passes no description, so it keeps reporting exactly what the clip contained. + /// + /// #### Parameters + /// + /// - `clip`: the clip data, which may be null + /// + /// - `description`: what the source advertised, or null to report only what was read + /// + /// #### Returns + /// + /// the content, never null + ClipboardContent contentFromClip(ClipData clip, ClipDescription description) { + ClipboardContent content = new ClipboardContent(); + if (clip == null) { + content.setData(ClipboardContent.MIME_TEXT, ""); + return content; + } + int sdk = android.os.Build.VERSION.SDK_INT; + String plain = null; + String html = null; + List fileUris = new ArrayList(); + // Every URI the clip carried that the source published, files or not. A link dragged out + // of a browser belongs here and not in fileUris: it is a URI, and it is not a document on + // disk. The two lists differ only by that, and by the transport URIs this exporter mints, + // which are in neither because the source never published them as URIs at all. + List publishedUris = new ArrayList(); + // URIs the content resolver could not name. An application defined type has no entry in + // Android's table, so a FileProvider serving it reports octet-stream or nothing at all. + List unnamedUris = new ArrayList(); + for (int i = 0; i < clip.getItemCount(); i++) { + ClipData.Item item = clip.getItemAt(i); + try { + Uri uri = item.getUri(); + if (uri != null) { + // Without the parameters, because a bare MIME type is what everything here + // compares against: a provider answering "text/plain; charset=utf-8" would + // file the document under a type no target asks for, and would slip past + // the MIME_TEXT check below that stops the synthesized empty text from + // overwriting it. + String type = bareMimeType(getContext().getContentResolver().getType(uri)); + if (type != null && type.startsWith("image/")) { try { - Uri uri = item.getUri(); - if (uri != null) { - String type = getContext().getContentResolver().getType(uri); - if (type != null && type.startsWith("image/")) { - InputStream in = getContext().getContentResolver().openInputStream(uri); - if (in != null) { - try { - byte[] bytes = Util.readInputStream(in); - content.setData(mimeForImageType(type), bytes); - hasImage = true; - } finally { - in.close(); - } - } - continue; + InputStream in = getContext().getContentResolver().openInputStream(uri); + if (in != null) { + try { + byte[] bytes = Util.readInputStream(in); + content.setData(imageMimeFor(type), bytes); + } finally { + in.close(); } - // Non-image URI -> file reference - fileUris.add(uri.toString()); - continue; } } catch (Throwable t) { + // The bytes are one representation of this URI, not the whole of + // it. A read that fails must still leave the file reference below. com.codename1.io.Log.e(t); } - if (html == null && sdk >= 16) { - String itemHtml = item.getHtmlText(); - if (itemHtml != null && itemHtml.length() > 0) { - html = itemHtml; - } + } else if (type != null && type.length() > 0 + && !"application/octet-stream".equals(type)) { + // A typed URI is a file reference *and* that type. Reducing it to a file + // alone let a target filtering on, say, application/pdf accept the hover + // -- the description advertised the type -- and then be refused the + // drop, because the content it is filtered against a second time no + // longer had it. The bytes are promised rather than read: a target that + // only wants the path should not pay for a document it never opens. + if (!content.hasMimeType(type)) { + content.setDataProvider(type, uriBytesProvider(uri)); } - if (plain == null) { - CharSequence text = item.coerceToText(getContext()); - if (text != null && text.length() > 0) { - plain = text.toString(); - } + } else { + unnamedUris.add(uri); + } + // A URI item is a file reference as well as whatever its type made of it -- + // unless it is one this exporter minted to carry bytes. The image branch + // used to return before reaching this at all, so dragging a PNG *file* + // produced image bytes and no file, and a target filtering on MIME_FILE + // accepted the hover -- the description still advertised text/uri-list -- + // and was refused the drop. Adding every URI unconditionally is the other + // error: a payload of nothing but application/pdf bytes travels as a + // content URI without text/uri-list ever being advertised, and calling that + // a file both invents a representation the source never published and lets + // a nested file-only target take a drop the PDF-capable one was chosen for + // while it hovered. + // + // The two are told apart by the exporter's own record of what it minted, + // not by anything about the URI or its name -- an application may publish a + // file called anything at all. + if (!isGeneratedClipFile(uri)) { + publishedUris.add(uri.toString()); + if (namesALocalFile(uri)) { + fileUris.add(uri.toString()); } } - if (html != null) { - content.setData(ClipboardContent.MIME_HTML, html); + // No continue: an item carrying a URI carries the clip's text too, because + // that is where this exporter puts it -- a text item of its own would be a + // second object being dragged. Returning here dropped the fallback the + // source published on its own round trip. + } + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + if (html == null && sdk >= 16) { + // Empty markup is a value, not an absence: getHtmlText answers null when the + // item carries no HTML at all, so anything else is what the source published. + // Discarding it left fillAdvertisedTypes to rebuild the advertised text/html + // from the plain text, handing the target something the source never wrote -- + // and this exporter publishes exactly that item for content whose HTML is empty. + html = item.getHtmlText(); + } + if (plain == null) { + // What the item literally carries first, and empty counts: getText answers + // null when the item holds no text at all, so anything else is what the + // source published -- the same reading getHtmlText gets above. Discarding an + // empty one left an advertised text/markdown with nothing to restore it + // from, and a target that took the hover on that type was refused the drop. + CharSequence literal = item.getText(); + if (literal != null) { + plain = literal.toString(); + } else if (item.getUri() == null) { + // Nothing literal, so it is derived -- and only for an item with no URI. + // coerceToText on one of those goes and reads the document behind it, + // which is a different value altogether and none of this branch's + // business. An empty derivation means the item had nothing to give + // rather than that the source published nothing, so it does not stop + // the search. + CharSequence derived = item.coerceToText(getContext()); + if (derived != null && derived.length() > 0) { + plain = derived.toString(); + } + } + } + } + if (html != null) { + // A value the clip's own item published, so it wins over a URI the resolver happened + // to type text/html -- an .html file being dragged. Same rule as the text below, + // and the reason that one needs a guard and this one does not: there is no + // synthesized empty HTML to write over a representation that already answered. + content.setData(ClipboardContent.MIME_HTML, html); + } + if (!fileUris.isEmpty()) { + content.setFiles(fileUris.toArray(new String[fileUris.size()])); + } + if (plain != null) { + content.setData(ClipboardContent.MIME_TEXT, plain); + } else if (!content.hasMimeType(ClipboardContent.MIME_TEXT) + && description != null && description.hasMimeType(ClipboardContent.MIME_TEXT)) { + // The clip promised text and no item produced it, so the empty string keeps that + // promise: a target that accepted the hover on text/plain would otherwise be + // refused the drop it was told it could have. Only then, though -- a clip that + // never mentioned text does not acquire it here. findTarget runs again against the + // materialized content, so inventing text/plain let a nested text-only component + // take a drop the type-capable ancestor had been chosen for while it hovered, and + // that component never saw an enter event at all. + // + // Nor over a representation that answered: a URI the resolver typed text/plain, + // which is what a dragged .txt is, has already registered the document's own + // contents, and writing over that handed the target an empty document. + content.setData(ClipboardContent.MIME_TEXT, ""); + } + if (description != null) { + fillAdvertisedTypes(content, description, plain, publishedUris, unnamedUris); + } + return content; + } + + /// The content URIs this exporter minted to carry bytes, oldest first. + /// + /// Remembered, not recognized. The file name cannot answer the question: an application may + /// publish a file of its own by any name it likes, and one called cn1-clip-roundtrip.txt is + /// exactly what the clipboard round trip publishes -- which a prefix test then threw away + /// as one of ours, losing the file reference it had just copied. The type cannot answer it + /// either, since a PDF published as bytes and a PDF published as a file both arrive as + /// application/pdf. Only the exporter knows, so the exporter records it. + /// + /// Bounded: a clip that has been replaced on the clipboard can no longer be pasted, so the + /// oldest entries are of no further use. A clip that outlives the process falls back to + /// being read as a file, which is what it was read as before any of this existed. + /// It also names the file, because every one of these is a file this application wrote + /// into its own cache and nothing else will ever come back for it. A clip that has been + /// replaced cannot be pasted, so when one falls off the end its file goes with it -- + /// otherwise copying documents or images repeatedly leaves every one of them on disk for + /// the life of the installation. + /// + /// Kept by the clip rather than one file at a time. A single payload can stage more files + /// than any per-file bound, and counting them individually deleted the earliest ones while + /// clipDataFor was still building the very clip that referenced them -- so the clip went + /// out pointing at files that were already gone. Whole clips are what is forgotten, never + /// the one being assembled. + private static final int GENERATED_CLIP_MEMORY = 8; + private static final java.util.LinkedHashMap STAGED_CLIP_FILES = + new java.util.LinkedHashMap(); + + /// One file staged for a clip: where it is, and whether it carries a representation's + /// bytes rather than being a file the source published. + private static final class StagedClipFile { + private final String path; + private final boolean transport; + private final long clip; + + StagedClipFile(String path, boolean transport, long clip) { + this.path = path; + this.transport = transport; + this.clip = clip; + } + } + + /// The clip being assembled. Incremented as each one starts, so everything staged for it + /// is recognisable as belonging together. + private static long stagingClip; + + private static long beginStagingClip() { + synchronized (STAGED_CLIP_FILES) { + return ++stagingClip; + } + } + + private static void rememberStagedClipFile(Uri uri, File file, boolean transport) { + synchronized (STAGED_CLIP_FILES) { + long clip = stagingClip; + STAGED_CLIP_FILES.remove(uri.toString()); + STAGED_CLIP_FILES.put(uri.toString(), + new StagedClipFile(file.getAbsolutePath(), transport, clip)); + // Whole clips, and never the newest ones: the clip being built is still growing, + // and the one before it may be what the clipboard or a running drag is carrying. + long forgetBefore = clip - GENERATED_CLIP_MEMORY; + java.util.Iterator> entries = + STAGED_CLIP_FILES.entrySet().iterator(); + while (entries.hasNext()) { + StagedClipFile staged = entries.next().getValue(); + if (staged.clip <= forgetBefore) { + entries.remove(); + deleteStagedClipFile(staged); + } + } + } + } + + /// Removes a staged file, and the directory it was given to itself when it had one. + /// + /// Best effort by design: a file that will not delete is one the cache directory will + /// eventually reclaim, which is what a cache directory is for -- and is also what bounds + /// the files left behind by a process that ended before it could let go of them. + private static void deleteStagedClipFile(StagedClipFile staged) { + try { + File file = new File(staged.path); + File holder = file.getParentFile(); + if (file.delete() && holder != null + && holder.getName().startsWith(SHARED_COPY_PREFIX)) { + holder.delete(); + } + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + /// True when this content URI is one `#writeAsProviderUri(byte[], java.lang.String, + /// java.lang.String)` minted to carry a representation's bytes, rather than a file the + /// source published. + private static boolean isGeneratedClipFile(Uri uri) { + synchronized (STAGED_CLIP_FILES) { + StagedClipFile staged = STAGED_CLIP_FILES.get(uri.toString()); + return staged != null && staged.transport; + } + } + + /// True when this URI names something on this device rather than somewhere on the web. + /// + /// A link dragged out of a browser arrives as a text/uri-list item whose URI is https, + /// and calling that a file handed a file-only target a URL through getFiles() as though + /// it were a document on disk. It is still carried, under MIME_URI_LIST, which is what + /// it actually is. + private static boolean namesALocalFile(Uri uri) { + String scheme = uri.getScheme(); + if (scheme == null) { + // A bare path, which is a local file by construction. + return true; + } + scheme = scheme.toLowerCase(); + return "content".equals(scheme) || "file".equals(scheme); + } + + /// A MIME type without its parameters, lower case, or null when there is none. + private static String bareMimeType(String type) { + if (type == null) { + return null; + } + int semicolon = type.indexOf(';'); + String bare = (semicolon < 0 ? type : type.substring(0, semicolon)).trim().toLowerCase(); + return bare.length() == 0 ? null : bare; + } + + /// Reads a content URI's bytes when something actually asks for them. + /// + /// The drag-and-drop permission this drop was granted lasts for the life of the activity -- + /// nothing calls release() on it -- so a read that happens a moment later on the event + /// dispatch thread still succeeds. + private ClipboardDataProvider uriBytesProvider(final Uri uri) { + return new ClipboardDataProvider() { + @Override + public Object getClipboardData(String mimeType) { + try { + InputStream in = getContext().getContentResolver().openInputStream(uri); + if (in == null) { + return null; + } + byte[] bytes; + try { + bytes = Util.readInputStream(in); + } finally { + in.close(); } - if (!fileUris.isEmpty()) { - content.setData(ClipboardContent.MIME_FILE, - fileUris.size() == 1 ? (Object) fileUris.get(0) : (Object) fileUris.toArray(new String[0])); + // A text type reads back as text: the framework's getText() answers null + // for a byte array, so a Markdown representation that went out as a typed + // URI would come back unreadable to the very API that asked for it. + if (bytes != null && mimeType != null && mimeType.startsWith("text/")) { + return new String(bytes, "UTF-8"); } - content.setData(ClipboardContent.MIME_TEXT, plain == null ? "" : plain); - if (hasImage || html != null || !fileUris.isEmpty()) { - response[0] = content; - } else { - response[0] = plain; + return bytes; + } catch (Throwable t) { + com.codename1.io.Log.e(t); + return null; + } + } + }; + } + + /// Fills the MIME types the drag advertised but the read did not produce, from what it did. + /// + /// An Android clip carries a single text payload and the description says what that text + /// is, so a type the description names and the clip did not otherwise yield is that text -- + /// `text/uri-list` excepted, which is the list of URIs the clip carried. A type with no + /// value to give it is left absent rather than advertised empty. + private void fillAdvertisedTypes(ClipboardContent content, ClipDescription description, + String plain, List publishedUris, List unnamedUris) { + List unsatisfiedBinary = new ArrayList(); + List unsatisfiedText = new ArrayList(); + for (int iter = 0; iter < description.getMimeTypeCount(); iter++) { + String mime = description.getMimeType(iter); + if (mime == null) { + continue; + } + mime = mime.toLowerCase(); + if (content.hasMimeType(mime)) { + continue; + } + if ("text/uri-list".equals(mime)) { + // Every URI, not only the ones that name files: a URI list is a URI list, and a + // link the source published belongs in it even though it is not a document. + if (!publishedUris.isEmpty()) { + StringBuilder uris = new StringBuilder(); + for (int j = 0; j < publishedUris.size(); j++) { + if (j > 0) { + uris.append("\r\n"); + } + uris.append(publishedUris.get(j)); } + content.setData(ClipboardContent.MIME_URI_LIST, uris.toString()); } + continue; } - }); - return response[0]; + // A text type is *not* assumed to be the carried text here. The exporter writes a + // text representation whose value differs from that text into a content URI exactly + // as it writes binary, so assuming made a target asking for an application's own + // text format receive the plain fallback instead of the value it published. + if (mime.startsWith("text/")) { + unsatisfiedText.add(mime); + } else { + unsatisfiedBinary.add(mime); + } + } + List unclaimed = new ArrayList(unnamedUris); + for (int iter = unclaimed.size() - 1; iter >= 0; iter--) { + Uri uri = unclaimed.get(iter); + String named = mimeForUnnamedUri(uri, unsatisfiedBinary, unsatisfiedText); + if (named != null) { + content.setDataProvider(named, uriBytesProvider(uri)); + unsatisfiedBinary.remove(named); + unsatisfiedText.remove(named); + unclaimed.remove(iter); + } + } + if (unclaimed.size() == 1) { + // One representation the clip promised and could not produce, and one URI whose + // type Android could not name: the pairing cannot be anything else. A byte backed + // type is taken first because bytes can only have come from a URI, where a text one + // may also be another reading of the text the clip carries. With more of either it + // could be, and inventing an association would tell a target it has something it + // may not -- which is the failure this whole path exists to avoid -- so those are + // left absent and the target correctly refuses. + String only = null; + if (unsatisfiedBinary.size() == 1) { + only = unsatisfiedBinary.remove(0); + } else if (unsatisfiedBinary.isEmpty() && unsatisfiedText.size() == 1) { + only = unsatisfiedText.remove(0); + } + if (only != null) { + content.setDataProvider(only, uriBytesProvider(unclaimed.get(0))); + } + } + if (plain != null) { + for (int iter = 0; iter < unsatisfiedText.size(); iter++) { + // What is left: an Android clip carries a single text payload, and a text type + // no URI accounted for is another name for that payload -- which is exactly how + // the exporter advertises a reading whose value *is* the carried text. + content.setData(unsatisfiedText.get(iter), plain); + } + } + } + + /// The type an untyped content URI was published as, recovered from the name of the file it + /// serves. + /// + /// ContentResolver could not name it -- MimeTypeMap has no entry for an application defined + /// type, so the FileProvider serving it reports octet-stream. What this application wrote + /// still says so in its own name, exactly, which is the answer; a clip from elsewhere gets + /// the extension read as a type, which is a good guess and is treated as one -- an extension + /// two advertised types share answers nothing. + private String mimeForUnnamedUri(Uri uri, List binary, List text) { + String name = displayNameFor(uri); + if (name == null) { + return null; + } + String declared = decodeMimeFromFileName(name); + if (declared != null) { + // Written by this application, which named the type outright. It answers even when + // it names a type that is not among the candidates -- that means the type is already + // satisfied, or was never advertised, and either way this URI is not the missing + // one. Guessing past an exact answer would be strictly worse. + return binary.contains(declared) || text.contains(declared) ? declared : null; + } + int dot = name.lastIndexOf('.'); + if (dot < 0 || dot == name.length() - 1) { + return null; + } + String extension = name.substring(dot + 1).toLowerCase(); + String match = null; + for (int pass = 0; pass < 2; pass++) { + List candidates = pass == 0 ? binary : text; + for (int iter = 0; iter < candidates.size(); iter++) { + String candidate = candidates.get(iter); + if (extension.equals(extensionForMime(candidate))) { + if (match != null) { + return null; + } + match = candidate; + } + } + } + return match; + } + + /// The file name behind a content URI, which is where the extension an exporter chose + /// survives. A provider that will not answer OpenableColumns still has the name in its path. + private String displayNameFor(Uri uri) { + Cursor cursor = null; + try { + cursor = getContext().getContentResolver().query(uri, + new String[]{android.provider.OpenableColumns.DISPLAY_NAME}, + null, null, null); + if (cursor != null && cursor.moveToFirst()) { + int column = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME); + if (column >= 0) { + String name = cursor.getString(column); + if (name != null && name.length() > 0) { + return name; + } + } + } + } catch (Throwable t) { + // Fall through to the path below. + } finally { + if (cursor != null) { + cursor.close(); + } + } + return uri.getLastPathSegment(); } public static MediaException createMediaException(int extra) { diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidNativeDragAndDrop.java b/Ports/Android/src/com/codename1/impl/android/AndroidNativeDragAndDrop.java new file mode 100644 index 00000000000..14f5380495b --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidNativeDragAndDrop.java @@ -0,0 +1,458 @@ +/* + * 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 Oracle 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.codename1.impl.android; + +import android.content.ClipData; +import android.content.ClipDescription; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Point; +import android.os.Build; +import android.view.DragEvent; +import android.view.View; + +import com.codename1.io.Log; +import com.codename1.ui.ClipboardContent; +import com.codename1.ui.ClipboardDataProvider; +import com.codename1.ui.NativeDragAndDrop; +import com.codename1.ui.NativeDragOperation; + +/// Android's drag and drop, wired to the framework's. +/// +/// Android has had in-application drag and drop since Ice Cream Sandwich, but a drag could not +/// leave the application until Nougat added `View#DRAG_FLAG_GLOBAL`. That is the split reported +/// by `#isSupported()` and `#isOutsideApplicationSupported()`: on a phone before Nougat a drag +/// can still move things around inside the application, and on a tablet or a Chromebook running +/// Nougat or later it can be dropped into another application beside it. +/// +/// #### Files +/// +/// A dragged file travels as a `content:` URI rather than a path, which is also how the +/// clipboard carries one, so the same `AndroidImplementation#contentFromClip(android.content.ClipData)` +/// reader serves both. A URI from another application is only readable while the drop's +/// permission grant is held, which is why the content is read inside the drop callback rather +/// than handed to the event dispatch thread to read later. +final class AndroidNativeDragAndDrop { + /// The operation currently being dragged out of this application, so that the outcome + /// reported when the drag ends can be attributed to it, and the action last agreed with the + /// framework, reported back when the drop is accepted -- Android's drag events carry no + /// copy/move/link distinction of their own. + /// + /// Both are written from the Codename One event dispatch thread and read from the Android + /// UI thread, so the lock is what publishes one to the other. + private static final Object LOCK = new Object(); + private static NativeDragOperation exporting; + + /// What the framework last answered about a drag over this surface, or `UNDECIDED` before it + /// has answered anything. + /// + /// The distinction matters: `NativeDragOperation#ACTION_NONE` is a refusal, and Android + /// hands ACTION_DROP to a subscribed view whatever it answered to the location events -- so + /// treating a refusal as "no answer yet" and substituting a default turned a target's + /// reject() back into a delivered drop. + private static final int UNDECIDED = -1; + private static int lastAction = UNDECIDED; + + /// What a drop of *our own* session onto one of our own components settled on, kept until + /// the session ends so the source is told what really happened. Android's drag events carry + /// no action, so this is the only place the true answer exists. + private static int localDropAction = NativeDragOperation.ACTION_NONE; + + private static NativeDragOperation exporting() { + synchronized (LOCK) { + return exporting; + } + } + + private static void setExporting(NativeDragOperation op) { + synchronized (LOCK) { + exporting = op; + } + } + + private static int lastAction() { + synchronized (LOCK) { + return lastAction; + } + } + + private static void setLastAction(int action) { + synchronized (LOCK) { + lastAction = action; + } + } + + private static int localDropAction() { + synchronized (LOCK) { + return localDropAction; + } + } + + private static void setLocalDropAction(int action) { + synchronized (LOCK) { + localDropAction = action; + } + } + + private AndroidNativeDragAndDrop() { + } + + /// Returns true when this device can drag and drop at all. + static boolean isSupported() { + return Build.VERSION.SDK_INT >= 11; + } + + /// Returns true when a drag started here can be dropped into another application, which + /// Android only allows from Nougat onwards. + static boolean isOutsideApplicationSupported() { + return Build.VERSION.SDK_INT >= 24; + } + + /// Makes the Codename One surface a drop target. Called once, as the view is created. + /// + /// #### Parameters + /// + /// - `impl`: the implementation, used to read dropped content + /// + /// - `view`: the Android view Codename One renders into + static void install(final AndroidImplementation impl, final View view) { + if (!isSupported() || view == null) { + return; + } + try { + view.setOnDragListener(new View.OnDragListener() { + @Override + public boolean onDrag(View v, DragEvent event) { + return handle(impl, event); + } + }); + } catch (Throwable err) { + Log.e(err); + } + } + + /// Starts an Android drag for the operation the framework decided on. Invoked on the + /// Codename One event dispatch thread; the drag itself has to begin on the Android UI + /// thread, which is what the post below is for. + static boolean startDrag(final AndroidImplementation impl, final NativeDragOperation op) { + if (op == null || !isSupported()) { + return false; + } + final CodenameOneSurface surface = impl.myView; + final View view = surface == null ? null : surface.getAndroidView(); + if (view == null) { + return false; + } + final ClipData clip = toClipData(impl, op.getContent()); + if (clip == null) { + return false; + } + setExporting(op); + setLastAction(UNDECIDED); + setLocalDropAction(NativeDragOperation.ACTION_NONE); + view.post(new Runnable() { + @Override + public void run() { + boolean started = false; + try { + View.DragShadowBuilder shadow = shadowFor(view, op); + if (Build.VERSION.SDK_INT >= 24) { + int flags = View.DRAG_FLAG_GLOBAL | View.DRAG_FLAG_GLOBAL_URI_READ; + started = view.startDragAndDrop(clip, shadow, null, flags); + } else { + started = view.startDrag(clip, shadow, null, 0); + } + } catch (Throwable err) { + Log.e(err); + } + if (!started) { + setExporting(null); + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_NONE); + } + } + }); + return true; + } + + /// Forgets a prepared operation because the press turned out to be a click. + static void cancelDrag() { + setExporting(null); + } + + // ------------------------------------------------------------------------------------ + + private static boolean handle(AndroidImplementation impl, DragEvent event) { + try { + switch (event.getAction()) { + case DragEvent.ACTION_DRAG_STARTED: + // Returning true is what subscribes this view to the rest of the drag; + // a view that answers false here never sees another event, so the answer + // is unconditional and the real filtering happens per position below. + return true; + case DragEvent.ACTION_DRAG_ENTERED: + setLastAction(NativeDragAndDrop.dragEnter(0, (int) event.getX(), (int) event.getY(), + describe(event.getClipDescription()), allowedActions())); + return true; + case DragEvent.ACTION_DRAG_LOCATION: + setLastAction(NativeDragAndDrop.dragOver(0, (int) event.getX(), (int) event.getY(), + describe(event.getClipDescription()), allowedActions())); + return true; + case DragEvent.ACTION_DRAG_EXITED: + NativeDragAndDrop.dragExit(0); + setLastAction(UNDECIDED); + return true; + case DragEvent.ACTION_DROP: + return drop(impl, event); + case DragEvent.ACTION_DRAG_ENDED: + // Whatever happened, nothing is hovered any more. Android delivers this to + // every subscribed view, and it is the only event that arrives on the paths + // that otherwise clear nothing: a drop the target refused returns before + // NativeDragAndDrop.drop(), and a drag from another application never + // reaches the dragCompleted() below. A stale target left the component + // stuck at its refusal for the next drag, which was then routed as a move + // over it rather than an entry. + NativeDragAndDrop.dragExit(0); + if (exporting() != null) { + // Settled *before* the operation is forgotten. Reading the allowed + // actions afterwards is how this reported every move as a copy: with + // nothing exporting, allowedActions() answers with its copy fallback. + int completed = completedAction(event.getResult()); + setExporting(null); + NativeDragAndDrop.dragCompleted(completed); + } + setLastAction(UNDECIDED); + setLocalDropAction(NativeDragOperation.ACTION_NONE); + return true; + default: + return false; + } + } catch (Throwable err) { + Log.e(err); + return false; + } + } + + /// What to tell the source a finished session actually did. + /// + /// A drop onto one of this application's own components knows exactly what was accepted, + /// and that is the answer -- without it a move accepted locally was reported as a copy and + /// a source relying on ACTION_MOVE to delete its data never did. + /// + /// A drop into *another* application cannot be answered so precisely: Android's drag + /// protocol carries no notion of copy versus move, and ACTION_DRAG_ENDED reports only a + /// boolean. Copy is the honest reading of "it succeeded and we do not know how", and it is + /// also the safe one, because reporting a move the receiver may not have performed would + /// have the source delete data nothing else holds. That holds even for an operation that + /// allows only a move: what the source was willing to permit says nothing about what the + /// receiver did, and an ordinary Android target simply copies the clip. + private static int completedAction(boolean result) { + if (!result) { + return NativeDragOperation.ACTION_NONE; + } + int local = localDropAction(); + if (local != NativeDragOperation.ACTION_NONE) { + return local; + } + // A copy, whatever the source was willing to allow. An external receiver has read the + // clip and Android gives it no way to say more than that, so a copy is what actually + // happened. Answering ACTION_MOVE because the source offered nothing else would infer + // ownership from our own wishes: the receiver may simply have copied, and a source that + // deletes on ACTION_MOVE would then destroy the only remaining copy. + return NativeDragOperation.ACTION_COPY; + } + + private static boolean drop(AndroidImplementation impl, DragEvent event) { + // A URI dropped by another application is only readable while this grant is held, and + // the grant only exists from here on. Reading the content inside this method rather + // than on the event dispatch thread is what keeps a dropped file readable. + if (Build.VERSION.SDK_INT >= 24 && impl.getActivity() != null) { + try { + impl.getActivity().requestDragAndDropPermissions(event); + } catch (Throwable err) { + // A drag from within this application needs no grant and refuses one. + Log.e(err); + } + } + // With the description, so the types the drag advertised while it hovered survive into + // the content the drop is filtered against -- otherwise a target that accepted the + // hover on MIME_URI_LIST is refused the drop it was promised. + ClipboardContent content = impl.contentFromClip(event.getClipData(), event.getClipDescription()); + int decided = lastAction(); + if (decided == NativeDragOperation.ACTION_NONE) { + // Refused while it hovered. Android delivers ACTION_DROP to a subscribed view even + // so, and substituting a default here is what turned a target's reject() back into + // a delivered drop. Reporting failure also makes ACTION_DRAG_ENDED report no action. + return false; + } + int action = decided == UNDECIDED ? preferred(allowedActions()) : decided; + int accepted = NativeDragAndDrop.drop(0, (int) event.getX(), (int) event.getY(), content, action); + setLastAction(NativeDragOperation.ACTION_NONE); + if (exporting() != null) { + // Our own drag, dropped on our own surface: remember what the target took, because + // ACTION_DRAG_ENDED is about to be asked and has no way of knowing. + setLocalDropAction(accepted); + } + return accepted != NativeDragOperation.ACTION_NONE; + } + + /// The actions in play. A drag this application started offers whatever its source allowed; + /// one arriving from another application is a copy, because Android's cross-application + /// drag has no way to express anything else. + private static int allowedActions() { + NativeDragOperation op = exporting(); + return op == null ? NativeDragOperation.ACTION_COPY : op.getAllowedActions(); + } + + private static int preferred(int actions) { + if ((actions & NativeDragOperation.ACTION_COPY) != 0) { + return NativeDragOperation.ACTION_COPY; + } + if ((actions & NativeDragOperation.ACTION_MOVE) != 0) { + return NativeDragOperation.ACTION_MOVE; + } + if ((actions & NativeDragOperation.ACTION_LINK) != 0) { + return NativeDragOperation.ACTION_LINK; + } + return NativeDragOperation.ACTION_NONE; + } + + /// Describes a drag in progress from its MIME types alone. + /// + /// Android does not hand over the data until the drop, so every representation here is a + /// provider that answers null. That is enough: a drop target decides whether it wants the + /// drag from the MIME types, and the real content arrives on the drop. + private static ClipboardContent describe(ClipDescription description) { + NativeDragOperation op = exporting(); + if (op != null) { + // This application's own drag, so there is nothing to infer: the operation says + // what it offers. Android has already built the whole clip by now, so handing the + // source's own content to the hover costs nothing and cannot disagree with the + // drop -- which is the failure every rule below is trying to avoid. + return op.getContent(); + } + ClipboardContent content = new ClipboardContent(); + if (description == null) { + return content; + } + for (int iter = 0; iter < description.getMimeTypeCount(); iter++) { + String mime = description.getMimeType(iter); + if (mime == null) { + continue; + } + mime = mime.toLowerCase(); + if ("text/uri-list".equals(mime)) { + // Android carries a dragged file as a URI, which is what the framework calls a + // file list; advertise both so either kind of target matches. + // + // This is the one thing a hover cannot get right on Android. A file manager + // and a browser both describe themselves as text/uri-list, and the clip that + // would tell them apart -- content: against https: -- is withheld until the + // drop. Promising files is the useful way to be wrong: a link drag is then + // refused at the drop rather than never accepted at all. + declare(content, ClipboardContent.MIME_FILE); + declare(content, ClipboardContent.MIME_URI_LIST); + continue; + } + declare(content, mime); + if (!mime.startsWith("text/")) { + // A type that is not text is carried by a URI, because that is the only way + // an Android clip carries anything else -- and a URI another application put + // in a clip is a file reference as well as that type. Without this, an + // ordinary content:// PDF from another app describes itself as application/pdf + // alone, a target restricted to files refused every hover event, and the drop + // never happened -- while contentFromClip went on to call the very same URI a + // file. + declare(content, ClipboardContent.MIME_FILE); + declare(content, ClipboardContent.MIME_URI_LIST); + } + } + return content; + } + + private static void declare(ClipboardContent content, String mime) { + if (content.hasMimeType(mime)) { + return; + } + content.setDataProvider(mime, new ClipboardDataProvider() { + @Override + public Object getClipboardData(String requested) { + // Android reveals nothing until the drop; the drop callback replaces this with + // the real content. + return null; + } + }); + } + + /// Builds the Android clip for an outgoing drag. The clipboard already knows how to turn a + /// `ClipboardContent` into a clip -- including writing image bytes out through the + /// application's file provider so another application can read them -- so this reuses that + /// rather than growing a second conversion that would drift from it. + /// + /// #### Promised representations are produced here, not on demand + /// + /// `ClipboardContent#setDataProvider(java.lang.String, com.codename1.ui.ClipboardDataProvider)` + /// is honoured lazily on the desktop and on iOS, where the platform asks for a + /// representation when a receiver reads it. Android has no such moment: startDragAndDrop + /// takes a complete `android.content.ClipData`, and a clip carries text or a URI to a file + /// that already exists. So every provider runs when the drag begins, including for a drag + /// the user goes on to abandon. + /// + /// A content provider resolving its bytes on demand would restore the guarantee, but it + /// needs a second provider declared in the manifest -- which is generated by the builder, + /// in another repository -- so it is not something this file can reach for. + private static ClipData toClipData(AndroidImplementation impl, ClipboardContent content) { + try { + return impl.clipDataFor(content); + } catch (Throwable err) { + Log.e(err); + return null; + } + } + + /// The image under the finger during the drag: whatever the operation supplied, rendered at + /// the offset the gesture grabbed it by. + private static View.DragShadowBuilder shadowFor(View view, NativeDragOperation op) { + com.codename1.ui.Image image = op.getDragImage(); + Object peer = image == null ? null : image.getImage(); + if (!(peer instanceof Bitmap)) { + return new View.DragShadowBuilder(view); + } + final Bitmap bitmap = (Bitmap) peer; + final int touchX = Math.max(0, Math.min(bitmap.getWidth(), op.getDragImageOffsetX())); + final int touchY = Math.max(0, Math.min(bitmap.getHeight(), op.getDragImageOffsetY())); + return new View.DragShadowBuilder(view) { + @Override + public void onProvideShadowMetrics(Point size, Point touch) { + size.set(Math.max(1, bitmap.getWidth()), Math.max(1, bitmap.getHeight())); + touch.set(touchX, touchY); + } + + @Override + public void onDrawShadow(Canvas canvas) { + canvas.drawBitmap(bitmap, 0, 0, null); + } + }; + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java b/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java index dc196d6c081..aacc1322f32 100644 --- a/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java +++ b/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java @@ -827,6 +827,11 @@ public boolean onTouchEvent(MotionEvent event) { cn1GrabbedPointer = false; break; case MotionEvent.ACTION_CANCEL: + // A cancelled touch delivers no release, so nothing else tells the framework + // this gesture is over. An operation a press had staged for a native drag + // would otherwise outlive it, and the port would still be holding the drag it + // was asked to prepare. + com.codename1.ui.NativeDragAndDrop.gestureCancelled(); cn1GrabbedPointer = false; break; case MotionEvent.ACTION_MOVE: diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSENativeDragAndDrop.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSENativeDragAndDrop.java new file mode 100644 index 00000000000..964621b54b2 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSENativeDragAndDrop.java @@ -0,0 +1,792 @@ +/* + * 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 Oracle 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.codename1.impl.javase; + +import com.codename1.io.Log; +import com.codename1.ui.ClipboardContent; +import com.codename1.ui.ClipboardDataProvider; +import com.codename1.ui.NativeDragAndDrop; +import com.codename1.ui.NativeDragOperation; + +import java.awt.EventQueue; +import java.awt.GraphicsEnvironment; +import java.awt.Point; +import java.awt.datatransfer.DataFlavor; +import java.awt.datatransfer.Transferable; +import java.awt.dnd.DnDConstants; +import java.awt.dnd.DropTarget; +import java.awt.dnd.DropTargetDragEvent; +import java.awt.dnd.DropTargetDropEvent; +import java.awt.dnd.DropTargetEvent; +import java.awt.dnd.DropTargetListener; +import java.awt.event.InputEvent; +import java.awt.event.MouseEvent; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStream; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.JComponent; +import javax.swing.TransferHandler; + +/// Bridges Codename One's native drag and drop onto AWT's, which is what lets a desktop +/// application drag content out of itself -- onto the desktop, into a file manager, into another +/// application's window -- and accept drags coming the other way. +/// +/// #### Which thread does what +/// +/// AWT delivers drag notifications on its own event dispatch thread, which is not Codename +/// One's. Nothing here ever blocks one on the other: the Codename One event thread calls into +/// `javax.swing.TransferHandler` through `java.awt.EventQueue#invokeLater`, and the drop +/// callbacks answer AWT from `NativeDragAndDrop`, which resolves the target without needing the +/// Codename One event thread. That is not fastidiousness -- the Codename One event thread +/// blocks on AWT to blit every frame, so a synchronous call the other way deadlocks the +/// simulator on the first drag. +/// +/// #### Why the drag reads nothing until the drop +/// +/// While a drag is merely passing over the window the transferable's *data* is not reliably +/// readable -- on some platforms it does not exist yet -- but its list of flavors always is. So +/// a drag in progress is described by a `ClipboardContent` whose representations are all +/// `ClipboardDataProvider`s: enough for a drop target to say whether it wants a `text/html` or +/// a file list, without a byte being transferred for a drag that ends up going somewhere else. +/// On the drop the content is materialized eagerly instead, because the transferable stops +/// being readable the moment the drop callback returns. +final class JavaSENativeDragAndDrop { + /// The operation the Codename One event thread has asked to export, read by the transfer + /// handler on the AWT thread when the drag actually starts. One process drags one thing at + /// a time, so a single slot is the whole of the state; the lock is what publishes it from + /// one thread to the other. + private static final Object LOCK = new Object(); + private static NativeDragOperation exporting; + + private static NativeDragOperation exporting() { + synchronized (LOCK) { + return exporting; + } + } + + private static void setExporting(NativeDragOperation op) { + synchronized (LOCK) { + exporting = op; + } + } + + private JavaSENativeDragAndDrop() { + } + + /// Makes one canvas both an AWT drop target and a drag source. + /// + /// #### Parameters + /// + /// - `canvas`: the canvas, which is the surface for the main form or for one window + static void install(JavaSEPort.C canvas) { + if (GraphicsEnvironment.isHeadless()) { + return; + } + try { + canvas.setTransferHandler(new Cn1TransferHandler()); + new DropTarget(canvas, DnDConstants.ACTION_COPY_OR_MOVE | DnDConstants.ACTION_LINK, + new Cn1DropTargetListener(canvas), true); + } catch (Throwable err) { + // A desktop with no drag and drop service leaves the canvas as it was; the + // lightweight drag and drop is unaffected. + Log.e(err); + } + } + + /// Starts an AWT drag for the operation Codename One has decided on. Invoked on the + /// Codename One event dispatch thread while the mouse button is still down. + /// + /// #### Returns + /// + /// true when the export was handed to AWT; the outcome arrives later through + /// `NativeDragAndDrop#dragCompleted(int)` + static boolean startDrag(final JavaSEPort port, final NativeDragOperation op) { + if (op == null || GraphicsEnvironment.isHeadless()) { + return false; + } + final JavaSEPort.C target = port.dndGestureCanvas != null ? port.dndGestureCanvas : port.canvas; + if (target == null) { + return false; + } + final InputEvent trigger = port.dndLastInputEvent(); + if (!(trigger instanceof MouseEvent)) { + // AWT seeds a drag from the mouse event that provoked it and refuses without one. + return false; + } + final double scale = target.awtOverlayScale(); + final java.awt.Image dragImage = toAwtDragImage(op, scale); + final Point offset = new Point( + (int) (op.getDragImageOffsetX() / scale), + (int) (op.getDragImageOffsetY() / scale)); + setExporting(op); + EventQueue.invokeLater(new Runnable() { + @Override + public void run() { + try { + TransferHandler handler = target.getTransferHandler(); + if (handler == null) { + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_NONE); + return; + } + // Every session, including the ones with no image at all. The handler + // belongs to the canvas and outlives the drag, so setting it only when + // there is one left the previous drag's picture on it and exported the + // next payload under a preview of something else entirely. + handler.setDragImage(dragImage); + handler.setDragImageOffset(dragImage == null ? new Point(0, 0) : offset); + handler.exportAsDrag(target, trigger, toAwtAction(preferred(op.getAllowedActions()))); + } catch (Throwable err) { + Log.e(err); + setExporting(null); + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_NONE); + } + } + }); + return true; + } + + /// Forgets a prepared operation because the press turned out to be a click. + static void cancelDrag() { + setExporting(null); + } + + /// Codename One pixels per AWT point over a canvas. + /// + /// The display's backing scale, and the skin's zoom where there is a skin. A simulator + /// showing a device at a zoom other than 1 draws its content at that zoom on top of the + /// backing scale, so a preview divided by the backing scale alone came out zoomLevel times + /// too large or too small, with its grab point displaced by the same factor. + /// + /// #### Parameters + /// + /// - `skinned`: true when the canvas is showing a device skin, which is what makes the + /// zoom apply + /// + /// - `backingScale`: the backing scale of the display the canvas is on + /// + /// - `zoom`: the skin's zoom level + static double overlayScale(boolean skinned, double backingScale, float zoom) { + if (skinned && zoom > 0) { + return backingScale / zoom; + } + return backingScale; + } + + /// Renders the operation's drag image at the size AWT expects. + /// + /// Codename One images are in surface pixels while AWT places a drag image in points, so on + /// a scaled display the image has to come down or the user drags a picture twice the size + /// of the thing they grabbed. + private static java.awt.Image toAwtDragImage(NativeDragOperation op, double scale) { + com.codename1.ui.Image image = op.getDragImage(); + if (image == null) { + return null; + } + Object peer = image.getImage(); + if (!(peer instanceof java.awt.Image)) { + return null; + } + java.awt.Image awt = (java.awt.Image) peer; + // Both ways, not just down. A skin zoomed past 1 puts fewer Codename One pixels in + // an AWT point, so the preview has to grow -- and the grab point below is divided by + // the same number either way, which would put it outside an image that stayed as it + // was. Compared with a tolerance because it is a ratio of two measured scales. + if (scale <= 0 || Math.abs(scale - 1.0) < 0.001) { + return awt; + } + int w = Math.max(1, (int) (image.getWidth() / scale)); + int h = Math.max(1, (int) (image.getHeight() / scale)); + BufferedImage scaled = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + java.awt.Graphics2D g = scaled.createGraphics(); + g.setRenderingHint(java.awt.RenderingHints.KEY_INTERPOLATION, + java.awt.RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.drawImage(awt, 0, 0, w, h, null); + g.dispose(); + return scaled; + } + + // ------------------------------------------------------------------------------------ + // Action mapping. AWT's constants are a different bit set from ours, and both sides use + // masks in some places and a single action in others. + // ------------------------------------------------------------------------------------ + + static int toAwtActions(int actions) { + int out = DnDConstants.ACTION_NONE; + if ((actions & NativeDragOperation.ACTION_COPY) != 0) { + out |= DnDConstants.ACTION_COPY; + } + if ((actions & NativeDragOperation.ACTION_MOVE) != 0) { + out |= DnDConstants.ACTION_MOVE; + } + if ((actions & NativeDragOperation.ACTION_LINK) != 0) { + out |= DnDConstants.ACTION_LINK; + } + return out; + } + + static int fromAwtActions(int actions) { + int out = NativeDragOperation.ACTION_NONE; + if ((actions & DnDConstants.ACTION_COPY) != 0) { + out |= NativeDragOperation.ACTION_COPY; + } + if ((actions & DnDConstants.ACTION_MOVE) != 0) { + out |= NativeDragOperation.ACTION_MOVE; + } + if ((actions & DnDConstants.ACTION_LINK) != 0) { + out |= NativeDragOperation.ACTION_LINK; + } + return out; + } + + static int toAwtAction(int action) { + return toAwtActions(action); + } + + static int preferred(int actions) { + if ((actions & NativeDragOperation.ACTION_COPY) != 0) { + return NativeDragOperation.ACTION_COPY; + } + if ((actions & NativeDragOperation.ACTION_MOVE) != 0) { + return NativeDragOperation.ACTION_MOVE; + } + if ((actions & NativeDragOperation.ACTION_LINK) != 0) { + return NativeDragOperation.ACTION_LINK; + } + return NativeDragOperation.ACTION_NONE; + } + + // ------------------------------------------------------------------------------------ + // Reading an AWT transferable as a ClipboardContent. + // ------------------------------------------------------------------------------------ + + /// Maps an AWT flavor onto the MIME type Codename One names that representation by, or null + /// when the flavor carries nothing the framework can express. + private static String mimeFor(DataFlavor flavor) { + if (flavor == null) { + return null; + } + if (DataFlavor.javaFileListFlavor.equals(flavor)) { + return ClipboardContent.MIME_FILE; + } + if (DataFlavor.imageFlavor.equals(flavor)) { + return ClipboardContent.MIME_PNG; + } + if (flavor.getRepresentationClass() != null + && java.awt.Image.class.isAssignableFrom(flavor.getRepresentationClass())) { + // A decoded image, whatever the flavor calls itself. All this can produce from one + // is a PNG, so PNG is what it advertises -- filing PNG bytes under image/jpeg or + // image/webp because the flavor said so handed a target bytes it could not decode + // by the type it had asked for. A source offering the real encoded bytes offers + // them through a stream flavor as well, and that one keeps its own type. + return ClipboardContent.MIME_PNG; + } + if (DataFlavor.stringFlavor.equals(flavor)) { + return ClipboardContent.MIME_TEXT; + } + String primary = flavor.getPrimaryType(); + String sub = flavor.getSubType(); + if (primary == null || sub == null) { + return null; + } + String mime = (primary + "/" + sub).toLowerCase(); + if ("application/rtf".equals(mime)) { + return ClipboardContent.MIME_RTF; + } + if ("application/x-java-file-list".equals(mime)) { + return ClipboardContent.MIME_FILE; + } + if (mime.startsWith("application/x-java")) { + // AWT's own transport flavors -- serialized objects, local object references, the + // text-encoding list. They describe how a payload travels between Java processes, + // not what it is, and reading them can hand back arbitrary live objects. + return null; + } + if (mime.startsWith("text/") || mime.startsWith("image/")) { + return mime; + } + // Anything else the source offers in a shape this can actually read. RichTransferable + // exports arbitrary binary types on the way out, so refusing them on the way in left a + // component filtered to, say, application/pdf unable to receive one at all. + Class representation = flavor.getRepresentationClass(); + if (representation != null + && (InputStream.class.isAssignableFrom(representation) + || byte[].class.equals(representation) + || String.class.equals(representation) + || java.io.Reader.class.isAssignableFrom(representation))) { + return mime; + } + return null; + } + + /// Describes a transferable as a `ClipboardContent`. + /// + /// #### Parameters + /// + /// - `transferable`: the AWT transferable + /// + /// - `flavors`: the flavors it is offering, in the order AWT reported them + /// + /// - `eager`: true to read every representation now, which is only correct inside a drop + /// callback; false to register providers that read on demand, which is what a drag in + /// progress needs + static ClipboardContent contentFor(final Transferable transferable, DataFlavor[] flavors, boolean eager) { + ClipboardContent content = new ClipboardContent(); + if (transferable == null || flavors == null) { + return content; + } + for (int iter = 0; iter < flavors.length; iter++) { + final DataFlavor flavor = flavors[iter]; + final String mime = mimeFor(flavor); + if (mime == null || content.hasMimeType(mime)) { + // The first flavor offering a MIME type wins: AWT lists them in the source's + // preference order, and the richer representation is the earlier one. + continue; + } + if (eager) { + Object value = readValue(transferable, flavor, mime); + if (value != null) { + content.setData(mime, value); + } + } else { + content.setDataProvider(mime, new ClipboardDataProvider() { + @Override + public Object getClipboardData(String requested) { + return readValue(transferable, flavor, requested); + } + }); + } + } + // A file list is also a URI list as far as most applications are concerned, and a drag + // out of a Linux file manager offers only the latter while one out of the Finder offers + // only the former. Presenting both means a drop target that asks for either gets them + // whichever spelling the source used -- the same pair this port publishes on the way + // out. Declared the same way the rest of the content is -- eagerly on a drop, on demand + // during a drag -- so describing a drag still reads nothing. + // + // Only one of the two can fire for any one content, since each is conditioned on the + // other spelling being the one that is present, so neither provider can end up reading + // the other. + if (!content.hasMimeType(ClipboardContent.MIME_FILE) && content.hasMimeType(ClipboardContent.MIME_URI_LIST)) { + if (eager) { + content.setFiles(pathsFromUriList(content.getText(ClipboardContent.MIME_URI_LIST))); + } else { + // Only when the list really does name files. A URI list is not a file list: + // a link dragged out of a browser is one line of http, and declaring files + // for it made a file-only target light up and then be refused the drop, + // because the materialized content -- which keeps only the file: entries -- + // no longer had what the hover had promised. + // + // This is the one representation the description reads a value to decide, + // and it is a short piece of text rather than the file or the image the + // laziness elsewhere exists to defer. Where the platform will not part with + // even that until the drop -- which several do -- nothing is declared, which + // is the safe direction: refusing a drag this port cannot describe beats + // accepting one it cannot deliver. + final String[] named = pathsFromUriList(uriListDuringDrag(transferable, flavors)); + if (named != null) { + content.setDataProvider(ClipboardContent.MIME_FILE, new ClipboardDataProvider() { + @Override + public Object getClipboardData(String requested) { + return named.length == 1 ? (Object) named[0] : named; + } + }); + } + } + } else if (!content.hasMimeType(ClipboardContent.MIME_URI_LIST) + && content.hasMimeType(ClipboardContent.MIME_FILE)) { + // The other direction, which was missing: a Finder or Explorer drag offers only + // javaFileListFlavor, so a component filtered to MIME_URI_LIST refused an ordinary + // file drag from the one source every desktop user has. + if (eager) { + String uris = uriListFrom(content); + if (uris != null) { + content.setData(ClipboardContent.MIME_URI_LIST, uris); + } + } else { + final ClipboardContent describing = content; + content.setDataProvider(ClipboardContent.MIME_URI_LIST, new ClipboardDataProvider() { + @Override + public Object getClipboardData(String requested) { + return uriListFrom(describing); + } + }); + } + } + return content; + } + + /// The `text/uri-list` spelling of a content's files: one `file:` URI per line, CRLF + /// separated as RFC 2483 has it, or null when it names none. + private static String uriListFrom(ClipboardContent content) { + String[] paths = content.getFiles(); + if (paths == null || paths.length == 0) { + return null; + } + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < paths.length; iter++) { + String path = paths[iter]; + if (path == null || path.length() == 0) { + continue; + } + out.append(path.startsWith("file:") ? path : new File(path).toURI().toString()); + out.append("\r\n"); + } + return out.length() == 0 ? null : out.toString(); + } + + /// Reads one representation out of a transferable, converting it into the value type the + /// MIME type implies. Returns null rather than throwing: a flavor that turns out to be + /// unreadable is simply one the drop does not offer. + private static Object readValue(Transferable transferable, DataFlavor flavor, String mime) { + try { + if (mime.startsWith("text/") && flavor.isFlavorTextType() + && !String.class.equals(flavor.getRepresentationClass())) { + // Before the read below, not after it. A text flavor that hands over bytes or a + // stream has to be decoded through the flavor's own reader, and that reader + // fetches the data itself -- so reading first and calling it afterwards + // transferred everything twice, leaked the first stream, and lost the + // representation outright where a source produces it only once. + String text = textFromFlavor(transferable, flavor); + if (text != null) { + return text; + } + } + Object out = transferable.getTransferData(flavor); + if (out == null) { + return null; + } + if (ClipboardContent.MIME_FILE.equals(mime)) { + return filePaths(out); + } + if (ClipboardContent.MIME_PNG.equals(mime) && out instanceof java.awt.Image) { + return JavaSEPort.imageToPngBytes((java.awt.Image) out); + } + if (mime.startsWith("image/")) { + if (out instanceof byte[]) { + return out; + } + if (out instanceof InputStream) { + return readBytes((InputStream) out); + } + if (out instanceof java.awt.Image) { + return JavaSEPort.imageToPngBytes((java.awt.Image) out); + } + return null; + } + if (out instanceof byte[]) { + return out; + } + if (!mime.startsWith("text/") && out instanceof InputStream) { + // A non-text type read as a stream is bytes, not characters; decoding it as + // UTF-8 the way the text path does would corrupt a PDF or an archive. + return readBytes((InputStream) out); + } + return JavaSEPort.clipboardText(out); + } catch (Throwable err) { + return null; + } + } + + /// The URI list a drag is offering, read while it is still hovering, or null when the + /// platform will not produce one yet. + /// + /// Deliberately narrow: only the `text/uri-list` flavor, and only to answer whether the + /// drag names files. Everything else stays deferred. + private static String uriListDuringDrag(Transferable transferable, DataFlavor[] flavors) { + if (transferable == null || flavors == null) { + return null; + } + for (int iter = 0; iter < flavors.length; iter++) { + DataFlavor flavor = flavors[iter]; + if (!ClipboardContent.MIME_URI_LIST.equals(mimeFor(flavor)) + || !String.class.equals(flavor.getRepresentationClass())) { + // Only the spelling that can be read twice. This runs on every drag event -- + // the description is rebuilt for each one -- so consuming a stream here would + // spend a one-shot source on the first hover and leave the drop with nothing. + // A String flavor hands back the same string however often it is asked. + continue; + } + Object value = readValue(transferable, flavor, ClipboardContent.MIME_URI_LIST); + if (value instanceof String) { + return (String) value; + } + } + return null; + } + + /// Reads a text flavor through the reader the flavor itself supplies, which applies the + /// charset the flavor declares. Returns null when the flavor will not produce one, leaving + /// the caller's own handling to run. + /// + /// A text flavor is free to hand over bytes -- text/html;class="[B" and + /// text/plain;class=java.io.InputStream are both ordinary on the desktop -- and the encoding + /// those bytes are in is a parameter of the flavor rather than something to assume. Storing + /// them as a binary payload made getText() answer null for a type the drop had just + /// accepted, and decoding them as UTF-8 by hand gets a charset=UTF-16 flavor wrong. + private static String textFromFlavor(Transferable transferable, DataFlavor flavor) { + try { + java.io.Reader reader = flavor.getReaderForText(transferable); + if (reader == null) { + return null; + } + try { + StringBuilder out = new StringBuilder(); + char[] buffer = new char[2048]; + int read; + while ((read = reader.read(buffer)) >= 0) { + out.append(buffer, 0, read); + } + return out.toString(); + } finally { + reader.close(); + } + } catch (Throwable err) { + return null; + } + } + + /// Turns whatever a file flavor produced -- a list of files, or a URI list as text -- into + /// absolute paths. + private static Object filePaths(Object value) throws Exception { + if (value instanceof List) { + List list = (List) value; + List paths = new ArrayList(); + for (Object o : list) { + if (o instanceof File) { + paths.add(((File) o).getAbsolutePath()); + } else if (o != null) { + paths.add(o.toString()); + } + } + if (paths.isEmpty()) { + return null; + } + return paths.size() == 1 ? (Object) paths.get(0) : paths.toArray(new String[paths.size()]); + } + String[] fromUris = pathsFromUriList(JavaSEPort.clipboardText(value)); + if (fromUris == null) { + return null; + } + return fromUris.length == 1 ? (Object) fromUris[0] : fromUris; + } + + /// Parses the newline separated `text/uri-list` format, keeping only the `file:` entries, + /// which is what a drop from a file manager or the desktop consists of. + private static String[] pathsFromUriList(String uriList) { + if (uriList == null || uriList.length() == 0) { + return null; + } + List paths = new ArrayList(); + String[] lines = uriList.split("\r\n|\n|\r"); + for (int iter = 0; iter < lines.length; iter++) { + String line = lines[iter].trim(); + if (line.length() == 0 || line.charAt(0) == '#') { + continue; + } + try { + if (line.startsWith("file:")) { + paths.add(new File(new URI(line)).getAbsolutePath()); + } + } catch (Throwable err) { + // Not a URI this platform writes; skip it rather than fail the whole drop. + } + } + return paths.isEmpty() ? null : paths.toArray(new String[paths.size()]); + } + + private static byte[] readBytes(InputStream input) throws Exception { + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int read; + while ((read = input.read(buffer)) >= 0) { + out.write(buffer, 0, read); + } + return out.toByteArray(); + } finally { + input.close(); + } + } + + // ------------------------------------------------------------------------------------ + + /// Exports whatever `#startDrag(JavaSEPort, com.codename1.ui.NativeDragOperation)` staged. + /// The payload travels as the same `JavaSEPort.RichTransferable` a copy uses, which is why + /// a drag out of the application lands correctly in a text editor, an image editor and a + /// file manager alike. + private static final class Cn1TransferHandler extends TransferHandler { + @Override + public int getSourceActions(JComponent c) { + NativeDragOperation op = exporting(); + return op == null ? NONE : toAwtActions(op.getAllowedActions()); + } + + @Override + protected Transferable createTransferable(JComponent c) { + NativeDragOperation op = exporting(); + return op == null ? null : new JavaSEPort.RichTransferable(op.getContent()); + } + + @Override + protected void exportDone(JComponent source, Transferable data, int action) { + setExporting(null); + NativeDragAndDrop.dragCompleted(preferred(fromAwtActions(action))); + } + } + + /// Receives drags entering one canvas and routes them into the framework. + private static final class Cn1DropTargetListener implements DropTargetListener { + private final JavaSEPort.C canvas; + + Cn1DropTargetListener(JavaSEPort.C canvas) { + this.canvas = canvas; + } + + @Override + public void dragEnter(DropTargetDragEvent e) { + respond(e, true); + } + + @Override + public void dragOver(DropTargetDragEvent e) { + respond(e, false); + } + + @Override + public void dropActionChanged(DropTargetDragEvent e) { + respond(e, false); + } + + @Override + public void dragExit(DropTargetEvent e) { + try { + NativeDragAndDrop.dragExit(canvas.windowId); + } catch (Throwable err) { + Log.e(err); + } + } + + @Override + public void drop(DropTargetDropEvent e) { + try { + int allowed = fromAwtActions(e.getSourceActions()); + int action = preferred(fromAwtActions(e.getDropAction())); + if (action == NativeDragOperation.ACTION_NONE) { + action = preferred(allowed); + } + Point at = e.getLocation(); + int x = canvas.scaleCoordinateX(at.x); + int y = canvas.scaleCoordinateY(at.y); + // What the framework will settle on, asked before anything is committed. AWT + // has to be told the action when the drop is accepted, and that is before the + // transferable can be read -- so accepting the action AWT proposed and only + // then learning the target had chosen another reported a copy to the source + // through exportDone while handing the target a move. + action = NativeDragAndDrop.plannedDropAction(canvas.windowId, x, y, + contentFor(e.getTransferable(), e.getCurrentDataFlavors(), false), action); + if (action == NativeDragOperation.ACTION_NONE) { + e.rejectDrop(); + return; + } + // Before reading anything: on every platform the transferable only becomes + // readable once the drop has been accepted, and it stops being readable when + // this method returns -- which is why the content is materialized here rather + // than handed to the event dispatch thread as a live view of the transfer. + e.acceptDrop(toAwtAction(action)); + ClipboardContent content = contentFor(e.getTransferable(), e.getCurrentDataFlavors(), true); + int accepted = NativeDragAndDrop.drop(canvas.windowId, x, y, content, action); + e.dropComplete(accepted != NativeDragOperation.ACTION_NONE); + } catch (Throwable err) { + Log.e(err); + try { + e.dropComplete(false); + } catch (Throwable ignored) { + // The drop is already over; nothing left to report to. + } + } finally { + // The drop is the end of the session, and AWT sends no dragExit after it: a + // rejected drop returns above without reaching NativeDragAndDrop.drop(), and a + // drag that arrived from another application has no exportDone() either, so + // without this the target stayed hovered at its own refusal and the next drag + // over it was routed as a move rather than an entry. Free after a drop that did + // go through, which clears the target itself. + try { + NativeDragAndDrop.dragExit(canvas.windowId); + } catch (Throwable ignored) { + // Nothing left to clean up. + } + } + } + + /// What the source permits *at this instant*. + /// + /// getSourceActions is the whole mask the source offered, and answering with it alone + /// made the framework prefer a copy every time -- so holding the platform's modifier to + /// ask for a move changed nothing, because getDropAction, which is where AWT records + /// that choice, was never read. The user's choice wins where the source allows it, and + /// the full mask stands where it does not. + /// + /// The narrowed set is what the framework reports as + /// `com.codename1.ui.NativeDropEvent#getAllowedActions()`, and that is the answer that + /// method owes: what may be accepted here and now. Reporting the unnarrowed mask instead + /// would invite a target to accept the copy the user has just said they do not want, and + /// nothing on this side could then tell the framework which of the two to believe. + private int allowedActionsFor(DropTargetDragEvent e) { + int sourceActions = fromAwtActions(e.getSourceActions()); + int chosen = fromAwtActions(e.getDropAction()) & sourceActions; + return chosen == NativeDragOperation.ACTION_NONE ? sourceActions : chosen; + } + + private void respond(DropTargetDragEvent e, boolean entering) { + try { + Point at = e.getLocation(); + int x = canvas.scaleCoordinateX(at.x); + int y = canvas.scaleCoordinateY(at.y); + ClipboardContent content = contentFor(e.getTransferable(), e.getCurrentDataFlavors(), false); + int allowed = allowedActionsFor(e); + int action = entering + ? NativeDragAndDrop.dragEnter(canvas.windowId, x, y, content, allowed) + : NativeDragAndDrop.dragOver(canvas.windowId, x, y, content, allowed); + if (action == NativeDragOperation.ACTION_NONE) { + e.rejectDrag(); + } else { + e.acceptDrag(toAwtAction(action)); + } + } catch (Throwable err) { + Log.e(err); + try { + e.rejectDrag(); + } catch (Throwable ignored) { + // The drag has already moved on. + } + } + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 2540198e600..afb0dab11c3 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -635,6 +635,15 @@ public Boolean isDarkMode() { private static boolean fullScreen; public float screenshotActualZoomLevel; private InputEvent lastInputEvent; + /// The canvas the gesture currently in progress started on, so a native drag can be + /// exported from the window the user is actually dragging in rather than the main one. + C dndGestureCanvas; + + /// The most recent mouse event, which is what `javax.swing.TransferHandler#exportAsDrag` + /// needs in order to seed a drag session. + InputEvent dndLastInputEvent() { + return lastInputEvent; + } public static double retinaScale = 1.0; static JMenuItem pause; @@ -1881,18 +1890,56 @@ public static void addFormChangeListener(com.codename1.ui.events.ActionListener formChangeListener.addListener(al); } + // ------------------------------------------------------------------------------------ + // Native drag and drop. The desktop is the one platform where a drag genuinely leaves the + // application -- onto another window, into a file manager, onto the desktop itself -- so + // this port implements the whole contract. See JavaSENativeDragAndDrop. + // ------------------------------------------------------------------------------------ + + @Override + public boolean isNativeDragAndDropSupported() { + return !java.awt.GraphicsEnvironment.isHeadless(); + } + + @Override + public boolean isNativeDragOutsideApplicationSupported() { + return isNativeDragAndDropSupported(); + } + + @Override + public boolean startNativeDrag(com.codename1.ui.NativeDragOperation op) { + return JavaSENativeDragAndDrop.startDrag(this, op); + } + + @Override + public void cancelNativeDrag() { + JavaSENativeDragAndDrop.cancelDrag(); + } + @Override public void copyToClipboard(Object obj) { - if (obj instanceof String || obj instanceof ClipboardContent) { - final String text = obj instanceof ClipboardContent - ? ((ClipboardContent) obj).getText(ClipboardContent.MIME_TEXT) : (String)obj; - final ClipboardContent rich = obj instanceof ClipboardContent - ? (ClipboardContent)obj : null; + if (obj instanceof ClipboardContent) { + // Without reading anything out of it. The text was fetched here and then never used + // -- the branch it fed cannot be reached for a ClipboardContent -- so a representation + // registered through setDataProvider was built the moment something was copied, even + // when no consumer ever asked for text or the consumer chose another flavor + // altogether. Writing the file or encoding the image is exactly what a provider + // exists to put off. RichTransferable resolves it if a consumer reads it. + final ClipboardContent rich = (ClipboardContent) obj; EventQueue.invokeLater(new Runnable() { public void run() { Toolkit toolkit = Toolkit.getDefaultToolkit(); Clipboard clipboard = toolkit.getSystemClipboard(); - clipboard.setContents(rich == null ? new StringSelection(text) : new RichTransferable(rich), null); + clipboard.setContents(new RichTransferable(rich), null); + } + }); + } else if (obj instanceof String) { + final String text = (String) obj; + EventQueue.invokeLater(new Runnable() { + public void run() { + Toolkit toolkit = Toolkit.getDefaultToolkit(); + Clipboard clipboard = toolkit.getSystemClipboard(); + clipboard.setContents(new StringSelection(text), null); } }); } else { @@ -1909,57 +1956,149 @@ public void run() { super.copyToClipboard(obj); //To change body of generated methods, choose Tools | Templates. } - private static final class RichTransferable implements Transferable { + /// Publishes a `ClipboardContent` to AWT, for a clipboard copy and for a native drag alike. + /// + /// The flavors are derived from the MIME types alone, never by reading the values. That is + /// deliberate: a drag may offer a representation registered through + /// `ClipboardContent#setDataProvider(java.lang.String, com.codename1.ui.ClipboardDataProvider)` + /// -- the file that is only written if the user actually drops on the desktop -- and reading + /// values here in order to decide what to advertise would build every one of them at the + /// moment the drag starts, which is exactly what the providers exist to avoid. + static final class RichTransferable implements Transferable { private final ClipboardContent data; private final DataFlavor[] flavors; RichTransferable(ClipboardContent data) { this.data = data; ArrayList available = new ArrayList(); - if (data.getText(ClipboardContent.MIME_TEXT) != null) { + String[] mimeTypes = data.getMimeTypes(); + boolean hasFiles = data.hasMimeType(ClipboardContent.MIME_FILE); + if (data.hasMimeType(ClipboardContent.MIME_TEXT)) { available.add(DataFlavor.stringFlavor); } - if (imageBytes(data) != null) { - available.add(DataFlavor.imageFlavor); - } - if (fileList(data) != null) { + if (hasFiles) { available.add(DataFlavor.javaFileListFlavor); + // GTK and a good deal of the web offer files this way and nothing else, so a + // drag that names files advertises both spellings of "these are files". + addTextFlavor(available, ClipboardContent.MIME_URI_LIST); } - String[] mimeTypes = data.getMimeTypes(); for (int i = 0; i < mimeTypes.length; i++) { - if (!ClipboardContent.MIME_TEXT.equals(mimeTypes[i]) - && !ClipboardContent.MIME_FILE.equals(mimeTypes[i]) - && data.getText(mimeTypes[i]) != null) { - available.add(new DataFlavor(mimeTypes[i] + ";class=java.lang.String", mimeTypes[i])); + String mime = mimeTypes[i]; + if (ClipboardContent.MIME_TEXT.equals(mime) || ClipboardContent.MIME_FILE.equals(mime)) { + continue; + } + if (mime.startsWith("image/")) { + // Only when something can actually decode this encoding. Desktop receivers + // commonly pick the standard image flavor ahead of the MIME specific stream, + // so advertising it for a WebP -- which ImageIO has no reader for -- lost the + // whole drop to an UnsupportedFlavorException for a payload that was + // perfectly readable as bytes. + if (hasImageReader(mime) && !available.contains(DataFlavor.imageFlavor)) { + available.add(DataFlavor.imageFlavor); + } + addBinaryFlavor(available, mime); + } else if (mime.startsWith("text/")) { + addTextFlavor(available, mime); + } else { + addBinaryFlavor(available, mime); } } flavors = available.toArray(new DataFlavor[available.size()]); } - private static byte[] imageBytes(ClipboardContent data) { - byte[] b = data.getBytes(ClipboardContent.MIME_PNG); - if (b == null) { - b = data.getBytes(ClipboardContent.MIME_JPEG); + /// Adds a flavor whose representation class is `String`, which is how AWT carries text + /// of a specific MIME type -- `text/html` and `text/rtf` among them. + private static void addTextFlavor(ArrayList available, String mime) { + try { + DataFlavor flavor = new DataFlavor(mime + ";class=java.lang.String", mime); + if (!available.contains(flavor)) { + available.add(flavor); + } + } catch (Exception ex) { + // A MIME type AWT will not parse simply is not advertised. + } + } + + /// Adds a flavor whose representation is a stream of bytes, which is the only way to + /// hand an arbitrary binary payload -- a PDF, an archive -- to another application. + private static void addBinaryFlavor(ArrayList available, String mime) { + try { + DataFlavor flavor = new DataFlavor(mime + ";class=java.io.InputStream", mime); + if (!available.contains(flavor)) { + available.add(flavor); + } + } catch (Exception ex) { + // As above. } - if (b == null) { - b = data.getBytes(ClipboardContent.MIME_GIF); + } + + /// True when this JVM has an `ImageIO` reader for the encoding, which is the question + /// `DataFlavor#imageFlavor` really asks. Asked by MIME type, so it reads no value and a + /// lazily provided representation is not built in order to answer it. + private static boolean hasImageReader(String mime) { + try { + return ImageIO.getImageReadersByMIMEType(mime).hasNext(); + } catch (Throwable err) { + return false; + } + } + + /// Decodes whichever image representation this content offers that `ImageIO` can read. + /// + /// The three encodings the framework names come first because they are what a Codename + /// One source publishes; anything else -- a BMP, a TIFF, whatever a reader plugin adds -- + /// is tried afterwards, so the standard image flavor serves the same set of types + /// `#hasImageReader(java.lang.String)` advertised it for. + private static java.awt.Image decodeImage(ClipboardContent data) { + java.awt.Image img = decodeImage(data, ClipboardContent.MIME_PNG); + if (img == null) { + img = decodeImage(data, ClipboardContent.MIME_JPEG); + } + if (img == null) { + img = decodeImage(data, ClipboardContent.MIME_GIF); + } + if (img != null) { + return img; + } + String[] mimeTypes = data.getMimeTypes(); + for (int i = 0; i < mimeTypes.length; i++) { + String mime = mimeTypes[i]; + if (!mime.startsWith("image/") || ClipboardContent.MIME_PNG.equals(mime) + || ClipboardContent.MIME_JPEG.equals(mime) + || ClipboardContent.MIME_GIF.equals(mime) || !hasImageReader(mime)) { + continue; + } + img = decodeImage(data, mime); + if (img != null) { + return img; + } + } + return null; + } + + private static java.awt.Image decodeImage(ClipboardContent data, String mime) { + try { + // Resolving the representation is inside this, not before it. A provider is + // allowed to fail, and one throwing on the PNG used to escape the whole + // flavor -- so a payload whose JPEG was perfectly good answered the standard + // image flavor with an exception instead of the JPEG. + byte[] bytes = data.getBytes(mime); + if (bytes == null) { + return null; + } + return ImageIO.read(new ByteArrayInputStream(bytes)); + } catch (Throwable err) { + // A representation that will not resolve, or does not decode, is not the + // image flavor's answer; the next one, or the byte stream flavor, still is. + return null; } - return b; } /// Resolves the `application/x-file-list` representation (a single path/URI `String` or a /// `String[]` of them) to AWT `File` objects for the native file-list clipboard flavor. private static java.util.List fileList(ClipboardContent data) { - Object value = data.getData(ClipboardContent.MIME_FILE); - if (value == null) { - return null; - } - String[] paths; - if (value instanceof String[]) { - paths = (String[]) value; - } else if (value instanceof String) { - paths = new String[] { (String) value }; - } else { + String[] paths = data.getFiles(); + if (paths == null) { return null; } java.util.List files = new java.util.ArrayList(); @@ -2000,21 +2139,18 @@ public boolean isDataFlavorSupported(DataFlavor flavor) { return false; } - public Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorException { + public Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorException, IOException { if (DataFlavor.stringFlavor.equals(flavor)) { - return data.getText(ClipboardContent.MIME_TEXT); + String text = data.getText(ClipboardContent.MIME_TEXT); + if (text != null) { + return text; + } + throw new UnsupportedFlavorException(flavor); } if (DataFlavor.imageFlavor.equals(flavor)) { - byte[] bytes = imageBytes(data); - if (bytes != null) { - try { - java.awt.Image img = ImageIO.read(new ByteArrayInputStream(bytes)); - if (img != null) { - return img; - } - } catch (IOException ex) { - // fall through to unsupported - } + java.awt.Image img = decodeImage(data); + if (img != null) { + return img; } throw new UnsupportedFlavorException(flavor); } @@ -2025,10 +2161,33 @@ public Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorExcepti } throw new UnsupportedFlavorException(flavor); } - String value = data.getText(flavor.getPrimaryType() + "/" + flavor.getSubType()); - if (value != null) { + String mime = (flavor.getPrimaryType() + "/" + flavor.getSubType()).toLowerCase(); + if (ClipboardContent.MIME_URI_LIST.equals(mime) && !data.hasMimeType(ClipboardContent.MIME_URI_LIST)) { + // Synthesized from the file list rather than stored, so a source only has to + // name its files once. + java.util.List files = fileList(data); + if (files != null) { + StringBuilder uris = new StringBuilder(); + for (java.io.File f : files) { + uris.append(f.toURI().toString()).append("\r\n"); + } + return uris.toString(); + } + throw new UnsupportedFlavorException(flavor); + } + Object value = data.getData(mime); + if (value instanceof String) { + if (InputStream.class.equals(flavor.getRepresentationClass())) { + return new ByteArrayInputStream(((String) value).getBytes("UTF-8")); + } return value; } + if (value instanceof byte[]) { + if (InputStream.class.equals(flavor.getRepresentationClass())) { + return new ByteArrayInputStream((byte[]) value); + } + return new String((byte[]) value, "UTF-8"); + } throw new UnsupportedFlavorException(flavor); } } @@ -2104,7 +2263,7 @@ public Object getPasteDataFromClipboard() { /// Encodes an AWT clipboard image as PNG bytes so it can travel through the CN1 clipboard as an /// {@code image/png} representation. - private static byte[] imageToPngBytes(java.awt.Image image) { + static byte[] imageToPngBytes(java.awt.Image image) { try { BufferedImage buffered; if (image instanceof BufferedImage) { @@ -2127,7 +2286,7 @@ private static byte[] imageToPngBytes(java.awt.Image image) { } } - private static String clipboardText(Object value) throws IOException { + static String clipboardText(Object value) throws IOException { if (value instanceof String) { return (String)value; } @@ -3085,6 +3244,17 @@ private void repaintCanvasTopLevel() { return retinaScale; } + /// Codename One pixels per AWT point for anything AWT lays over this canvas. + /// + /// The backing scale is only half of it. With a device skin the canvas also draws + /// at zoomLevel, which is why scaleCoordinateX/Y map a press back through + /// retinaScale / zoomLevel -- so this is the same factor the pointer is measured + /// with, read the other way round. + double awtOverlayScale() { + return JavaSENativeDragAndDrop.overlayScale( + getScreenCoordinates() != null, canvasScale(), zoomLevel); + } + int surfaceWidth() { if (windowId == 0) { return getDisplayWidthImpl(); @@ -3109,6 +3279,9 @@ int surfaceHeight() { addHierarchyBoundsListener(this); setFocusable(true); setOpaque(false); + // Native drag and drop, both directions: this canvas becomes an AWT drop target + // and gets a transfer handler that can export a Codename One drag to the desktop. + JavaSENativeDragAndDrop.install(this); installNativeMagnificationListeners(); addHierarchyListener(new HierarchyListener() { public void hierarchyChanged(HierarchyEvent e) { @@ -4044,7 +4217,7 @@ public void actionPerformed(ActionEvent e) { return true; } - private int scaleCoordinateX(int coordinate) { + int scaleCoordinateX(int coordinate) { if (getScreenCoordinates() != null) { return (int) (retinaScale * coordinate / zoomLevel - (getScreenCoordinates().x + x)); } @@ -4053,7 +4226,7 @@ private int scaleCoordinateX(int coordinate) { return (int)(coordinate * canvasScale()); } - private int scaleCoordinateY(int coordinate) { + int scaleCoordinateY(int coordinate) { if (getScreenCoordinates() != null) { return (int) (retinaScale * coordinate / zoomLevel - (getScreenCoordinates().y + y)); } @@ -4071,6 +4244,7 @@ public void mousePressed(MouseEvent e) { } } this.mouseDown = true; + JavaSEPort.this.dndGestureCanvas = this; com.codename1.ui.TopLevelContainer f = canvasTopLevel(); if (f != null) { int x = scaleCoordinateX(e.getX()); diff --git a/Ports/iOSPort/nativeSources/CN1DragAndDrop.h b/Ports/iOSPort/nativeSources/CN1DragAndDrop.h new file mode 100644 index 00000000000..18506db2d8d --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1DragAndDrop.h @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2012, 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 Oracle 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. + */ + +#ifndef CN1DragAndDrop_h +#define CN1DragAndDrop_h + +#import +#include "TargetConditionals.h" +#import "CN1AppleUI.h" + +/* + * Native drag and drop for the UIKit ports: iOS, iPadOS and Mac Catalyst. + * + * UIKit owns the drag gesture. UIDragInteraction has its own recognizer -- a long press on a + * phone, a lift on a trackpad -- and asks the delegate what is being dragged at the moment it + * fires. So the framework cannot start a session on its own drag threshold the way the desktop + * port does; instead it stages the operation on the press (CN1PrepareNativeDrag) and this file + * calls back into Java when UIKit decides a drag has begun. + * + * The payload itself is fetched at that later moment rather than on the press, because a drag + * that may carry a file the application has not written yet must not write it every time the + * user merely touches the component. + * + * watchOS, tvOS and the native AppKit port have no UIDragInteraction; there the whole file + * compiles to the unsupported answers below and the framework keeps its lightweight in-form + * drag and drop. + */ + +/// True when this build can drag through the operating system at all. +BOOL CN1DragAndDropSupported(void); + +/// True when a drag started here can be dropped outside the application. On iPadOS and Mac +/// Catalyst it can; on iPhone a drag stays inside the application, because there is no second +/// application on screen to drop it into. +BOOL CN1DragOutsideAppSupported(void); + +/// Remembers the Codename One surface, so the interactions can be attached to it later. +/// +/// Neither interaction is attached here. UIDragInteraction recognizes its gesture with a +/// recognizer installed on the view, and installing one changes how every touch on that view is +/// delivered -- a plain tap stopped reaching the framework at all. The overwhelming majority of +/// applications never drag anything and must not pay for that; the drop half is withheld on the +/// same principle rather than on measurement. +/// +/// The parameter degrades to `id` on watchOS, which has no CN1View at all -- WatchKit draws +/// through WKInterface objects and CN1AppleUI.h deliberately leaves the alias undefined there. +/// CN1RenderingView's peer argument does the same thing for the same reason. Naming the type +/// unconditionally broke every watch build, since this header reaches that slice too. +#if TARGET_OS_WATCH +void CN1InstallDragAndDrop(id view); +#else +void CN1InstallDragAndDrop(CN1View* view); +#endif + +/// Attaches the drag interaction, because the application has a component that can be dragged +/// out. Idempotent, and safe to call from any thread. +void CN1EnableNativeDragSource(void); + +/// Attaches the drop interaction, because the application has a component that accepts drops. +/// Idempotent, and safe to call from any thread. +void CN1EnableNativeDropTarget(void); + +/// Stages the drag a press has made possible: which representations it can offer, what the +/// receiver may do with them, and the image to show under the finger. The representations are +/// named but not built -- CN1AddNativeDragPayload delivers the bytes once the drag really +/// starts. +/// +/// mimeTypes is newline separated. +void CN1PrepareNativeDrag(NSString* mimeTypes, int allowedActions, NSData* dragImagePng, + int touchX, int touchY); + +/// Clears the payload, ready for the representations of the session UIKit has just started, +/// and records the id the framework has given that session. +/// +/// The id travels with every load handler this session registers, so a representation read +/// late -- after the user has begun another drag -- is resolved against the operation it +/// belongs to rather than whatever is being dragged by then. +void CN1BeginNativeDragPayload(int sessionId); + +/// Names a representation the drag can offer without producing it. +/// +/// The bytes are fetched through CN1NativeDragDeliverResolve when a receiver actually reads +/// that type, which is what setDataProvider promises: beginning a drag and abandoning it must +/// not build anything. Every MIME type the operation advertises is declared here, rather than a +/// fixed list of the framework's own -- an operation carrying only `text/markdown` was +/// advertised and never forwarded, so the drag began with no items and UIKit cancelled it. +void CN1DeclareNativeDragPayload(NSString* mimeType); + +/// Adds the file list, which is the one representation that cannot be deferred: UIKit needs the +/// number of items when the session begins, and that is the number of files. +/// +/// `paths` is newline separated. +void CN1AddNativeDragFiles(NSString* paths); + +/// Drops whatever CN1PrepareNativeDrag staged, because the press turned out to be a tap. +void CN1CancelNativeDrag(void); + +/* + * The Java side of the bridge. Defined in IOSNative.m, where every piece of ParparVM thread + * state handling lives, so that this file stays plain UIKit. + */ + +/// Reports a drag moving over the surface and returns the action a drop would perform, or 0. +int CN1NativeDragDeliverOver(int x, int y, NSString* mimeTypes, int allowedActions, BOOL entering); + +/// Reports a drag leaving the surface. +void CN1NativeDragDeliverExit(void); + +/// Starts a drop, clearing whatever a previous one left. +void CN1NativeDragDeliverDropBegin(void); + +/// Adds one representation to the drop being assembled. Every representation UIKit loaded goes +/// through here rather than a fixed list, so a drag carrying markdown, a GIF or an +/// application's own type is not accepted while it hovers and then found empty at the drop -- +/// which would refuse the very target that agreed to take it. +/// +/// `text` and `binary` are alternatives; `application/x-file-list` arrives as newline separated +/// paths in `text`. +void CN1NativeDragDeliverDropAdd(NSString* mimeType, NSString* text, NSData* binary); + +/// Adds a representation of the drop that is backed by a file already on disk. +/// +/// A provider that vends a file commonly advertises the document's own content type as well. +/// Loading that as data would read the whole document into memory on top of the copy this +/// already makes -- fatal for a large one -- so the type is named against the copy instead and +/// read only if a target asks for it. +/// +/// `charset` is the encoding the representation's uniform type identifier declared, or nil +/// when it declared none. Without it the Java side has only the path and reads every text +/// representation as UTF-8, which turns a public.utf16-plain-text alternative into rubbish. +void CN1NativeDragDeliverDropAddFile(NSString* mimeType, NSString* path, NSString* charset); + +/// Delivers the assembled drop and returns the action actually accepted, or 0 when nothing took +/// it. +/// +/// `local` is whether the drag being dropped is one this application started, taken when the +/// drop began rather than when its loads finished -- by then the drag running may be another. +/// `allowedActions` is the mask this session offered and `local` whether the drag is one this +/// application started -- both taken when the drop began rather than when its loads finished, +/// because by then the drag the framework remembers may be another one. +int CN1NativeDragDeliverDropCommit(int x, int y, int action, int allowedActions, BOOL local); + +/// Produces one representation of a drag on demand. Returns nil when the operation that +/// session belongs to can no longer supply it. +NSData* CN1NativeDragDeliverResolve(NSString* mimeType, int sessionId); + +/// Reports that the last item provider able to read this session's payload has gone, so the +/// framework may drop it. +/// +/// An NSItemProvider may be kept by a receiver and read long after the gesture ended, so the +/// payload cannot be released on any schedule of the drag's own -- holding a fixed number of +/// recent drags instead means a provider older than that answers nothing. The providers +/// themselves say when they are done: each one retains a token for the session, and the last +/// release calls this. +void CN1NativeDragDeliverPayloadReleased(int sessionId); + +/// Announces that UIKit has started a drag session. Returns the actions the framework's staged +/// operation allows, or 0 when it has none -- in which case no drag begins. The Java side calls +/// CN1BeginNativeDragPayload and CN1AddNativeDragPayload from inside this call. +int CN1NativeDragDeliverSessionStarted(void); + +/// Reports the outcome of a session this application started. +void CN1NativeDragDeliverCompleted(int action); + +#endif diff --git a/Ports/iOSPort/nativeSources/CN1DragAndDrop.m b/Ports/iOSPort/nativeSources/CN1DragAndDrop.m new file mode 100644 index 00000000000..dae6d709aa2 --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1DragAndDrop.m @@ -0,0 +1,1202 @@ +/* + * Copyright (c) 2012, 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 Oracle 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. + */ + +#import "CN1DragAndDrop.h" + +#if !TARGET_OS_OSX && !TARGET_OS_WATCH && !TARGET_OS_TV +#import +#if __has_include() +#import +#endif +#endif + +#define CN1_DND_ACTION_NONE 0 +#define CN1_DND_ACTION_COPY 1 +#define CN1_DND_ACTION_MOVE 2 +#define CN1_DND_ACTION_LINK 4 + +#if TARGET_OS_OSX || TARGET_OS_WATCH || TARGET_OS_TV + +// No UIDragInteraction on these targets. The framework asks first and falls back to its +// lightweight in-form drag and drop, so these answers are the whole implementation. + +BOOL CN1DragAndDropSupported(void) { + return NO; +} + +BOOL CN1DragOutsideAppSupported(void) { + return NO; +} + +#if TARGET_OS_WATCH +void CN1InstallDragAndDrop(id view) { +} +#else +void CN1InstallDragAndDrop(CN1View* view) { +} +#endif + +void CN1EnableNativeDragSource(void) { +} + +void CN1EnableNativeDropTarget(void) { +} + +void CN1PrepareNativeDrag(NSString* mimeTypes, int allowedActions, NSData* dragImagePng, + int touchX, int touchY) { +} + +void CN1BeginNativeDragPayload(int sessionId) { +} + +void CN1DeclareNativeDragPayload(NSString* mimeType) { +} + +void CN1AddNativeDragFiles(NSString* paths) { +} + +void CN1CancelNativeDrag(void) { +} + +#else + +/// The device pixel scale, owned by the view controller. UIKit reports drag positions in +/// points and the framework works in pixels, exactly as the touch path does. +extern float scaleValue; + +/// The file URL an item of type public.file-url carries, whichever way the provider hands it +/// over. +/// +/// This is the representation, decoded -- not a file *of* it. loadFileRepresentation writes a +/// copy of the data of the type it is given, and the data of type public.file-url is the URL +/// itself, so asking for a file of that yields a temporary file whose contents are a URL +/// string. Nor can the document be found by picking some other identifier the provider +/// happens to register first: that may be a thumbnail or a fallback, and a provider offering +/// nothing but the URL has no other identifier at all. The URL says where the document is, +/// and it is the only thing that does. +static NSURL* cn1FileUrlFromItem(id item) { + if ([item isKindOfClass:[NSURL class]]) { + return (NSURL*) item; + } + if ([item isKindOfClass:[NSData class]]) { + // The bytes of a URL, which is what the type is. + return [NSURL URLWithDataRepresentation:(NSData*) item relativeToURL:nil]; + } + if ([item isKindOfClass:[NSString class]]) { + return [NSURL URLWithString:(NSString*) item]; + } + return nil; +} + +/// What the press staged: the representations the drag could offer, what a receiver may do +/// with them, and the preview. Named but not built -- see CN1DragAndDrop.h. +static NSArray* cn1PreparedMimes = nil; + +/// What a receiver is allowed to do with the drag. Staged by the press and replaced by the +/// authoritative set when a session really starts; it is what a *local* drop session is told +/// the source allows. UIKit carries no action information for a session that arrived from +/// another application, so those are told copy -- see cn1AllowedActionsFor. +static int cn1SessionActions = CN1_DND_ACTION_NONE; + +/// The drag session this source started, retained for as long as it runs and released when +/// UIKit reports it finished -- which it always does for a session it started here. +/// +/// Every drag begun anywhere in this application has a non-nil localDragSession, so "local" +/// alone said yes to a drag some other interaction started -- and cn1SessionActions, which +/// nothing cleared, then handed that unrelated drag the last Codename One drag's mask. A +/// move-only one would have proposed a move to a source that never offered it. +static id cn1OutgoingSession = nil; +static UIImage* cn1PreparedPreview = nil; +static CGPoint cn1PreparedTouch; + +/// The payload of the session UIKit is currently running, delivered by +/// CN1DeclareNativeDragPayload once the drag has actually begun -- the MIME types it can offer, +/// not their values, which are fetched only if a receiver reads them. +static NSMutableArray* cn1DragMimes = nil; +static NSMutableArray* cn1DragFileUrls = nil; + +/// The framework's id for the session being built, captured by every load handler it registers +/// so a late read resolves against its own operation and not the next drag's. +static int cn1DragSessionId = 0; + +/// The last action the framework agreed to, reused when a drop arrives without one. +static int cn1LastDropAction = CN1_DND_ACTION_NONE; + +/// True while this application is the source of the session in progress. +static BOOL cn1DraggingOut = NO; + +/// The drop sessions whose representations are still loading. +/// +/// UIKit ends a session as soon as performDrop: returns, which is long before the asynchronous +/// loads that drop depends on have answered. The end must not clear the hover state while the +/// drop about to use it is still in flight -- doing so made the commit find its own target gone +/// and fall back to the declarative answer, discarding whatever the target's callbacks had +/// decided. +/// +/// Per session, not a single flag. A slow NSItemProvider can leave one assembly running while +/// the user completes a second drop, and one boolean answered for both: the first assembly's +/// completion cleared it, so the second session's end went on to dispatch an exit while its own +/// loads were still running -- and its commit then found no target of its own to honour, which +/// is the very failure the flag exists to prevent. +/// +/// The table holds its sessions weakly; each assembly's own completion block is what keeps its +/// session alive for as long as the answer is needed. +static NSHashTable* cn1LoadingDropSessions = nil; + +static BOOL cn1DropIsLoading(id session) { + return session != nil && cn1LoadingDropSessions != nil + && [cn1LoadingDropSessions containsObject:session]; +} + +/// A drop of this application's own session onto its own surface, still loading. +/// +/// UIKit asks the source what happened -- dragInteraction:session:didEndWithOperation: -- as +/// soon as performDrop: returns, which is before the asynchronous loads that drop depends on +/// have answered. Reporting the operation UIKit proposed at that moment would tell a source +/// its data had been moved even when the framework went on to refuse the drop, and a source +/// that deletes on ACTION_MOVE would then delete data nothing ever received. So the completion +/// waits for the real answer, whichever of the two arrives first. +/// +/// All three are read and written on the main thread only, by callbacks UIKit serializes. +static BOOL cn1LocalDropInFlight = NO; +static BOOL cn1EndDeferred = NO; +static int cn1LocalDropResult = -1; + +/// The surface, remembered so the drag interaction can be attached later, and the delegate that +/// serves both interactions. The delegate outlives the surface, which lives for the life of the +/// process, and the interactions hold it weakly. +static CN1View* cn1DragSurface = nil; +static id cn1DragDelegate = nil; + +/// The MIME types the framework names, mapped onto the uniform type identifiers UIKit and +/// every other application on the system speak. +/// The uniform type identifier MobileCoreServices knows a MIME type by, or nil. +/// +/// Deprecated from iOS 15, which is why it is reached only when UTType is unavailable; the +/// warning is silenced rather than the call avoided, because on those releases it is the only +/// way to name a type the system will recognize. +static NSString* cn1LegacyUtiForMime(NSString* mime) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + CFStringRef identifier = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, + (CFStringRef) mime, NULL); +#pragma clang diagnostic pop + if (identifier == NULL) { + return nil; + } + NSString* result = [(NSString*) identifier autorelease]; + // A MIME type it does not know produces a dynamic identifier, which carries no more meaning + // to a receiver than the MIME type itself and reads far worse. + return [result hasPrefix:@"dyn."] ? nil : result; +} + +static NSString* cn1UtiForMime(NSString* mime) { + if ([mime isEqualToString:@"text/plain"]) { + return @"public.utf8-plain-text"; + } + if ([mime isEqualToString:@"text/html"]) { + return @"public.html"; + } + if ([mime isEqualToString:@"text/rtf"]) { + return @"public.rtf"; + } + if ([mime isEqualToString:@"text/markdown"]) { + return @"net.daringfireball.markdown"; + } + if ([mime isEqualToString:@"image/png"]) { + return @"public.png"; + } + if ([mime isEqualToString:@"image/jpeg"]) { + return @"public.jpeg"; + } + if ([mime isEqualToString:@"image/gif"]) { + return @"com.compuserve.gif"; + } + if ([mime isEqualToString:@"application/x-file-list"]) { + return @"public.file-url"; + } + if ([mime isEqualToString:@"text/uri-list"]) { + return @"public.url"; + } + // Anything else -- text/asciidoc, application/pdf, an application's own type -- still has + // to travel. Ask the system to name it, and fall back to the MIME type itself, which is an + // opaque identifier that a receiver which does not know it simply never asks for. + // Returning nil here would drop a representation the application deliberately published, + // and a drag whose *only* representation was one of those would begin with no items at all + // and be cancelled on the spot. +#if __has_include() + if (@available(iOS 14.0, *)) { + UTType* type = [UTType typeWithMIMEType:mime]; + if (type != nil && type.identifier.length > 0) { + return type.identifier; + } + return mime; + } +#endif + // Below iOS 14 UTType does not exist, and an application may deploy that far back through + // the ios.deployment_target build hint. Without this every type not named above -- including + // application/pdf -- was published under its MIME string, which no other application asking + // for the standard identifier would ever match. + NSString* legacy = cn1LegacyUtiForMime(mime); + return legacy != nil ? legacy : mime; +} + +/// The MIME type MobileCoreServices knows a uniform type identifier by, or nil. The reverse of +/// cn1LegacyUtiForMime, and deprecated from iOS 15 for the same reason. +static NSString* cn1LegacyMimeForUti(NSString* uti) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + CFStringRef mime = UTTypeCopyPreferredTagWithClass((CFStringRef) uti, kUTTagClassMIMEType); +#pragma clang diagnostic pop + if (mime == NULL) { + return nil; + } + NSString* result = [(NSString*) mime autorelease]; + return result.length > 0 ? [result lowercaseString] : nil; +} + +static NSString* cn1MimeForUti(NSString* uti) { + if ([uti isEqualToString:@"public.utf8-plain-text"] || [uti isEqualToString:@"public.plain-text"] + || [uti isEqualToString:@"public.text"]) { + return @"text/plain"; + } + if ([uti isEqualToString:@"public.html"]) { + return @"text/html"; + } + if ([uti isEqualToString:@"public.rtf"]) { + return @"text/rtf"; + } + if ([uti isEqualToString:@"net.daringfireball.markdown"]) { + return @"text/markdown"; + } + if ([uti isEqualToString:@"public.png"]) { + return @"image/png"; + } + if ([uti isEqualToString:@"public.jpeg"]) { + return @"image/jpeg"; + } + if ([uti isEqualToString:@"com.compuserve.gif"]) { + return @"image/gif"; + } + if ([uti isEqualToString:@"public.file-url"]) { + return @"application/x-file-list"; + } + if ([uti isEqualToString:@"public.url"]) { + return @"text/uri-list"; + } + if ([uti containsString:@"/"]) { + // One of ours: cn1UtiForMime falls back to the MIME type as the identifier. + return uti; + } +#if __has_include() + if (@available(iOS 14.0, *)) { + UTType* type = [UTType typeWithIdentifier:uti]; + if (type != nil && type.preferredMIMEType.length > 0) { + return type.preferredMIMEType; + } + return nil; + } +#endif + // Below iOS 14, the same way round as cn1UtiForMime goes. Answering nil here regardless of + // the identifier left a standard type such as com.adobe.pdf unnamed on those releases, so a + // drag of one was neither discovered while it hovered nor materialized when it dropped -- + // the outgoing direction had its legacy conversion and the incoming one did not. + NSString* legacy = cn1LegacyMimeForUti(uti); + if (legacy != nil) { + return legacy; + } + // A dynamic or private identifier with no MIME equivalent. Naming it anyway would fill the + // content with identifiers no drop target could match on. + return nil; +} + +/// What the source of this drop session allows. +/// +/// A session this application started knows exactly, and using copy for it -- which is what +/// this did at first -- meant a move-only drag had no action in common with a move-only target +/// and could not be dropped at all, while a copy-or-move drag could only ever be proposed as a +/// copy, so no in-application reorder could report a move to its source. +/// +/// A session from another application is a different matter: UIKit tells a drop interaction +/// nothing about what the far side permits, so copy is the only defensible reading -- and the +/// safe one, since proposing a move the source never offered would have it delete data on the +/// strength of our guess. +static int cn1AllowedActionsFor(id session) { + // The session this source started, not merely one that started somewhere in this + // application: another interaction's drag is as foreign to this framework as one from + // another application, and is told the same thing. + if (session.localDragSession != nil && cn1OutgoingSession != nil + && session.localDragSession == cn1OutgoingSession + && cn1SessionActions != CN1_DND_ACTION_NONE) { + return cn1SessionActions; + } + return CN1_DND_ACTION_COPY; +} + +/// One action out of a set, preferring a copy because it is the one that cannot destroy the +/// source's data. +static int cn1DefaultAction(int actions) { + if ((actions & CN1_DND_ACTION_COPY) != 0) { + return CN1_DND_ACTION_COPY; + } + if ((actions & CN1_DND_ACTION_MOVE) != 0) { + return CN1_DND_ACTION_MOVE; + } + if ((actions & CN1_DND_ACTION_LINK) != 0) { + return CN1_DND_ACTION_LINK; + } + return CN1_DND_ACTION_NONE; +} + +static UIDropOperation cn1DropOperationFor(int action) { + if ((action & CN1_DND_ACTION_MOVE) != 0) { + return UIDropOperationMove; + } + if ((action & CN1_DND_ACTION_LINK) != 0) { + // UIKit has no "link"; a receiver that wanted one still wants the transfer to happen, + // and copy is the operation that says so without claiming the source loses its data. + return UIDropOperationCopy; + } + if ((action & CN1_DND_ACTION_COPY) != 0) { + return UIDropOperationCopy; + } + return UIDropOperationCancel; +} + +/// The MIME types a drag in progress is offering, newline separated, derived from the type +/// identifiers alone. UIKit does not hand over the data until the drop and the framework only +/// needs the names in order to decide whether any component wants the drag. +static NSString* cn1MimesForSession(id session) { + NSMutableArray* mimes = [NSMutableArray array]; + for (UIDragItem* item in session.items) { + for (NSString* uti in item.itemProvider.registeredTypeIdentifiers) { + NSString* mime = cn1MimeForUti(uti); + if (mime != nil && ![mimes containsObject:mime]) { + [mimes addObject:mime]; + } + } + // A provider that can vend a file is a file drag whatever else it also offers, which is + // how a document dragged out of Files reaches a target that asked for files. Both + // spellings of that, as the other two ports publish them: a component filtered to + // MIME_URI_LIST refused an ordinary drag out of Files while the same component took + // it from the Finder and from a file manager on Android. + if ([item.itemProvider hasItemConformingToTypeIdentifier:@"public.file-url"]) { + if (![mimes containsObject:@"application/x-file-list"]) { + [mimes addObject:@"application/x-file-list"]; + } + if (![mimes containsObject:@"text/uri-list"]) { + [mimes addObject:@"text/uri-list"]; + } + } + } + return [mimes componentsJoinedByString:@"\n"]; +} + +BOOL CN1DragAndDropSupported(void) { + if (@available(iOS 11.0, *)) { + return YES; + } + return NO; +} + +BOOL CN1DragOutsideAppSupported(void) { +#if TARGET_OS_MACCATALYST + if (@available(iOS 11.0, *)) { + return YES; + } + return NO; +#else + // iOS 15 brought drag and drop between applications to the phone -- hold the item with one + // finger, switch applications with another, drop -- so from there on the idiom no longer + // decides it. Before that a drag could only leave the application on an iPad, where a + // second application can be on screen to receive it; on a phone the same session worked but + // could only end on one of this application's own components. + if (@available(iOS 15.0, *)) { + return YES; + } + if (@available(iOS 11.0, *)) { + return [UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad; + } + return NO; +#endif +} + +void CN1PrepareNativeDrag(NSString* mimeTypes, int allowedActions, NSData* dragImagePng, + int touchX, int touchY) { + NSArray* mimes = mimeTypes == nil || mimeTypes.length == 0 + ? nil : [mimeTypes componentsSeparatedByString:@"\n"]; +#ifndef CN1_USE_ARC + [cn1PreparedMimes release]; + [cn1PreparedPreview release]; +#endif + cn1PreparedMimes = mimes; + cn1SessionActions = allowedActions; + // At the screen's scale, because the framework renders in device pixels and UIKit lays + // the preview out in points. Decoded at scale 1, a snapshot from a 2x or 3x screen is + // that many times too big -- and the touch offset below is converted to points, so the + // grab point lands somewhere else on an image of the wrong size as well. + cn1PreparedPreview = dragImagePng == nil ? nil + : [UIImage imageWithData:dragImagePng scale:(scaleValue > 0 ? scaleValue : 1)]; + cn1PreparedTouch = CGPointMake(touchX / scaleValue, touchY / scaleValue); +#ifndef CN1_USE_ARC + [cn1PreparedMimes retain]; + [cn1PreparedPreview retain]; +#endif +} + +void CN1BeginNativeDragPayload(int sessionId) { +#ifndef CN1_USE_ARC + [cn1DragMimes release]; + [cn1DragFileUrls release]; +#endif + cn1DragMimes = [[NSMutableArray alloc] init]; + cn1DragFileUrls = [[NSMutableArray alloc] init]; + cn1DragSessionId = sessionId; +} + +void CN1DeclareNativeDragPayload(NSString* mimeType) { + if (mimeType == nil || mimeType.length == 0 || cn1DragMimes == nil + || [cn1DragMimes containsObject:mimeType]) { + return; + } + [cn1DragMimes addObject:mimeType]; +} + +void CN1AddNativeDragFiles(NSString* paths) { + if (paths == nil || paths.length == 0 || cn1DragFileUrls == nil) { + return; + } + for (NSString* entry in [paths componentsSeparatedByString:@"\n"]) { + if (entry.length == 0) { + continue; + } + // ClipboardContent's file representation permits a raw local path as well as a file: + // URI, and URLWithString: turns a path into a scheme-less relative URL that no receiver + // can open. An absolute path is obvious; a relative one -- exports/report.pdf -- looks + // enough like a URL to be parsed as one, and was then quietly dropped from the drag + // because an item provider cannot vend it. Anything that does not come back with a + // scheme is a path. + NSURL* url; + if ([entry hasPrefix:@"/"] || [entry hasPrefix:@"~"]) { + url = [NSURL fileURLWithPath:[entry stringByExpandingTildeInPath]]; + } else { + url = [NSURL URLWithString:entry]; + if (url == nil || url.scheme == nil) { + url = [NSURL fileURLWithPath:entry]; + } + } + if (url != nil) { + [cn1DragFileUrls addObject:url]; + } + } +} + +void CN1CancelNativeDrag(void) { +#ifndef CN1_USE_ARC + [cn1PreparedMimes release]; + [cn1PreparedPreview release]; +#endif + cn1PreparedMimes = nil; + cn1PreparedPreview = nil; + cn1SessionActions = CN1_DND_ACTION_NONE; +} + +/// Holds a drag's payload alive for exactly as long as something can still read it. +/// +/// Every load handler this session registers captures the token, and copying a block retains +/// what it captures, so the token outlives the gesture precisely when an NSItemProvider does -- +/// which is the case that matters, since a receiver may keep a provider and read it much later. +/// When the last provider goes the token deallocates and the framework drops the payload. It +/// carries only the session id: the bytes stay on the Java side, unbuilt until read. +@interface CN1DragPayloadToken : NSObject { + int _sessionId; +} +@property (nonatomic) int sessionId; +@end + +@implementation CN1DragPayloadToken + +@synthesize sessionId = _sessionId; + +- (void)dealloc { + const int released = _sessionId; + // dealloc runs on whichever thread released the last provider. Every other call into the + // framework from this file is made on the main thread, and this one is no different. + dispatch_async(dispatch_get_main_queue(), ^{ + CN1NativeDragDeliverPayloadReleased(released); + }); +#ifndef CN1_USE_ARC + [super dealloc]; +#endif +} + +@end + +/// Reads a text representation with the encoding its uniform type identifier declares. +/// +/// cn1MimeForUti maps public.utf8-plain-text, public.utf16-plain-text and +/// public.utf16-external-plain-text all onto text/plain, because that is the MIME type they all +/// are -- but they do not agree about the bytes. Decoding UTF-16 as UTF-8 answers nil, which +/// dropped a representation the drag had advertised and refused the very target that accepted +/// it on the strength of it. Worse, UTF-16 in little endian without a byte order mark decodes +/// as UTF-8 *successfully*, into text full of NULs, so trying UTF-8 first and falling back is +/// not a substitute for reading what the identifier says. +/// +/// Anything the identifier does not pin down is handed to NSString's own detection rather than +/// assumed, and only a representation nothing can read at all comes back nil. +/// The charset a uniform type identifier declares, by the name java.nio understands, or nil +/// when the identifier says nothing about the encoding. +/// +/// A representation handed over as a file keeps only its path, so this is what travels with +/// it: the Java side reads the bytes later and would otherwise have to assume UTF-8, which +/// turns a public.utf16-plain-text alternative into rubbish. +static NSString* cn1CharsetNameForUti(NSString* uti) { + if ([uti isEqualToString:@"public.utf16-plain-text"] + || [uti isEqualToString:@"public.utf16-external-plain-text"]) { + return @"UTF-16"; + } + if ([uti isEqualToString:@"public.utf8-plain-text"]) { + return @"UTF-8"; + } + return nil; +} + +static NSString* cn1TextFromData(NSData* data, NSString* uti) { + NSStringEncoding declared = 0; + NSString* charset = cn1CharsetNameForUti(uti); + if ([charset isEqualToString:@"UTF-16"]) { + declared = NSUTF16StringEncoding; + } else if ([charset isEqualToString:@"UTF-8"]) { + declared = NSUTF8StringEncoding; + } + if (declared != 0) { + NSString* exact = [[[NSString alloc] initWithData:data encoding:declared] autorelease]; + if (exact != nil) { + return exact; + } + } + NSString* utf8 = [[[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] autorelease]; + if (utf8 != nil) { + return utf8; + } + NSString* detected = nil; + [NSString stringEncodingForData:data + encodingOptions:@{NSStringEncodingDetectionSuggestedEncodingsKey: + @[@(NSUTF16StringEncoding), + @(NSUTF16LittleEndianStringEncoding), + @(NSUTF16BigEndianStringEncoding), + @(NSISOLatin1StringEncoding)]} + convertedString:&detected + usedLossyConversion:NULL]; + return detected; +} + +/// Copies a file a drop handed over into somewhere that outlives the handler, and returns the +/// path -- or nil when the copy failed. +/// +/// Every URL an NSItemProvider produces is valid only for the duration of the completion handler +/// it arrives in, so a path handed to the framework without copying is unreadable by the time +/// the event dispatch thread sees it. The name is kept because a receiver commonly shows it. +static NSString* cn1CopyDroppedFile(NSURL* url) { + NSString* name = url.lastPathComponent; + if (name == nil || name.length == 0) { + name = @"dropped"; + } + NSString* target = [NSTemporaryDirectory() stringByAppendingPathComponent: + [NSString stringWithFormat:@"cn1-drop-%@-%@", + [[NSUUID UUID] UUIDString], name]]; + NSError* copyError = nil; + if (![[NSFileManager defaultManager] copyItemAtURL:url + toURL:[NSURL fileURLWithPath:target] + error:©Error]) { + return nil; + } + return target; +} + +API_AVAILABLE(ios(11.0)) +@interface CN1DragAndDropDelegate : NSObject +@end + +@implementation CN1DragAndDropDelegate + +// ---- the drag out half ------------------------------------------------------------------ + +- (NSArray *)dragInteraction:(UIDragInteraction *)interaction + itemsForBeginningSession:(id)session { + if (cn1PreparedMimes == nil || cn1PreparedMimes.count == 0) { + return @[]; + } + // Asking the framework now, rather than on the press, is what lets a promised file stay + // unwritten until a drag really happens. The Java side names its representations from + // inside this call, through CN1DeclareNativeDragPayload; their values are fetched only if a + // receiver reads them. + int allowed = CN1NativeDragDeliverSessionStarted(); + if (allowed == CN1_DND_ACTION_NONE) { + // Nothing was staged, or nothing may be done with what was. Either way no session + // begins; the framework side has already tidied up whatever it had. + return @[]; + } + // The authoritative set, which is what a local drop session is told the source allows -- + // and the session it belongs to, so no other drag in this application can inherit it. + cn1SessionActions = allowed; +#ifndef CN1_USE_ARC + [cn1OutgoingSession release]; +#endif + cn1OutgoingSession = session; +#ifndef CN1_USE_ARC + [cn1OutgoingSession retain]; +#endif + cn1DraggingOut = YES; + cn1LocalDropInFlight = NO; + cn1EndDeferred = NO; + cn1LocalDropResult = -1; + + NSMutableArray* items = [NSMutableArray array]; + // The payload's keeper. Released once below, after everything that could read it has been + // registered; from then on the load handlers are its only owners, so it dies with the last + // of them -- and with no handlers at all it dies here, which is equally correct because + // nothing can read the payload then either. + CN1DragPayloadToken* payloadToken = [[CN1DragPayloadToken alloc] init]; + payloadToken.sessionId = cn1DragSessionId; + + // Every representation the operation declared, registered lazily. The value is fetched when + // a receiver reads that type, not now: a drag that is begun and abandoned must not have + // written the file or encoded the image it was merely offering. NSItemProvider allows a + // load handler to answer asynchronously, which is what lets the fetch happen on the main + // thread where every other call into the framework from this file happens. Nothing is + // retained by hand either -- copying a block retains what it captures, and an explicit + // retain here leaked the whole payload of every drag. + void (^registerDeclared)(NSItemProvider*) = ^(NSItemProvider* provider) { + for (NSString* mime in cn1DragMimes) { + NSString* uti = cn1UtiForMime(mime); + if (uti == nil) { + continue; + } + [provider registerDataRepresentationForTypeIdentifier:uti + visibility:NSItemProviderRepresentationVisibilityAll + loadHandler:^NSProgress *(void (^completion)(NSData *, NSError *)) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(CN1NativeDragDeliverResolve(mime, payloadToken.sessionId), nil); + }); + return nil; + }]; + } + }; + + // Files first, one item each: a receiver that copies documents expects one item per + // document, and collapsing several into one loses all but the first. + BOOL declaredAttached = NO; + for (NSURL* url in cn1DragFileUrls) { + NSItemProvider* provider = [[NSItemProvider alloc] initWithContentsOfURL:url]; + if (provider == nil) { + continue; + } + if (!declaredAttached) { + // The other representations belong to this same object, not to one of their own. + // Given a file and a text fallback, adding a second item made UIKit expose them as + // two dragged things, so a receiver could import the document *and* a stray piece + // of text instead of choosing the best form of one. + // + // Note the one case this cannot express: a declared type whose UTI is also the + // file's own -- a text/plain fallback beside a .txt -- registers a second + // representation under an identifier the provider already vends, and which of the + // two a receiver gets is NSItemProvider's choice rather than ours. Both are + // honestly that type, so neither answer is wrong; there is simply no way to say + // which was meant. + registerDeclared(provider); + declaredAttached = YES; + } + UIDragItem* item = [[UIDragItem alloc] initWithItemProvider:provider]; + [items addObject:item]; +#ifndef CN1_USE_ARC + [provider release]; + [item release]; +#endif + } + if (!declaredAttached && cn1DragMimes.count > 0) { + NSItemProvider* provider = [[NSItemProvider alloc] init]; + registerDeclared(provider); + UIDragItem* item = [[UIDragItem alloc] initWithItemProvider:provider]; + [items addObject:item]; +#ifndef CN1_USE_ARC + [provider release]; + [item release]; +#endif + } +#ifndef CN1_USE_ARC + // The registered load handlers own it from here; under ARC the local strong reference goes + // at the end of this scope and does the same thing. + [payloadToken release]; +#endif + if (items.count == 0) { + cn1DraggingOut = NO; + CN1NativeDragDeliverCompleted(CN1_DND_ACTION_NONE); + } + return items; +} + +- (UITargetedDragPreview *)dragInteraction:(UIDragInteraction *)interaction + previewForLiftingItem:(UIDragItem *)item + session:(id)session { + // UITargetedDragPreview, which is what UIDragInteractionDelegate declares. This returned a + // UIDragPreview instead -- an unrelated class -- so UIKit was handed an object it would go + // on to send UITargetedDragPreview messages to. Clang does not warn about the mismatch, and + // nothing but a device would have shown it. + if (cn1PreparedPreview == nil || interaction.view == nil) { + // Without one UIKit snapshots the interaction's view, which is the whole Codename One + // surface; nil here leaves UIKit to its default rather than dragging the entire screen. + return nil; + } + UIImageView* view = [[UIImageView alloc] initWithImage:cn1PreparedPreview]; + // Positioned so the point the finger grabbed stays under the finger. Untargeted, the + // preview is centred whereever UIKit chooses and the image jumps out from under the touch + // the moment the drag lifts -- which is also why setDragImageOffset had no effect at all. + CGPoint touch = [session locationInView:interaction.view]; + CGSize size = cn1PreparedPreview.size; + CGPoint centre = CGPointMake(touch.x - cn1PreparedTouch.x + size.width / 2.0, + touch.y - cn1PreparedTouch.y + size.height / 2.0); + UIDragPreviewTarget* target = [[UIDragPreviewTarget alloc] initWithContainer:interaction.view + center:centre]; + UIDragPreviewParameters* parameters = [[UIDragPreviewParameters alloc] init]; + UITargetedDragPreview* preview = [[UITargetedDragPreview alloc] initWithView:view + parameters:parameters + target:target]; +#ifndef CN1_USE_ARC + [view release]; + [target release]; + [parameters release]; + [preview autorelease]; +#endif + return preview; +} + +- (BOOL)dragInteraction:(UIDragInteraction *)interaction +sessionAllowsMoveOperation:(id)session { + // What the operation actually permits. UIKit allows a move by default for a session that + // stays inside the application, and storing the mask for this framework's own drop delegate + // does not constrain a *different* UIDropInteraction here -- so a copy-only drag landing on + // one of those could be moved, didEndWithOperation: would report the move, and a source + // following the documented advice would delete data the operation had explicitly refused to + // allow moving. + // + // The mask belongs to the session this interaction started; see cn1OutgoingSession. + return (cn1SessionActions & CN1_DND_ACTION_MOVE) != 0; +} + +- (void)dragInteraction:(UIDragInteraction *)interaction + session:(id)session + didEndWithOperation:(UIDropOperation)operation { + cn1DraggingOut = NO; + const int allowed = (cn1OutgoingSession == session) ? cn1SessionActions : CN1_DND_ACTION_NONE; + if (cn1OutgoingSession == session) { +#ifndef CN1_USE_ARC + [cn1OutgoingSession release]; +#endif + cn1OutgoingSession = nil; + cn1SessionActions = CN1_DND_ACTION_NONE; + } + if (cn1LocalDropInFlight) { + // The drop landed here and is still assembling. Its answer is the true one, so the + // completion goes out when it arrives rather than on UIKit's proposal. + cn1EndDeferred = YES; + return; + } + int action = CN1_DND_ACTION_NONE; + if (cn1LocalDropResult >= 0) { + action = cn1LocalDropResult; + cn1LocalDropResult = -1; + } else if (operation == UIDropOperationCopy) { + action = CN1_DND_ACTION_COPY; + } else if (operation == UIDropOperationMove) { + action = CN1_DND_ACTION_MOVE; + } + // Never more than the source allowed. The refusal above is what should keep a move from + // being performed at all; this is the second half of it, because the cost of being wrong + // here is a source deleting data on the strength of an action it never offered. + // + // With one reading rather than a refusal. UIKit has no link operation, so a receiver + // that takes a link-only drag is reported as having copied it -- and clamping that to + // nothing told the source its drag had been cancelled when it had in fact been + // accepted. Only when link is the whole of what was offered, where there is nothing + // else the copy could have meant. + if (allowed != CN1_DND_ACTION_NONE && (action & allowed) != action) { + action = (action == CN1_DND_ACTION_COPY && allowed == CN1_DND_ACTION_LINK) + ? CN1_DND_ACTION_LINK : CN1_DND_ACTION_NONE; + } + CN1NativeDragDeliverCompleted(action); +} + +// ---- the drop half ---------------------------------------------------------------------- + +- (BOOL)dropInteraction:(UIDropInteraction *)interaction canHandleSession:(id)session { + return YES; +} + +- (void)dropInteraction:(UIDropInteraction *)interaction sessionDidEnter:(id)session { + CGPoint point = [session locationInView:interaction.view]; + cn1LastDropAction = CN1NativeDragDeliverOver((int)(point.x * scaleValue), (int)(point.y * scaleValue), + cn1MimesForSession(session), + cn1AllowedActionsFor(session), YES); +} + +- (UIDropProposal *)dropInteraction:(UIDropInteraction *)interaction + sessionDidUpdate:(id)session { + CGPoint point = [session locationInView:interaction.view]; + cn1LastDropAction = CN1NativeDragDeliverOver((int)(point.x * scaleValue), (int)(point.y * scaleValue), + cn1MimesForSession(session), + cn1AllowedActionsFor(session), NO); + UIDropProposal* proposal = [[UIDropProposal alloc] initWithDropOperation:cn1DropOperationFor(cn1LastDropAction)]; +#ifndef CN1_USE_ARC + [proposal autorelease]; +#endif + return proposal; +} + +- (void)dropInteraction:(UIDropInteraction *)interaction sessionDidExit:(id)session { + cn1LastDropAction = CN1_DND_ACTION_NONE; + CN1NativeDragDeliverExit(); +} + +- (void)dropInteraction:(UIDropInteraction *)interaction sessionDidEnd:(id)session { + // UIKit sends this whether or not sessionDidExit ran: a session cancelled, or ended, while + // still inside this surface never exits. Without it the framework kept the last target + // hovered, and the next session entering that same component was routed as a move over it + // -- inheriting the ended session's answer, with no enter callback ever arriving. + // + // Not while *this* session's drop is still loading, though. This arrives as soon as + // performDrop: returns, which is before the asynchronous loads have answered, and clearing + // the target there would leave the commit to find it gone. That path clears the hover state + // itself. + if (cn1DropIsLoading(session)) { + return; + } + cn1LastDropAction = CN1_DND_ACTION_NONE; + CN1NativeDragDeliverExit(); +} + +- (void)dropInteraction:(UIDropInteraction *)interaction performDrop:(id)session { + CGPoint point = [session locationInView:interaction.view]; + const int x = (int)(point.x * scaleValue); + const int y = (int)(point.y * scaleValue); + const int action = cn1LastDropAction == CN1_DND_ACTION_NONE + ? cn1DefaultAction(cn1AllowedActionsFor(session)) : cn1LastDropAction; + if (cn1LoadingDropSessions == nil) { + cn1LoadingDropSessions = [NSHashTable weakObjectsHashTable]; +#ifndef CN1_USE_ARC + [cn1LoadingDropSessions retain]; +#endif + } + [cn1LoadingDropSessions addObject:session]; + // Two different questions, and they were one answer. + // + // Whether the drag started inside this application is what the framework reports as + // NativeDropEvent.isLocal(), and a drag any interaction here started is local by that + // reading. + const BOOL localAssembly = (session.localDragSession != nil); + // Whether *this* framework's source is the one that started it is a narrower thing, and it + // is what the completion bookkeeping below belongs to. Another UIDragInteraction's drag also + // has a localDragSession, so answering the first question for the second had an unrelated + // drop take ownership of the state a Codename One source was waiting on -- and complete that + // source with its own result. The session identity that already answers this for the action + // mask answers it here too. + const BOOL ownsCompletion = localAssembly && cn1OutgoingSession != nil + && session.localDragSession == cn1OutgoingSession; + // The mask this session offers, taken now. By the time a slow provider has finished the + // framework's own memory of it belongs to whatever drag is running then. + const int sessionActions = cn1AllowedActionsFor(session); + if (ownsCompletion) { + cn1LocalDropInFlight = YES; + cn1EndDeferred = NO; + cn1LocalDropResult = -1; + } + + // Every representation is loaded asynchronously and independently, so the framework is only + // told about the drop once they have all answered. Delivering per representation instead + // would give the application several drops for one gesture, each missing the others. + // + // MIME type -> {data, the uniform type identifier it arrived under}. The identifier is kept + // because it is what says how to read the bytes; see cn1TextFromData. + NSMutableDictionary* collected = [[NSMutableDictionary alloc] init]; + NSMutableArray* files = [[NSMutableArray alloc] init]; + // The representations a file-vending provider also advertises, each named against a file on + // disk rather than read into memory: {MIME type, path, charset}. One that is another name + // for the document shares the document's own copy; one that is a representation of its own + // gets a copy of its own. The charset is empty unless the identifier declared one, which + // is the only thing that can tell the Java side how to read a text file it never saw the + // identifier for. + NSMutableArray* fileBacked = [[NSMutableArray alloc] init]; + dispatch_group_t group = dispatch_group_create(); + + for (UIDragItem* item in session.items) { + NSItemProvider* provider = item.itemProvider; + BOOL vendsFile = [provider hasItemConformingToTypeIdentifier:@"public.file-url"]; + if (vendsFile) { + // A document provider commonly offers both a file URL and the document's own + // content type. Taking only the file made cn1MimesForSession advertise a type the + // drop could not then produce, so a target filtered to it accepted the hover and + // was refused the drop. + dispatch_group_enter(group); + // The item, not a file representation of it: see cn1FileUrlFromItem. + [provider loadItemForTypeIdentifier:@"public.file-url" + options:nil + completionHandler:^(id item, NSError* error) { + NSURL* url = cn1FileUrlFromItem((id) item); + // The URL is only valid inside this handler, so the file is copied out before + // it is named to the application. A path handed over without copying is + // unreadable by the time the event dispatch thread sees it. + // + // A failure here is not the end of the item: a cloud-backed document that + // will not materialize still leaves whatever else the provider advertised, + // and the hover has already promised those. Nesting the alternatives under + // this meant an unavailable file took every one of them with it and the + // target that accepted the drag got nothing at all. + // + // A document in another application's container is reachable only for as + // long as its security scope is held, and only some URLs have one -- the + // start call says which by its answer. + BOOL scoped = url != nil && [url startAccessingSecurityScopedResource]; + NSString* target = url == nil ? nil : cn1CopyDroppedFile(url); + if (scoped) { + [url stopAccessingSecurityScopedResource]; + } + // The document's own location, which is what tells one of its other names + // apart from a representation of its own. Nil when there is no document. + NSString* documentPath = target == nil ? nil : url.path; + if (target != nil) { + @synchronized (files) { + [files addObject:target]; + } + } + { + { + // Issued from in here, rather than beside the file load, so that the + // comparison above is possible at all. + for (NSString* uti in provider.registeredTypeIdentifiers) { + if ([uti isEqualToString:@"public.file-url"]) { + continue; + } + NSString* mime = cn1MimeForUti(uti); + if (mime == nil) { + continue; + } + dispatch_group_enter(group); + // As a file rather than as data, deliberately: a representation + // that really is the document would otherwise be read whole into + // memory on top of the copy, which is how an application runs out + // of it. Naming every one of them against the document instead -- + // which is what this did -- is wrong the other way: a drag that + // offers a file *and* something else, as this framework's own + // source does for a file with a text fallback, then served the + // file's bytes under the fallback's type. + [provider loadFileRepresentationForTypeIdentifier:uti + completionHandler:^(NSURL* alt, NSError* altError) { + if (alt != nil) { + NSString* altTarget = (documentPath != nil + && [alt.path isEqualToString:documentPath]) + ? target // another name for the document + : cn1CopyDroppedFile(alt); + if (altTarget != nil) { + NSString* charset = cn1CharsetNameForUti(uti); + @synchronized (fileBacked) { + [fileBacked addObject:@[mime, altTarget, + charset == nil ? @"" : charset]]; + } + } + } + dispatch_group_leave(group); + }]; + } + } + } + dispatch_group_leave(group); + }]; + // Its other representations were loaded above, where the document is known. + continue; + } + for (NSString* uti in provider.registeredTypeIdentifiers) { + NSString* mime = cn1MimeForUti(uti); + if (mime == nil) { + continue; + } + dispatch_group_enter(group); + [provider loadDataRepresentationForTypeIdentifier:uti + completionHandler:^(NSData* data, NSError* error) { + if (data != nil) { + @synchronized (collected) { + NSArray* existing = [collected objectForKey:mime]; + if (existing == nil) { + // The identifier is kept beside the bytes because it is what says + // how to read them: several standard text UTIs map to the same MIME + // type and disagree about the encoding. + [collected setObject:@[data, uti] forKey:mime]; + } else if ([mime isEqualToString:@"text/uri-list"]) { + // A URI list is a list. Several public.url items all arrive under + // this one type, and keeping whichever asynchronous load happened to + // finish first threw away every URL the user dragged but one. RFC + // 2483 separates them with CRLF, which is what everything else here + // writes and reads. + NSMutableData* joined = + [NSMutableData dataWithData:[existing objectAtIndex:0]]; + [joined appendBytes:"\r\n" length:2]; + [joined appendData:data]; + [collected setObject:@[joined, [existing objectAtIndex:1]] forKey:mime]; + } + } + } + dispatch_group_leave(group); + }]; + } + } + + dispatch_group_notify(group, dispatch_get_main_queue(), ^{ + // Every representation that answered, not a fixed list: a drag offering markdown, a GIF + // or an application's own type was accepted while it hovered on the strength of its + // advertised types, and materializing fewer of them refused the very target that took + // it. + CN1NativeDragDeliverDropBegin(); + for (NSString* mime in collected) { + NSArray* pair = [collected objectForKey:mime]; + NSData* data = [pair objectAtIndex:0]; + if ([mime hasPrefix:@"text/"]) { + CN1NativeDragDeliverDropAdd(mime, cn1TextFromData(data, [pair objectAtIndex:1]), nil); + } else { + CN1NativeDragDeliverDropAdd(mime, nil, data); + } + } + for (NSArray* named in fileBacked) { + NSString* charset = [named objectAtIndex:2]; + CN1NativeDragDeliverDropAddFile([named objectAtIndex:0], [named objectAtIndex:1], + charset.length == 0 ? nil : charset); + } + if (files.count > 0) { + CN1NativeDragDeliverDropAdd(@"application/x-file-list", + [files componentsJoinedByString:@"\n"], nil); + // The same files as a URI list, which is what the session advertised and so what + // a target filtered to it accepted the hover on. RFC 2483 separates them with + // CRLF, which is what the other ports write and read. + NSMutableString* uris = [NSMutableString string]; + for (NSString* path in files) { + [uris appendString:[[NSURL fileURLWithPath:path] absoluteString]]; + [uris appendString:@"\r\n"]; + } + CN1NativeDragDeliverDropAdd(@"text/uri-list", uris, nil); + } + // An assembly overtaken by a newer session still commits. The framework keeps one hover + // state, because one drag is what a platform runs, so a late commit does disturb a drag + // begun since -- the newer target is sent an exit and its next update re-enters it, a + // flicker that repairs itself. Withholding the commit instead would lose a drop the + // user actually performed, and unperformed work is worse than a repaired frame. + int accepted = CN1NativeDragDeliverDropCommit(x, y, action, sessionActions, localAssembly); + cn1LastDropAction = CN1_DND_ACTION_NONE; + // This assembly's commit cleared the hover state itself, so its own end -- which went + // past long ago -- can stop holding off. Only this one: another drop still loading is + // still entitled to its answer. + [cn1LoadingDropSessions removeObject:session]; + if (ownsCompletion && cn1LocalDropInFlight) { + cn1LocalDropInFlight = NO; + if (cn1EndDeferred) { + cn1EndDeferred = NO; + CN1NativeDragDeliverCompleted(accepted); + } else { + cn1LocalDropResult = accepted; + } + } +#ifndef CN1_USE_ARC + [collected release]; + [files release]; + [fileBacked release]; +#endif + }); +#ifndef CN1_USE_ARC + dispatch_release(group); +#endif +} + +@end + +void CN1InstallDragAndDrop(CN1View* view) { + if (view == nil || !CN1DragAndDropSupported()) { + return; + } + if (@available(iOS 11.0, *)) { + cn1DragSurface = view; + // The delegate is deliberately not released: the interactions hold their delegate + // weakly and it has to outlive the surface, which lives for the life of the process. + cn1DragDelegate = [[CN1DragAndDropDelegate alloc] init]; + } +} + +/// True when an interaction of this class is already on the surface. Both enable calls run for +/// every component an application marks, which for a list is every row. +static BOOL cn1HasInteraction(Class kind) { + for (id existing in cn1DragSurface.interactions) { + if ([existing isKindOfClass:kind]) { + return YES; + } + } + return NO; +} + +void CN1EnableNativeDragSource(void) { + if (!CN1DragAndDropSupported()) { + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (@available(iOS 11.0, *)) { + if (cn1DragSurface == nil || cn1DragDelegate == nil + || cn1HasInteraction([UIDragInteraction class])) { + return; + } + UIDragInteraction* drag = [[UIDragInteraction alloc] initWithDelegate:cn1DragDelegate]; + // Without this a drag never begins on iPhone: UIKit enables drag interactions on + // iPad by default and leaves them off elsewhere. + drag.enabled = YES; + [cn1DragSurface addInteraction:drag]; +#ifndef CN1_USE_ARC + [drag release]; +#endif + } + }); +} + +void CN1EnableNativeDropTarget(void) { + if (!CN1DragAndDropSupported()) { + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (@available(iOS 11.0, *)) { + if (cn1DragSurface == nil || cn1DragDelegate == nil + || cn1HasInteraction([UIDropInteraction class])) { + return; + } + UIDropInteraction* drop = [[UIDropInteraction alloc] initWithDelegate:cn1DragDelegate]; + [cn1DragSurface addInteraction:drop]; +#ifndef CN1_USE_ARC + [drop release]; +#endif + } + }); +} + +#endif diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m index f5123cc0edb..cd33c184b43 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m @@ -43,6 +43,7 @@ #import "METALView.h" #import "CN1Metalcompat.h" #endif +#import "CN1DragAndDrop.h" #import "ExecutableOp.h" #import "FillRect.h" #import "ClipRect.h" @@ -3262,6 +3263,8 @@ - (void)viewDidLoad { [self cn1InstallHoverRecognizer]; [self cn1InstallScrollRecognizer]; [self cn1InstallPinchRecognizer]; + // Native drag and drop, both directions. Inert where the platform has no drag interaction. + CN1InstallDragAndDrop(self.view); [self cn1InstallRotationRecognizer]; //replaceViewDidLoad [self initGoogleConnect]; @@ -3283,6 +3286,8 @@ - (void)viewDidLoad { [self cn1InstallHoverRecognizer]; [self cn1InstallScrollRecognizer]; [self cn1InstallPinchRecognizer]; + // Native drag and drop, both directions. Inert where the platform has no drag interaction. + CN1InstallDragAndDrop(self.view); [self cn1InstallRotationRecognizer]; //replaceViewDidLoad [self initGoogleConnect]; diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 17165743dec..3af526039fd 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -53,6 +53,7 @@ #endif #import "CN1AudioUnit.h" #import "CN1AppleUI.h" +#import "CN1DragAndDrop.h" #if TARGET_OS_OSX /* @@ -1054,6 +1055,122 @@ JAVA_OBJECT com_codename1_impl_ios_IOSNative_getClipboardContent___java_lang_Str extern NSData* arrayToData(JAVA_OBJECT arr); extern JAVA_OBJECT nsDataToByteArr(NSData *data); +/* + * Native drag and drop. The UIKit half lives in CN1DragAndDrop.m; everything here is the + * ParparVM boundary, kept in this file with the other bridges so all the thread-state handling + * stays in one place. + */ + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isNativeDragAndDropSupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return CN1DragAndDropSupported() ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isNativeDragOutsideAppSupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return CN1DragOutsideAppSupported() ? JAVA_TRUE : JAVA_FALSE; +} + +void com_codename1_impl_ios_IOSNative_prepareNativeDrag___java_lang_String_int_byte_1ARRAY_int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT mimeTypes, JAVA_INT allowedActions, JAVA_OBJECT dragImagePng, JAVA_INT touchX, JAVA_INT touchY) { + POOL_BEGIN(); + NSString* mimes = mimeTypes == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG mimeTypes); + NSData* preview = dragImagePng == JAVA_NULL ? nil : arrayToData(dragImagePng); + // On the main thread: the interactions and the state they read live there, and this is + // called from the event dispatch thread as the press is dispatched. + dispatch_async(dispatch_get_main_queue(), ^{ + CN1PrepareNativeDrag(mimes, (int)allowedActions, preview, (int)touchX, (int)touchY); + }); + POOL_END(); +} + +void com_codename1_impl_ios_IOSNative_beginNativeDragPayload___int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT sessionId) { + CN1BeginNativeDragPayload((int)sessionId); +} + +void com_codename1_impl_ios_IOSNative_declareNativeDragPayload___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT mimeType) { + POOL_BEGIN(); + // Synchronously, unlike prepare: these run inside the session-started callback on the main + // thread and the item providers are built from them the moment it returns. + CN1DeclareNativeDragPayload(mimeType == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG mimeType)); + POOL_END(); +} + +void com_codename1_impl_ios_IOSNative_addNativeDragFiles___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT paths) { + POOL_BEGIN(); + CN1AddNativeDragFiles(paths == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG paths)); + POOL_END(); +} + +void com_codename1_impl_ios_IOSNative_cancelNativeDrag__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + dispatch_async(dispatch_get_main_queue(), ^{ + CN1CancelNativeDrag(); + }); +} + +void com_codename1_impl_ios_IOSNative_enableNativeDragSource__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + CN1EnableNativeDragSource(); +} + +void com_codename1_impl_ios_IOSNative_enableNativeDropTarget__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + CN1EnableNativeDropTarget(); +} + +int CN1NativeDragDeliverOver(int x, int y, NSString* mimeTypes, int allowedActions, BOOL entering) { + return (int)com_codename1_impl_ios_IOSImplementation_nativeDragOverCallback___int_int_java_lang_String_int_boolean_R_int( + CN1_THREAD_GET_STATE_PASS_ARG x, y, + fromNSString(CN1_THREAD_GET_STATE_PASS_ARG mimeTypes), + allowedActions, entering ? JAVA_TRUE : JAVA_FALSE); +} + +void CN1NativeDragDeliverExit(void) { + com_codename1_impl_ios_IOSImplementation_nativeDragExitCallback__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); +} + +void CN1NativeDragDeliverDropBegin(void) { + com_codename1_impl_ios_IOSImplementation_nativeDropBeginCallback__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); +} + +void CN1NativeDragDeliverDropAdd(NSString* mimeType, NSString* text, NSData* binary) { + com_codename1_impl_ios_IOSImplementation_nativeDropAddCallback___java_lang_String_java_lang_String_byte_1ARRAY( + CN1_THREAD_GET_STATE_PASS_ARG + fromNSString(CN1_THREAD_GET_STATE_PASS_ARG mimeType), + fromNSString(CN1_THREAD_GET_STATE_PASS_ARG text), + binary == nil ? JAVA_NULL : nsDataToByteArr(binary)); +} + +void CN1NativeDragDeliverDropAddFile(NSString* mimeType, NSString* path, NSString* charset) { + com_codename1_impl_ios_IOSImplementation_nativeDropAddFileCallback___java_lang_String_java_lang_String_java_lang_String( + CN1_THREAD_GET_STATE_PASS_ARG + fromNSString(CN1_THREAD_GET_STATE_PASS_ARG mimeType), + fromNSString(CN1_THREAD_GET_STATE_PASS_ARG path), + fromNSString(CN1_THREAD_GET_STATE_PASS_ARG charset)); +} + +NSData* CN1NativeDragDeliverResolve(NSString* mimeType, int sessionId) { + JAVA_OBJECT bytes = com_codename1_impl_ios_IOSImplementation_nativeDragResolveCallback___java_lang_String_int_R_byte_1ARRAY( + CN1_THREAD_GET_STATE_PASS_ARG fromNSString(CN1_THREAD_GET_STATE_PASS_ARG mimeType), sessionId); + return bytes == JAVA_NULL ? nil : arrayToData(bytes); +} + +void CN1NativeDragDeliverPayloadReleased(int sessionId) { + com_codename1_impl_ios_IOSImplementation_nativeDragPayloadReleasedCallback___int( + CN1_THREAD_GET_STATE_PASS_ARG sessionId); +} + +int CN1NativeDragDeliverDropCommit(int x, int y, int action, int allowedActions, BOOL local) { + return (int)com_codename1_impl_ios_IOSImplementation_nativeDropCommitCallback___int_int_int_int_boolean_R_int( + CN1_THREAD_GET_STATE_PASS_ARG x, y, action, allowedActions, + local ? JAVA_TRUE : JAVA_FALSE); +} + +int CN1NativeDragDeliverSessionStarted(void) { + return (int)com_codename1_impl_ios_IOSImplementation_nativeDragSessionStartedCallback___R_int( + CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); +} + +void CN1NativeDragDeliverCompleted(int action) { + com_codename1_impl_ios_IOSImplementation_nativeDragCompletedCallback___int( + CN1_THREAD_GET_STATE_PASS_ARG action); +} + #if TARGET_OS_OSX /// The pasteboard type the bytes actually are, by magic number, or nil when /// they are none of the three the Java side can hand us. diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index c8cfe878a4c..a067d92c7ae 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -23,6 +23,11 @@ package com.codename1.impl.ios; import com.codename1.ui.Desktop; +import com.codename1.ui.ClipboardContent; +import com.codename1.ui.ClipboardDataProvider; +import com.codename1.ui.EncodedImage; +import com.codename1.ui.NativeDragAndDrop; +import com.codename1.ui.NativeDragOperation; import com.codename1.background.BackgroundFetch; import com.codename1.capture.VideoCaptureConstraints; import com.codename1.codescan.CodeScanner; @@ -9131,10 +9136,375 @@ public Object getPasteDataFromClipboard() { return super.getPasteDataFromClipboard(); } + + // ------------------------------------------------------------------------------------ + // Native drag and drop. + // + // UIKit owns the drag gesture: UIDragInteraction has its own recognizer and asks what is + // being dragged when it fires, so the framework cannot start a session on its own drag + // threshold the way the desktop port does. It stages the operation on the press instead, + // and CN1DragAndDrop.m calls nativeDragSessionStartedCallback below when UIKit decides a + // drag has begun. The payload is fetched at that moment rather than on the press, so a + // drag offering a file the application has not written yet does not write it every time + // the user merely touches the component. + // ------------------------------------------------------------------------------------ + + @Override + public boolean isNativeDragAndDropSupported() { + return nativeInstance.isNativeDragAndDropSupported(); + } + + @Override + public boolean isNativeDragOutsideApplicationSupported() { + return nativeInstance.isNativeDragOutsideAppSupported(); + } + + @Override + public boolean isNativeDragImageNeededOnPrepare() { + // UIKit asks for the lift preview at the instant its own recognizer fires, which is + // not a moment at which this port can render a component. + return true; + } + + @Override + public void prepareNativeDrag(NativeDragOperation op) { + ClipboardContent content = op.getContent(); + nativeInstance.prepareNativeDrag(join(content.getMimeTypes()), op.getAllowedActions(), + pngBytes(op.getDragImage()), op.getDragImageOffsetX(), op.getDragImageOffsetY()); + } + + @Override + public void cancelNativeDrag() { + nativeInstance.cancelNativeDrag(); + } + + @Override + public void nativeDragSourceRegistered() { + nativeInstance.enableNativeDragSource(); + } + + @Override + public void nativeDropTargetRegistered() { + nativeInstance.enableNativeDropTarget(); + } + + /// Encodes a drag preview as PNG, the one image format the whole bridge speaks. + private static byte[] pngBytes(Image image) { + if (image == null) { + return null; + } + try { + return EncodedImage.createFromImage(image, false).getImageData(); + } catch (Throwable err) { + com.codename1.io.Log.e(err); + return null; + } + } + + private static String join(String[] values) { + if (values == null || values.length == 0) { + return null; + } + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < values.length; iter++) { + if (iter > 0) { + out.append('\n'); + } + out.append(values[iter]); + } + return out.toString(); + } + + private static String[] split(String value) { + if (value == null || value.length() == 0) { + return null; + } + java.util.List out = new java.util.ArrayList(); + int start = 0; + while (start <= value.length()) { + int next = value.indexOf('\n', start); + String entry = next < 0 ? value.substring(start) : value.substring(start, next); + if (entry.length() > 0) { + out.add(entry); + } + if (next < 0) { + break; + } + start = next + 1; + } + return out.isEmpty() ? null : out.toArray(new String[out.size()]); + } + + /// Describes a drag in progress from its MIME types alone. UIKit hands over no data until + /// the drop, and a drop target only needs the names in order to say whether it wants the + /// drag; the providers registered here answer null until the real content arrives. + private static ClipboardContent describe(String mimeTypes) { + ClipboardContent content = new ClipboardContent(); + String[] mimes = split(mimeTypes); + if (mimes == null) { + return content; + } + for (int iter = 0; iter < mimes.length; iter++) { + content.setDataProvider(mimes[iter], new ClipboardDataProvider() { + @Override + public Object getClipboardData(String mimeType) { + return null; + } + }); + } + return content; + } + + /// Invoked from CN1DragAndDrop.m as a drag moves over the surface. Returns the action a + /// drop would perform right now, or zero. + public static int nativeDragOverCallback(int x, int y, String mimeTypes, int allowedActions, + boolean entering) { + ClipboardContent content = describe(mimeTypes); + if (entering) { + return NativeDragAndDrop.dragEnter(0, x, y, content, allowedActions); + } + return NativeDragAndDrop.dragOver(0, x, y, content, allowedActions); + } + + /// Invoked from CN1DragAndDrop.m when a drag leaves the surface without dropping. + public static void nativeDragExitCallback() { + NativeDragAndDrop.dragExit(0); + } + + /// Every exported drag whose payload something can still read, under the session id its + /// load handlers carry. + /// + /// Deliberately not the active drag: an item provider's load handler is asynchronous by + /// design, and a receiving application is free to defer reading a representation until + /// after the session has ended -- by which point the active drag has been cleared and the + /// lookup would answer with nothing at all. + /// + /// Matched by id, because a single slot answered a late read with *whatever was being + /// dragged by then*: a handler from the previous drag resolving after the next one began + /// produced that one's bytes. Nor is it a fixed number of recent drags -- a receiver may + /// keep an item provider and read it at any later point, and any bound expires payloads + /// that are still legitimately readable. The providers themselves say when they are done: + /// CN1DragAndDrop.m gives every load handler a token for its session and calls + /// #nativeDragPayloadReleasedCallback(int) when the last of them is released, so a payload + /// is held for exactly as long as something can ask for it and no longer. + private static final java.util.Map exportedDrags = + new java.util.HashMap(); + private static int nextDragSessionId; + + /// The drop being assembled by CN1DragAndDrop.m, one representation at a time. + /// + /// Only ever touched from the three callbacks below, which UIKit runs in order on the main + /// thread, so it needs no guarding of its own. + private static ClipboardContent pendingDrop; + + /// Invoked from CN1DragAndDrop.m as a drop begins, before its representations arrive. + public static void nativeDropBeginCallback() { + pendingDrop = new ClipboardContent(); + } + + /// Invoked from CN1DragAndDrop.m once per representation the drop carries. + /// + /// Every representation UIKit loaded is delivered here rather than a fixed list of the + /// framework's own: forwarding fewer meant a drag carrying markdown or an application's own + /// type was accepted while it hovered and then materialized without it, so the target that + /// agreed to take the drop was refused it. + public static void nativeDropAddCallback(String mimeType, String text, byte[] binary) { + if (pendingDrop == null || mimeType == null || mimeType.length() == 0) { + return; + } + if (ClipboardContent.MIME_FILE.equals(mimeType)) { + pendingDrop.setFiles(split(text)); + return; + } + // Length is not a test of presence. A representation the drag advertised and that is + // legitimately empty -- an empty string, a zero byte payload -- was discarded here, so + // the materialized content lacked a type the hover had accepted and the drop was then + // refused by the very target that agreed to take it. Null is absent; empty is present. + if (binary != null) { + pendingDrop.setData(mimeType, binary); + } else if (text != null) { + pendingDrop.setData(mimeType, text); + } + } + + /// Invoked from CN1DragAndDrop.m for a representation that is a file already on disk. + /// + /// A document provider advertises the document's own content type as well as a file URL, + /// and a target may filter on either. Promising the type against the copy rather than + /// loading it keeps the two in agreement without reading a large document into memory on + /// top of copying it -- the bytes are only read if something asks for that type. + public static void nativeDropAddFileCallback(String mimeType, final String path, + final String charset) { + if (pendingDrop == null || mimeType == null || mimeType.length() == 0 + || path == null || path.length() == 0 || pendingDrop.hasMimeType(mimeType)) { + return; + } + pendingDrop.setDataProvider(mimeType, new ClipboardDataProvider() { + @Override + public Object getClipboardData(String requested) { + try { + java.io.InputStream in = com.codename1.io.FileSystemStorage.getInstance() + .openInputStream(path.startsWith("/") ? "file://" + path : path); + if (in == null) { + return null; + } + byte[] bytes; + try { + bytes = com.codename1.io.Util.readInputStream(in); + } finally { + in.close(); + } + // A text type reads back as text. A document provider from Files commonly + // offers a plain text representation beside its file URL, and answering + // that with bytes made getText() and NativeDropEvent.getText() null for a + // type the drop had just accepted. + // + // In the encoding the representation's own type identifier declared, when + // it declared one. This side never sees that identifier -- it has a path + // and a MIME type -- so assuming UTF-8 turned a UTF-16 alternative into + // rubbish while the same representation read as data came through intact. + if (bytes != null && requested != null && requested.startsWith("text/")) { + return new String(bytes, charsetOrUtf8(charset)); + } + return bytes; + } catch (Throwable err) { + com.codename1.io.Log.e(err); + return null; + } + } + }); + } + + /// The named charset when this platform has it, and UTF-8 otherwise -- which is what the + /// unnamed case means anyway. + private static String charsetOrUtf8(String charset) { + if (charset == null || charset.length() == 0) { + return "UTF-8"; + } + return charset; + } + + /// Invoked from CN1DragAndDrop.m once every representation has arrived. Returns the action + /// accepted, or zero when nothing under the pointer took it. + public static int nativeDropCommitCallback(int x, int y, int action, int allowedActions, + boolean local) { + ClipboardContent content = pendingDrop == null ? new ClipboardContent() : pendingDrop; + pendingDrop = null; + // What *this* drop was offering and where it came from, both taken by the native side + // when the drop began. Asking now would ask about whichever drag is running by the time + // a slow item provider finished loading, and that can be a different one. + return NativeDragAndDrop.drop(0, x, y, content, action, allowedActions, local); + } + + /// Invoked from CN1DragAndDrop.m when UIKit starts a drag session. Hands the payload down + /// -- which is where a promised representation is finally built -- and returns the actions + /// the operation allows, or zero when the framework has nothing staged and no drag should + /// begin. + public static int nativeDragSessionStartedCallback() { + NativeDragOperation op = NativeDragAndDrop.dragSessionStarted(); + if (op == null) { + return 0; + } + int sessionId; + synchronized (exportedDrags) { + sessionId = ++nextDragSessionId; + exportedDrags.put(Integer.valueOf(sessionId), op); + } + ClipboardContent content = op.getContent(); + nativeInstance.beginNativeDragPayload(sessionId); + // Names, not values. Reading a representation here would build every promised file and + // encode every promised image at the moment the drag begins -- including for a drag the + // user then abandons -- which is the opposite of what setDataProvider promises. The + // native side asks for one through nativeDragResolveCallback if a receiver reads it. + String[] mimeTypes = content.getMimeTypes(); + for (int iter = 0; iter < mimeTypes.length; iter++) { + String mime = mimeTypes[iter]; + if (ClipboardContent.MIME_FILE.equals(mime)) { + // The exception, and it is UIKit's: the session needs its item count when it + // begins, and for a file drag that count is the number of files. So this one + // representation is resolved now. + nativeInstance.addNativeDragFiles(join(content.getFiles())); + continue; + } + nativeInstance.declareNativeDragPayload(mime); + } + return op.getAllowedActions(); + } + + /// Invoked from CN1DragAndDrop.m when a receiver reads one of the drag's representations. + /// + /// This is where a promised value is finally produced -- the file written, the image + /// encoded -- so a drag that nobody reads costs nothing. + /// + /// #### Parameters + /// + /// - `mimeType`: the representation being read + /// + /// - `sessionId`: the drag the reading item provider belongs to + /// + /// #### Returns + /// + /// its bytes, or null when that drag can no longer supply it + public static byte[] nativeDragResolveCallback(String mimeType, int sessionId) { + // Matched by session id, not simply the latest: a handler from an earlier drag must + // answer for that drag or not at all. See the field. + NativeDragOperation op; + synchronized (exportedDrags) { + op = exportedDrags.get(Integer.valueOf(sessionId)); + } + if (op == null || mimeType == null || mimeType.length() == 0) { + return null; + } + // Only the provider call is inside the broad catch: it runs application code, which may + // throw anything. The casts below sit outside it deliberately -- a cast reached through + // catch(Throwable) is exactly what ParparVM cannot report, since its CHECKCAST does not + // throw and the handler would never run on iOS. + Object value; + try { + value = op.getContent().getData(mimeType); + } catch (Throwable err) { + com.codename1.io.Log.e(err); + return null; + } + if (value instanceof byte[]) { + return (byte[]) value; + } + if (value instanceof String) { + try { + return ((String) value).getBytes("UTF-8"); + } catch (java.io.UnsupportedEncodingException err) { + com.codename1.io.Log.e(err); + } + } + return null; + } + + /// Invoked from CN1DragAndDrop.m when a session this application started has ended. + public static void nativeDragCompletedCallback(int action) { + NativeDragAndDrop.dragCompleted(action); + } + + /// Invoked from CN1DragAndDrop.m once the last item provider that could read a drag's + /// payload has been released, which is the only moment the payload is certainly unreadable. + /// + /// #### Parameters + /// + /// - `sessionId`: the drag whose payload may be dropped + public static void nativeDragPayloadReleasedCallback(int sessionId) { + synchronized (exportedDrags) { + exportedDrags.remove(Integer.valueOf(sessionId)); + } + } + @Override public void copyToClipboard(Object obj) { if(obj instanceof com.codename1.ui.ClipboardContent) { com.codename1.ui.ClipboardContent content = (com.codename1.ui.ClipboardContent)obj; + // Every representation, now, including any registered as a provider. The pasteboard + // is a system store that outlives this process, so what goes on it has to be the + // data and not a promise this application has to still be running to keep -- a lazily + // registered item pastes as nothing once the application is gone. The drag path is + // where the laziness pays off, and it keeps it; see ClipboardDataProvider. nativeInstance.setClipboardContent( content.getText(com.codename1.ui.ClipboardContent.MIME_TEXT), content.getText(com.codename1.ui.ClipboardContent.MIME_HTML), diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index bf685409486..399e9d5a12e 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -348,6 +348,84 @@ native void fillGradient(int kind, int stopCount, float[] positions, float[] pre native String getClipboardContent(String mimeType); native byte[] getClipboardImage(); native String getClipboardFileUris(); + + /// Native drag and drop. UIKit owns the drag gesture, so the framework stages what a press + /// has made draggable and the native side asks for the payload once UIDragInteraction + /// decides a drag has begun. See `Ports/iOSPort/nativeSources/CN1DragAndDrop.m`. + native boolean isNativeDragAndDropSupported(); + + /// True where a drag started in this application can be dropped in another one, which is + /// iPadOS and Mac Catalyst rather than a phone in full screen. + native boolean isNativeDragOutsideAppSupported(); + + /// Stages the representations a drag could offer -- named, not built -- along with what a + /// receiver may do with them and the image to show under the finger. + /// + /// #### Parameters + /// + /// - `mimeTypes`: newline separated MIME types + /// + /// - `allowedActions`: the `com.codename1.ui.NativeDragOperation` action bit set + /// + /// - `dragImagePng`: the preview as PNG bytes, or null for the platform default + /// + /// - `touchX`: the press position within the drag image + /// + /// - `touchY`: the press position within the drag image + native void prepareNativeDrag(String mimeTypes, int allowedActions, byte[] dragImagePng, + int touchX, int touchY); + + /// Clears the payload, ready for the representations of the session UIKit has just started, + /// and records the id the framework gave that session. Called from inside the + /// session-started callback, so that a promised representation is built only once a drag + /// really happens. + /// + /// #### Parameters + /// + /// - `sessionId`: travels with every load handler this session registers, so a + /// representation read after another drag has begun resolves against its own operation + native void beginNativeDragPayload(int sessionId); + + /// Names a representation the drag can offer, without producing it. + /// + /// Every MIME type the operation advertises goes through here rather than a fixed list, so + /// nothing the application published is silently left behind -- a drag offering only + /// `text/markdown` used to advertise it and then carry nothing, which UIKit cancels. The + /// value is produced only if a receiver reads that type, which is what + /// `com.codename1.ui.ClipboardContent#setDataProvider(java.lang.String, com.codename1.ui.ClipboardDataProvider)` + /// promises. + /// + /// #### Parameters + /// + /// - `mimeType`: the representation's MIME type + native void declareNativeDragPayload(String mimeType); + + /// Adds the file list, the one representation that cannot be deferred: UIKit needs the + /// number of items when the session begins, and for a file drag that is the number of + /// files. + /// + /// #### Parameters + /// + /// - `paths`: newline separated file paths or `file:` URIs + native void addNativeDragFiles(String paths); + + /// Drops whatever `#prepareNativeDrag(java.lang.String, int, byte[], int, int)` staged, + /// because the press turned out to be a tap. + native void cancelNativeDrag(); + + /// Attaches the drag interaction, because the application has a component that can be + /// dragged out. + /// + /// UIKit recognizes the drag gesture with a recognizer installed on the surface, and having + /// one there changes how every touch is delivered -- with it attached unconditionally a + /// plain tap stopped reaching the framework. So it is attached on demand, and an + /// application that never drags anything keeps exactly the touch handling it had. + /// Idempotent. + native void enableNativeDragSource(); + + /// Attaches the drop interaction, because the application has a component that accepts + /// drops. Withheld until then on the same principle as the drag interaction. Idempotent. + native void enableNativeDropTarget(); native void setPinchToZoomEnabled(long peer, boolean e); native void setNativeBrowserScrollingEnabled(long peer, boolean e); diff --git a/Samples/samples/NativeDragAndDropSample/NativeDragAndDropSample.java b/Samples/samples/NativeDragAndDropSample/NativeDragAndDropSample.java new file mode 100644 index 00000000000..74c944fc942 --- /dev/null +++ b/Samples/samples/NativeDragAndDropSample/NativeDragAndDropSample.java @@ -0,0 +1,225 @@ +/* + * 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 Oracle 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.codename1.samples; + +import static com.codename1.ui.CN.*; + +import com.codename1.io.FileSystemStorage; +import com.codename1.io.Log; +import com.codename1.ui.ClipboardContent; +import com.codename1.ui.ClipboardDataProvider; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.NativeDragAndDrop; +import com.codename1.ui.NativeDragOperation; +import com.codename1.ui.NativeDropEvent; +import com.codename1.ui.Toolbar; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.Border; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.Resources; + +import java.io.OutputStream; + +/** + * Native operating system drag and drop: dragging content out of the application, and accepting + * a drag that came from somewhere else. + * + *

Drag the first card onto a text editor and the text lands there; drag the second onto the + * desktop or into a file manager and a real file appears -- written only at the moment of the + * drop, because the payload promises it rather than building it. Drag anything from another + * application onto the drop zone and it is described there.

+ */ +public class NativeDragAndDropSample { + + private Form current; + private Resources theme; + private Label status; + + public void init(Object context) { + updateNetworkThreadCount(2); + theme = UIManager.initFirstTheme("/theme"); + Toolbar.setGlobalToolbar(true); + Log.bindCrashProtection(true); + } + + public void start() { + if (current != null) { + current.show(); + return; + } + Form f = new Form("Native Drag and Drop", new BorderLayout()); + + status = new Label(NativeDragAndDrop.isSupported() + ? (NativeDragAndDrop.isDragOutsideApplicationSupported() + ? "Drags can leave this application" + : "Drags work inside this application only") + : "This platform has no native drag and drop"); + + Container body = new Container(BoxLayout.y()); + body.add(status); + body.add(textDragSource()); + body.add(fileDragSource()); + body.add(dropZone()); + + f.add(BorderLayout.CENTER, body); + f.show(); + } + + /** A card that drags plain text and HTML, so every receiver takes the best form it knows. */ + private Component textDragSource() { + Label card = new Label("Drag me into a text editor"); + card.getStyle().setBorder(Border.createLineBorder(2, 0x3366cc)); + card.getAllStyles().setPadding(8, 8, 8, 8); + + ClipboardContent content = new ClipboardContent() + .setData(ClipboardContent.MIME_TEXT, "Dragged out of Codename One") + .setData(ClipboardContent.MIME_HTML, + "Dragged out of Codename One"); + card.setNativeDragOperation(new NativeDragOperation(content) + .setAllowedActions(NativeDragOperation.ACTION_COPY) + .setLabel("Codename One text")); + return card; + } + + /** + * A card that drags a file which does not exist yet. + * + *

The file is registered as a provider rather than written up front, so a drag the user + * abandons costs nothing: the provider only runs if a receiver actually asks for the file + * list, which is what dropping on the desktop or in a file manager does.

+ */ + private Component fileDragSource() { + Label card = new Label("Drag me onto the desktop"); + card.getStyle().setBorder(Border.createLineBorder(2, 0x33aa55)); + card.getAllStyles().setPadding(8, 8, 8, 8); + + ClipboardContent content = new ClipboardContent() + .setData(ClipboardContent.MIME_TEXT, "codenameone-note.txt") + .setDataProvider(ClipboardContent.MIME_FILE, new ClipboardDataProvider() { + @Override + public Object getClipboardData(String mimeType) { + return writeNote(); + } + }); + + NativeDragOperation op = new NativeDragOperation(content) + .setAllowedActions(NativeDragOperation.ACTION_COPY) + .setLabel("codenameone-note.txt"); + op.addCompletionListener(e -> { + NativeDragOperation done = (NativeDragOperation) e.getSource(); + setStatus(done.getPerformedAction() == NativeDragOperation.ACTION_NONE + ? "The file drag was cancelled" + : "The file was dropped"); + }); + card.setNativeDragOperation(op); + return card; + } + + /** Writes the promised file and returns its path, or null when it could not be written. */ + private String writeNote() { + FileSystemStorage fs = FileSystemStorage.getInstance(); + String path = fs.getAppHomePath() + "codenameone-note.txt"; + try { + OutputStream out = fs.openOutputStream(path); + try { + out.write("Written by Codename One when you dropped it.\n".getBytes("UTF-8")); + } finally { + out.close(); + } + return path; + } catch (Exception err) { + Log.e(err); + return null; + } + } + + /** A zone that accepts anything dropped on it, from this application or from another one. */ + private Component dropZone() { + final Container zone = new Container(BoxLayout.y()) { + @Override + protected void nativeDragEnter(NativeDropEvent ev) { + getAllStyles().setBgColor(0xddeeff); + getAllStyles().setBgTransparency(255); + repaint(); + } + + @Override + protected void nativeDragExit(NativeDropEvent ev) { + getAllStyles().setBgTransparency(0); + repaint(); + } + + @Override + protected void nativeDrop(NativeDropEvent ev) { + getAllStyles().setBgTransparency(0); + repaint(); + } + }; + zone.getStyle().setBorder(Border.createDashedBorder(2, 0x888888)); + zone.getAllStyles().setPadding(16, 16, 16, 16); + zone.add(new Label("Drop anything here")); + zone.setNativeDropTarget(true); + zone.addNativeDropListener(e -> { + NativeDropEvent drop = (NativeDropEvent) e; + zone.removeAll(); + zone.add(new Label(drop.isLocal() ? "Dropped from this app" : "Dropped from elsewhere")); + String[] files = drop.getFiles(); + if (files != null) { + for (String file : files) { + zone.add(new Label(file)); + } + } else { + String text = drop.getText(); + zone.add(new Label(text == null ? "no text" : text)); + for (String mime : drop.getContent().getMimeTypes()) { + zone.add(new Label("offered: " + mime)); + } + } + zone.getComponentForm().revalidateWithAnimationSafety(); + }); + return zone; + } + + private void setStatus(String text) { + status.setText(text); + status.repaint(); + } + + public void stop() { + current = getCurrentForm(); + if (current instanceof Dialog) { + ((Dialog) current).dispose(); + current = getCurrentForm(); + } + } + + public void destroy() { + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/advancedtopics/NativeDragAndDropDemo.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/advancedtopics/NativeDragAndDropDemo.java new file mode 100644 index 00000000000..7dbff1a463d --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/advancedtopics/NativeDragAndDropDemo.java @@ -0,0 +1,131 @@ +/* + * 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 Oracle 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.advancedtopics; + +import com.codename1.io.FileSystemStorage; +import com.codename1.ui.ClipboardContent; +import com.codename1.ui.ClipboardDataProvider; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.NativeDragAndDrop; +import com.codename1.ui.NativeDragOperation; +import com.codename1.ui.NativeDropEvent; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; + +import java.io.OutputStream; + +public class NativeDragAndDropDemo { + + // tag::nativeDragSource[] + public void showDragSource() { + Form hi = new Form("Drag Out", BoxLayout.y()); + Label card = new Label("Drag me into another application"); + + ClipboardContent content = new ClipboardContent() + .setData(ClipboardContent.MIME_TEXT, "Dragged out of Codename One") + .setData(ClipboardContent.MIME_HTML, "Dragged out of Codename One"); + + card.setNativeDragOperation(new NativeDragOperation(content) + .setAllowedActions(NativeDragOperation.ACTION_COPY)); + + hi.add(card); + hi.show(); + } + // end::nativeDragSource[] + + // tag::nativeFileDrag[] + public void showFileDragSource() { + Form hi = new Form("Drag A File Out", BoxLayout.y()); + Label card = new Label("Drag me onto the desktop"); + + // The file is promised rather than written. The provider runs when a receiver actually + // asks for the file list, so a drag the user abandons costs nothing. + ClipboardContent content = new ClipboardContent() + .setData(ClipboardContent.MIME_TEXT, "note.txt") + .setDataProvider(ClipboardContent.MIME_FILE, new ClipboardDataProvider() { + @Override + public Object getClipboardData(String mimeType) { + return writeNote(); + } + }); + + NativeDragOperation op = new NativeDragOperation(content); + op.addCompletionListener(e -> + System.out.println("performed action: " + + ((NativeDragOperation) e.getSource()).getPerformedAction())); + card.setNativeDragOperation(op); + + hi.add(card); + hi.show(); + } + + private String writeNote() { + FileSystemStorage fs = FileSystemStorage.getInstance(); + String path = fs.getAppHomePath() + "note.txt"; + try { + OutputStream out = fs.openOutputStream(path); + try { + out.write("Written on drop\n".getBytes("UTF-8")); + } finally { + out.close(); + } + return path; + } catch (Exception err) { + return null; + } + } + // end::nativeFileDrag[] + + // tag::nativeDropTarget[] + public void showDropTarget() { + Form hi = new Form("Drop Here", new BorderLayout()); + Container zone = new Container(BoxLayout.y()); + zone.add(new Label("Drop files here")); + + zone.setNativeDropTarget(true); + zone.setAcceptedDropMimeTypes(ClipboardContent.MIME_FILE); + zone.setAcceptedDropActions(NativeDragOperation.ACTION_COPY); + zone.addNativeDropListener(e -> { + NativeDropEvent drop = (NativeDropEvent) e; + for (String path : drop.getFiles()) { + zone.add(new Label(path)); + } + zone.getComponentForm().revalidateWithAnimationSafety(); + }); + + hi.add(BorderLayout.CENTER, zone); + hi.show(); + } + // end::nativeDropTarget[] + + // tag::nativeDragSupport[] + public boolean canDragOut() { + return NativeDragAndDrop.isSupported() + && NativeDragAndDrop.isDragOutsideApplicationSupported(); + } + // end::nativeDragSupport[] +} diff --git a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc index 373be8cb879..a183ccd80a0 100644 --- a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc +++ b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc @@ -1257,7 +1257,9 @@ Unlike other platforms that tried to create overly generic catch all APIs Codena In Codename One components can be dragged and drop targets are always components. The logic of actually performing the operation indicated by the drop is the responsibility of the person implementing the drop. -NOTE: Some platforms for example: AWT allow dragging abstract concepts such as mime type elements. This allows dragging things like a text file into the app, but that use case isn't realistic in mobile +This lightweight dragging never leaves the application. To drag content into another +application, onto the desktop or into a file manager -- and to accept a drag coming the other +way -- see <<_native_operating_system_drag_drop>> below. The code below allows you to rearrange the items based on a sensible order. Notice it relies on the default `Container` drop behavior: @@ -1289,6 +1291,114 @@ In the drop target you can override the following methods: * `drop` - the logic for dropping/moving the component must be implemented here! +==== Native operating system drag & drop + +The section above moves a component around inside one form. Native dragging hands the gesture to +the operating system instead, so a drag can end in another application, on the desktop or in a +file manager, and a drag started anywhere on the machine can end on one of your components. + +The payload is a `ClipboardContent`, the same object a copy publishes. That's the whole idea: a +drag is a copy that the user aims with the pointer, so whatever the application can already put +on the clipboard it can already drag out, and whatever it can paste it can already accept as a +drop. Offering several representations lets one drag land correctly in unrelated applications -- +a text editor takes the `text/html` representation, a plain text field takes `text/plain`, and +the desktop takes the file list. + +To drag something out, give the component an operation: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/advancedtopics/NativeDragAndDropDemo.java[tag=nativeDragSource,indent=0] +---- + +Dragging a file works the same way, except that the file often doesn't exist yet and the user +may drop it nowhere at all. Register it as a provider and it's written at the moment a receiver +asks for it rather than when the drag starts: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/advancedtopics/NativeDragAndDropDemo.java[tag=nativeFileDrag,indent=0] +---- + +Two platforms can't defer quite that far. An Android clip has to be complete before the drag +begins, so a provider runs there as the drag starts; an iOS drag resolves its file list at the +same moment, because the system needs to know how many items the drag carries. Keep a provider +cheap enough to run once per drag. + +`NativeDragOperation.ACTION_MOVE` means the receiver takes ownership and the source deletes its +copy. The source only learns whether that happened once the operating system has finished, which +is why the outcome arrives through the completion listener rather than from the call that started +the drag. + +Receiving a drop is the mirror image. The deepest component under the pointer that's a native +drop target and accepts the content wins, so a target nested inside another takes precedence and +a target that refuses a particular payload lets an ancestor have it: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/advancedtopics/NativeDragAndDropDemo.java[tag=nativeDropTarget,indent=0] +---- + +`NativeDropEvent` also reports whether the drag started inside this application, which is how a +container that reorders its own items tells that case from an import. + +===== Where it works + +Native dragging needs the platform to have it, and platforms differ on whether a drag may leave +the application at all -- a desktop can drop onto any other window, a tablet can drop into an +application beside it, and a phone in full screen has nowhere for a drag to go even though drags +within the application still work. Check both before offering the affordance: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/advancedtopics/NativeDragAndDropDemo.java[tag=nativeDragSupport,indent=0] +---- + +Where the platform has none of this, the calls are harmless no-ops: the component simply can't be +dragged through the operating system, and the lightweight dragging above is unaffected. + +[cols="2,1,1,4"] +|=== +|Platform |Drags |Leaves the app |Notes + +|Desktop (the simulator and "run as desktop app") +|yes |yes +|Drops onto other windows, the desktop and file managers. Text, HTML, images, arbitrary +binary payloads and multiple files. + +|Android +|yes |Nougat and later +|A drag crosses applications through `DRAG_FLAG_GLOBAL`, which arrived in Nougat. Files +travel as content URIs, the same way the clipboard carries them. + +|iPadOS and Mac Catalyst +|yes |yes +|Drops into another application beside this one, and into Files or the Finder. + +|iPhone +|yes |iOS 15 and later +|iOS 15 brought dragging between applications to the phone: hold the item with one finger, +switch applications with another, drop. Before that the session worked but could only end on one +of the application's own components. + +|Everything else +|no |no +|`isSupported()` answers false and the lightweight dragging above is unaffected. +|=== + +===== Threading + +Drops arrive on the operating system's own drag thread rather than the event dispatch thread. The +framework resolves which component a drag is over on that thread, from the accepted MIME types +and actions alone, and dispatches the callbacks -- `nativeDragEnter`, `nativeDragOver`, +`nativeDragExit` and `nativeDrop` -- on the event dispatch thread. A filter set through +`setAcceptedDropMimeTypes` therefore takes effect from the first drag event, while a decision +made inside a callback reaches the cursor one event later. + +The one method that runs off the event dispatch thread is `canAcceptNativeDrop`, which exists for +a target whose answer depends on more than the MIME type. Read the content and the component's +own configuration there; leave the user interface alone. + === Android Lollipop ActionBar customization diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java index 1a5ac2d7a60..ddaea0b3392 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java @@ -419,8 +419,13 @@ static void register(List h) { h.add(new Hint("android.file_paths") .group(HintGroup.ANDROID) .type(HintType.STRING) - .def(" ") - .platform("android")); + .def(" ") + .platform("android") + .doc("The FileProvider roots written into file_paths.xml, besides the" + + " cache/intent_files one the framework always needs. A file has to" + + " be under one of these for the application to hand it to another" + + " application -- when sharing, or when dragging it out. Setting this" + + " replaces the default rather than adding to it.")); h.add(new Hint("android.firebaseAnalytics") .group(HintGroup.ANDROID) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index f0b655f35c2..d4cd3513ca8 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -5432,7 +5432,17 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { String filePathsContent = "\n" + "\n" + " \n" + - request.getArg("android.file_paths", " ") + + // The roots a file has to be under for FileProvider to serve it. The + // application's own directories, and its external ones -- a document or a + // video picked by the user lives out there, FileSystemStorage lists those + // roots, and getUriForFile throws for anything outside them, so sharing + // such a file meant copying it first. Declaring a root exposes nothing by + // itself: a URI is still minted per file, and only for files this + // application deliberately shares. + // + // Only the default. An application that sets the hint still says exactly + // what it wants and gets nothing it did not ask for. + request.getArg("android.file_paths", " ") + ""; try { diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java index fb6c878ed4b..d55bf34ea6b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java +++ b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java @@ -115,6 +115,17 @@ public class TestCodenameOneImplementation extends CodenameOneImplementation { private Message lastSentMessage; private int refreshContactsCount; + /// Native drag and drop, faked so the framework's outbound path can be exercised without a + /// real operating system drag: the last operation prepared and the last one started are + /// recorded, and startNativeDrag reports success only while nativeDragAndDropSupported is on. + private boolean nativeDragAndDropSupported; + private com.codename1.ui.NativeDragOperation preparedNativeDrag; + private com.codename1.ui.NativeDragOperation startedNativeDrag; + private int cancelledNativeDrags; + private boolean nativeDragStartRefused; + private int nativeDragSourceRegistrations; + private int nativeDropTargetRegistrations; + private final TestFont defaultFont = new TestFont(8, 16); private int displayWidth = 1080; private int displayHeight = 1920; @@ -5474,4 +5485,90 @@ public void windowPointerDraggedForTest(int windowId, int x, int y) { public void windowPointerReleasedForTest(int windowId, int x, int y) { windowPointerReleased(windowId, x, y); } + + // ------------------------------------------------------------------------------------ + // Native drag and drop test hooks + // ------------------------------------------------------------------------------------ + + /// Turns the fake native drag and drop on, which is what makes + /// `com.codename1.ui.NativeDragAndDrop#isSupported()` true for a test. + public void setNativeDragAndDropSupported(boolean supported) { + this.nativeDragAndDropSupported = supported; + } + + @Override + public boolean isNativeDragAndDropSupported() { + return nativeDragAndDropSupported; + } + + @Override + public void prepareNativeDrag(com.codename1.ui.NativeDragOperation op) { + preparedNativeDrag = op; + } + + @Override + public boolean startNativeDrag(com.codename1.ui.NativeDragOperation op) { + if (!nativeDragAndDropSupported || nativeDragStartRefused) { + return false; + } + startedNativeDrag = op; + return true; + } + + /// Makes startNativeDrag refuse, which is how a port whose operating system owns the drag + /// gesture behaves: it starts no session of its own and announces the platform's later. + public void setNativeDragStartRefused(boolean refused) { + this.nativeDragStartRefused = refused; + } + + @Override + public void cancelNativeDrag() { + cancelledNativeDrags++; + preparedNativeDrag = null; + } + + /// The operation the last press staged, or null. + public com.codename1.ui.NativeDragOperation getPreparedNativeDrag() { + return preparedNativeDrag; + } + + /// The operation the last drag actually started, or null. + public com.codename1.ui.NativeDragOperation getStartedNativeDrag() { + return startedNativeDrag; + } + + /// How many prepared operations were dropped because the press turned out to be a click. + public int getCancelledNativeDrags() { + return cancelledNativeDrags; + } + + @Override + public void nativeDragSourceRegistered() { + nativeDragSourceRegistrations++; + } + + /// How many times the framework has told the port that this application wants to drag. A + /// port whose platform needs a gesture recognizer installs it on the strength of this. + public int getNativeDragSourceRegistrations() { + return nativeDragSourceRegistrations; + } + + @Override + public void nativeDropTargetRegistered() { + nativeDropTargetRegistrations++; + } + + /// How many times the framework has told the port that this application accepts drops. + public int getNativeDropTargetRegistrations() { + return nativeDropTargetRegistrations; + } + + /// Forgets everything recorded, so one test does not see another's drag. + public void resetNativeDragState() { + preparedNativeDrag = null; + startedNativeDrag = null; + cancelledNativeDrags = 0; + nativeDragSourceRegistrations = 0; + nativeDropTargetRegistrations = 0; + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/NativeDragAndDropTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/NativeDragAndDropTest.java new file mode 100644 index 00000000000..7b1a82ae915 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/NativeDragAndDropTest.java @@ -0,0 +1,1595 @@ +/* + * 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 Oracle 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.codename1.ui; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.ui.animations.Motion; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.layouts.BorderLayout; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Native (operating system) drag and drop: the payload container, the target resolution the + * ports rely on, and the gesture that hands a press to the platform. + * + *

The platform half is faked through {@code TestCodenameOneImplementation}, which records the + * operation the framework prepared and started instead of talking to a window system. What is + * under test here is everything above the port: which component a drag resolves to, what it + * answers the operating system, and which callbacks fire.

+ */ +class NativeDragAndDropTest extends UITestBase { + + /** A component that records every native drag callback it receives. */ + private static final class DropRecorder extends Container { + final List events = new ArrayList(); + ClipboardContent dropped; + int rejectAction = -1; + + @Override + protected void nativeDragEnter(NativeDropEvent ev) { + events.add("enter"); + if (rejectAction >= 0) { + ev.accept(rejectAction); + } + } + + @Override + protected void nativeDragOver(NativeDropEvent ev) { + events.add("over"); + if (rejectAction >= 0) { + ev.accept(rejectAction); + } + } + + @Override + protected void nativeDragExit(NativeDropEvent ev) { + events.add("exit"); + } + + @Override + protected void nativeDrop(NativeDropEvent ev) { + events.add("drop"); + dropped = ev.getContent(); + } + } + + private DropRecorder addTarget(Form form) { + DropRecorder target = new DropRecorder(); + target.setNativeDropTarget(true); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, target); + form.revalidate(); + return target; + } + + /// Drives a drag the way the framework really does. CodenameOneImplementation wraps a + /// single pointer into one-element arrays and Display dispatches *those*, and Form and + /// Window implement that overload separately from the scalar one -- so a test that calls + /// the scalar overload exercises a path no port takes. + private static void drag(Form form, int x, int y) { + form.pointerDragged(new int[]{x}, new int[]{y}); + } + + private static ClipboardContent textContent(String text) { + return new ClipboardContent().setData(ClipboardContent.MIME_TEXT, text); + } + + // ------------------------------------------------------------------------------------ + // ClipboardContent as a drag payload + // ------------------------------------------------------------------------------------ + + @Test + void lazyRepresentationIsNotBuiltUntilItIsRead() { + final int[] calls = {0}; + ClipboardContent content = new ClipboardContent() + .setData(ClipboardContent.MIME_TEXT, "report") + .setDataProvider(ClipboardContent.MIME_FILE, new ClipboardDataProvider() { + public Object getClipboardData(String mimeType) { + calls[0]++; + return "/tmp/report.pdf"; + } + }); + + assertTrue(content.hasMimeType(ClipboardContent.MIME_FILE), + "a promised representation is advertised before it is built"); + assertEquals(0, calls[0], "advertising a representation must not build it"); + + assertArrayEquals(new String[]{"/tmp/report.pdf"}, content.getFiles()); + assertEquals(1, calls[0], "reading it builds it"); + assertArrayEquals(new String[]{"/tmp/report.pdf"}, content.getFiles()); + assertEquals(1, calls[0], "reading it again reuses the value rather than writing the file twice"); + } + + @Test + void fileListReadsBackWhicheverWayItWasStored() { + ClipboardContent one = new ClipboardContent().setFiles(new String[]{"/tmp/a.txt"}); + assertEquals("/tmp/a.txt", one.getData(ClipboardContent.MIME_FILE), + "a single file is stored as a plain string, which is what the ports expect"); + assertArrayEquals(new String[]{"/tmp/a.txt"}, one.getFiles()); + + ClipboardContent many = new ClipboardContent().setFiles(new String[]{"/tmp/a.txt", "/tmp/b.txt"}); + assertArrayEquals(new String[]{"/tmp/a.txt", "/tmp/b.txt"}, many.getFiles()); + + assertNull(new ClipboardContent().getFiles(), "no files means null rather than an empty array"); + assertNull(new ClipboardContent().setFiles(null).getFiles()); + } + + // On the event dispatch thread, because EventDispatcher defers a firing made from any other + // thread and the listener would then not have run by the time the assertion below reads it. + @FormTest + void anOperationDefaultsToCopyAndReportsNothingUntilItCompletes() { + NativeDragOperation op = new NativeDragOperation("hello"); + assertEquals(NativeDragOperation.ACTION_COPY, op.getAllowedActions()); + assertEquals("hello", op.getContent().getText(ClipboardContent.MIME_TEXT)); + assertEquals(NativeDragOperation.ACTION_NONE, op.getPerformedAction()); + + final int[] completed = {-1}; + op.addCompletionListener(e -> completed[0] = ((NativeDragOperation) e.getSource()).getPerformedAction()); + op.fireCompleted(NativeDragOperation.ACTION_MOVE); + assertEquals(NativeDragOperation.ACTION_MOVE, op.getPerformedAction()); + assertEquals(NativeDragOperation.ACTION_MOVE, completed[0], + "a source that offered a move learns here, and only here, that it must delete its copy"); + } + + // ------------------------------------------------------------------------------------ + // Resolving the target and answering the operating system + // ------------------------------------------------------------------------------------ + + @FormTest + void aDragOverATargetIsAcceptedAndDeliversEnterThenOver() { + Form form = Display.getInstance().getCurrent(); + DropRecorder target = addTarget(form); + + int x = target.getAbsoluteX() + 5; + int y = target.getAbsoluteY() + 5; + int action = NativeDragAndDrop.dragEnter(0, x, y, textContent("hi"), + NativeDragOperation.ACTION_COPY | NativeDragOperation.ACTION_MOVE); + assertEquals(NativeDragOperation.ACTION_COPY, action, + "a target that expresses no preference copies, which cannot destroy the source's data"); + + NativeDragAndDrop.dragOver(0, x + 1, y + 1, textContent("hi"), NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + assertEquals("enter", target.events.get(0)); + assertTrue(target.events.contains("over")); + + NativeDragAndDrop.dragExit(0); + flushSerialCalls(); + assertEquals("exit", target.events.get(target.events.size() - 1)); + } + + @FormTest + void aDragOverNothingIsRejected() { + Form form = Display.getInstance().getCurrent(); + addTarget(form); + form.setNativeDropTarget(false); + + // The title area is outside the target; nothing there accepts drops. + int action = NativeDragAndDrop.dragEnter(0, 1, 1, textContent("hi"), NativeDragOperation.ACTION_COPY); + assertEquals(NativeDragOperation.ACTION_NONE, action); + NativeDragAndDrop.dragExit(0); + flushSerialCalls(); + } + + @FormTest + void aMimeFilterRefusesTheDragFromTheVeryFirstEvent() { + Form form = Display.getInstance().getCurrent(); + DropRecorder target = addTarget(form); + target.setAcceptedDropMimeTypes(ClipboardContent.MIME_FILE); + + int x = target.getAbsoluteX() + 5; + int y = target.getAbsoluteY() + 5; + assertEquals(NativeDragOperation.ACTION_NONE, + NativeDragAndDrop.dragEnter(0, x, y, textContent("hi"), NativeDragOperation.ACTION_COPY), + "text is not a file, so the target never sees the drag at all"); + flushSerialCalls(); + assertTrue(target.events.isEmpty()); + + ClipboardContent files = new ClipboardContent().setFiles(new String[]{"/tmp/a.txt"}); + assertEquals(NativeDragOperation.ACTION_COPY, + NativeDragAndDrop.dragEnter(0, x, y, files, NativeDragOperation.ACTION_COPY)); + NativeDragAndDrop.dragExit(0); + flushSerialCalls(); + } + + @FormTest + void anActionTheTargetRefusesIsNotOffered() { + Form form = Display.getInstance().getCurrent(); + DropRecorder target = addTarget(form); + target.setAcceptedDropActions(NativeDragOperation.ACTION_MOVE); + + int x = target.getAbsoluteX() + 5; + int y = target.getAbsoluteY() + 5; + assertEquals(NativeDragOperation.ACTION_NONE, + NativeDragAndDrop.dragEnter(0, x, y, textContent("hi"), NativeDragOperation.ACTION_COPY), + "a copy-only source and a move-only target have nothing in common"); + NativeDragAndDrop.dragExit(0); + + assertEquals(NativeDragOperation.ACTION_MOVE, + NativeDragAndDrop.dragEnter(0, x, y, textContent("hi"), + NativeDragOperation.ACTION_COPY | NativeDragOperation.ACTION_MOVE)); + NativeDragAndDrop.dragExit(0); + flushSerialCalls(); + } + + @FormTest + void theDeepestTargetWins() { + Form form = Display.getInstance().getCurrent(); + DropRecorder outer = addTarget(form); + DropRecorder inner = new DropRecorder(); + inner.setNativeDropTarget(true); + outer.setLayout(new BorderLayout()); + outer.add(BorderLayout.CENTER, inner); + form.revalidate(); + + NativeDragAndDrop.drop(0, inner.getAbsoluteX() + 2, inner.getAbsoluteY() + 2, + textContent("hi"), NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + assertTrue(inner.events.contains("drop")); + assertFalse(outer.events.contains("drop")); + } + + @FormTest + void aTargetThatRefusesThisPayloadLetsAnAncestorHaveIt() { + Form form = Display.getInstance().getCurrent(); + DropRecorder outer = addTarget(form); + DropRecorder inner = new DropRecorder(); + inner.setNativeDropTarget(true); + inner.setAcceptedDropMimeTypes(ClipboardContent.MIME_FILE); + outer.setLayout(new BorderLayout()); + outer.add(BorderLayout.CENTER, inner); + form.revalidate(); + + NativeDragAndDrop.drop(0, inner.getAbsoluteX() + 2, inner.getAbsoluteY() + 2, + textContent("hi"), NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + assertFalse(inner.events.contains("drop")); + assertTrue(outer.events.contains("drop")); + } + + @FormTest + void dropDeliversTheContentAndNotifiesTheListener() { + Form form = Display.getInstance().getCurrent(); + DropRecorder target = addTarget(form); + final NativeDropEvent[] seen = new NativeDropEvent[1]; + target.addNativeDropListener(e -> seen[0] = (NativeDropEvent) e); + + ClipboardContent content = new ClipboardContent() + .setData(ClipboardContent.MIME_TEXT, "two files") + .setFiles(new String[]{"/tmp/a.txt", "/tmp/b.txt"}); + int accepted = NativeDragAndDrop.drop(0, target.getAbsoluteX() + 5, target.getAbsoluteY() + 5, + content, NativeDragOperation.ACTION_COPY); + assertEquals(NativeDragOperation.ACTION_COPY, accepted); + flushSerialCalls(); + + assertNotNull(target.dropped); + assertArrayEquals(new String[]{"/tmp/a.txt", "/tmp/b.txt"}, target.dropped.getFiles(), + "a drop of several files arrives as several files"); + assertNotNull(seen[0]); + assertEquals(ActionEvent.Type.NativeDrop, seen[0].getEventType()); + assertEquals("two files", seen[0].getText()); + assertFalse(seen[0].isLocal(), "a drag this application did not start is not local"); + } + + @FormTest + void aRejectionInEnterSurvivesAnAlreadyQueuedOver() { + Form form = Display.getInstance().getCurrent(); + final List seen = new ArrayList(); + // Decides in nativeDragEnter and nowhere else, which is the case the queued over event + // used to undo. + Container target = new Container() { + @Override + protected void nativeDragEnter(NativeDropEvent ev) { + seen.add("enter"); + ev.reject(); + } + + @Override + protected void nativeDrop(NativeDropEvent ev) { + seen.add("drop"); + } + }; + target.setNativeDropTarget(true); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, target); + form.revalidate(); + + int x = target.getAbsoluteX() + 5; + int y = target.getAbsoluteY() + 5; + NativeDragAndDrop.dragEnter(0, x, y, textContent("hi"), NativeDragOperation.ACTION_COPY); + // A second motion event before the queue drains, so its callback is queued behind the + // enter that has not run yet. + NativeDragAndDrop.dragOver(0, x + 1, y + 1, textContent("hi"), NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + + assertEquals(NativeDragOperation.ACTION_NONE, + NativeDragAndDrop.dragOver(0, x + 2, y + 2, textContent("hi"), + NativeDragOperation.ACTION_COPY), + "the refusal made in nativeDragEnter must not be undone by an over event that " + + "was queued before it ran"); + assertEquals(NativeDragOperation.ACTION_NONE, + NativeDragAndDrop.drop(0, x, y, textContent("hi"), NativeDragOperation.ACTION_COPY)); + flushSerialCalls(); + assertFalse(seen.contains("drop")); + NativeDragAndDrop.dragExit(0); + flushSerialCalls(); + } + + @FormTest + void aRejectionMadeWhileHoveringSurvivesTheDrop() { + Form form = Display.getInstance().getCurrent(); + DropRecorder target = addTarget(form); + // The target refuses from inside its callback, which is the only place it can change + // its mind about a payload the declarative filters already let through. + target.rejectAction = NativeDragOperation.ACTION_NONE; + + int x = target.getAbsoluteX() + 5; + int y = target.getAbsoluteY() + 5; + NativeDragAndDrop.dragEnter(0, x, y, textContent("hi"), NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + + // The port drops carrying the action it was handed before that callback ran -- its + // answer is one drag event behind by construction -- so the refusal only survives if + // the drop honours the target's own latest word rather than recomputing. + assertEquals(NativeDragOperation.ACTION_NONE, + NativeDragAndDrop.drop(0, x, y, textContent("hi"), NativeDragOperation.ACTION_COPY), + "a target that refused while hovering must not be handed the drop anyway"); + flushSerialCalls(); + assertFalse(target.events.contains("drop"), + "and no drop event is delivered, which is what reject() promises"); + } + + @FormTest + void aTargetThatCannotPerformTheActionLetsAnAncestorHaveIt() { + Form form = Display.getInstance().getCurrent(); + DropRecorder outer = addTarget(form); + outer.setAcceptedDropActions(NativeDragOperation.ACTION_COPY); + DropRecorder inner = new DropRecorder(); + inner.setNativeDropTarget(true); + inner.setAcceptedDropActions(NativeDragOperation.ACTION_MOVE); + outer.setLayout(new BorderLayout()); + outer.add(BorderLayout.CENTER, inner); + form.revalidate(); + + // A copy-only drag over a move-only target nested in a copy-capable one. Choosing the + // inner target on the content alone and only then finding it can do nothing swallowed + // the drag: refusing on the action is the same kind of refusal as refusing on the MIME + // type, and the walk has to treat it the same way. + int x = inner.getAbsoluteX() + 2; + int y = inner.getAbsoluteY() + 2; + assertEquals(NativeDragOperation.ACTION_COPY, + NativeDragAndDrop.dragEnter(0, x, y, textContent("hi"), + NativeDragOperation.ACTION_COPY), + "the copy-capable ancestor takes it"); + NativeDragAndDrop.drop(0, x, y, textContent("hi"), NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + assertTrue(outer.events.contains("drop")); + assertFalse(inner.events.contains("drop")); + } + + @FormTest + void eachGestureRendersItsOwnDragImage() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + try { + Form form = Display.getInstance().getCurrent(); + Container source = new Container(); + NativeDragOperation op = new NativeDragOperation("reused"); + source.setNativeDragOperation(op); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, source); + form.revalidate(); + + int x = source.getAbsoluteX() + 10; + int y = source.getAbsoluteY() + 10; + form.pointerPressed(x, y); + drag(form, x + 200, y + 200); + Image first = op.getDragImage(); + assertNotNull(first); + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + + // The same reusable operation, dragged again. The generated snapshot must not have + // become the operation's permanent image: the component may look different now and + // the press landed somewhere else. + form.pointerPressed(x + 4, y + 4); + drag(form, x + 200, y + 200); + Image second = op.getDragImage(); + assertNotNull(second); + assertNotSame(first, second, + "a framework-rendered preview belongs to its gesture, not to the operation"); + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + + // An image the application supplied is never replaced. + Image supplied = Image.createImage(4, 4); + op.setDragImage(supplied); + form.pointerPressed(x, y); + drag(form, x + 200, y + 200); + assertSame(supplied, op.getDragImage(), "an application's own image is left alone"); + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + } finally { + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void dropOnNothingReportsFailureSoThePortCanTellTheSource() { + Form form = Display.getInstance().getCurrent(); + addTarget(form); + assertEquals(NativeDragOperation.ACTION_NONE, + NativeDragAndDrop.drop(0, 1, 1, textContent("hi"), NativeDragOperation.ACTION_COPY)); + flushSerialCalls(); + } + + // ------------------------------------------------------------------------------------ + // The gesture: a press on a drag source becomes an operating system drag + // ------------------------------------------------------------------------------------ + + @FormTest + void reusingAnOperationForgetsTheOutcomeOfTheLastDrag() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + try { + NativeDragOperation op = new NativeDragOperation("reused") + .setAllowedActions(NativeDragOperation.ACTION_COPY | NativeDragOperation.ACTION_MOVE); + assertTrue(NativeDragAndDrop.startDrag(null, op)); + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_MOVE); + flushSerialCalls(); + assertEquals(NativeDragOperation.ACTION_MOVE, op.getPerformedAction()); + + // The same instance is offered for every drag of its component, so the second drag + // must not go on reporting the first one's result while it is still running. + assertTrue(NativeDragAndDrop.startDrag(null, op)); + assertEquals(NativeDragOperation.ACTION_NONE, op.getPerformedAction(), + "a drag in flight has performed nothing yet, whatever the last one did"); + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_NONE); + flushSerialCalls(); + } finally { + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void aSecondDragIsRefusedWhileOneIsStillRunning() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + try { + NativeDragOperation first = new NativeDragOperation("first"); + NativeDragOperation second = new NativeDragOperation("second"); + assertTrue(NativeDragAndDrop.startDrag(null, first)); + assertFalse(NativeDragAndDrop.startDrag(null, second), + "one drag at a time; the second must not displace the first"); + assertSame(first, NativeDragAndDrop.getActiveDrag()); + + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_MOVE); + flushSerialCalls(); + assertEquals(NativeDragOperation.ACTION_MOVE, first.getPerformedAction(), + "the first source still learns its outcome, which is what tells it to delete"); + assertEquals(NativeDragOperation.ACTION_NONE, second.getPerformedAction()); + + assertTrue(NativeDragAndDrop.startDrag(null, second), + "and once the session is over the next drag starts normally"); + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_NONE); + flushSerialCalls(); + } finally { + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void becomingADragSourceTellsThePort() { + implementation.resetNativeDragState(); + try { + Container cmp = new Container(); + assertEquals(0, implementation.getNativeDragSourceRegistrations()); + + cmp.setNativeDragOperation(new NativeDragOperation("x")); + assertEquals(1, implementation.getNativeDragSourceRegistrations(), + "a platform that needs a gesture recognizer installs it on the strength of " + + "this, so an application that never drags keeps its touch handling"); + + cmp.setNativeDragSource(true); + assertEquals(2, implementation.getNativeDragSourceRegistrations()); + + cmp.setNativeDragOperation(null); + cmp.setNativeDragSource(false); + assertEquals(2, implementation.getNativeDragSourceRegistrations(), + "giving up on dragging is not a request for a recognizer"); + + assertEquals(0, implementation.getNativeDropTargetRegistrations()); + cmp.setNativeDropTarget(true); + assertEquals(1, implementation.getNativeDropTargetRegistrations()); + cmp.setNativeDropTarget(false); + assertEquals(1, implementation.getNativeDropTargetRegistrations()); + } finally { + implementation.resetNativeDragState(); + } + } + + @FormTest + void aDragOnANativeDragSourceIsHandedToThePlatform() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + try { + Form form = Display.getInstance().getCurrent(); + Container source = new Container(); + source.setNativeDragOperation(new NativeDragOperation("dragged out")); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, source); + form.revalidate(); + + int x = source.getAbsoluteX() + 10; + int y = source.getAbsoluteY() + 10; + form.pointerPressed(x, y); + assertNotNull(implementation.getPreparedNativeDrag(), + "the press stages the payload so a platform that owns the gesture can ask for it"); + assertNull(implementation.getStartedNativeDrag(), "a press alone is not a drag"); + + drag(form, x + 200, y + 200); + assertNotNull(implementation.getStartedNativeDrag(), "moving far enough starts the session"); + assertSame(source, implementation.getStartedNativeDrag().getSource()); + assertSame(implementation.getStartedNativeDrag(), NativeDragAndDrop.getActiveDrag()); + + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + assertNull(NativeDragAndDrop.getActiveDrag()); + } finally { + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void aDragSourceInsideADraggableContainerIsStillStaged() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + try { + Form form = Display.getInstance().getCurrent(); + // The form primes drag and drop on the pressed component and then again on its + // nearest draggable ancestor. The drag source sits between the two, so the second + // pass cannot find it -- and must not throw away what the first pass staged. + Container draggableOuter = new Container(new BorderLayout()); + draggableOuter.setDraggable(true); + Container source = new Container(); + source.setNativeDragOperation(new NativeDragOperation("from the middle")); + draggableOuter.add(BorderLayout.CENTER, source); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, draggableOuter); + form.revalidate(); + + int x = source.getAbsoluteX() + 10; + int y = source.getAbsoluteY() + 10; + form.pointerPressed(x, y); + assertNotNull(implementation.getPreparedNativeDrag()); + + drag(form, x + 200, y + 200); + assertNotNull(implementation.getStartedNativeDrag()); + assertSame(source, implementation.getStartedNativeDrag().getSource()); + + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + } finally { + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void aDragSourceInTheTitleAreaIsStaged() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + try { + Form form = Display.getInstance().getCurrent(); + // The title area has its own press branch, and it is the one branch that never + // primed drag and drop -- so a Toolbar component given an operation could not be + // dragged while the same component in the content pane could. + Container source = new Container(); + source.setNativeDragOperation(new NativeDragOperation("from the title")); + source.setPreferredSize(new com.codename1.ui.geom.Dimension(40, 40)); + form.getTitleArea().add(BorderLayout.EAST, source); + form.revalidate(); + + int x = source.getAbsoluteX() + 2; + int y = source.getAbsoluteY() + 2; + assertTrue(y < form.getContentPane().getAbsoluteY(), + "the fixture has to sit in the title area for this to test anything"); + + form.pointerPressed(x, y); + assertNotNull(implementation.getPreparedNativeDrag()); + drag(form, x + 200, y + 200); + assertNotNull(implementation.getStartedNativeDrag()); + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + } finally { + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void aDisabledDragSourceIsNotDraggable() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + try { + Form form = Display.getInstance().getCurrent(); + Container source = new Container(); + source.setNativeDragOperation(new NativeDragOperation("dragged out")); + source.setEnabled(false); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, source); + form.revalidate(); + + int x = source.getAbsoluteX() + 10; + int y = source.getAbsoluteY() + 10; + form.pointerPressed(x, y); + assertNull(implementation.getPreparedNativeDrag(), + "a Form primes drag and drop before its own isEnabled gate, so the check " + + "has to be here or a disabled control is draggable on the main " + + "surface and not in a window"); + + drag(form, x + 200, y + 200); + assertNull(implementation.getStartedNativeDrag()); + form.pointerReleased(x + 200, y + 200); + + // Enabled again, the very same component drags. + source.setEnabled(true); + form.pointerPressed(x, y); + assertNotNull(implementation.getPreparedNativeDrag()); + drag(form, x + 200, y + 200); + assertNotNull(implementation.getStartedNativeDrag()); + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + } finally { + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void aStaleDragCallbackDoesNotSpeakForANewerOne() { + Form form = Display.getInstance().getCurrent(); + // Decides from the payload rather than from a field, because the whole point is that + // the stale callback runs later and must still carry *its own* drag's decision. + Container target = new Container() { + @Override + protected void nativeDragEnter(NativeDropEvent ev) { + ev.accept("first".equals(ev.getText()) + ? NativeDragOperation.ACTION_MOVE : NativeDragOperation.ACTION_COPY); + } + }; + target.setNativeDropTarget(true); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, target); + form.revalidate(); + + int x = target.getAbsoluteX() + 5; + int y = target.getAbsoluteY() + 5; + // A drag arrives, settles on a move, and leaves -- with its callback still queued. + NativeDragAndDrop.dragEnter(0, x, y, textContent("first"), + NativeDragOperation.ACTION_COPY | NativeDragOperation.ACTION_MOVE); + NativeDragAndDrop.dragExit(0); + + // What the operating system would be told, read from between the stale callback and + // the new drag's own. Nothing else can see the window: once the new drag's callback + // runs it puts the right answer back, so an assertion after the queue drains would + // pass whether or not the stale one had spoken. + final int[] observed = { -1 }; + Display.getInstance().callSerially(new Runnable() { + public void run() { + observed[0] = NativeDragAndDrop.dragOver(0, x, y, textContent("second"), + NativeDragOperation.ACTION_COPY); + } + }); + + // The second, copy-only drag enters the same component before the queue drains. + NativeDragAndDrop.dragEnter(0, x, y, textContent("second"), NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + + assertEquals(NativeDragOperation.ACTION_COPY, observed[0], + "the first drag's move decision must not be handed to the second, copy-only drag"); + NativeDragAndDrop.dragExit(0); + flushSerialCalls(); + } + + @FormTest + void theGesturesOwnPreviewSurvivesTheStart() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + try { + Form form = Display.getInstance().getCurrent(); + Container source = new Container(); + source.setPreferredSize(new com.codename1.ui.geom.Dimension(40, 40)); + NativeDragOperation op = new NativeDragOperation("dragged by hand"); + source.setNativeDragOperation(op); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, source); + form.revalidate(); + + // Pressed near one corner, not in the middle. + int x = source.getAbsoluteX() + 3; + int y = source.getAbsoluteY() + 4; + form.pointerPressed(x, y); + drag(form, x + 200, y + 200); + + assertNotNull(implementation.getStartedNativeDrag()); + assertEquals(3, op.getDragImageOffsetX(), + "the preview hangs from where the press actually landed; re-rendering it " + + "at the start replaces that with the component's centre and the " + + "image jumps out from under the pointer"); + assertEquals(4, op.getDragImageOffsetY()); + } finally { + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_NONE); + flushSerialCalls(); + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void aDragStartedInCodeStillGetsTheSourcesPreview() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + try { + Form form = Display.getInstance().getCurrent(); + Container source = new Container(); + source.setPreferredSize(new com.codename1.ui.geom.Dimension(40, 40)); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, source); + form.revalidate(); + + NativeDragOperation op = new NativeDragOperation("started in code"); + assertTrue(NativeDragAndDrop.startDrag(source, op)); + + assertNotNull(op.getDragImage(), + "this entry point documents the source as providing the default preview, " + + "and without one Android snapshots the whole surface while JavaSE " + + "drags nothing at all"); + assertTrue(op.isDragImageGenerated(), + "and it is the framework's snapshot, not something the application supplied"); + + } finally { + // In the finally, not after the assertions: a session left active wedges every test + // that follows, so a failure here would be reported as twenty. + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void aDropThatMaterializedNothingIsNotADrop() { + Form form = Display.getInstance().getCurrent(); + DropRecorder target = addTarget(form); + int x = target.getAbsoluteX() + 5; + int y = target.getAbsoluteY() + 5; + + NativeDragAndDrop.dragEnter(0, x, y, textContent("hi"), NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + + // Every representation failed to be read: a transferable that threw, or a one-shot + // stream already spent. What arrives is a payload with nothing in it. + int accepted = NativeDragAndDrop.drop(0, x, y, new ClipboardContent(), + NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + + assertEquals(NativeDragOperation.ACTION_NONE, accepted, + "the source is told the transfer did not happen, because it did not"); + assertFalse(target.events.contains("drop"), + "and a target that takes anything is not handed nothing and told it was a drop"); + assertTrue(target.events.contains("exit"), + "the component that was hovering still hears that the drag left it"); + } + + @FormTest + void aDropWhoseTargetMovedIsStillDeliveredToIt() { + Form form = Display.getInstance().getCurrent(); + DropRecorder target = addTarget(form); + int x = target.getAbsoluteX() + 5; + int y = target.getAbsoluteY() + 5; + + NativeDragAndDrop.dragEnter(0, x, y, textContent("hi"), NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + + // The tree is rebuilt while a slow item provider is still loading: the component that + // accepted the drag is still there, but no longer where the release happened. + form.removeComponent(target); + Container filler = new Container(); + form.add(BorderLayout.CENTER, filler); + Container elsewhere = new Container(); + elsewhere.setNativeDropTarget(false); + form.add(BorderLayout.SOUTH, elsewhere); + form.revalidate(); + // Put it back somewhere the release point does not reach. + form.add(BorderLayout.NORTH, target); + target.setPreferredSize(new com.codename1.ui.geom.Dimension(1, 1)); + form.revalidate(); + + NativeDragAndDrop.drop(0, x, y, textContent("hi"), NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + + assertTrue(target.events.contains("drop"), + "the component that accepted this drag is still the one that accepted it; the " + + "payload goes to it rather than on the floor because the tree moved " + + "while the providers were loading"); + assertNotNull(target.dropped); + } + + @FormTest + void aTargetThatStoppedTakingPointerEventsIsNotHandedTheDelayedDrop() { + Form form = Display.getInstance().getCurrent(); + DropRecorder target = addTarget(form); + int x = target.getAbsoluteX() + 5; + int y = target.getAbsoluteY() + 5; + + NativeDragAndDrop.dragEnter(0, x, y, textContent("hi"), NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + assertEquals("[enter]", target.events.toString()); + + // Opted out while a slow item provider was still loading, so by the time the drop + // arrives the walk no longer reaches it -- and neither may the fallback that answers + // when the walk finds nothing. + target.setIgnorePointerEvents(true); + NativeDragAndDrop.drop(0, x, y, textContent("hi"), NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + + assertFalse(target.events.contains("drop"), + "a component that stopped taking pointer events is not a drop target, however " + + "recently it was hovering"); + assertNull(target.dropped); + } + + @FormTest + void aDropThatLandsElsewhereTellsTheComponentItLeft() { + Form form = Display.getInstance().getCurrent(); + DropRecorder left = new DropRecorder(); + DropRecorder landed = new DropRecorder(); + left.setNativeDropTarget(true); + landed.setNativeDropTarget(true); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.NORTH, left); + form.add(BorderLayout.SOUTH, landed); + left.setPreferredSize(new com.codename1.ui.geom.Dimension(40, 40)); + landed.setPreferredSize(new com.codename1.ui.geom.Dimension(40, 40)); + form.revalidate(); + + NativeDragAndDrop.dragEnter(0, left.getAbsoluteX() + 5, left.getAbsoluteY() + 5, + textContent("hi"), NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + assertEquals("[enter]", left.events.toString()); + + // Moved and released in one go, so the release resolves somewhere the drag never + // hovered. + NativeDragAndDrop.drop(0, landed.getAbsoluteX() + 5, landed.getAbsoluteY() + 5, + textContent("hi"), NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + + assertEquals("[enter, exit]", left.events.toString(), + "the drag ended for the component it was over, and nothing else can tell it: " + + "the port's own cleanup finds the target already cleared"); + assertEquals("[drop]", landed.events.toString()); + } + + @FormTest + void anEntryAfterASessionThatNeverExitedIsStillAnEntry() { + Form form = Display.getInstance().getCurrent(); + DropRecorder target = addTarget(form); + int x = target.getAbsoluteX() + 5; + int y = target.getAbsoluteY() + 5; + + // A session that refuses and then ends without ever leaving the component. Ports have + // paths that reach neither drop() nor dragExit() -- an Android drop the target refused, + // an iOS session cancelled inside the surface -- so the framework can be left hovering. + target.rejectAction = NativeDragOperation.ACTION_NONE; + NativeDragAndDrop.dragEnter(0, x, y, textContent("first"), NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + assertEquals("[enter]", target.events.toString()); + + // The next session enters the same component. + target.rejectAction = -1; + target.events.clear(); + int answer = NativeDragAndDrop.dragEnter(0, x, y, textContent("second"), + NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + + assertEquals("[exit, enter]", target.events.toString(), + "an entry is an entry: routed as a move over a target left behind by the last " + + "session, the component never hears about the new drag at all"); + assertEquals(NativeDragOperation.ACTION_COPY, answer, + "and it must not inherit the refusal the ended session left, which is never " + + "recomputed because a refusal is a decision"); + + NativeDragAndDrop.dragExit(0); + flushSerialCalls(); + } + + /// A form that leaves the "grab a moving list" press to the dragStopFlag recovery, which + /// is what `Form#resumeDragAfterScrolling(int, int)` documents overriding it for. + private static final class NoResumeForm extends Form { + @Override + protected void initGlobalToolbar() { + } + + @Override + protected boolean resumeDragAfterScrolling(int x, int y) { + return false; + } + } + + @FormTest + void grabbingAScrollingContainerStopsItRatherThanDraggingOut() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + NoResumeForm form = new NoResumeForm(); + try { + Container scroller = new Container(new BorderLayout()); + scroller.setScrollableY(true); + Container source = new Container(); + source.setNativeDragOperation(new NativeDragOperation("row")); + scroller.add(BorderLayout.CENTER, source); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, scroller); + form.show(); + flushSerialCalls(); + + // A glide in progress, which is what makes this press a "stop the scroll" press: + // Form defers the real pointerPressed to the first drag packet. + scroller.draggedMotionY = Motion.createLinearMotion(0, 100, 1000); + scroller.draggedMotionY.start(); + + int x = source.getAbsoluteX() + 5; + int y = source.getAbsoluteY() + 5; + form.pointerPressed(x, y); + drag(form, x + 200, y + 200); + assertNull(implementation.getStartedNativeDrag(), + "grabbing a moving list stops it; handing the row to the operating system " + + "on that first packet makes the list impossible to stop"); + + // The glide is over, and a deliberate drag from here still starts one. + scroller.draggedMotionY = null; + drag(form, x + 400, y + 400); + assertNotNull(implementation.getStartedNativeDrag(), + "and the feature still works once the scroll has been taken over"); + + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + } finally { + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_NONE); + flushSerialCalls(); + } + } + + @FormTest + void aDropAssembledLateReportsItsOwnActionsRatherThanTheNewDragS() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + try { + Form form = Display.getInstance().getCurrent(); + DropRecorder hovered = new DropRecorder(); + DropRecorder target = new DropRecorder(); + hovered.setNativeDropTarget(true); + target.setNativeDropTarget(true); + hovered.setPreferredSize(new com.codename1.ui.geom.Dimension(40, 40)); + target.setPreferredSize(new com.codename1.ui.geom.Dimension(40, 40)); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.NORTH, hovered); + form.add(BorderLayout.SOUTH, target); + form.revalidate(); + final NativeDropEvent[] seen = { null }; + target.addNativeDropListener(new com.codename1.ui.events.ActionListener() { + public void actionPerformed(ActionEvent ev) { + if (ev.getEventType() == ActionEvent.Type.NativeDrop) { + seen[0] = (NativeDropEvent) ev; + } + } + }); + + // A move-only drag of our own is hovering elsewhere by the time a copy-only drop + // that arrived from another application finishes loading. It has overwritten what + // the framework remembers of the earlier one. + NativeDragAndDrop.dragEnter(0, hovered.getAbsoluteX() + 5, hovered.getAbsoluteY() + 5, + textContent("ours"), NativeDragOperation.ACTION_MOVE); + flushSerialCalls(); + NativeDragAndDrop.drop(0, target.getAbsoluteX() + 5, target.getAbsoluteY() + 5, + textContent("theirs"), NativeDragOperation.ACTION_COPY, + NativeDragOperation.ACTION_COPY, false); + flushSerialCalls(); + + assertNotNull(seen[0]); + assertEquals(NativeDragOperation.ACTION_COPY, seen[0].getAllowedActions(), + "the drop reports what its own drag offered, not what the drag that has " + + "since started offers"); + assertEquals(NativeDragOperation.ACTION_COPY, seen[0].getAcceptedAction(), + "and the copy it is performing is accepted rather than measured against a " + + "move-only mask and refused outright"); + } finally { + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_NONE); + flushSerialCalls(); + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void aDropAssembledLateIsNotLocalJustBecauseADragIsRunning() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + try { + Form form = Display.getInstance().getCurrent(); + DropRecorder target = addTarget(form); + final Boolean[] seen = { null }; + target.addNativeDropListener(new com.codename1.ui.events.ActionListener() { + public void actionPerformed(ActionEvent ev) { + if (ev.getEventType() == ActionEvent.Type.NativeDrop) { + seen[0] = Boolean.valueOf(((NativeDropEvent) ev).isLocal()); + } + } + }); + int x = target.getAbsoluteX() + 5; + int y = target.getAbsoluteY() + 5; + + // A drag this application started, running while a drop that arrived from elsewhere + // finally finishes loading -- which is what a slow item provider does on iOS. + assertTrue(NativeDragAndDrop.startDrag(null, + new NativeDragOperation("ours, and still going"))); + NativeDragAndDrop.drop(0, x, y, textContent("theirs"), + NativeDragOperation.ACTION_COPY, NativeDragOperation.ACTION_COPY, false); + flushSerialCalls(); + + assertEquals(Boolean.FALSE, seen[0], + "the drop came from another application; asking which drag is running now " + + "answers about a different one, and a target telling reordering " + + "from importing would take foreign content as an internal move"); + } finally { + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_NONE); + flushSerialCalls(); + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void acceptingTheWholeSetIsNotAcceptingAnAction() { + Form form = Display.getInstance().getCurrent(); + DropRecorder target = addTarget(form); + int x = target.getAbsoluteX() + 5; + int y = target.getAbsoluteY() + 5; + int both = NativeDragOperation.ACTION_COPY | NativeDragOperation.ACTION_MOVE; + // accept(getAllowedActions()) is the obvious thing to write and means nothing: there is + // no agreeing to two actions at once. + target.rejectAction = both; + + NativeDragAndDrop.dragEnter(0, x, y, textContent("hi"), both); + flushSerialCalls(); + int answer = NativeDragAndDrop.dragOver(0, x, y, textContent("hi"), both); + + assertEquals(NativeDragOperation.ACTION_NONE, answer, + "a set is not an action; stored as one it reaches the ports, which each read it " + + "their own way -- the iOS mapping picks the move out of copy-or-move"); + + NativeDragAndDrop.dragExit(0); + flushSerialCalls(); + } + + @FormTest + void aGestureTheWindowHandsOverDoesNotLeaveADragStaged() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + implementation.setMultiWindowSupported(true); + Window w = new Window("holds a drag source"); + try { + Container source = new Container(); + source.setNativeDragOperation(new NativeDragOperation("behind the dialog")); + w.setLayout(new BorderLayout()); + w.add(BorderLayout.CENTER, source); + w.show(); + flushSerialCalls(); + + int x = source.getAbsoluteX() + 10; + int y = source.getAbsoluteY() + 10; + w.pointerPressed(x, y); + assertNotNull(implementation.getPreparedNativeDrag(), "the press staged one"); + + // What showing a dialog from a press handler does: the pointer changes hands and + // no release ever arrives for the gesture that was in flight. + w.pushPointerInputScope(new Container()); + w.pointerDragged(new int[]{x + 200}, new int[]{y + 200}); + + assertNull(implementation.getStartedNativeDrag(), + "the component that staged this is behind whatever took the pointer, and " + + "the native hook runs before the cancellation is looked at"); + } finally { + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_NONE); + flushSerialCalls(); + w.dispose(); + flushSerialCalls(); + implementation.setMultiWindowSupported(false); + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void aCancelledGestureDoesNotLeaveADragStaged() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + try { + Form form = Display.getInstance().getCurrent(); + Container source = new Container(); + source.setNativeDragOperation(new NativeDragOperation("staged")); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, source); + form.revalidate(); + + int x = source.getAbsoluteX() + 10; + int y = source.getAbsoluteY() + 10; + form.pointerPressed(x, y); + assertNotNull(implementation.getPreparedNativeDrag()); + + // What a platform that cancels a touch does: no release ever arrives. + NativeDragAndDrop.gestureCancelled(); + drag(form, x + 200, y + 200); + assertNull(implementation.getStartedNativeDrag(), + "the gesture the press belonged to is over, so nothing it staged may still " + + "be dragged"); + } finally { + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_NONE); + flushSerialCalls(); + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void aPressAtTheSamePixelIsStillANewPress() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + try { + Form form = Display.getInstance().getCurrent(); + Container first = new Container(); + first.setNativeDragOperation(new NativeDragOperation("the first press")); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, first); + form.revalidate(); + + int x = first.getAbsoluteX() + 10; + int y = first.getAbsoluteY() + 10; + form.pointerPressed(x, y); + + // A second press at the very same pixel, with no release or cancellation between + // them to clear what the first staged -- which is what a platform that drops a + // gesture on the floor leaves behind. The component is given a different payload + // first: identified by position, the second press inherits the first one's. + NativeDragOperation second = new NativeDragOperation("the second press"); + first.setNativeDragOperation(second); + form.pointerPressed(x, y); + drag(form, x + 200, y + 200); + + assertNotNull(implementation.getStartedNativeDrag()); + assertSame(second, implementation.getStartedNativeDrag(), + "a press is not its coordinates: the second press has its own payload"); + } finally { + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_NONE); + flushSerialCalls(); + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void aDragStartedInCodeSpendsWhatThePressStaged() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + try { + Form form = Display.getInstance().getCurrent(); + Container source = new Container(); + source.setNativeDragOperation(new NativeDragOperation("staged by the press")); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, source); + form.revalidate(); + + int x = source.getAbsoluteX() + 10; + int y = source.getAbsoluteY() + 10; + form.pointerPressed(x, y); + assertNotNull(implementation.getPreparedNativeDrag(), "the press staged one"); + + // A long press handler starting a drag of its own, which is what this entry point + // is for. + NativeDragOperation started = new NativeDragOperation("started in code"); + assertTrue(NativeDragAndDrop.startDrag(source, started)); + + int cancelledBefore = implementation.getCancelledNativeDrags(); + form.pointerReleased(x, y); + assertEquals(cancelledBefore, implementation.getCancelledNativeDrags(), + "the release must not cancel the port's staging underneath a session that " + + "is already running"); + assertSame(started, NativeDragAndDrop.getActiveDrag(), + "and the drag started in code is still the running one"); + } finally { + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_NONE); + flushSerialCalls(); + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void aTargetCannotAcceptMoreThanItDeclared() { + Form form = Display.getInstance().getCurrent(); + DropRecorder target = addTarget(form); + // Declared copy-only, and its listener asks for a move anyway. + target.setAcceptedDropActions(NativeDragOperation.ACTION_COPY); + target.rejectAction = NativeDragOperation.ACTION_MOVE; + int x = target.getAbsoluteX() + 5; + int y = target.getAbsoluteY() + 5; + int both = NativeDragOperation.ACTION_COPY | NativeDragOperation.ACTION_MOVE; + + NativeDragAndDrop.dragEnter(0, x, y, textContent("hi"), both); + flushSerialCalls(); + int answer = NativeDragAndDrop.dragOver(0, x, y, textContent("hi"), both); + + assertEquals(NativeDragOperation.ACTION_NONE, answer, + "a move is the source deleting its copy, and this target said it does not do " + + "moves -- honouring the listener over the declaration that made the " + + "component eligible would destroy data on its word"); + + NativeDragAndDrop.dragExit(0); + flushSerialCalls(); + } + + @FormTest + void theDropEventReportsWhatTheSourceAllowedAndWhatIsHappening() { + Form form = Display.getInstance().getCurrent(); + DropRecorder target = addTarget(form); + final NativeDropEvent[] seen = { null }; + target.addNativeDropListener(new com.codename1.ui.events.ActionListener() { + public void actionPerformed(ActionEvent ev) { + if (ev.getEventType() == ActionEvent.Type.NativeDrop) { + seen[0] = (NativeDropEvent) ev; + } + } + }); + int x = target.getAbsoluteX() + 5; + int y = target.getAbsoluteY() + 5; + int both = NativeDragOperation.ACTION_COPY | NativeDragOperation.ACTION_MOVE; + + // The source offers both and the target asks for the move, which is the case the + // question is about: the mask and the choice are different answers. + target.rejectAction = NativeDragOperation.ACTION_MOVE; + NativeDragAndDrop.dragEnter(0, x, y, textContent("hi"), both); + flushSerialCalls(); + NativeDragAndDrop.drop(0, x, y, textContent("hi"), NativeDragOperation.ACTION_MOVE); + flushSerialCalls(); + + assertNotNull(seen[0], "the drop has to reach the target for any of this to be asked"); + assertEquals(both, seen[0].getAllowedActions(), + "getAllowedActions is what the *source* permits, and reporting the chosen " + + "action there makes a copy-or-move source look move-only"); + assertEquals(NativeDragOperation.ACTION_MOVE, seen[0].getAcceptedAction(), + "and the action being performed is the one that was chosen, not the copy a " + + "source allowing both would default to"); + } + + @FormTest + void aSecondFingerIsAPinchRatherThanADragToHandOver() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + try { + Form form = Display.getInstance().getCurrent(); + Container source = new Container(); + source.setNativeDragOperation(new NativeDragOperation("row")); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, source); + form.revalidate(); + + int x = source.getAbsoluteX() + 10; + int y = source.getAbsoluteY() + 10; + form.pointerPressed(x, y); + form.pointerDragged(new int[]{x + 200, x + 260}, new int[]{y + 200, y + 40}); + assertNull(implementation.getStartedNativeDrag(), + "two pointers are a pinch or a two-finger scroll, not something to hand to " + + "the operating system as a drag"); + + form.pointerReleased(x + 200, y + 200); + } finally { + // As everywhere else here: a session left active wedges every test after this one, + // so a failure has to be reported as one failure. + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_NONE); + flushSerialCalls(); + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void theScalarDragOverloadStartsADragToo() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + try { + Form form = Display.getInstance().getCurrent(); + Container source = new Container(); + source.setNativeDragOperation(new NativeDragOperation("row")); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, source); + form.revalidate(); + + int x = source.getAbsoluteX() + 10; + int y = source.getAbsoluteY() + 10; + form.pointerPressed(x, y); + // Not the overload the ports drive, but public API an application may call, and the + // two must not diverge again. + form.pointerDragged(x + 200, y + 200); + assertNotNull(implementation.getStartedNativeDrag()); + } finally { + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_NONE); + flushSerialCalls(); + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void aClickOnANativeDragSourceDragsNothing() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + try { + Form form = Display.getInstance().getCurrent(); + Container source = new Container(); + source.setNativeDragOperation(new NativeDragOperation("dragged out")); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, source); + form.revalidate(); + + int x = source.getAbsoluteX() + 10; + int y = source.getAbsoluteY() + 10; + form.pointerPressed(x, y); + form.pointerReleased(x, y); + assertNull(implementation.getStartedNativeDrag()); + assertEquals(1, implementation.getCancelledNativeDrags(), + "the staged payload is dropped, so a later gesture elsewhere cannot start this drag"); + } finally { + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void aPlatformThatOwnsTheGestureKeepsTheStagedOperationUntilItStarts() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + // startNativeDrag refuses, which is what a port whose operating system owns the drag + // gesture looks like: iOS starts the session from its own long press and announces it + // afterwards through dragSessionStarted(). + implementation.setNativeDragStartRefused(true); + try { + Form form = Display.getInstance().getCurrent(); + Container source = new Container(); + NativeDragOperation op = new NativeDragOperation("dragged out"); + source.setNativeDragOperation(op); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, source); + form.revalidate(); + + int x = source.getAbsoluteX() + 10; + int y = source.getAbsoluteY() + 10; + form.pointerPressed(x, y); + drag(form, x + 200, y + 200); + assertNull(NativeDragAndDrop.getActiveDrag(), + "the port refused, so no session is running yet"); + // A second drag packet must not offer the same gesture again. + drag(form, x + 220, y + 220); + + assertSame(op, NativeDragAndDrop.dragSessionStarted(), + "the staged operation is still there for the platform's own recognizer"); + assertSame(op, NativeDragAndDrop.getActiveDrag()); + assertNull(NativeDragAndDrop.dragSessionStarted(), + "and it is only handed over once"); + + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + assertEquals(NativeDragOperation.ACTION_COPY, op.getPerformedAction()); + } finally { + implementation.setNativeDragStartRefused(false); + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_NONE); + } + } + + @FormTest + void anOperationThatPermitsNothingNeverBecomesADrag() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + try { + Form form = Display.getInstance().getCurrent(); + Container source = new Container(); + NativeDragOperation op = new NativeDragOperation("nothing may be done with me") + .setAllowedActions(NativeDragOperation.ACTION_NONE); + source.setNativeDragOperation(op); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, source); + form.revalidate(); + + int x = source.getAbsoluteX() + 10; + int y = source.getAbsoluteY() + 10; + form.pointerPressed(x, y); + drag(form, x + 200, y + 200); + assertNull(implementation.getStartedNativeDrag(), + "no receiver could accept it, so there is no drag to run"); + assertFalse(NativeDragAndDrop.startDrag(source, op), + "and asking for one directly is refused on the same terms"); + assertNull(NativeDragAndDrop.dragSessionStarted(), + "a platform whose own recognizer fires later gets the same answer -- a " + + "session that can never complete would wedge every drag after it"); + assertNull(NativeDragAndDrop.getActiveDrag()); + + form.pointerReleased(x + 200, y + 200); + } finally { + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void aDeferredSessionGivesTheSourceBackTheVisibilityTheLightweightDragTook() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + // The port refuses the start, so the gesture carries on as a lightweight drag -- which + // hides the source and carries its image -- until the platform's own recognizer fires. + implementation.setNativeDragStartRefused(true); + try { + Form form = Display.getInstance().getCurrent(); + Container source = new Container(); + source.setDraggable(true); + NativeDragOperation op = new NativeDragOperation("dragged out"); + source.setNativeDragOperation(op); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, source); + form.revalidate(); + + int x = source.getAbsoluteX() + 10; + int y = source.getAbsoluteY() + 10; + form.pointerPressed(x, y); + drag(form, x + 200, y + 200); + assertFalse(source.isVisible(), + "the lightweight drag took the gesture and hid the source it is carrying"); + + assertSame(op, NativeDragAndDrop.dragSessionStarted()); + flushSerialCalls(); + assertTrue(source.isVisible(), + "the native session draws its own preview and never runs the lightweight " + + "drop, so nothing else would ever make the source visible again"); + + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + } finally { + implementation.setNativeDragStartRefused(false); + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_NONE); + } + } + + @FormTest + void aDraggedChildGetsItsVisibilityBackWhenItsAncestorIsTheSource() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + try { + Form form = Display.getInstance().getCurrent(); + // A drag source is found by walking up from the press, so pressing the draggable + // child stages the ancestor. The lightweight drag that a small motion starts still + // belongs to the child. + Container source = new Container(new BorderLayout()); + source.setNativeDragOperation(new NativeDragOperation("the ancestor is the source")); + Container child = new Container(); + child.setDraggable(true); + source.add(BorderLayout.CENTER, child); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, source); + form.revalidate(); + + int x = child.getAbsoluteX() + 5; + int y = child.getAbsoluteY() + 5; + form.pointerPressed(x, y); + drag(form, x + 1, y + 1); + assertFalse(child.isVisible(), + "a motion too small to be a native drag starts the child's lightweight one, " + + "which hides the child while it carries its image"); + assertSame(child, form.getDraggedComponent()); + + drag(form, x + 200, y + 200); + assertSame(source, implementation.getStartedNativeDrag().getSource()); + assertTrue(child.isVisible(), + "the operating system owns the gesture now and no lightweight drop will " + + "ever run, so cancelling only the source left the child hidden"); + assertNull(form.getDraggedComponent()); + + NativeDragAndDrop.dragCompleted(NativeDragOperation.ACTION_COPY); + flushSerialCalls(); + } finally { + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void aReleaseAfterARefusedStartDoesNotLeaveTheDragArmed() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(true); + implementation.setNativeDragStartRefused(true); + try { + Form form = Display.getInstance().getCurrent(); + Container source = new Container(); + source.setNativeDragOperation(new NativeDragOperation("dragged out")); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, source); + form.revalidate(); + + int x = source.getAbsoluteX() + 10; + int y = source.getAbsoluteY() + 10; + form.pointerPressed(x, y); + drag(form, x + 200, y + 200); + form.pointerReleased(x + 200, y + 200); + assertNull(NativeDragAndDrop.dragSessionStarted(), + "a gesture that ended cannot be turned into a drag by a later recognizer"); + } finally { + implementation.setNativeDragStartRefused(false); + implementation.setNativeDragAndDropSupported(false); + implementation.resetNativeDragState(); + } + } + + @FormTest + void withoutPlatformSupportTheGestureIsLeftAlone() { + implementation.resetNativeDragState(); + implementation.setNativeDragAndDropSupported(false); + Form form = Display.getInstance().getCurrent(); + Container source = new Container(); + source.setNativeDragOperation(new NativeDragOperation("dragged out")); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, source); + form.revalidate(); + + int x = source.getAbsoluteX() + 10; + int y = source.getAbsoluteY() + 10; + form.pointerPressed(x, y); + drag(form, x + 200, y + 200); + assertNull(implementation.getPreparedNativeDrag()); + assertNull(implementation.getStartedNativeDrag()); + form.pointerReleased(x + 200, y + 200); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/JavaSENativeDragAndDropTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSENativeDragAndDropTest.java new file mode 100644 index 00000000000..4420251f3fe --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSENativeDragAndDropTest.java @@ -0,0 +1,476 @@ +/* + * 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 Oracle 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.codename1.impl.javase; + +import com.codename1.ui.ClipboardContent; +import com.codename1.ui.ClipboardDataProvider; +import com.codename1.ui.NativeDragOperation; +import org.junit.jupiter.api.Test; + +import java.awt.datatransfer.DataFlavor; +import java.awt.datatransfer.Transferable; +import java.awt.datatransfer.UnsupportedFlavorException; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The two conversions that decide whether a desktop drag lands correctly: the + * {@code ClipboardContent} the application offers becomes an AWT transferable other applications + * understand, and the transferable another application drops becomes a {@code ClipboardContent}. + * + *

These run headless -- they never open a window or start a real drag -- because everything + * platform specific about the drag is AWT's, while everything that can be wrong is in the + * mapping.

+ */ +class JavaSENativeDragAndDropTest { + + private static DataFlavor flavorFor(Transferable t, String mime) { + for (DataFlavor f : t.getTransferDataFlavors()) { + if (f.isMimeTypeEqual(mime)) { + return f; + } + } + return null; + } + + /** A transferable that serves fixed values, standing in for another application's drag. */ + private static final class FakeTransferable implements Transferable { + private final List flavors = new ArrayList(); + private final List values = new ArrayList(); + int reads; + + FakeTransferable add(DataFlavor flavor, Object value) { + flavors.add(flavor); + values.add(value); + return this; + } + + public DataFlavor[] getTransferDataFlavors() { + return flavors.toArray(new DataFlavor[flavors.size()]); + } + + public boolean isDataFlavorSupported(DataFlavor flavor) { + return flavors.contains(flavor); + } + + public Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorException, IOException { + int index = flavors.indexOf(flavor); + if (index < 0) { + throw new UnsupportedFlavorException(flavor); + } + reads++; + return values.get(index); + } + } + + // ------------------------------------------------------------------------------------ + // Dragging out + // ------------------------------------------------------------------------------------ + + @Test + void richTextIsOfferedInEveryFormatItWasGiven() throws Exception { + ClipboardContent content = new ClipboardContent() + .setData(ClipboardContent.MIME_TEXT, "hello") + .setData(ClipboardContent.MIME_HTML, "hello"); + Transferable t = new JavaSEPort.RichTransferable(content); + + assertTrue(t.isDataFlavorSupported(DataFlavor.stringFlavor)); + assertEquals("hello", t.getTransferData(DataFlavor.stringFlavor)); + + DataFlavor html = flavorFor(t, ClipboardContent.MIME_HTML); + assertNotNull(html, "a rich text editor asks for text/html and has to find it"); + assertEquals("hello", t.getTransferData(html)); + } + + @Test + void filesAreOfferedBothAsAFileListAndAsAUriList() throws Exception { + File a = File.createTempFile("cn1-dnd-a", ".txt"); + File b = File.createTempFile("cn1-dnd-b", ".txt"); + a.deleteOnExit(); + b.deleteOnExit(); + ClipboardContent content = new ClipboardContent() + .setData(ClipboardContent.MIME_TEXT, "two files") + .setFiles(new String[]{a.getAbsolutePath(), b.getAbsolutePath()}); + Transferable t = new JavaSEPort.RichTransferable(content); + + assertTrue(t.isDataFlavorSupported(DataFlavor.javaFileListFlavor), + "a drop on the desktop or in a file manager reads the file list flavor"); + List files = (List) t.getTransferData(DataFlavor.javaFileListFlavor); + assertEquals(2, files.size(), "a drag of several files stays a drag of several files"); + assertEquals(a.getAbsolutePath(), ((File) files.get(0)).getAbsolutePath()); + + DataFlavor uriList = flavorFor(t, ClipboardContent.MIME_URI_LIST); + assertNotNull(uriList, "GTK targets ask for text/uri-list and nothing else"); + String uris = (String) t.getTransferData(uriList); + assertTrue(uris.contains(a.toURI().toString())); + assertTrue(uris.contains(b.toURI().toString())); + } + + @Test + void aPromisedFileIsNotBuiltUntilTheDropReadsIt() throws Exception { + final int[] built = {0}; + final File promised = File.createTempFile("cn1-dnd-promise", ".txt"); + promised.deleteOnExit(); + ClipboardContent content = new ClipboardContent() + .setData(ClipboardContent.MIME_TEXT, "report") + .setDataProvider(ClipboardContent.MIME_FILE, new ClipboardDataProvider() { + public Object getClipboardData(String mimeType) { + built[0]++; + return promised.getAbsolutePath(); + } + }); + + Transferable t = new JavaSEPort.RichTransferable(content); + assertTrue(t.isDataFlavorSupported(DataFlavor.javaFileListFlavor), + "the file flavor is advertised from the MIME type alone"); + assertEquals(0, built[0], + "starting the drag must not write the file -- the user may drop it nowhere"); + + List files = (List) t.getTransferData(DataFlavor.javaFileListFlavor); + assertEquals(1, files.size()); + assertEquals(1, built[0], "the drop is what builds it"); + } + + @Test + void binaryContentIsOfferedAsAStream() throws Exception { + byte[] pdf = new byte[]{'%', 'P', 'D', 'F'}; + ClipboardContent content = new ClipboardContent() + .setData(ClipboardContent.MIME_TEXT, "doc") + .setData("application/pdf", pdf); + Transferable t = new JavaSEPort.RichTransferable(content); + + DataFlavor flavor = flavorFor(t, "application/pdf"); + assertNotNull(flavor, "an arbitrary binary payload has to reach other applications somehow"); + Object value = t.getTransferData(flavor); + assertTrue(value instanceof InputStream); + byte[] read = new byte[4]; + ((InputStream) value).read(read); + assertArrayEquals(pdf, read); + } + + @Test + void anImageEncodingNothingCanDecodeDoesNotClaimTheStandardImageFlavor() { + ClipboardContent content = new ClipboardContent() + .setData("image/webp", new byte[]{'R', 'I', 'F', 'F'}); + Transferable t = new JavaSEPort.RichTransferable(content); + + assertFalse(t.isDataFlavorSupported(DataFlavor.imageFlavor), + "a desktop receiver commonly picks the standard image flavor ahead of the " + + "MIME specific stream, so claiming it for an encoding ImageIO cannot " + + "read loses the whole drop to an UnsupportedFlavorException"); + assertNotNull(flavorFor(t, "image/webp"), + "the bytes are still perfectly readable by anything that wants that type"); + } + + @Test + void aFailingImageProviderDoesNotTakeTheOtherEncodingsWithIt() throws Exception { + java.io.ByteArrayOutputStream jpeg = new java.io.ByteArrayOutputStream(); + javax.imageio.ImageIO.write( + new java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_RGB), + "jpeg", jpeg); + ClipboardContent content = new ClipboardContent() + .setDataProvider(ClipboardContent.MIME_PNG, new ClipboardDataProvider() { + @Override + public Object getClipboardData(String mimeType) { + throw new IllegalStateException("this one cannot be produced"); + } + }) + .setData(ClipboardContent.MIME_JPEG, jpeg.toByteArray()); + Transferable t = new JavaSEPort.RichTransferable(content); + + assertTrue(t.getTransferData(DataFlavor.imageFlavor) instanceof java.awt.Image, + "a provider is allowed to fail, and one failing on the PNG must not take a " + + "perfectly good JPEG with it"); + } + + @Test + void aDecodableImageStillClaimsTheStandardImageFlavor() throws Exception { + ClipboardContent content = new ClipboardContent() + .setData(ClipboardContent.MIME_PNG, onePixelPng()); + Transferable t = new JavaSEPort.RichTransferable(content); + + assertTrue(t.isDataFlavorSupported(DataFlavor.imageFlavor)); + assertTrue(t.getTransferData(DataFlavor.imageFlavor) instanceof java.awt.Image); + } + + @Test + void theStandardImageFlavorServesAnyEncodingItWasAdvertisedFor() throws Exception { + java.io.ByteArrayOutputStream bmp = new java.io.ByteArrayOutputStream(); + javax.imageio.ImageIO.write( + new java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_RGB), + "bmp", bmp); + ClipboardContent content = new ClipboardContent().setData("image/bmp", bmp.toByteArray()); + Transferable t = new JavaSEPort.RichTransferable(content); + + assertTrue(t.isDataFlavorSupported(DataFlavor.imageFlavor), + "ImageIO reads BMP, so the flavor is advertised"); + assertTrue(t.getTransferData(DataFlavor.imageFlavor) instanceof java.awt.Image, + "and what is advertised has to be servable -- reading only the three encodings " + + "the framework names left this one throwing"); + } + + private static byte[] onePixelPng() throws IOException { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + javax.imageio.ImageIO.write( + new java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_ARGB), + "png", out); + return out.toByteArray(); + } + + @Test + void anUnofferedFlavorIsRefusedRatherThanAnsweredWithNull() { + ClipboardContent content = new ClipboardContent().setData(ClipboardContent.MIME_TEXT, "hi"); + final Transferable t = new JavaSEPort.RichTransferable(content); + assertThrows(UnsupportedFlavorException.class, + () -> t.getTransferData(DataFlavor.javaFileListFlavor)); + } + + // ------------------------------------------------------------------------------------ + // Receiving a drop + // ------------------------------------------------------------------------------------ + + @Test + void describingADragInProgressReadsNoData() { + FakeTransferable t = new FakeTransferable() + .add(DataFlavor.stringFlavor, "hello") + .add(DataFlavor.javaFileListFlavor, Arrays.asList(new File("/tmp/a.txt"))); + + ClipboardContent content = JavaSENativeDragAndDrop.contentFor(t, t.getTransferDataFlavors(), false); + assertTrue(content.hasMimeType(ClipboardContent.MIME_TEXT)); + assertTrue(content.hasMimeType(ClipboardContent.MIME_FILE)); + assertEquals(0, t.reads, + "a drag merely passing over the window must not pull data across; on several " + + "platforms the data does not exist until the drop"); + } + + @Test + void aTextFlavorCarriedAsBytesStillReadsAsText() throws Exception { + DataFlavor htmlBytes = new DataFlavor("text/html;charset=UTF-8;class=\"[B\""); + FakeTransferable t = new FakeTransferable() + .add(DataFlavor.stringFlavor, "plain") + .add(htmlBytes, "hi".getBytes("UTF-8")); + + ClipboardContent content = JavaSENativeDragAndDrop.contentFor(t, t.getTransferDataFlavors(), true); + assertEquals("hi", content.getText(ClipboardContent.MIME_HTML), + "the flavor declares its own charset; storing its bytes as a binary payload " + + "made getText() null for a type the drop had just accepted"); + } + + @Test + void copyingDoesNotBuildAPromisedRepresentation() { + final int[] built = { 0 }; + ClipboardContent content = new ClipboardContent() + .setDataProvider(ClipboardContent.MIME_TEXT, new ClipboardDataProvider() { + @Override + public Object getClipboardData(String mimeType) { + built[0]++; + return "expensive"; + } + }); + + // Constructing a port overwrites the global JavaSEPort.instance, and other test classes + // reach through that static to drive the live Display -- so it goes back exactly as it + // was. JavaSEPortFontMappingTest documents the same hazard. + JavaSEPort previous = JavaSEPort.instance; + try { + new JavaSEPort().copyToClipboard(content); + } catch (Throwable headlessOrUninitialised) { + // The clipboard itself is not reachable from a test JVM. What is under test happens + // before that: whether putting the content on the clipboard reads it. + } finally { + JavaSEPort.instance = previous; + } + + assertEquals(0, built[0], + "a representation registered as a provider is built when a consumer reads it, " + + "not when something is copied -- writing the file or encoding the " + + "image is exactly what deferring it is for"); + } + + @Test + void aStreamedTextFlavorIsReadOnlyOnce() throws Exception { + DataFlavor htmlStream = new DataFlavor("text/html;charset=UTF-8;class=java.io.InputStream"); + FakeTransferable t = new FakeTransferable() + .add(htmlStream, new ByteArrayInputStream("once".getBytes("UTF-8"))); + + ClipboardContent content = JavaSENativeDragAndDrop.contentFor(t, t.getTransferDataFlavors(), true); + assertEquals("once", content.getText(ClipboardContent.MIME_HTML)); + assertEquals(1, t.reads, + "a source that produces its stream once loses the representation on the second " + + "ask, and one that produces a fresh stream transfers everything twice"); + } + + @Test + void aTextFlavorCarriedAsAStreamStillReadsAsText() throws Exception { + DataFlavor htmlStream = new DataFlavor("text/html;charset=UTF-16;class=java.io.InputStream"); + FakeTransferable t = new FakeTransferable() + .add(htmlStream, new ByteArrayInputStream("x".getBytes("UTF-16"))); + + ClipboardContent content = JavaSENativeDragAndDrop.contentFor(t, t.getTransferDataFlavors(), true); + assertEquals("x", content.getText(ClipboardContent.MIME_HTML), + "and a charset that is not UTF-8 has to be honoured rather than assumed"); + } + + @Test + void aDroppedFileListBecomesFilePaths() { + FakeTransferable t = new FakeTransferable() + .add(DataFlavor.javaFileListFlavor, + Arrays.asList(new File("/tmp/a.txt"), new File("/tmp/b.txt"))); + + ClipboardContent content = JavaSENativeDragAndDrop.contentFor(t, t.getTransferDataFlavors(), true); + assertArrayEquals(new String[]{"/tmp/a.txt", "/tmp/b.txt"}, content.getFiles()); + } + + @Test + void aLinkDraggedFromABrowserIsNotAFileDrag() throws Exception { + DataFlavor uriList = new DataFlavor("text/uri-list;class=java.lang.String"); + FakeTransferable t = new FakeTransferable() + .add(uriList, "https://www.codenameone.com/\r\n"); + + ClipboardContent hovering = JavaSENativeDragAndDrop.contentFor(t, t.getTransferDataFlavors(), false); + assertFalse(hovering.hasMimeType(ClipboardContent.MIME_FILE), + "a URI list is not a file list, and a file-only target that lights up for one " + + "is refused the drop it was promised"); + + ClipboardContent dropped = JavaSENativeDragAndDrop.contentFor(t, t.getTransferDataFlavors(), true); + assertFalse(dropped.hasMimeType(ClipboardContent.MIME_FILE), + "and the drop agrees with the hover, which is the whole point"); + } + + @Test + void hoveringDoesNotConsumeAStreamedUriList() throws Exception { + DataFlavor uriStream = new DataFlavor("text/uri-list;class=java.io.InputStream"); + FakeTransferable t = new FakeTransferable() + .add(uriStream, new ByteArrayInputStream( + (new File("/tmp/a.txt").toURI() + "\r\n").getBytes("UTF-8"))); + + JavaSENativeDragAndDrop.contentFor(t, t.getTransferDataFlavors(), false); + JavaSENativeDragAndDrop.contentFor(t, t.getTransferDataFlavors(), false); + assertEquals(0, t.reads, + "the description is rebuilt for every drag event, so reading a one-shot source " + + "to classify it spends it on the first hover and leaves the drop with " + + "nothing"); + } + + @Test + void aFileUriListIsStillAFileDragWhileItHovers() throws Exception { + DataFlavor uriList = new DataFlavor("text/uri-list;class=java.lang.String"); + FakeTransferable t = new FakeTransferable() + .add(uriList, new File("/tmp/a.txt").toURI() + "\r\n"); + + ClipboardContent hovering = JavaSENativeDragAndDrop.contentFor(t, t.getTransferDataFlavors(), false); + assertTrue(hovering.hasMimeType(ClipboardContent.MIME_FILE), + "a Linux file manager offers only this spelling, and a file target has to be " + + "able to accept it while the drag is still hovering"); + } + + @Test + void aDroppedFileListAlsoBecomesAUriList() { + FakeTransferable t = new FakeTransferable() + .add(DataFlavor.javaFileListFlavor, Arrays.asList(new File("/tmp/a.txt"), new File("/tmp/b.txt"))); + + ClipboardContent content = JavaSENativeDragAndDrop.contentFor(t, t.getTransferDataFlavors(), true); + String uris = content.getText(ClipboardContent.MIME_URI_LIST); + assertNotNull(uris, "the Finder and Explorer offer only javaFileListFlavor, so a target " + + "filtered to text/uri-list refused the one source every desktop user has"); + assertTrue(uris.contains("file:/"), uris); + assertTrue(uris.contains("a.txt") && uris.contains("b.txt"), uris); + } + + @Test + void describingAFileDragDeclaresTheUriListWithoutReadingIt() { + FakeTransferable t = new FakeTransferable() + .add(DataFlavor.javaFileListFlavor, Arrays.asList(new File("/tmp/a.txt"))); + + ClipboardContent content = JavaSENativeDragAndDrop.contentFor(t, t.getTransferDataFlavors(), false); + assertTrue(content.hasMimeType(ClipboardContent.MIME_URI_LIST), + "a hover is filtered against the advertised types, so the pair has to be " + + "declared before the drop as well"); + assertEquals(0, t.reads, "and declaring it must still read nothing"); + } + + @Test + void aDroppedUriListAlsoBecomesFilePaths() throws Exception { + DataFlavor uriList = new DataFlavor(ClipboardContent.MIME_URI_LIST + ";class=java.lang.String", + ClipboardContent.MIME_URI_LIST); + FakeTransferable t = new FakeTransferable() + .add(uriList, new File("/tmp/a.txt").toURI() + "\r\n" + new File("/tmp/b.txt").toURI() + "\r\n"); + + ClipboardContent content = JavaSENativeDragAndDrop.contentFor(t, t.getTransferDataFlavors(), true); + assertArrayEquals(new String[]{"/tmp/a.txt", "/tmp/b.txt"}, content.getFiles(), + "a drop out of a Linux file manager only offers uri-list, and still has to yield files"); + } + + @Test + void theRichestRepresentationOfOneMimeTypeWins() throws Exception { + DataFlavor htmlString = new DataFlavor(ClipboardContent.MIME_HTML + ";class=java.lang.String", + ClipboardContent.MIME_HTML); + DataFlavor htmlStream = new DataFlavor(ClipboardContent.MIME_HTML + ";class=java.io.InputStream", + ClipboardContent.MIME_HTML); + FakeTransferable t = new FakeTransferable() + .add(htmlString, "first") + .add(htmlStream, new ByteArrayInputStream("second".getBytes("UTF-8"))); + + ClipboardContent content = JavaSENativeDragAndDrop.contentFor(t, t.getTransferDataFlavors(), true); + assertEquals("first", content.getText(ClipboardContent.MIME_HTML), + "AWT lists flavors in the source's preference order, so the first one is the answer"); + } + + @Test + void actionsMapBothWays() { + assertEquals(NativeDragOperation.ACTION_COPY, + JavaSENativeDragAndDrop.fromAwtActions( + JavaSENativeDragAndDrop.toAwtActions(NativeDragOperation.ACTION_COPY))); + int all = NativeDragOperation.ACTION_COPY | NativeDragOperation.ACTION_MOVE + | NativeDragOperation.ACTION_LINK; + assertEquals(all, JavaSENativeDragAndDrop.fromAwtActions(JavaSENativeDragAndDrop.toAwtActions(all))); + assertEquals(NativeDragOperation.ACTION_COPY, JavaSENativeDragAndDrop.preferred(all), + "a copy is preferred because it cannot destroy the source's data"); + assertEquals(NativeDragOperation.ACTION_NONE, + JavaSENativeDragAndDrop.preferred(NativeDragOperation.ACTION_NONE)); + } + + @Test + void theDragPreviewIsScaledByTheSkinZoomAsWellAsTheDisplay() { + assertEquals(2.0, JavaSENativeDragAndDrop.overlayScale(false, 2.0, 1), 0.0001, + "with no skin the backing scale is the whole of it"); + assertEquals(2.0, JavaSENativeDragAndDrop.overlayScale(false, 2.0, 4), 0.0001, + "and the zoom belongs to the skin, so without one it means nothing"); + assertEquals(1.0, JavaSENativeDragAndDrop.overlayScale(true, 2.0, 2), 0.0001, + "a skin zoomed to 2 on a display backed at 2 draws one Codename One pixel per " + + "point, so the preview is already the size AWT wants"); + assertEquals(4.0, JavaSENativeDragAndDrop.overlayScale(true, 2.0, 0.5f), 0.0001, + "and a skin at half size holds twice as many of them again"); + assertEquals(2.0, JavaSENativeDragAndDrop.overlayScale(true, 2.0, 0), 0.0001, + "a zoom of zero is not a scale anything can be divided by"); + } +} diff --git a/scripts/cast-semantics-baseline.txt b/scripts/cast-semantics-baseline.txt index 5a68c973a08..91529f6e27c 100644 --- a/scripts/cast-semantics-baseline.txt +++ b/scripts/cast-semantics-baseline.txt @@ -20,7 +20,6 @@ com/codename1/impl/android/AndroidBluetooth#manager()Landroid/bluetooth/Bluetoot com/codename1/impl/android/AndroidCameraImpl#createPreviewPeer()Lcom/codename1/ui/PeerComponent;|cast to android.view.View inside catch(java.lang.Throwable) com/codename1/impl/android/AndroidCameraImpl#onImageProxy(Ljava/lang/Object;)V|cast to android.media.Image inside catch(java.lang.Throwable) com/codename1/impl/android/AndroidCameraImpl#onImageProxy(Ljava/lang/Object;)V|cast to java.lang.Integer inside catch(java.lang.Throwable) -com/codename1/impl/android/AndroidDB#execute(Ljava/lang/String;[Ljava/lang/Object;)V|cast to [B inside catch(java.lang.Exception) com/codename1/impl/android/AndroidImplementation#acquirePushWakeLock(J)V|cast to android.os.PowerManager inside catch(java.lang.Exception) com/codename1/impl/android/AndroidImplementation#cancelBackgroundProcessing(Ljava/lang/String;)V|cast to android.app.job.JobScheduler inside catch(java.lang.Throwable) com/codename1/impl/android/AndroidImplementation#cancelBackgroundWork(Ljava/lang/String;)V|cast to android.app.job.JobScheduler inside catch(java.lang.Throwable) @@ -57,8 +56,7 @@ com/codename1/impl/android/AndroidImplementation#signingCertificatesViaReflectio com/codename1/impl/android/AndroidImplementation#signingCertificatesViaReflection(Landroid/content/pm/PackageManager;Ljava/lang/String;)[Landroid/content/pm/Signature;|cast to java.lang.Boolean inside catch(java.lang.Throwable) com/codename1/impl/android/AndroidImplementation#vibrate(I)V|cast to android.os.Vibrator inside catch(java.lang.Throwable) com/codename1/impl/android/AndroidImplementation$46#onReceive(Landroid/content/Context;Landroid/content/Intent;)V|cast to android.content.ComponentName inside catch(java.lang.Throwable) -com/codename1/impl/android/AndroidImplementation$48#run()V|cast to com.codename1.ui.ClipboardContent inside catch(java.lang.Throwable) -com/codename1/impl/android/AndroidImplementation$62#invoke(Ljava/lang/Object;Ljava/lang/reflect/Method;[Ljava/lang/Object;)Ljava/lang/Object;|cast to java.lang.String inside catch(java.lang.Throwable) +com/codename1/impl/android/AndroidImplementation$63#invoke(Ljava/lang/Object;Ljava/lang/reflect/Method;[Ljava/lang/Object;)Ljava/lang/Object;|cast to java.lang.String inside catch(java.lang.Throwable) com/codename1/impl/android/AndroidImplementation$SetCurrentFormImpl#run()V|cast to [Ljava.lang.Class; inside catch(java.lang.Throwable) com/codename1/impl/android/AndroidImplementation$SetCurrentFormImpl#run()V|cast to [Ljava.lang.Object; inside catch(java.lang.Throwable) com/codename1/impl/android/AndroidImplementation$SetCurrentFormImpl#run()V|cast to android.graphics.Bitmap inside catch(java.lang.Throwable)