From 2ba2cfadd2b2fb24e758509cf569b78255dc9ec4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 24 May 2026 21:29:08 +0300 Subject: [PATCH 1/5] Add ShareResult callback API + iOS Share Extension authoring helper ShareService.share(...) and Display.share(...) can now report the outcome of a share request through a ShareResultListener: SHARED_TO(packageName), DISMISSED, or FAILED(message). iOS plumbs the result via UIActivityViewController.completionWithItemsHandler; Android plumbs the chosen target via Intent.createChooser's IntentSender callback (API 22+) and documents that Android exposes no dismissal signal. ShareButton exposes setShareResultListener(...). IOSShareExtensionBuilder generates a complete .ios.appext bundle (Info.plist with NSExtension activation rules, App Group entitlements, a ShareViewController.swift that posts the payload via UserDefaults(suiteName:), and buildSettings.properties) so apps no longer need to bootstrap the extension target in Xcode. Composes with the existing IPhoneBuilder.extractAppExtensions pipeline. Developer-guide section added under The-Components-Of-Codename-One. Unit tests cover ShareResult, ShareService delivery semantics, and the extension builder file map / zip output / validation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/codename1/components/ShareButton.java | 38 +- .../impl/CodenameOneImplementation.java | 18 + .../src/com/codename1/share/ShareResult.java | 124 +++++ .../codename1/share/ShareResultListener.java | 33 ++ .../src/com/codename1/share/ShareService.java | 44 ++ CodenameOne/src/com/codename1/ui/Display.java | 47 +- .../impl/android/AndroidImplementation.java | 81 ++- Ports/iOSPort/nativeSources/IOSNative.m | 93 ++++ .../codename1/impl/ios/IOSImplementation.java | 68 ++- .../src/com/codename1/impl/ios/IOSNative.java | 5 + .../The-Components-Of-Codename-One.asciidoc | 112 ++++ .../util/IOSShareExtensionBuilder.java | 506 ++++++++++++++++++ .../util/IOSShareExtensionBuilderTest.java | 164 ++++++ .../com/codename1/share/ShareResultTest.java | 63 +++ .../share/ShareServiceResultDeliveryTest.java | 80 +++ 15 files changed, 1463 insertions(+), 13 deletions(-) create mode 100644 CodenameOne/src/com/codename1/share/ShareResult.java create mode 100644 CodenameOne/src/com/codename1/share/ShareResultListener.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSShareExtensionBuilder.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSShareExtensionBuilderTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/share/ShareResultTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/share/ShareServiceResultDeliveryTest.java diff --git a/CodenameOne/src/com/codename1/components/ShareButton.java b/CodenameOne/src/com/codename1/components/ShareButton.java index 32a99d1e9b..6f4fff05a2 100644 --- a/CodenameOne/src/com/codename1/components/ShareButton.java +++ b/CodenameOne/src/com/codename1/components/ShareButton.java @@ -25,6 +25,8 @@ import com.codename1.share.EmailShare; import com.codename1.share.FacebookShare; import com.codename1.share.SMSShare; +import com.codename1.share.ShareResult; +import com.codename1.share.ShareResultListener; import com.codename1.share.ShareService; import com.codename1.ui.Button; import com.codename1.ui.Command; @@ -76,6 +78,7 @@ public class ShareButton extends Button implements ActionListener { private String textToShare; private String imageToShare; private String imageMimeType; + private ShareResultListener shareResultListener; /// Default constructor public ShareButton() { @@ -133,6 +136,26 @@ public void addShareService(ShareService share) { shareServices.addElement(share); } + /// Listener invoked once when the underlying share sheet (native or + /// fallback dialog) finishes. May be `null` to clear a prior + /// listener. + /// + /// On platforms that cannot observe the chosen target the listener + /// still fires with [ShareResult#sharedTo] passing a `null` package + /// name, so the app can resume its flow. + /// + /// #### Since + /// + /// 9.0 + public void setShareResultListener(ShareResultListener listener) { + this.shareResultListener = listener; + } + + /// Returns the listener registered via [#setShareResultListener] or null. + public ShareResultListener getShareResultListener() { + return shareResultListener; + } + /// invoked when the button is pressed /// /// #### Parameters @@ -145,13 +168,14 @@ public void actionPerformed(ActionEvent evt) { Display.getInstance().callSerially(new Runnable() { @Override public void run() { + final ShareResultListener listener = shareResultListener; if (Display.getInstance().isNativeShareSupported()) { Display.getInstance().share(textToShare, imageToShare, imageMimeType, new Rectangle( ShareButton.this.getAbsoluteX(), ShareButton.this.getAbsoluteY(), ShareButton.this.getWidth(), ShareButton.this.getHeight() - )); + ), listener); return; } Vector sharing; @@ -171,17 +195,27 @@ public void run() { share.setMessage(textToShare); share.setImage(imageToShare, imageMimeType); share.setOriginalForm(getComponentForm()); + share.setShareResultListener(listener); } List l = new List(sharing); l.setCommandList(true); final Dialog dialog = new Dialog("Share"); dialog.setLayout(new BorderLayout()); dialog.addComponent(BorderLayout.CENTER, l); - dialog.placeButtonCommands(new Command[]{new Command("Cancel")}); + final boolean[] picked = new boolean[1]; + dialog.placeButtonCommands(new Command[]{new Command("Cancel") { + @Override + public void actionPerformed(ActionEvent ev) { + if (!picked[0] && listener != null) { + listener.onResult(ShareResult.dismissed()); + } + } + }}); l.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent evt) { + picked[0] = true; dialog.dispose(); } }); diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index f78ed769c1..f9e020cbfb 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -53,6 +53,8 @@ import com.codename1.media.MediaRecorderBuilder; import com.codename1.messaging.Message; import com.codename1.notifications.LocalNotification; +import com.codename1.share.ShareResult; +import com.codename1.share.ShareResultListener; import com.codename1.payment.Purchase; import com.codename1.payment.PurchaseCallback; import com.codename1.push.PushCallback; @@ -7241,6 +7243,22 @@ public void share(String text, String image, String mimeType, Rectangle sourceRe } + /// Share variant that delivers an outcome through `listener`. + /// + /// The default implementation delegates to the legacy + /// [#share(String,String,String,Rectangle)] entry point and reports + /// `SHARED_TO(null)` once it returns, since this base class has no + /// way to observe the platform sheet. Ports that can observe the + /// result (iOS, Android API 22+) override this method. + /// + /// `listener` is guaranteed non-null by [com.codename1.ui.Display#share]. + public void share(String text, String image, String mimeType, Rectangle sourceRect, ShareResultListener listener) { + share(text, image, mimeType, sourceRect); + if (listener != null) { + listener.onResult(ShareResult.sharedTo(null)); + } + } + // BEGIN TRANSFORMATION METHODS--------------------------------------------------------- /// Called before internal paint of component starts diff --git a/CodenameOne/src/com/codename1/share/ShareResult.java b/CodenameOne/src/com/codename1/share/ShareResult.java new file mode 100644 index 0000000000..942d02db4d --- /dev/null +++ b/CodenameOne/src/com/codename1/share/ShareResult.java @@ -0,0 +1,124 @@ +/* + * 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. + */ +package com.codename1.share; + +/// Outcome of a share request initiated through [com.codename1.ui.Display#share] +/// or [com.codename1.components.ShareButton]. +/// +/// Status semantics: +/// +/// - `SHARED_TO`: the user picked a target and the system accepted the +/// share. `getPackageName()` returns the chosen target's identifier: +/// the Android package name (e.g. `com.whatsapp`) or, on iOS, +/// the `UIActivityType` (e.g. `com.apple.UIKit.activity.PostToTwitter`). +/// - `DISMISSED`: the user cancelled the share sheet without picking a +/// target. +/// - `FAILED`: the share could not be completed. `getError()` may carry a +/// short, platform-supplied description (no stack traces). +/// +/// Instances are immutable. Construct through the static factories. +public final class ShareResult { + + /// Status of a [ShareResult]. + public static final int STATUS_SHARED_TO = 1; + + /// User dismissed/cancelled the share sheet. + public static final int STATUS_DISMISSED = 2; + + /// Share could not be completed. + public static final int STATUS_FAILED = 3; + + private final int status; + private final String packageName; + private final String error; + + private ShareResult(int status, String packageName, String error) { + this.status = status; + this.packageName = packageName; + this.error = error; + } + + /// Build a `SHARED_TO` result. + /// + /// `packageName` is the platform-specific identifier of the chosen + /// target (Android package name or iOS `UIActivityType`). It may be + /// `null` when the platform does not expose the selection (older + /// Android versions, web share API). + public static ShareResult sharedTo(String packageName) { + return new ShareResult(STATUS_SHARED_TO, packageName, null); + } + + /// Build a `DISMISSED` result. + public static ShareResult dismissed() { + return new ShareResult(STATUS_DISMISSED, null, null); + } + + /// Build a `FAILED` result with an optional platform message. + public static ShareResult failed(String message) { + return new ShareResult(STATUS_FAILED, null, message); + } + + /// Numeric status: one of [#STATUS_SHARED_TO], [#STATUS_DISMISSED], [#STATUS_FAILED]. + public int getStatus() { + return status; + } + + /// True iff status == [#STATUS_SHARED_TO]. + public boolean isSharedTo() { + return status == STATUS_SHARED_TO; + } + + /// True iff status == [#STATUS_DISMISSED]. + public boolean isDismissed() { + return status == STATUS_DISMISSED; + } + + /// True iff status == [#STATUS_FAILED]. + public boolean isFailed() { + return status == STATUS_FAILED; + } + + /// Chosen target identifier when the user picked a destination, otherwise null. + public String getPackageName() { + return packageName; + } + + /// Platform-supplied message when [#isFailed] is true, otherwise null. + public String getError() { + return error; + } + + @Override + public String toString() { + switch (status) { + case STATUS_SHARED_TO: + return "ShareResult{SHARED_TO " + packageName + "}"; + case STATUS_DISMISSED: + return "ShareResult{DISMISSED}"; + case STATUS_FAILED: + return "ShareResult{FAILED " + error + "}"; + default: + return "ShareResult{?}"; + } + } +} diff --git a/CodenameOne/src/com/codename1/share/ShareResultListener.java b/CodenameOne/src/com/codename1/share/ShareResultListener.java new file mode 100644 index 0000000000..91fe29a53e --- /dev/null +++ b/CodenameOne/src/com/codename1/share/ShareResultListener.java @@ -0,0 +1,33 @@ +/* + * 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. + */ +package com.codename1.share; + +/// Receives the outcome of a share request. Always invoked on the EDT. +/// +/// Pass into [com.codename1.ui.Display#share] or [com.codename1.components.ShareButton#setShareResultListener]. +public interface ShareResultListener { + + /// Called once when the share sheet finishes (target picked, dismissed, + /// or failed). Never invoked more than once for the same share request. + void onResult(ShareResult result); +} diff --git a/CodenameOne/src/com/codename1/share/ShareService.java b/CodenameOne/src/com/codename1/share/ShareService.java index ecc8f44ca3..48e2bcf4db 100644 --- a/CodenameOne/src/com/codename1/share/ShareService.java +++ b/CodenameOne/src/com/codename1/share/ShareService.java @@ -9,6 +9,8 @@ import com.codename1.ui.Image; import com.codename1.ui.events.ActionEvent; +// ShareResult / ShareResultListener live in the same package. + /// This is an abstract sharing service. /// /// @author Chen @@ -18,6 +20,8 @@ public abstract class ShareService extends Command { private String image; private String mimeType; private Form original; + private ShareResultListener shareResultListener; + private boolean resultDelivered; /// Constructor with the service name and icon /// @@ -107,6 +111,46 @@ public void finish() { if (original != null) { original.showBack(); } + if (!resultDelivered) { + deliverResult(ShareResult.sharedTo(getCommandName())); + } + } + + /// Registers a listener to be notified once when the share completes. + /// + /// Set by [com.codename1.components.ShareButton] before the service + /// is invoked. Subclasses normally do not call this directly. + /// + /// #### Since + /// + /// 9.0 + public void setShareResultListener(ShareResultListener listener) { + this.shareResultListener = listener; + this.resultDelivered = false; + } + + /// Returns the registered result listener (may be null). + public ShareResultListener getShareResultListener() { + return shareResultListener; + } + + /// Delivers a [ShareResult] to the registered listener exactly once. + /// + /// Subclasses can call this to report a `DISMISSED` (user cancelled) + /// or `FAILED` outcome. [#finish] already reports a default + /// `SHARED_TO(commandName)` if no explicit result was delivered. + /// + /// #### Since + /// + /// 9.0 + protected void deliverResult(ShareResult result) { + if (resultDelivered) { + return; + } + resultDelivered = true; + if (shareResultListener != null && result != null) { + shareResultListener.onResult(result); + } } } diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 85637188ef..f90fb3516a 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -39,6 +39,8 @@ import com.codename1.location.LocationManager; import com.codename1.security.Biometrics; import com.codename1.security.SecureStorage; +import com.codename1.share.ShareResult; +import com.codename1.share.ShareResultListener; import com.codename1.media.Media; import com.codename1.media.MediaRecorderBuilder; import com.codename1.messaging.Message; @@ -5154,7 +5156,50 @@ public void share(String text, String image, String mimeType) { /// some platforms to provide a hint as to where the share dialog overlay should pop up. Particularly, /// on the iPad with iOS 8 and higher. public void share(String textOrPath, String image, String mimeType, Rectangle sourceRect) { - impl.share(textOrPath, image, mimeType, sourceRect); + share(textOrPath, image, mimeType, sourceRect, null); + } + + /// Like [#share(String,String,String,Rectangle)] but reports the + /// outcome through `listener` on the EDT. + /// + /// `listener` may be `null`. If the underlying platform cannot report + /// the outcome (older Android, Web Share API), the listener is still + /// invoked with [ShareResult#sharedTo] passing a `null` package name + /// so the app can resume its flow. + /// + /// #### Parameters + /// + /// - `textOrPath`: String to share, or path to file to share. + /// + /// - `image`: file path to the image or null + /// + /// - `mimeType`: type of the image or file. null if just sharing text + /// + /// - `sourceRect`: source rectangle hint for the share popover. May be null. + /// + /// - `listener`: callback for the share outcome. May be null. + /// + /// #### Since + /// + /// 9.0 + public void share(String textOrPath, String image, String mimeType, Rectangle sourceRect, ShareResultListener listener) { + if (listener == null) { + impl.share(textOrPath, image, mimeType, sourceRect); + return; + } + final ShareResultListener finalListener = listener; + impl.share(textOrPath, image, mimeType, sourceRect, new ShareResultListener() { + @Override + public void onResult(final ShareResult result) { + final ShareResult r = result != null ? result : ShareResult.sharedTo(null); + callSerially(new Runnable() { + @Override + public void run() { + finalListener.onResult(r); + } + }); + } + }); } /// The localization manager allows adapting values for display in different locales thru parsing and formatting diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index b571bf6b67..79af5c945c 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -7733,6 +7733,11 @@ public boolean isNativeShareSupported() { @Override public void share(String text, String image, String mimeType, Rectangle sourceRect){ + share(text, image, mimeType, sourceRect, null); + } + + @Override + public void share(String text, String image, String mimeType, Rectangle sourceRect, final com.codename1.share.ShareResultListener listener) { /*if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to perform share")){ return; }*/ @@ -7750,7 +7755,81 @@ public void share(String text, String image, String mimeType, Rectangle sourceRe shareIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(fixAttachmentPath(image))); shareIntent.putExtra(Intent.EXTRA_TEXT, text); } - getContext().startActivity(Intent.createChooser(shareIntent, "Share with...")); + + Intent chooser; + try { + if (listener != null && android.os.Build.VERSION.SDK_INT >= 22) { + chooser = buildShareChooserWithCallback(shareIntent, listener); + } else { + chooser = Intent.createChooser(shareIntent, "Share with..."); + } + } catch (Throwable t) { + // Fall back to the plain chooser, then synthesise a listener + // result so the app does not hang on an unfulfilled callback. + chooser = Intent.createChooser(shareIntent, "Share with..."); + if (listener != null) { + listener.onResult(com.codename1.share.ShareResult.sharedTo(null)); + } + } + getContext().startActivity(chooser); + } + + private static int nextShareReceiverId = 1; + + @TargetApi(22) + private Intent buildShareChooserWithCallback(Intent shareIntent, final com.codename1.share.ShareResultListener listener) { + final Context appCtx = getContext().getApplicationContext(); + final String action = appCtx.getPackageName() + ".CN1_SHARE_CHOSEN." + (nextShareReceiverId++); + // The receiver fires once when the user picks a target. Android + // does not expose a dismissal signal for the chooser, so the + // listener simply does not fire on user-cancel (see comment + // further down). + final boolean[] delivered = new boolean[1]; + BroadcastReceiver receiver = new BroadcastReceiver() { + @Override + public void onReceive(Context ctx, Intent intent) { + if (delivered[0]) return; + delivered[0] = true; + try { appCtx.unregisterReceiver(this); } catch (Throwable ignore) {} + String pkg = null; + try { + android.content.ComponentName cn = intent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT); + if (cn != null) pkg = cn.getPackageName(); + } catch (Throwable ignore) {} + listener.onResult(com.codename1.share.ShareResult.sharedTo(pkg)); + } + }; + IntentFilter filter = new IntentFilter(action); + boolean registered = false; + if (android.os.Build.VERSION.SDK_INT >= 33) { + // RECEIVER_EXPORTED = 0x2 -- constant exists at runtime on + // API 33+ but is not present in older android.jar build deps, + // so call the 3-arg overload via reflection to stay source- + // compatible. + try { + java.lang.reflect.Method m = Context.class.getMethod( + "registerReceiver", BroadcastReceiver.class, IntentFilter.class, int.class); + m.invoke(appCtx, receiver, filter, Integer.valueOf(0x2)); + registered = true; + } catch (Throwable ignore) {} + } + if (!registered) { + appCtx.registerReceiver(receiver, filter); + } + // Android's chooser IntentSender callback never fires on + // dismissal: there is no public API to observe a user-cancel. + // Apps that need a dismissal signal must use Activity-resume. + + Intent pi = new Intent(action).setPackage(appCtx.getPackageName()); + int piFlags = PendingIntent.FLAG_UPDATE_CURRENT; + if (android.os.Build.VERSION.SDK_INT >= 31) { + // FLAG_MUTABLE was introduced in API 31; its numeric value + // (0x02000000) is referenced here directly so the source + // still compiles against pre-31 android.jar build deps. + piFlags |= 0x02000000; + } + PendingIntent pendingIntent = PendingIntent.getBroadcast(appCtx, 0, pi, piFlags); + return Intent.createChooser(shareIntent, "Share with...", pendingIntent.getIntentSender()); } /** diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 20c5a9a6e0..4cc9f21401 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -8771,6 +8771,99 @@ void com_codename1_impl_ios_IOSNative_socialShare___java_lang_String_long_com_co }); } +// Same as socialShare but installs a completionWithItemsHandler block on +// the UIActivityViewController that calls back into Java with the chosen +// activity type (UIActivityType*), cancellation, or error. Status codes +// mirror com.codename1.share.ShareResult: 1=SHARED_TO, 2=DISMISSED, 3=FAILED. +void com_codename1_impl_ios_IOSNative_socialShareWithCallback___java_lang_String_long_com_codename1_ui_geom_Rectangle_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT text, JAVA_LONG imagePeer, JAVA_OBJECT rectangle, JAVA_INT callbackId) { + NSString* someText = toNSString(CN1_THREAD_STATE_PASS_ARG text); + BOOL useRect = rectangle ? YES:NO; + __block CGRect cgrect = CGRectMake(0,0,0,0); + if (useRect){ + cgrect = cn1RectToCGRect(CN1_THREAD_GET_STATE_PASS_ARG rectangle); + cgrect.origin.x = cgrect.origin.x / scaleValue; + cgrect.origin.y = cgrect.origin.y / scaleValue; + cgrect.size.width = cgrect.size.width / scaleValue; + cgrect.size.height = cgrect.size.height / scaleValue; + } + int cbId = (int)callbackId; + dispatch_async(dispatch_get_main_queue(), ^{ + POOL_BEGIN(); + NSArray* dataToShare; + if(imagePeer != 0) { + GLUIImage* glll = (BRIDGE_CAST GLUIImage*)((void *)imagePeer); + UIImage* i = [glll getImage]; + if(someText != nil) { + dataToShare = [NSArray arrayWithObjects:someText, i, nil]; + } else { + dataToShare = [NSArray arrayWithObjects:i, nil]; + } + } else { + BOOL shareFile = NO; + if (someText != nil && [someText hasPrefix:@"file:"]) { + NSURL* fileURL = [NSURL fileURLWithPath:[someText substringFromIndex:5]]; + if ([[NSFileManager defaultManager] fileExistsAtPath:[fileURL path]]) { + shareFile = YES; + dataToShare = [NSArray arrayWithObjects:fileURL, nil]; + } + } + if (!shareFile) { + dataToShare = [NSArray arrayWithObjects:someText, nil]; + } + } + + UIActivityViewController* activityViewController = [[UIActivityViewController alloc] initWithActivityItems:dataToShare + applicationActivities:nil]; +#ifdef NEW_CODENAME_ONE_VM + if ( [activityViewController respondsToSelector:@selector(popoverPresentationController)] ) { + activityViewController.popoverPresentationController.sourceView = [CodenameOne_GLViewController instance].view; + int SCREEN_HEIGHT = [CodenameOne_GLViewController instance].view.bounds.size.height; + int SCREEN_WIDTH = [CodenameOne_GLViewController instance].view.bounds.size.width; + if ( useRect ){ + if (cgrect.origin.y < SCREEN_HEIGHT/4 && cgrect.origin.y+cgrect.size.height > 3*SCREEN_HEIGHT/4){ + cgrect = CGRectMake( + cgrect.origin.x, + cgrect.origin.y+cgrect.size.height/2-10, + cgrect.size.width, + 10 + ); + } + activityViewController.popoverPresentationController.sourceRect = cgrect; + } else { + CGRect cgrect = CGRectMake(0, 0, SCREEN_WIDTH, 60); + activityViewController.popoverPresentationController.sourceRect = cgrect; + } + + } +#endif + // UIActivityType is an NSString* typedef introduced in iOS 10; + // use NSString* directly so the source compiles against older + // SDKs while remaining ABI-compatible on iOS 10+. + activityViewController.completionWithItemsHandler = ^(NSString *activityType, BOOL completed, NSArray *returnedItems, NSError *activityError) { + JAVA_INT status; + NSString* activityTypeStr = nil; + NSString* errMsg = nil; + if (activityError != nil) { + status = 3; + errMsg = [activityError localizedDescription]; + } else if (completed) { + status = 1; + if (activityType != nil) { + activityTypeStr = activityType; + } + } else { + status = 2; + } + JAVA_OBJECT jActivityType = activityTypeStr != nil ? fromNSString(CN1_THREAD_GET_STATE_PASS_ARG activityTypeStr) : JAVA_NULL; + JAVA_OBJECT jErrMsg = errMsg != nil ? fromNSString(CN1_THREAD_GET_STATE_PASS_ARG errMsg) : JAVA_NULL; + com_codename1_impl_ios_IOSImplementation_socialShareCallback___int_int_java_lang_String_java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG (JAVA_INT)cbId, status, jActivityType, jErrMsg); + }; + [[CodenameOne_GLViewController instance] presentViewController:activityViewController animated:YES completion:^{}]; + POOL_END(); + repaintUI(); + }); +} + extern BOOL isVKBAlwaysOpen(); extern BOOL vkbAlwaysOpen; diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index e069ccc214..e4e7d73439 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -9428,22 +9428,72 @@ public boolean isNativeShareSupported(){ @Override public void share(String text, String image, String mimeType, Rectangle sourceRect){ - if(image != null && image.length() > 0) { + share(text, image, mimeType, sourceRect, null); + } + + @Override + public void share(String text, String image, String mimeType, Rectangle sourceRect, com.codename1.share.ShareResultListener listener) { + long imagePeer = 0; + if (image != null && image.length() > 0) { try { Image img = Image.createImage(image); - if(img == null) { - nativeInstance.socialShare(text, 0, sourceRect ); - return; + if (img != null) { + NativeImage n = (NativeImage) img.getImage(); + imagePeer = n.peer; } - NativeImage n = (NativeImage)img.getImage(); - nativeInstance.socialShare(text, n.peer, sourceRect); - } catch(IOException err) { + } catch (IOException err) { err.printStackTrace(); + if (listener != null) { + listener.onResult(com.codename1.share.ShareResult.failed("Error loading image: " + image)); + return; + } Dialog.show("Error", "Error loading image: " + image, "OK", null); + return; } - } else { - nativeInstance.socialShare(text, 0, sourceRect); } + if (listener == null) { + nativeInstance.socialShare(text, imagePeer, sourceRect); + return; + } + int callbackId = registerShareCallback(listener); + nativeInstance.socialShareWithCallback(text, imagePeer, sourceRect, callbackId); + } + + // Pending share-result callbacks. Native code invokes + // socialShareCallback(...) once per id. + private static final java.util.HashMap pendingShareCallbacks = new java.util.HashMap(); + private static int nextShareCallbackId = 1; + + private static synchronized int registerShareCallback(com.codename1.share.ShareResultListener l) { + int id = nextShareCallbackId++; + pendingShareCallbacks.put(Integer.valueOf(id), l); + return id; + } + + /// Invoked from native code with the outcome of a share. Public so the + /// VM-emitted symbol stays stable. `status` matches + /// [com.codename1.share.ShareResult]: 1=SHARED_TO, 2=DISMISSED, 3=FAILED. + public static void socialShareCallback(int callbackId, int status, String activityType, String errorMessage) { + com.codename1.share.ShareResultListener listener; + synchronized (IOSImplementation.class) { + listener = pendingShareCallbacks.remove(Integer.valueOf(callbackId)); + } + if (listener == null) { + return; + } + com.codename1.share.ShareResult result; + switch (status) { + case 1: + result = com.codename1.share.ShareResult.sharedTo(activityType); + break; + case 2: + result = com.codename1.share.ShareResult.dismissed(); + break; + default: + result = com.codename1.share.ShareResult.failed(errorMessage); + break; + } + listener.onResult(result); } private Purchase pur; diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 8776bef39f..9b163db8d4 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -521,6 +521,11 @@ native void fillGradient(int kind, int stopCount, float[] positions, float[] pre native void openStringPicker(String[] stringArray, int selection, int x, int y, int w, int h, int preferredWidth, int preferredHeight); native void socialShare(String text, long imagePeer, Rectangle sourceRect); + + // Same as socialShare but reports the outcome via + // IOSImplementation.socialShareCallback(int, String, String) using + // the supplied callbackId. Status: 1=SHARED_TO, 2=DISMISSED, 3=FAILED. + native void socialShareWithCallback(String text, long imagePeer, Rectangle sourceRect, int callbackId); // facebook connect public native void facebookLogin(Object callback); diff --git a/docs/developer-guide/The-Components-Of-Codename-One.asciidoc b/docs/developer-guide/The-Components-Of-Codename-One.asciidoc index 6a84a07ec9..e127f4688c 100644 --- a/docs/developer-guide/The-Components-Of-Codename-One.asciidoc +++ b/docs/developer-guide/The-Components-Of-Codename-One.asciidoc @@ -2278,6 +2278,118 @@ image::img/components-sharebutton-android.png[The share button running on the An IMPORTANT: The `ShareButton` features some share service classes to allow plugging in more share services. For example, this functionality is relevant to devices where native sharing isn't supported. This code isn't used on iOS/Android... +==== Share Result Callback + +Since version 9.0 the share API can report what the user did with the share sheet. Register a `ShareResultListener` on either `ShareButton` or directly with `Display.share(...)` and you receive a single `ShareResult` describing the outcome: + +* `SHARED_TO(packageName)` -- the user picked a destination. `packageName` is the chosen target's identifier: an Android package name (e.g. `com.whatsapp`) or, on iOS, a `UIActivityType` such as `com.apple.UIKit.activity.PostToTwitter`. It may be `null` when the platform does not expose the selection (e.g. older Android, Web Share API). +* `DISMISSED` -- the user cancelled without picking a target. iOS reports this reliably; Android's chooser has no public dismissal signal, so on Android the listener simply does not fire on cancel. +* `FAILED` -- the share could not be completed. `getError()` may carry a short platform-supplied message. + +The listener is always invoked on the EDT, exactly once per share request. + +[source,java] +---- +ShareButton sb = new ShareButton(); +sb.setTextToShare("Check this out!"); +sb.setShareResultListener(result -> { + if (result.isSharedTo()) { + Log.p("Shared to " + result.getPackageName()); + } else if (result.isDismissed()) { + Log.p("User dismissed the share sheet"); + } else if (result.isFailed()) { + Log.p("Share failed: " + result.getError()); + } +}); +form.add(sb); +---- + +If you call `Display` directly instead of using `ShareButton`, pass the listener as the final argument to the new overload: + +[source,java] +---- +Display.getInstance().share( + "Check this out!", imagePath, "image/png", sourceRect, + result -> handleResult(result)); +---- + +When the share is dispatched through the non-native fallback dialog (platforms without `isNativeShareSupported`), the listener still fires: it reports `DISMISSED` when the user picks the Cancel button, and the underlying `ShareService` implementations report `SHARED_TO` once they call `ShareService.finish()`. Custom `ShareService` subclasses can call `deliverResult(ShareResult.failed("..."))` from inside `share(...)` to publish a failure explicitly; otherwise `finish()` falls back to a default `SHARED_TO(commandName)`. + +[[ios-share-extension-section]] +=== iOS Share Extension Authoring Helper + +An iOS *share extension* is a separate app target that the system shows inside `UIActivityViewController` when the user shares from another app (Safari, Photos, etc.). It runs in a sandboxed process and persists payloads to the host app via an *App Group* shared `NSUserDefaults` suite. + +Codename One's build pipeline already wires `.ios.appext` zip archives under `src/main/resources` into the generated Xcode project (see `IPhoneBuilder.extractAppExtensions`). The `IOSShareExtensionBuilder` helper, added in version 9.0, generates that archive for you so you do not have to create the extension target in Xcode first. + +==== What the helper produces + +For a single call the helper writes four files at the root of the extension bundle: + +* `Info.plist` -- declares the `NSExtension` dictionary, activation rules (`SupportsText` / `SupportsWebURL` / `SupportsImage`), and the principal class (`ShareViewController`). +* `.entitlements` -- declares `com.apple.security.application-groups` with the configured App Group identifier. +* `ShareViewController.swift` -- a `SLComposeServiceViewController` subclass that extracts text / URL / image attachments from `extensionContext`, packs them into a dictionary, and writes that dictionary to `UserDefaults(suiteName: appGroupId)` under the key `cn1.shareExtension.payload`. +* `buildSettings.properties` -- Xcode build setting overrides (deployment target, Swift version, entitlements path, plist path) picked up by `IPhoneBuilder`. + +==== Generating the bundle + +Call the builder from a Maven plugin, an Ant task or a one-shot `main`: + +[source,java] +---- +import com.codename1.util.IOSShareExtensionBuilder; + +new IOSShareExtensionBuilder() + .setExtensionName("MyShareExtension") + .setDisplayName("Share to MyApp") + .setHostBundleId("com.example.myapp") + .setAppGroupId("group.com.example.myapp.shared") + .acceptText(true) + .acceptURLs(true) + .acceptImages(true) + .writeAppext(new File("src/main/resources/MyShareExtension.ios.appext")); +---- + +The next iOS build picks up the `.ios.appext` archive automatically; no Xcode steps are required. + +If you prefer a directory layout (for example, to commit the generated sources or to feed a future `ios/app_extensions/` pipeline) use `writeTo(File)` instead of `writeAppext(File)` -- the file contents are identical. + +==== Reading the payload from the host app + +The host app reads the most recent payload from the same App Group at startup or when it resumes: + +[source,objective-c] +---- +// Inside an iOS native interface +NSUserDefaults* shared = + [[NSUserDefaults alloc] initWithSuiteName:@"group.com.example.myapp.shared"]; +NSDictionary* payload = [shared dictionaryForKey:@"cn1.shareExtension.payload"]; +---- + +The payload dictionary contains: + +* `text` -- the user-composed text (`String`). +* `items` -- an array of `{ kind, value }` dictionaries where `kind` is `"text"`, `"url"` or `"image"`. For images, `value` is a file URL inside the App Group container. +* `timestamp` -- a `Double` UNIX time. + +Expose this to your CN1 code via a small native interface and clear the key once handled so the next share is unambiguous. + +==== Activation rules and validation + +The builder validates its inputs before writing anything: + +* `extensionName` must be a non-empty identifier (letters, digits, `_`, `-`). It becomes the Xcode target name and the `.appex` bundle name. The extension's bundle id is set to `.` by `IPhoneBuilder`. +* `appGroupId` must start with `group.` (an Apple requirement). Create the matching App Group in your Apple Developer account and add it to both the host app's and the extension's provisioning profile. +* At least one of `acceptText`, `acceptURLs`, `acceptImages` must be enabled, otherwise iOS will never present your extension. + +The default deployment target is iOS 12.0; override with `setDeploymentTarget("13.0")` if you need newer APIs in your customised controller. + +==== Customising the generated controller + +The generated Swift controller covers the common case (read attachments, persist to App Group, finish the request). If you need bespoke logic -- custom UI, server upload, image transcoding -- treat the helper's output as a starting point: run it once, copy the directory under `ios/app_extensions/MyShareExtension/`, edit `ShareViewController.swift`, and zip it back into `.ios.appext` (or wait for the directory-based pipeline tracked by issue #3427). + +NOTE: Provisioning profiles for the extension target are not generated by the helper. You still need to provision the extension's bundle id (`.`) and add the App Group capability to it on the Apple Developer portal, alongside the host app. + === Tabs The https://www.codenameone.com/javadoc/com/codename1/ui/Tabs.html[Tabs] `Container` arranges components into groups within "tabbed" containers. `Tabs` is a container type that allows leafing through its children using labeled toggle buttons. The tabs can be placed in many different ways (top, bottom, left or right) with the default being determined by the platform. This class also allows swiping between components to leaf between said tabs (for this purpose the tabs themselves can also be hidden). diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSShareExtensionBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSShareExtensionBuilder.java new file mode 100644 index 0000000000..ae941fa7b4 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSShareExtensionBuilder.java @@ -0,0 +1,506 @@ +/* + * 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. + */ +package com.codename1.util; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * Generates an iOS Share Extension bundle ready to be picked up by + * {@code IPhoneBuilder} during the Codename One iOS build. + * + *

iOS share extensions are independent app targets that the host OS + * presents in the system share sheet (UIActivityViewController) when a + * user shares from another app. Adding one to a Codename One project + * requires:

+ * + *
    + *
  • An {@code Info.plist} containing the NSExtension dictionary + * describing activation rules and the principal class.
  • + *
  • An entitlements file declaring a shared App Group identifier so + * the extension can write payloads readable by the host app.
  • + *
  • A Swift {@code ShareViewController} (subclassing + * {@code SLComposeServiceViewController}) that extracts the shared + * items from {@code extensionContext} and persists them into the + * App Group's shared {@code NSUserDefaults} or container.
  • + *
  • A {@code buildSettings.properties} file so the Xcode target uses + * the correct provisioning profile and entitlements.
  • + *
+ * + *

The Codename One build pipeline (see {@code IPhoneBuilder}) extracts + * any file under {@code src/main/resources/} ending in {@code .ios.appext} + * (a zip archive of the files above) and wires it into the generated + * Xcode project as an {@code app_extension} target. This class writes + * exactly that archive (or the staged directory used to build it).

+ * + *

Typical usage:

+ *
{@code
+ * new IOSShareExtensionBuilder()
+ *     .setExtensionName("MyShareExtension")
+ *     .setDisplayName("Share to MyApp")
+ *     .setHostBundleId("com.example.myapp")
+ *     .setAppGroupId("group.com.example.myapp.shared")
+ *     .acceptText(true)
+ *     .acceptURLs(true)
+ *     .acceptImages(true)
+ *     .writeAppext(new File("src/main/resources/MyShareExtension.ios.appext"));
+ * }
+ * + *

The host app reads the payload at next launch via NSUserDefaults + * with the same suite name (App Group id). The generated Swift code uses + * the key {@code cn1.shareExtension.payload} for the most recent shared + * item.

+ * + *

This class produces ASCII-only output; the generated Swift, plist + * and entitlements files are deterministic given the same inputs.

+ * + * @since 9.0 + */ +public final class IOSShareExtensionBuilder { + + /** UserDefaults key used by the generated extension to publish its payload. */ + public static final String PAYLOAD_KEY = "cn1.shareExtension.payload"; + + private String extensionName = "ShareExtension"; + private String displayName; + private String hostBundleId; + private String appGroupId; + private boolean acceptText = true; + private boolean acceptUrls; + private boolean acceptImages; + private int maxItemsPerActivation = 1; + private String deploymentTarget = "12.0"; + + /** Bare-bones constructor. Configure with the fluent setters. */ + public IOSShareExtensionBuilder() {} + + /** + * Sets the extension target name. This is the Xcode target name, the + * .appex bundle name and the on-disk directory name. Must be a + * non-empty ASCII identifier (letters, digits, hyphens, underscores). + * + * @param name extension target name + * @return this + */ + public IOSShareExtensionBuilder setExtensionName(String name) { + this.extensionName = name; + return this; + } + + /** + * Sets the user-visible name shown in the share sheet. If not set, + * the extension name is used. + */ + public IOSShareExtensionBuilder setDisplayName(String name) { + this.displayName = name; + return this; + } + + /** + * The host iOS app's bundle identifier (the main app the extension + * belongs to). Used to derive the extension bundle id and as a + * sanity check. Required. + */ + public IOSShareExtensionBuilder setHostBundleId(String id) { + this.hostBundleId = id; + return this; + } + + /** + * The App Group identifier shared between host app and extension. + * Apple requires that this starts with {@code group.}. Required. + */ + public IOSShareExtensionBuilder setAppGroupId(String id) { + this.appGroupId = id; + return this; + } + + /** Activation rule: accept plain-text items. Default true. */ + public IOSShareExtensionBuilder acceptText(boolean accept) { + this.acceptText = accept; + return this; + } + + /** Activation rule: accept URL items. Default false. */ + public IOSShareExtensionBuilder acceptURLs(boolean accept) { + this.acceptUrls = accept; + return this; + } + + /** Activation rule: accept image items. Default false. */ + public IOSShareExtensionBuilder acceptImages(boolean accept) { + this.acceptImages = accept; + return this; + } + + /** + * Maximum number of items per activation that the extension will + * advertise. Apple defaults to 1; set higher for batch-friendly + * extensions. Negative values are clamped to 1. + */ + public IOSShareExtensionBuilder setMaxItemsPerActivation(int max) { + this.maxItemsPerActivation = max < 1 ? 1 : max; + return this; + } + + /** + * iOS deployment target for the extension target. Defaults to + * {@code 12.0}. + */ + public IOSShareExtensionBuilder setDeploymentTarget(String target) { + this.deploymentTarget = target; + return this; + } + + // --- accessors used by callers/tests ------------------------------------- + + public String getExtensionName() { return extensionName; } + public String getDisplayName() { return displayName != null ? displayName : extensionName; } + public String getHostBundleId() { return hostBundleId; } + public String getAppGroupId() { return appGroupId; } + public boolean isAcceptText() { return acceptText; } + public boolean isAcceptURLs() { return acceptUrls; } + public boolean isAcceptImages() { return acceptImages; } + public int getMaxItemsPerActivation() { return maxItemsPerActivation; } + public String getDeploymentTarget() { return deploymentTarget; } + + /** + * Writes the share extension's source files into {@code outputDir}. + * Existing files inside {@code outputDir} are overwritten; siblings + * outside the canonical set are left untouched. + * + * @param outputDir directory to populate. Created if missing. + * @return the file map written, keyed by relative path inside the + * extension bundle. + * @throws IOException on I/O failure + * @throws IllegalStateException if required setters were not invoked + */ + public java.util.Map writeTo(File outputDir) throws IOException { + validate(); + if (outputDir == null) { + throw new IllegalArgumentException("outputDir must not be null"); + } + if (!outputDir.exists() && !outputDir.mkdirs()) { + throw new IOException("Could not create " + outputDir); + } + if (!outputDir.isDirectory()) { + throw new IOException(outputDir + " is not a directory"); + } + java.util.Map files = buildFileMap(); + for (java.util.Map.Entry e : files.entrySet()) { + File target = new File(outputDir, e.getKey()); + File parent = target.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new IOException("Could not create " + parent); + } + FileOutputStream fos = new FileOutputStream(target); + try { + fos.write(e.getValue()); + } finally { + fos.close(); + } + } + return files; + } + + /** + * Writes the share extension as a single {@code .ios.appext} zip + * archive. The archive layout matches what + * {@code IPhoneBuilder.extractAppExtensions} expects: each entry sits + * at the archive root, no leading folder. + * + * @param outputZip target archive. Parent directory created if + * missing. Existing file is overwritten. + * @return the file map written + * @throws IOException on I/O failure + */ + public java.util.Map writeAppext(File outputZip) throws IOException { + validate(); + if (outputZip == null) { + throw new IllegalArgumentException("outputZip must not be null"); + } + File parent = outputZip.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new IOException("Could not create " + parent); + } + java.util.Map files = buildFileMap(); + FileOutputStream fos = new FileOutputStream(outputZip); + try { + ZipOutputStream zos = new ZipOutputStream(fos); + try { + for (java.util.Map.Entry e : files.entrySet()) { + ZipEntry entry = new ZipEntry(e.getKey()); + zos.putNextEntry(entry); + zos.write(e.getValue()); + zos.closeEntry(); + } + } finally { + zos.close(); + } + } finally { + try { fos.close(); } catch (IOException ignore) {} + } + return files; + } + + private void validate() { + if (extensionName == null || extensionName.length() == 0) { + throw new IllegalStateException("extensionName must be set"); + } + if (!isIdentifier(extensionName)) { + throw new IllegalStateException( + "extensionName must be ASCII letters/digits/_/- only: " + extensionName); + } + if (hostBundleId == null || hostBundleId.length() == 0) { + throw new IllegalStateException("hostBundleId must be set"); + } + if (appGroupId == null || !appGroupId.startsWith("group.")) { + throw new IllegalStateException( + "appGroupId must start with 'group.' (Apple requirement): " + appGroupId); + } + if (!acceptText && !acceptUrls && !acceptImages) { + throw new IllegalStateException( + "At least one of acceptText / acceptURLs / acceptImages must be enabled"); + } + } + + private static boolean isIdentifier(String s) { + if (s.length() == 0) return false; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '_' || c == '-'; + if (!ok) return false; + } + return true; + } + + /** + * Builds the in-memory file map. Public for unit testing; production + * code should call {@link #writeTo} or {@link #writeAppext} instead. + */ + public java.util.Map buildFileMap() { + validate(); + java.util.LinkedHashMap map = new java.util.LinkedHashMap(); + map.put("Info.plist", utf8(buildInfoPlist())); + map.put(extensionName + ".entitlements", utf8(buildEntitlements())); + map.put("ShareViewController.swift", utf8(buildShareViewController())); + map.put("buildSettings.properties", utf8(buildBuildSettings())); + return map; + } + + private static byte[] utf8(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + private String buildInfoPlist() { + StringBuilder sb = new StringBuilder(2048); + sb.append("\n"); + sb.append("\n"); + sb.append("\n"); + sb.append("\n"); + plistKeyString(sb, "CFBundleDevelopmentRegion", "en"); + plistKeyString(sb, "CFBundleDisplayName", getDisplayName()); + plistKeyString(sb, "CFBundleExecutable", "$(EXECUTABLE_NAME)"); + plistKeyString(sb, "CFBundleIdentifier", "$(PRODUCT_BUNDLE_IDENTIFIER)"); + plistKeyString(sb, "CFBundleInfoDictionaryVersion", "6.0"); + plistKeyString(sb, "CFBundleName", "$(PRODUCT_NAME)"); + plistKeyString(sb, "CFBundlePackageType", "$(PRODUCT_BUNDLE_PACKAGE_TYPE)"); + plistKeyString(sb, "CFBundleShortVersionString", "1.0"); + plistKeyString(sb, "CFBundleVersion", "1"); + sb.append(" NSExtension\n"); + sb.append(" \n"); + sb.append(" NSExtensionAttributes\n"); + sb.append(" \n"); + sb.append(" NSExtensionActivationRule\n"); + sb.append(" \n"); + if (acceptText) { + sb.append(" NSExtensionActivationSupportsText\n"); + sb.append(" \n"); + } + if (acceptUrls) { + sb.append(" NSExtensionActivationSupportsWebURLWithMaxCount\n"); + sb.append(" ").append(maxItemsPerActivation).append("\n"); + sb.append(" NSExtensionActivationSupportsWebPageWithMaxCount\n"); + sb.append(" ").append(maxItemsPerActivation).append("\n"); + } + if (acceptImages) { + sb.append(" NSExtensionActivationSupportsImageWithMaxCount\n"); + sb.append(" ").append(maxItemsPerActivation).append("\n"); + } + sb.append(" \n"); + sb.append(" \n"); + sb.append(" NSExtensionMainStoryboard\n"); + sb.append(" MainInterface\n"); + sb.append(" NSExtensionPointIdentifier\n"); + sb.append(" com.apple.share-services\n"); + sb.append(" NSExtensionPrincipalClass\n"); + sb.append(" $(PRODUCT_MODULE_NAME).ShareViewController\n"); + sb.append(" \n"); + sb.append("\n"); + sb.append("\n"); + return sb.toString(); + } + + private static void plistKeyString(StringBuilder sb, String key, String value) { + sb.append(" ").append(escapeXml(key)).append("\n"); + sb.append(" ").append(escapeXml(value)).append("\n"); + } + + private static String escapeXml(String s) { + StringBuilder out = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '&': out.append("&"); break; + case '<': out.append("<"); break; + case '>': out.append(">"); break; + case '"': out.append("""); break; + case '\'': out.append("'"); break; + default: out.append(c); + } + } + return out.toString(); + } + + private String buildEntitlements() { + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append("\n"); + sb.append("\n"); + sb.append("\n"); + sb.append(" com.apple.security.application-groups\n"); + sb.append(" \n"); + sb.append(" ").append(escapeXml(appGroupId)).append("\n"); + sb.append(" \n"); + sb.append("\n"); + sb.append("\n"); + return sb.toString(); + } + + private String buildShareViewController() { + // The generated controller subclasses SLComposeServiceViewController + // so we get the standard sheet for free. didSelectPost iterates + // every attachment, normalises it to a string payload, and writes + // the latest payload to NSUserDefaults(suiteName:) for the host + // app to pick up on next launch. + StringBuilder sb = new StringBuilder(2048); + sb.append("import UIKit\n"); + sb.append("import Social\n"); + sb.append("import MobileCoreServices\n"); + sb.append("import UniformTypeIdentifiers\n"); + sb.append("\n"); + sb.append("/// Auto-generated by Codename One IOSShareExtensionBuilder.\n"); + sb.append("/// Persists the shared payload into App Group \"") + .append(appGroupId).append("\" under key \"") + .append(PAYLOAD_KEY).append("\".\n"); + sb.append("class ShareViewController: SLComposeServiceViewController {\n"); + sb.append("\n"); + sb.append(" private let appGroupId = \"").append(appGroupId).append("\"\n"); + sb.append(" private let payloadKey = \"").append(PAYLOAD_KEY).append("\"\n"); + sb.append("\n"); + sb.append(" override func isContentValid() -> Bool {\n"); + sb.append(" return true\n"); + sb.append(" }\n"); + sb.append("\n"); + sb.append(" override func didSelectPost() {\n"); + sb.append(" let composedText = self.contentText ?? \"\"\n"); + sb.append(" var collected: [[String: Any]] = []\n"); + sb.append(" let group = DispatchGroup()\n"); + sb.append(" if let items = self.extensionContext?.inputItems as? [NSExtensionItem] {\n"); + sb.append(" for item in items {\n"); + sb.append(" guard let attachments = item.attachments else { continue }\n"); + sb.append(" for provider in attachments {\n"); + sb.append(" if provider.hasItemConformingToTypeIdentifier(\"public.url\") {\n"); + sb.append(" group.enter()\n"); + sb.append(" provider.loadItem(forTypeIdentifier: \"public.url\", options: nil) { (data, _) in\n"); + sb.append(" if let u = data as? URL {\n"); + sb.append(" collected.append([\"kind\": \"url\", \"value\": u.absoluteString])\n"); + sb.append(" }\n"); + sb.append(" group.leave()\n"); + sb.append(" }\n"); + sb.append(" } else if provider.hasItemConformingToTypeIdentifier(\"public.plain-text\") {\n"); + sb.append(" group.enter()\n"); + sb.append(" provider.loadItem(forTypeIdentifier: \"public.plain-text\", options: nil) { (data, _) in\n"); + sb.append(" if let s = data as? String {\n"); + sb.append(" collected.append([\"kind\": \"text\", \"value\": s])\n"); + sb.append(" }\n"); + sb.append(" group.leave()\n"); + sb.append(" }\n"); + sb.append(" } else if provider.hasItemConformingToTypeIdentifier(\"public.image\") {\n"); + sb.append(" group.enter()\n"); + sb.append(" provider.loadItem(forTypeIdentifier: \"public.image\", options: nil) { (data, _) in\n"); + sb.append(" if let u = data as? URL {\n"); + sb.append(" collected.append([\"kind\": \"image\", \"value\": u.absoluteString])\n"); + sb.append(" }\n"); + sb.append(" group.leave()\n"); + sb.append(" }\n"); + sb.append(" }\n"); + sb.append(" }\n"); + sb.append(" }\n"); + sb.append(" }\n"); + sb.append(" group.notify(queue: .main) {\n"); + sb.append(" let payload: [String: Any] = [\n"); + sb.append(" \"text\": composedText,\n"); + sb.append(" \"items\": collected,\n"); + sb.append(" \"timestamp\": Date().timeIntervalSince1970\n"); + sb.append(" ]\n"); + sb.append(" if let defaults = UserDefaults(suiteName: self.appGroupId) {\n"); + sb.append(" defaults.set(payload, forKey: self.payloadKey)\n"); + sb.append(" defaults.synchronize()\n"); + sb.append(" }\n"); + sb.append(" self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)\n"); + sb.append(" }\n"); + sb.append(" }\n"); + sb.append("\n"); + sb.append(" override func configurationItems() -> [Any]! {\n"); + sb.append(" return []\n"); + sb.append(" }\n"); + sb.append("}\n"); + return sb.toString(); + } + + private String buildBuildSettings() { + // These properties override the defaults synthesised by + // IPhoneBuilder when wiring the extension target into Xcode. + StringBuilder sb = new StringBuilder(); + sb.append("# Auto-generated by Codename One IOSShareExtensionBuilder.\n"); + sb.append("# Picked up by com.codename1.builders.IPhoneBuilder when the\n"); + sb.append("# enclosing .ios.appext archive is extracted into the Xcode project.\n"); + sb.append("IPHONEOS_DEPLOYMENT_TARGET=").append(deploymentTarget).append("\n"); + sb.append("SWIFT_VERSION=5.0\n"); + sb.append("ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES=YES\n"); + sb.append("CODE_SIGN_ENTITLEMENTS=").append(extensionName).append("/") + .append(extensionName).append(".entitlements\n"); + sb.append("INFOPLIST_FILE=").append(extensionName).append("/Info.plist\n"); + return sb.toString(); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSShareExtensionBuilderTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSShareExtensionBuilderTest.java new file mode 100644 index 0000000000..d6d3e8ba1d --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSShareExtensionBuilderTest.java @@ -0,0 +1,164 @@ +package com.codename1.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class IOSShareExtensionBuilderTest { + + private IOSShareExtensionBuilder validBuilder() { + return new IOSShareExtensionBuilder() + .setExtensionName("MyShareExtension") + .setDisplayName("Share to MyApp") + .setHostBundleId("com.example.myapp") + .setAppGroupId("group.com.example.myapp.shared") + .acceptText(true) + .acceptURLs(true) + .acceptImages(true); + } + + @Test + public void buildFileMap_producesExpectedFiles() { + Map files = validBuilder().buildFileMap(); + Set names = new HashSet<>(files.keySet()); + assertTrue(names.contains("Info.plist"), "Info.plist must be present"); + assertTrue(names.contains("MyShareExtension.entitlements"), + "entitlements file must be named after the extension"); + assertTrue(names.contains("ShareViewController.swift"), + "Swift controller must be present"); + assertTrue(names.contains("buildSettings.properties"), + "buildSettings.properties must be present"); + } + + @Test + public void infoPlist_includesNSExtensionAndActivationRules() { + String plist = new String( + validBuilder().buildFileMap().get("Info.plist"), StandardCharsets.UTF_8); + assertTrue(plist.contains("com.apple.share-services"), + "share-services point identifier missing"); + assertTrue(plist.contains("ShareViewController"), + "principal class must reference ShareViewController"); + assertTrue(plist.contains("NSExtensionActivationSupportsText"), + "text activation rule missing"); + assertTrue(plist.contains("NSExtensionActivationSupportsWebURLWithMaxCount"), + "url activation rule missing"); + assertTrue(plist.contains("NSExtensionActivationSupportsImageWithMaxCount"), + "image activation rule missing"); + assertTrue(plist.contains("Share to MyApp"), + "display name missing from plist"); + } + + @Test + public void entitlements_includesAppGroup() { + String ent = new String( + validBuilder().buildFileMap().get("MyShareExtension.entitlements"), + StandardCharsets.UTF_8); + assertTrue(ent.contains("com.apple.security.application-groups"), + "application-groups key missing"); + assertTrue(ent.contains("group.com.example.myapp.shared"), + "app group id missing"); + } + + @Test + public void swiftSource_writesPayloadToAppGroupUserDefaults() { + String swift = new String( + validBuilder().buildFileMap().get("ShareViewController.swift"), + StandardCharsets.UTF_8); + assertTrue(swift.contains("SLComposeServiceViewController"), + "must subclass SLComposeServiceViewController"); + assertTrue(swift.contains("UserDefaults(suiteName:"), + "must use suiteName-based UserDefaults"); + assertTrue(swift.contains("group.com.example.myapp.shared"), + "must reference configured app group"); + assertTrue(swift.contains(IOSShareExtensionBuilder.PAYLOAD_KEY), + "must reference the payload key"); + assertTrue(swift.contains("completeRequest"), + "must complete the extension request"); + } + + @Test + public void buildSettings_referencesInfoAndEntitlements() { + String props = new String( + validBuilder().buildFileMap().get("buildSettings.properties"), + StandardCharsets.UTF_8); + assertTrue(props.contains("INFOPLIST_FILE=MyShareExtension/Info.plist"), + "INFOPLIST_FILE must point to the extension's Info.plist"); + assertTrue(props.contains("CODE_SIGN_ENTITLEMENTS=MyShareExtension/MyShareExtension.entitlements"), + "CODE_SIGN_ENTITLEMENTS must point to the extension's entitlements"); + } + + @Test + public void writeTo_writesAllFilesToDirectory(@TempDir Path tmp) throws Exception { + File outDir = new File(tmp.toFile(), "MyShareExtension"); + Map files = validBuilder().writeTo(outDir); + assertEquals(4, files.size()); + for (String name : files.keySet()) { + File f = new File(outDir, name); + assertTrue(f.exists(), name + " must exist on disk"); + assertTrue(f.length() > 0, name + " must not be empty"); + } + } + + @Test + public void writeAppext_producesReadableZipArchive(@TempDir Path tmp) throws Exception { + File zip = new File(tmp.toFile(), "MyShareExtension.ios.appext"); + Map files = validBuilder().writeAppext(zip); + assertTrue(zip.exists()); + assertTrue(zip.length() > 0); + + Set seen = new HashSet<>(); + try (FileInputStream fis = new FileInputStream(zip); + ZipInputStream zis = new ZipInputStream(fis)) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + seen.add(entry.getName()); + } + } + assertEquals(files.keySet(), seen, + "zip entries must match the file map exactly"); + } + + @Test + public void validation_rejectsMissingHostBundleId() { + IOSShareExtensionBuilder b = new IOSShareExtensionBuilder() + .setExtensionName("X") + .setAppGroupId("group.x") + .acceptText(true); + assertThrows(IllegalStateException.class, b::buildFileMap); + } + + @Test + public void validation_rejectsAppGroupMissingGroupPrefix() { + IOSShareExtensionBuilder b = validBuilder().setAppGroupId("com.example.bogus"); + assertThrows(IllegalStateException.class, b::buildFileMap); + } + + @Test + public void validation_rejectsAllAcceptanceRulesDisabled() { + IOSShareExtensionBuilder b = validBuilder() + .acceptText(false).acceptURLs(false).acceptImages(false); + assertThrows(IllegalStateException.class, b::buildFileMap); + } + + @Test + public void validation_rejectsNonIdentifierExtensionName() { + IOSShareExtensionBuilder b = validBuilder().setExtensionName("My Share!"); + assertThrows(IllegalStateException.class, b::buildFileMap); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/share/ShareResultTest.java b/maven/core-unittests/src/test/java/com/codename1/share/ShareResultTest.java new file mode 100644 index 0000000000..31790d0f96 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/share/ShareResultTest.java @@ -0,0 +1,63 @@ +package com.codename1.share; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ShareResultTest { + + @Test + void sharedTo_carriesPackageName() { + ShareResult r = ShareResult.sharedTo("com.whatsapp"); + assertTrue(r.isSharedTo()); + assertFalse(r.isDismissed()); + assertFalse(r.isFailed()); + assertEquals(ShareResult.STATUS_SHARED_TO, r.getStatus()); + assertEquals("com.whatsapp", r.getPackageName()); + assertNull(r.getError()); + } + + @Test + void sharedTo_acceptsNullPackageName() { + // Older Android and Web Share API cannot expose the chosen + // target; the listener still fires so app code can proceed. + ShareResult r = ShareResult.sharedTo(null); + assertTrue(r.isSharedTo()); + assertNull(r.getPackageName()); + } + + @Test + void dismissed_hasNoPackageOrError() { + ShareResult r = ShareResult.dismissed(); + assertTrue(r.isDismissed()); + assertFalse(r.isSharedTo()); + assertFalse(r.isFailed()); + assertEquals(ShareResult.STATUS_DISMISSED, r.getStatus()); + assertNull(r.getPackageName()); + assertNull(r.getError()); + } + + @Test + void failed_carriesErrorMessage() { + ShareResult r = ShareResult.failed("attachment too large"); + assertTrue(r.isFailed()); + assertFalse(r.isSharedTo()); + assertFalse(r.isDismissed()); + assertEquals(ShareResult.STATUS_FAILED, r.getStatus()); + assertEquals("attachment too large", r.getError()); + assertNull(r.getPackageName()); + } + + @Test + void toString_isDeterministicForEachStatus() { + assertEquals("ShareResult{SHARED_TO com.example}", + ShareResult.sharedTo("com.example").toString()); + assertEquals("ShareResult{DISMISSED}", + ShareResult.dismissed().toString()); + assertEquals("ShareResult{FAILED oops}", + ShareResult.failed("oops").toString()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/share/ShareServiceResultDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/share/ShareServiceResultDeliveryTest.java new file mode 100644 index 0000000000..8d6928f49b --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/share/ShareServiceResultDeliveryTest.java @@ -0,0 +1,80 @@ +package com.codename1.share; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.ui.Image; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ShareServiceResultDeliveryTest extends UITestBase { + + @FormTest + void finishDeliversSharedToResultExactlyOnce() { + TrackingShare svc = new TrackingShare(); + List received = new ArrayList<>(); + svc.setShareResultListener(received::add); + + svc.finish(); + // calling finish() again must not deliver a second result + svc.finish(); + + assertEquals(1, received.size(), + "finish() should deliver a result exactly once"); + ShareResult r = received.get(0); + assertTrue(r.isSharedTo()); + assertEquals("Tracking", r.getPackageName(), + "default SHARED_TO carries the command name as identifier"); + } + + @FormTest + void deliverResultRunsBeforeFinishFallback() { + TrackingShare svc = new TrackingShare(); + List received = new ArrayList<>(); + svc.setShareResultListener(received::add); + + svc.publishFailure("smtp down"); + svc.finish(); + + assertEquals(1, received.size(), + "explicit deliverResult must short-circuit the finish() fallback"); + assertTrue(received.get(0).isFailed()); + assertEquals("smtp down", received.get(0).getError()); + } + + @FormTest + void noListenerStillTracksDeliveryStateInternally() { + // A service without a listener should still respect "deliver once" + // semantics, so that adding a listener after the fact does not + // see stale results. + TrackingShare svc = new TrackingShare(); + svc.finish(); + List received = new ArrayList<>(); + svc.setShareResultListener(received::add); + svc.finish(); + // resultDelivered was cleared by setShareResultListener; + // second finish() now delivers once. + assertEquals(1, received.size()); + } + + private static class TrackingShare extends ShareService { + TrackingShare() { + super("Tracking", (Image) null); + } + + @Override + public void share(String text) { /* unused for these tests */ } + + @Override + public boolean canShareImage() { return true; } + + void publishFailure(String msg) { + deliverResult(ShareResult.failed(msg)); + } + } +} From 0ed453d4e9d0740eb7baf87c1a38f6ec0528b653 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 24 May 2026 22:01:54 +0300 Subject: [PATCH 2/5] Developer-guide quality gate fixes: contractions + American spelling Vale flagged six contraction/foreign-phrase issues in the new sections and LanguageTool flagged the British "normalises" / "synthesised" / "customised" spellings that leaked from comments into the generated HTML. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../impl/android/AndroidImplementation.java | 4 ++-- .../The-Components-Of-Codename-One.asciidoc | 12 ++++++------ .../com/codename1/util/IOSShareExtensionBuilder.java | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 79af5c945c..ad2afb889e 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -7764,8 +7764,8 @@ public void share(String text, String image, String mimeType, Rectangle sourceRe chooser = Intent.createChooser(shareIntent, "Share with..."); } } catch (Throwable t) { - // Fall back to the plain chooser, then synthesise a listener - // result so the app does not hang on an unfulfilled callback. + // Fall back to the plain chooser, then synthesize a listener + // result so the app doesn't hang on an unfulfilled callback. chooser = Intent.createChooser(shareIntent, "Share with..."); if (listener != null) { listener.onResult(com.codename1.share.ShareResult.sharedTo(null)); diff --git a/docs/developer-guide/The-Components-Of-Codename-One.asciidoc b/docs/developer-guide/The-Components-Of-Codename-One.asciidoc index e127f4688c..39e178a3fe 100644 --- a/docs/developer-guide/The-Components-Of-Codename-One.asciidoc +++ b/docs/developer-guide/The-Components-Of-Codename-One.asciidoc @@ -2282,9 +2282,9 @@ IMPORTANT: The `ShareButton` features some share service classes to allow pluggi Since version 9.0 the share API can report what the user did with the share sheet. Register a `ShareResultListener` on either `ShareButton` or directly with `Display.share(...)` and you receive a single `ShareResult` describing the outcome: -* `SHARED_TO(packageName)` -- the user picked a destination. `packageName` is the chosen target's identifier: an Android package name (e.g. `com.whatsapp`) or, on iOS, a `UIActivityType` such as `com.apple.UIKit.activity.PostToTwitter`. It may be `null` when the platform does not expose the selection (e.g. older Android, Web Share API). -* `DISMISSED` -- the user cancelled without picking a target. iOS reports this reliably; Android's chooser has no public dismissal signal, so on Android the listener simply does not fire on cancel. -* `FAILED` -- the share could not be completed. `getError()` may carry a short platform-supplied message. +* `SHARED_TO(packageName)` -- the user picked a destination. `packageName` is the chosen target's identifier: an Android package name (for example, `com.whatsapp`) or, on iOS, a `UIActivityType` such as `com.apple.UIKit.activity.PostToTwitter`. It may be `null` when the platform doesn't expose the selection (for example, older Android, Web Share API). +* `DISMISSED` -- the user cancelled without picking a target. iOS reports this reliably; Android's chooser has no public dismissal signal, so on Android the listener simply doesn't fire on cancel. +* `FAILED` -- the share couldn't be completed. `getError()` may carry a short platform-supplied message. The listener is always invoked on the EDT, exactly once per share request. @@ -2320,7 +2320,7 @@ When the share is dispatched through the non-native fallback dialog (platforms w An iOS *share extension* is a separate app target that the system shows inside `UIActivityViewController` when the user shares from another app (Safari, Photos, etc.). It runs in a sandboxed process and persists payloads to the host app via an *App Group* shared `NSUserDefaults` suite. -Codename One's build pipeline already wires `.ios.appext` zip archives under `src/main/resources` into the generated Xcode project (see `IPhoneBuilder.extractAppExtensions`). The `IOSShareExtensionBuilder` helper, added in version 9.0, generates that archive for you so you do not have to create the extension target in Xcode first. +Codename One's build pipeline already wires `.ios.appext` zip archives under `src/main/resources` into the generated Xcode project (see `IPhoneBuilder.extractAppExtensions`). The `IOSShareExtensionBuilder` helper, added in version 9.0, generates that archive for you so you don't have to create the extension target in Xcode first. ==== What the helper produces @@ -2382,13 +2382,13 @@ The builder validates its inputs before writing anything: * `appGroupId` must start with `group.` (an Apple requirement). Create the matching App Group in your Apple Developer account and add it to both the host app's and the extension's provisioning profile. * At least one of `acceptText`, `acceptURLs`, `acceptImages` must be enabled, otherwise iOS will never present your extension. -The default deployment target is iOS 12.0; override with `setDeploymentTarget("13.0")` if you need newer APIs in your customised controller. +The default deployment target is iOS 12.0; override with `setDeploymentTarget("13.0")` if you need newer APIs in your customized controller. ==== Customising the generated controller The generated Swift controller covers the common case (read attachments, persist to App Group, finish the request). If you need bespoke logic -- custom UI, server upload, image transcoding -- treat the helper's output as a starting point: run it once, copy the directory under `ios/app_extensions/MyShareExtension/`, edit `ShareViewController.swift`, and zip it back into `.ios.appext` (or wait for the directory-based pipeline tracked by issue #3427). -NOTE: Provisioning profiles for the extension target are not generated by the helper. You still need to provision the extension's bundle id (`.`) and add the App Group capability to it on the Apple Developer portal, alongside the host app. +NOTE: Provisioning profiles for the extension target aren't generated by the helper. You still need to provision the extension's bundle id (`.`) and add the App Group capability to it on the Apple Developer portal, alongside the host app. === Tabs diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSShareExtensionBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSShareExtensionBuilder.java index ae941fa7b4..c866b445b5 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSShareExtensionBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSShareExtensionBuilder.java @@ -409,7 +409,7 @@ private String buildEntitlements() { private String buildShareViewController() { // The generated controller subclasses SLComposeServiceViewController // so we get the standard sheet for free. didSelectPost iterates - // every attachment, normalises it to a string payload, and writes + // every attachment, normalizes it to a string payload, and writes // the latest payload to NSUserDefaults(suiteName:) for the host // app to pick up on next launch. StringBuilder sb = new StringBuilder(2048); @@ -489,7 +489,7 @@ private String buildShareViewController() { } private String buildBuildSettings() { - // These properties override the defaults synthesised by + // These properties override the defaults synthesized by // IPhoneBuilder when wiring the extension target into Xcode. StringBuilder sb = new StringBuilder(); sb.append("# Auto-generated by Codename One IOSShareExtensionBuilder.\n"); From a2ee8ea969420a82dfd896160f32eec7487f82fc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 25 May 2026 00:32:37 +0300 Subject: [PATCH 3/5] Hoist Cancel command out of array literal to satisfy Checkstyle The trailing '}}' was flagged as 'right curly not followed by whitespace' on JDK 8 quality gate. Co-Authored-By: Claude Opus 4.7 (1M context) --- CodenameOne/src/com/codename1/components/ShareButton.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/components/ShareButton.java b/CodenameOne/src/com/codename1/components/ShareButton.java index 6f4fff05a2..e50de46720 100644 --- a/CodenameOne/src/com/codename1/components/ShareButton.java +++ b/CodenameOne/src/com/codename1/components/ShareButton.java @@ -203,14 +203,15 @@ public void run() { dialog.setLayout(new BorderLayout()); dialog.addComponent(BorderLayout.CENTER, l); final boolean[] picked = new boolean[1]; - dialog.placeButtonCommands(new Command[]{new Command("Cancel") { + Command cancel = new Command("Cancel") { @Override public void actionPerformed(ActionEvent ev) { if (!picked[0] && listener != null) { listener.onResult(ShareResult.dismissed()); } } - }}); + }; + dialog.placeButtonCommands(new Command[]{cancel}); l.addActionListener(new ActionListener() { @Override From 9c6baf551d09b3c55e0fc2b9562ed32650594015 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 25 May 2026 07:58:21 +0300 Subject: [PATCH 4/5] Drop the bogus "Since 9.0" markers The version stamps were incorrect (actual current is 7.0.245). Remove them from the new APIs' Javadoc, the IOSShareExtensionBuilder class Javadoc, and the developer-guide prose. Leave pre-existing @since annotations in untouched code unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- CodenameOne/src/com/codename1/components/ShareButton.java | 4 ---- CodenameOne/src/com/codename1/share/ShareService.java | 8 -------- CodenameOne/src/com/codename1/ui/Display.java | 4 ---- .../The-Components-Of-Codename-One.asciidoc | 4 ++-- .../java/com/codename1/util/IOSShareExtensionBuilder.java | 2 -- 5 files changed, 2 insertions(+), 20 deletions(-) diff --git a/CodenameOne/src/com/codename1/components/ShareButton.java b/CodenameOne/src/com/codename1/components/ShareButton.java index e50de46720..9f62b8d3b3 100644 --- a/CodenameOne/src/com/codename1/components/ShareButton.java +++ b/CodenameOne/src/com/codename1/components/ShareButton.java @@ -143,10 +143,6 @@ public void addShareService(ShareService share) { /// On platforms that cannot observe the chosen target the listener /// still fires with [ShareResult#sharedTo] passing a `null` package /// name, so the app can resume its flow. - /// - /// #### Since - /// - /// 9.0 public void setShareResultListener(ShareResultListener listener) { this.shareResultListener = listener; } diff --git a/CodenameOne/src/com/codename1/share/ShareService.java b/CodenameOne/src/com/codename1/share/ShareService.java index 48e2bcf4db..6f39f89f91 100644 --- a/CodenameOne/src/com/codename1/share/ShareService.java +++ b/CodenameOne/src/com/codename1/share/ShareService.java @@ -120,10 +120,6 @@ public void finish() { /// /// Set by [com.codename1.components.ShareButton] before the service /// is invoked. Subclasses normally do not call this directly. - /// - /// #### Since - /// - /// 9.0 public void setShareResultListener(ShareResultListener listener) { this.shareResultListener = listener; this.resultDelivered = false; @@ -139,10 +135,6 @@ public ShareResultListener getShareResultListener() { /// Subclasses can call this to report a `DISMISSED` (user cancelled) /// or `FAILED` outcome. [#finish] already reports a default /// `SHARED_TO(commandName)` if no explicit result was delivered. - /// - /// #### Since - /// - /// 9.0 protected void deliverResult(ShareResult result) { if (resultDelivered) { return; diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index f90fb3516a..50b58b6fc5 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -5178,10 +5178,6 @@ public void share(String textOrPath, String image, String mimeType, Rectangle so /// - `sourceRect`: source rectangle hint for the share popover. May be null. /// /// - `listener`: callback for the share outcome. May be null. - /// - /// #### Since - /// - /// 9.0 public void share(String textOrPath, String image, String mimeType, Rectangle sourceRect, ShareResultListener listener) { if (listener == null) { impl.share(textOrPath, image, mimeType, sourceRect); diff --git a/docs/developer-guide/The-Components-Of-Codename-One.asciidoc b/docs/developer-guide/The-Components-Of-Codename-One.asciidoc index 39e178a3fe..e1a34e3ac7 100644 --- a/docs/developer-guide/The-Components-Of-Codename-One.asciidoc +++ b/docs/developer-guide/The-Components-Of-Codename-One.asciidoc @@ -2280,7 +2280,7 @@ IMPORTANT: The `ShareButton` features some share service classes to allow pluggi ==== Share Result Callback -Since version 9.0 the share API can report what the user did with the share sheet. Register a `ShareResultListener` on either `ShareButton` or directly with `Display.share(...)` and you receive a single `ShareResult` describing the outcome: +The share API can report what the user did with the share sheet. Register a `ShareResultListener` on either `ShareButton` or directly with `Display.share(...)` and you receive a single `ShareResult` describing the outcome: * `SHARED_TO(packageName)` -- the user picked a destination. `packageName` is the chosen target's identifier: an Android package name (for example, `com.whatsapp`) or, on iOS, a `UIActivityType` such as `com.apple.UIKit.activity.PostToTwitter`. It may be `null` when the platform doesn't expose the selection (for example, older Android, Web Share API). * `DISMISSED` -- the user cancelled without picking a target. iOS reports this reliably; Android's chooser has no public dismissal signal, so on Android the listener simply doesn't fire on cancel. @@ -2320,7 +2320,7 @@ When the share is dispatched through the non-native fallback dialog (platforms w An iOS *share extension* is a separate app target that the system shows inside `UIActivityViewController` when the user shares from another app (Safari, Photos, etc.). It runs in a sandboxed process and persists payloads to the host app via an *App Group* shared `NSUserDefaults` suite. -Codename One's build pipeline already wires `.ios.appext` zip archives under `src/main/resources` into the generated Xcode project (see `IPhoneBuilder.extractAppExtensions`). The `IOSShareExtensionBuilder` helper, added in version 9.0, generates that archive for you so you don't have to create the extension target in Xcode first. +Codename One's build pipeline already wires `.ios.appext` zip archives under `src/main/resources` into the generated Xcode project (see `IPhoneBuilder.extractAppExtensions`). The `IOSShareExtensionBuilder` helper generates that archive for you so you don't have to create the extension target in Xcode first. ==== What the helper produces diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSShareExtensionBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSShareExtensionBuilder.java index c866b445b5..f170c9581f 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSShareExtensionBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSShareExtensionBuilder.java @@ -81,8 +81,6 @@ * *

This class produces ASCII-only output; the generated Swift, plist * and entitlements files are deterministic given the same inputs.

- * - * @since 9.0 */ public final class IOSShareExtensionBuilder { From 4da05344063cad46d2a30d7fc9d0a1dbdcfab7ab Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 25 May 2026 15:57:05 +0300 Subject: [PATCH 5/5] Drop IPhoneBuilder references from developer guide IPhoneBuilder is an implementation detail of the maven plugin; the public guide should refer to "the iOS build pipeline" without naming the internal class. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../developer-guide/The-Components-Of-Codename-One.asciidoc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/developer-guide/The-Components-Of-Codename-One.asciidoc b/docs/developer-guide/The-Components-Of-Codename-One.asciidoc index e1a34e3ac7..aba7c75951 100644 --- a/docs/developer-guide/The-Components-Of-Codename-One.asciidoc +++ b/docs/developer-guide/The-Components-Of-Codename-One.asciidoc @@ -2320,7 +2320,7 @@ When the share is dispatched through the non-native fallback dialog (platforms w An iOS *share extension* is a separate app target that the system shows inside `UIActivityViewController` when the user shares from another app (Safari, Photos, etc.). It runs in a sandboxed process and persists payloads to the host app via an *App Group* shared `NSUserDefaults` suite. -Codename One's build pipeline already wires `.ios.appext` zip archives under `src/main/resources` into the generated Xcode project (see `IPhoneBuilder.extractAppExtensions`). The `IOSShareExtensionBuilder` helper generates that archive for you so you don't have to create the extension target in Xcode first. +Codename One's build pipeline already wires `.ios.appext` zip archives under `src/main/resources` into the generated Xcode project. The `IOSShareExtensionBuilder` helper generates that archive for you so you don't have to create the extension target in Xcode first. ==== What the helper produces @@ -2329,7 +2329,7 @@ For a single call the helper writes four files at the root of the extension bund * `Info.plist` -- declares the `NSExtension` dictionary, activation rules (`SupportsText` / `SupportsWebURL` / `SupportsImage`), and the principal class (`ShareViewController`). * `.entitlements` -- declares `com.apple.security.application-groups` with the configured App Group identifier. * `ShareViewController.swift` -- a `SLComposeServiceViewController` subclass that extracts text / URL / image attachments from `extensionContext`, packs them into a dictionary, and writes that dictionary to `UserDefaults(suiteName: appGroupId)` under the key `cn1.shareExtension.payload`. -* `buildSettings.properties` -- Xcode build setting overrides (deployment target, Swift version, entitlements path, plist path) picked up by `IPhoneBuilder`. +* `buildSettings.properties` -- Xcode build setting overrides (deployment target, Swift version, entitlements path, plist path) picked up by the iOS build pipeline. ==== Generating the bundle @@ -2378,7 +2378,7 @@ Expose this to your CN1 code via a small native interface and clear the key once The builder validates its inputs before writing anything: -* `extensionName` must be a non-empty identifier (letters, digits, `_`, `-`). It becomes the Xcode target name and the `.appex` bundle name. The extension's bundle id is set to `.` by `IPhoneBuilder`. +* `extensionName` must be a non-empty identifier (letters, digits, `_`, `-`). It becomes the Xcode target name and the `.appex` bundle name. The extension's bundle id is set to `.` by the iOS build pipeline. * `appGroupId` must start with `group.` (an Apple requirement). Create the matching App Group in your Apple Developer account and add it to both the host app's and the extension's provisioning profile. * At least one of `acceptText`, `acceptURLs`, `acceptImages` must be enabled, otherwise iOS will never present your extension.