From 80fa7088d8bf48cdea6d9be0b539c64eac8fe8da Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 12 Jun 2026 07:57:46 +0300 Subject: [PATCH 1/2] Add Apple Wallet issuer-provisioning extension support (iOS) Card issuers can now surface their cards inside the Wallet app ("From apps on your iPhone") with no native code in the project: - ios.wallet.* build hints make the iOS build generate the fixed Objective-C extension pair (non-UI PKIssuerProvisioningExtensionHandler + optional login-UI extension) via the new IOSWalletExtensionBuilder, wire them in as embedded app_extension targets (iOS 14 deployment target, PassKit linked) and inject the App Group into the app and extension entitlements. ios.wallet.*Inject hints inject custom ObjC at marker comments in every callback. - New Java API com.codename1.payment.WalletExtension/WalletPassEntry publishes pass entries, card art, auth token and the requires-authentication flag into the shared App Group (Display -> CodenameOneImplementation no-ops -> IOSImplementation -> IOSNative); the extension answers Wallet's 100ms status callback from that data and POSTs Apple's certificates/nonce to the customer's ios.wallet.issuerEndpoint for the encrypted pass payload. - Existing Xcode extensions (vendor SDKs / native apps) drop into the generic ios/app_extensions mechanism, which now also works without CocoaPods (project mutation hoisted out of the runPods block) and supports per-extension provisioning profiles (in-folder .mobileprovision or ios.appext..provisioningURL). - Extension target creation in the generated ruby is now idempotent; previously the post-dependency re-run of fix_xcode_schemes.rb duplicated extension targets. - Developer guide: new Apple Wallet Extension chapter + all new hints documented in the build hints table. Verified: 15 new unit tests + full plugin suite green; hellocodenameone ios-source build produces both extension targets and an arm64 simulator xcodebuild embeds both .appex bundles with the exact com.apple.PassKit.issuer-provisioning identifiers. Co-Authored-By: Claude Fable 5 --- .../impl/CodenameOneImplementation.java | 36 + .../codename1/payment/WalletExtension.java | 140 ++++ .../codename1/payment/WalletPassEntry.java | 143 ++++ CodenameOne/src/com/codename1/ui/Display.java | 48 ++ Ports/iOSPort/nativeSources/IOSNative.m | 155 ++++ .../codename1/impl/ios/IOSImplementation.java | 35 + .../src/com/codename1/impl/ios/IOSNative.java | 25 + .../Advanced-Topics-Under-The-Hood.asciidoc | 33 + .../Apple-Wallet-Extension.asciidoc | 142 ++++ docs/developer-guide/developer-guide.asciidoc | 2 + .../com/codename1/builders/IPhoneBuilder.java | 170 ++++- .../util/IOSWalletExtensionBuilder.java | 695 ++++++++++++++++++ .../util/IOSWalletExtensionBuilderTest.java | 171 +++++ 13 files changed, 1780 insertions(+), 15 deletions(-) create mode 100644 CodenameOne/src/com/codename1/payment/WalletExtension.java create mode 100644 CodenameOne/src/com/codename1/payment/WalletPassEntry.java create mode 100644 docs/developer-guide/Apple-Wallet-Extension.asciidoc create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSWalletExtensionBuilder.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWalletExtensionBuilderTest.java diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 74cc5ee883..9f4ec9be68 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -10389,6 +10389,42 @@ public boolean isReceiveSharedContentSupported() { return false; } + /// Returns true if the platform supports publishing data to a Wallet + /// issuer-provisioning extension (iOS only). Defaults to false. + public boolean isWalletExtensionSupported() { + return false; + } + + /// Removes all published Wallet extension pass entries from one of the + /// two lists. No-op on platforms without Wallet extension support. + /// + /// #### Parameters + /// + /// - `remote`: true for the Apple Watch list, false for the iPhone list + public void walletExtensionClearPassEntries(boolean remote) { + } + + /// Appends one pass entry to the published Wallet extension list. No-op + /// on platforms without Wallet extension support. + public void walletExtensionAddPassEntry(boolean remote, String identifier, String title, + String cardholderName, String accountSuffix, String network, String description, byte[] artPng) { + } + + /// Sets the Wallet extension requires-authentication flag. No-op on + /// platforms without Wallet extension support. + public void walletExtensionSetRequiresAuthentication(boolean requiresAuthentication) { + } + + /// Publishes the Wallet extension auth token, or removes it when null. + /// No-op on platforms without Wallet extension support. + public void walletExtensionSetAuthToken(String token) { + } + + /// Clears all published Wallet extension data. No-op on platforms + /// without Wallet extension support. + public void walletExtensionClear() { + } + /// Delivers shared content to the running application instance. onReceivedSharedContent /// is defined on com.codename1.system.Lifecycle, so apps that handle shared content /// extend Lifecycle; non-Lifecycle apps cannot override it and are skipped. The diff --git a/CodenameOne/src/com/codename1/payment/WalletExtension.java b/CodenameOne/src/com/codename1/payment/WalletExtension.java new file mode 100644 index 0000000000..455f1d6437 --- /dev/null +++ b/CodenameOne/src/com/codename1/payment/WalletExtension.java @@ -0,0 +1,140 @@ +/* + * 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.payment; + +import com.codename1.ui.Display; + +/// Publishes card data to the Apple Wallet issuer-provisioning extension so +/// users can add the issuer's cards to Apple Wallet from inside the Wallet +/// app ("From apps on your iPhone"). iOS only; on other platforms +/// [#isSupported()] returns false and all methods are no-ops. +/// +/// ### How it works +/// +/// The Wallet extension runs in a separate process, launched by the Wallet +/// app, usually while your app is not running. It cannot call into your Java +/// code. Instead your app pre-publishes the list of available cards (and an +/// auth token) with this class; the data is stored in the shared App Group +/// container where the generated extension reads it. The final provisioning +/// step - producing the encrypted pass payload - is performed by your issuer +/// backend: the extension POSTs Apple's certificates/nonce plus the card +/// identifier and auth token to the HTTPS endpoint configured in the +/// `ios.wallet.issuerEndpoint` build hint, which must respond with JSON +/// `{"activationData", "encryptedPassData", "ephemeralPublicKey"}` (base64). +/// +/// Call [#setPassEntries(WalletPassEntry[])] whenever the user's card list +/// changes (e.g. after login) and keep a fresh auth token published with +/// [#setAuthToken(java.lang.String)]; call [#clear()] on logout. +/// +/// ### Enabling the extension (no native code needed) +/// +/// Add these build hints to the iOS build: +/// +/// - `ios.wallet.extension=true` - generates the non-UI Wallet extension +/// - `ios.wallet.appGroup=group.com.mycompany.myapp` - shared App Group id +/// - `ios.wallet.issuerEndpoint=https://...` - issuer backend endpoint +/// - `ios.wallet.includeUI=true` + `ios.wallet.authEndpoint=https://...` - +/// optional in-Wallet login screen, only needed when you report +/// [#setRequiresAuthentication(boolean)] true instead of keeping a token +/// - `ios.wallet.*Inject` hints - inject custom Objective-C at key points +/// +/// Each extension needs its own App ID and provisioning profile carrying the +/// restricted `com.apple.developer.payment-pass-provisioning` entitlement, +/// which Apple grants per-app on request. For cloud builds either place the +/// `.mobileprovision` files in `src/main/resources` and name them in the +/// `ios.wallet.nonuiProvisioningProfile` / `ios.wallet.uiProvisioningProfile` +/// hints, or host them at the URLs given in the +/// `ios.wallet.nonuiProvisioningURL` / `ios.wallet.uiProvisioningURL` hints. +/// The card network must also list the extension App IDs in the pass +/// metadata (`associatedApplicationIdentifiers`) or Wallet never invokes +/// the extension. +/// +/// ### Bringing your own extension +/// +/// If you already have Xcode extension targets (e.g. from an issuer SDK +/// vendor or an existing native app), skip the `ios.wallet.*` hints and copy +/// each extension into `ios/app_extensions//` in your project instead: +/// its sources, `Info.plist`, `.entitlements`, an optional +/// `buildSettings.properties` with Xcode build settings (set +/// `IPHONEOS_DEPLOYMENT_TARGET=14.0` for Wallet extensions) and the +/// extension's `.mobileprovision` for cloud device builds. +public final class WalletExtension { + + private WalletExtension() { + } + + /// Returns true if the platform supports publishing Wallet extension + /// data: iOS 14 or newer with the `ios.wallet.extension` build hint + /// enabled. Returns false on all other platforms and in the simulator. + public static boolean isSupported() { + return Display.getInstance().isWalletExtensionSupported(); + } + + /// Publishes the cards that the Wallet extension offers for provisioning + /// on the iPhone itself, replacing any previously published list. Cards + /// already present in Wallet are filtered out automatically by the + /// extension. + /// + /// #### Parameters + /// + /// - `entries`: the available cards; an empty array or null clears the list + public static void setPassEntries(WalletPassEntry[] entries) { + Display.getInstance().walletExtensionSetPassEntries(false, entries); + } + + /// Publishes the cards offered for provisioning on a paired Apple Watch, + /// replacing any previously published list. Typically the same list as + /// [#setPassEntries(WalletPassEntry[])]. + /// + /// #### Parameters + /// + /// - `entries`: the available cards; an empty array or null clears the list + public static void setRemotePassEntries(WalletPassEntry[] entries) { + Display.getInstance().walletExtensionSetPassEntries(true, entries); + } + + /// Sets whether Wallet should present the login UI extension before + /// provisioning. Keep this false and maintain a fresh token with + /// [#setAuthToken(java.lang.String)] to skip in-Wallet login entirely. + public static void setRequiresAuthentication(boolean requiresAuthentication) { + Display.getInstance().walletExtensionSetRequiresAuthentication(requiresAuthentication); + } + + /// Publishes the auth token that the extension forwards to the issuer + /// endpoint (JSON `authToken` field and `Authorization: Bearer` header) + /// when the user adds a card. + /// + /// #### Parameters + /// + /// - `token`: the token, or null to remove it + public static void setAuthToken(String token) { + Display.getInstance().walletExtensionSetAuthToken(token); + } + + /// Clears everything previously published: pass entries, remote pass + /// entries, the auth token and the requires-authentication flag. Call + /// this when the user logs out. + public static void clear() { + Display.getInstance().walletExtensionClear(); + } +} diff --git a/CodenameOne/src/com/codename1/payment/WalletPassEntry.java b/CodenameOne/src/com/codename1/payment/WalletPassEntry.java new file mode 100644 index 0000000000..8abdfa6c1b --- /dev/null +++ b/CodenameOne/src/com/codename1/payment/WalletPassEntry.java @@ -0,0 +1,143 @@ +/* + * 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.payment; + +/// A card entry published to the Apple Wallet issuer-provisioning extension +/// through [WalletExtension]. Each entry describes one card the user can add +/// to Apple Wallet from inside the Wallet app. +/// +/// The `identifier` must match the card's primary account identifier known to +/// the issuer backend; Wallet uses it to filter out cards that are already +/// provisioned on the device, and it is echoed back to the issuer endpoint +/// when the user adds the card. +/// +/// The card art must be a PNG without personally identifiable information +/// (Apple requirement: square corners, no PII such as the full card number). +public class WalletPassEntry { + private String identifier; + private String title; + private String cardholderName; + private String primaryAccountSuffix; + private String paymentNetwork; + private String localizedDescription; + private byte[] artPng; + + /// Creates a blank entry; populate it with the fluent setters. + public WalletPassEntry() { + } + + /// Creates an entry with the required fields. + /// + /// #### Parameters + /// + /// - `identifier`: the card's primary account identifier + /// + /// - `title`: user visible card title shown in Wallet + /// + /// - `artPng`: PNG bytes of the card art, e.g. `EncodedImage.getImageData()` + public WalletPassEntry(String identifier, String title, byte[] artPng) { + this.identifier = identifier; + this.title = title; + this.artPng = artPng; + } + + /// Sets the card's primary account identifier. Required. + public WalletPassEntry identifier(String identifier) { + this.identifier = identifier; + return this; + } + + /// Sets the user visible card title shown in Wallet. Required. + public WalletPassEntry title(String title) { + this.title = title; + return this; + } + + /// Sets the cardholder name shown during provisioning. + public WalletPassEntry cardholderName(String cardholderName) { + this.cardholderName = cardholderName; + return this; + } + + /// Sets the last digits of the card number shown during provisioning, + /// e.g. `"1234"`. + public WalletPassEntry primaryAccountSuffix(String primaryAccountSuffix) { + this.primaryAccountSuffix = primaryAccountSuffix; + return this; + } + + /// Sets the payment network, e.g. `"Visa"` or `"MasterCard"`. Must match + /// one of Apple's `PKPaymentNetwork` constant values. + public WalletPassEntry paymentNetwork(String paymentNetwork) { + this.paymentNetwork = paymentNetwork; + return this; + } + + /// Sets the description shown during provisioning, e.g. `"My Bank Debit Card"`. + public WalletPassEntry localizedDescription(String localizedDescription) { + this.localizedDescription = localizedDescription; + return this; + } + + /// Sets the PNG bytes of the card art, e.g. `EncodedImage.getImageData()`. + /// Required; entries without art are not shown by Wallet. + public WalletPassEntry artPng(byte[] artPng) { + this.artPng = artPng; + return this; + } + + /// Returns the card's primary account identifier. + public String getIdentifier() { + return identifier; + } + + /// Returns the user visible card title. + public String getTitle() { + return title; + } + + /// Returns the cardholder name. + public String getCardholderName() { + return cardholderName; + } + + /// Returns the last digits of the card number. + public String getPrimaryAccountSuffix() { + return primaryAccountSuffix; + } + + /// Returns the payment network. + public String getPaymentNetwork() { + return paymentNetwork; + } + + /// Returns the description shown during provisioning. + public String getLocalizedDescription() { + return localizedDescription; + } + + /// Returns the PNG bytes of the card art. + public byte[] getArtPng() { + return artPng; + } +} diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 8173c7e35c..0b4f224216 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -6108,6 +6108,54 @@ public boolean isReceiveSharedContentSupported() { return impl.isReceiveSharedContentSupported(); } + /// Returns true if the platform supports publishing data to a Wallet + /// issuer-provisioning extension. Used internally by + /// `com.codename1.payment.WalletExtension`. + public boolean isWalletExtensionSupported() { + return impl.isWalletExtensionSupported(); + } + + /// Publishes the Wallet extension pass entries, replacing the previous + /// list. Used internally by `com.codename1.payment.WalletExtension`. + /// + /// #### Parameters + /// + /// - `remote`: true for the Apple Watch list, false for the iPhone list + /// + /// - `entries`: the available cards; null or empty clears the list + public void walletExtensionSetPassEntries(boolean remote, com.codename1.payment.WalletPassEntry[] entries) { + impl.walletExtensionClearPassEntries(remote); + if (entries != null) { + for (int i = 0; i < entries.length; i++) { + com.codename1.payment.WalletPassEntry e = entries[i]; + if (e == null) { + continue; + } + impl.walletExtensionAddPassEntry(remote, e.getIdentifier(), e.getTitle(), + e.getCardholderName(), e.getPrimaryAccountSuffix(), e.getPaymentNetwork(), + e.getLocalizedDescription(), e.getArtPng()); + } + } + } + + /// Sets the Wallet extension requires-authentication flag. Used + /// internally by `com.codename1.payment.WalletExtension`. + public void walletExtensionSetRequiresAuthentication(boolean requiresAuthentication) { + impl.walletExtensionSetRequiresAuthentication(requiresAuthentication); + } + + /// Publishes the Wallet extension auth token. Used internally by + /// `com.codename1.payment.WalletExtension`. + public void walletExtensionSetAuthToken(String token) { + impl.walletExtensionSetAuthToken(token); + } + + /// Clears all published Wallet extension data. Used internally by + /// `com.codename1.payment.WalletExtension`. + public void walletExtensionClear() { + impl.walletExtensionClear(); + } + /// Subscribes the device to a push topic. Used internally by /// `com.codename1.push.Push`. /// diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 8d3d7859db..07663b8666 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -11663,6 +11663,161 @@ JAVA_OBJECT com_codename1_impl_ios_IOSNative_getPendingSharedContent___java_lang return com_codename1_impl_ios_IOSNative_getPendingSharedContent___java_lang_String(CN1_THREAD_STATE_PASS_ARG me, appGroupId); } +// --- Wallet issuer-provisioning extension support --------------------------- +// The app publishes pass entries / auth token into the shared App Group where +// the generated Wallet extensions (see the ios.wallet.* build hints) read +// them. The group id comes from the CN1WalletAppGroup Info.plist key injected +// by the build. + +static NSString *cn1WalletGroupId() { + id v = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CN1WalletAppGroup"]; + return ([v isKindOfClass:[NSString class]] && [(NSString *)v length] > 0) ? (NSString *)v : nil; +} + +static NSUserDefaults *cn1WalletGroupDefaults() { + NSString *group = cn1WalletGroupId(); + return group == nil ? nil : [[NSUserDefaults alloc] initWithSuiteName:group]; +} + +static NSURL *cn1WalletGroupArtDir(BOOL create) { + NSString *group = cn1WalletGroupId(); + if (group == nil) { + return nil; + } + NSURL *container = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:group]; + if (container == nil) { + return nil; + } + NSURL *dir = [container URLByAppendingPathComponent:@"cn1wallet" isDirectory:YES]; + if (create) { + [[NSFileManager defaultManager] createDirectoryAtURL:dir withIntermediateDirectories:YES attributes:nil error:nil]; + } + return dir; +} + +static NSString *cn1WalletEntriesKey(JAVA_BOOLEAN remote) { + return remote ? @"cn1.wallet.remotePassEntries" : @"cn1.wallet.passEntries"; +} + +// Removes one list and deletes the card-art files its entries reference. Art +// files are uniquely named per entry so this never breaks the other list. +static void cn1WalletClearEntries(JAVA_BOOLEAN remote) { + NSUserDefaults *defaults = cn1WalletGroupDefaults(); + if (defaults == nil) { + return; + } + NSString *key = cn1WalletEntriesKey(remote); + NSArray *entries = [defaults arrayForKey:key]; + NSURL *artDir = cn1WalletGroupArtDir(NO); + for (id entry in entries) { + if (![entry isKindOfClass:[NSDictionary class]]) { + continue; + } + NSString *art = ((NSDictionary *)entry)[@"art"]; + if (art != nil && artDir != nil) { + [[NSFileManager defaultManager] removeItemAtURL:[artDir URLByAppendingPathComponent:art] error:nil]; + } + } + [defaults removeObjectForKey:key]; + [defaults synchronize]; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isWalletExtensionSupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + if (cn1WalletGroupId() == nil) { + return JAVA_FALSE; + } + if (@available(iOS 14, *)) { + return JAVA_TRUE; + } + return JAVA_FALSE; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isWalletExtensionSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return com_codename1_impl_ios_IOSNative_isWalletExtensionSupported___R_boolean(CN1_THREAD_STATE_PASS_ARG me); +} + +void com_codename1_impl_ios_IOSNative_walletExtensionClearPassEntries___boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_BOOLEAN remote) { + cn1WalletClearEntries(remote); +} + +void com_codename1_impl_ios_IOSNative_walletExtensionAddPassEntry___boolean_java_lang_String_java_lang_String_java_lang_String_java_lang_String_java_lang_String_java_lang_String_byte_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_BOOLEAN remote, JAVA_OBJECT identifier, JAVA_OBJECT title, JAVA_OBJECT cardholderName, JAVA_OBJECT accountSuffix, JAVA_OBJECT network, JAVA_OBJECT description, JAVA_OBJECT artPng) { + NSUserDefaults *defaults = cn1WalletGroupDefaults(); + if (defaults == nil || identifier == JAVA_NULL || artPng == JAVA_NULL) { + return; + } + NSURL *artDir = cn1WalletGroupArtDir(YES); + if (artDir == nil) { + return; + } + NSString *artName = [[[NSUUID UUID] UUIDString] stringByAppendingString:@".png"]; + NSData *artData = arrayToData(artPng); + if (artData == nil || ![artData writeToURL:[artDir URLByAppendingPathComponent:artName] atomically:YES]) { + return; + } + NSMutableDictionary *entry = [NSMutableDictionary dictionary]; + entry[@"identifier"] = toNSString(CN1_THREAD_STATE_PASS_ARG identifier); + entry[@"art"] = artName; + if (title != JAVA_NULL) { + entry[@"title"] = toNSString(CN1_THREAD_STATE_PASS_ARG title); + } + if (cardholderName != JAVA_NULL) { + entry[@"cardholderName"] = toNSString(CN1_THREAD_STATE_PASS_ARG cardholderName); + } + if (accountSuffix != JAVA_NULL) { + entry[@"accountSuffix"] = toNSString(CN1_THREAD_STATE_PASS_ARG accountSuffix); + } + if (network != JAVA_NULL) { + entry[@"network"] = toNSString(CN1_THREAD_STATE_PASS_ARG network); + } + if (description != JAVA_NULL) { + entry[@"description"] = toNSString(CN1_THREAD_STATE_PASS_ARG description); + } + NSString *key = cn1WalletEntriesKey(remote); + NSArray *existing = [defaults arrayForKey:key]; + NSMutableArray *updated = existing != nil ? [existing mutableCopy] : [NSMutableArray array]; + [updated addObject:entry]; + [defaults setObject:updated forKey:key]; + [defaults synchronize]; +} + +void com_codename1_impl_ios_IOSNative_walletExtensionSetRequiresAuthentication___boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_BOOLEAN requiresAuthentication) { + NSUserDefaults *defaults = cn1WalletGroupDefaults(); + if (defaults == nil) { + return; + } + [defaults setBool:(requiresAuthentication ? YES : NO) forKey:@"cn1.wallet.requiresAuthentication"]; + [defaults synchronize]; +} + +void com_codename1_impl_ios_IOSNative_walletExtensionSetAuthToken___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT token) { + NSUserDefaults *defaults = cn1WalletGroupDefaults(); + if (defaults == nil) { + return; + } + if (token == JAVA_NULL) { + [defaults removeObjectForKey:@"cn1.wallet.authToken"]; + } else { + [defaults setObject:toNSString(CN1_THREAD_STATE_PASS_ARG token) forKey:@"cn1.wallet.authToken"]; + } + [defaults synchronize]; +} + +void com_codename1_impl_ios_IOSNative_walletExtensionClear__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + NSUserDefaults *defaults = cn1WalletGroupDefaults(); + if (defaults == nil) { + return; + } + cn1WalletClearEntries(JAVA_FALSE); + cn1WalletClearEntries(JAVA_TRUE); + [defaults removeObjectForKey:@"cn1.wallet.authToken"]; + [defaults removeObjectForKey:@"cn1.wallet.requiresAuthentication"]; + [defaults synchronize]; + NSURL *artDir = cn1WalletGroupArtDir(NO); + if (artDir != nil) { + [[NSFileManager defaultManager] removeItemAtURL:artDir error:nil]; + } +} + // BEGIN IOSImplementation native code, this is used to optimize various "heavy" IOSImplementation methods #define DRAW_BGIMAGE_AT_GIVEN_POSITION_WITH_FILL_RECT(xpositionToDraw, ypositionToDraw) JAVA_BYTE bgTransparency = com_codename1_ui_plaf_Style_getBgTransparency___R_byte(threadStateData, s); \ diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index e544abdc32..ac1159a27b 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -10792,6 +10792,41 @@ public boolean isReceiveSharedContentSupported() { return true; } + @Override + public boolean isWalletExtensionSupported() { + return nativeInstance.isWalletExtensionSupported(); + } + + @Override + public void walletExtensionClearPassEntries(boolean remote) { + nativeInstance.walletExtensionClearPassEntries(remote); + } + + @Override + public void walletExtensionAddPassEntry(boolean remote, String identifier, String title, + String cardholderName, String accountSuffix, String network, String description, byte[] artPng) { + if (identifier == null || identifier.length() == 0 || artPng == null || artPng.length == 0) { + return; + } + nativeInstance.walletExtensionAddPassEntry(remote, identifier, title, + cardholderName, accountSuffix, network, description, artPng); + } + + @Override + public void walletExtensionSetRequiresAuthentication(boolean requiresAuthentication) { + nativeInstance.walletExtensionSetRequiresAuthentication(requiresAuthentication); + } + + @Override + public void walletExtensionSetAuthToken(String token) { + nativeInstance.walletExtensionSetAuthToken(token); + } + + @Override + public void walletExtensionClear() { + nativeInstance.walletExtensionClear(); + } + /// Invoked from native (on app activation) with the JSON payload written by the share /// extension. Parses it into a SharedContent and dispatches to the app. public static void fireSharedContentFromNative(String json) { diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index c337170b03..cdf74a99ba 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -826,6 +826,31 @@ native void nativeSetTransformMutable( /// shared App Group user defaults. Returns a JSON string or null if there is none. native String getPendingSharedContent(String appGroupId); + // --- Wallet issuer-provisioning extension (PassKit) --------------------- + // The App Group id is read natively from the CN1WalletAppGroup Info.plist + // key injected by the build when ios.wallet.extension is enabled. + + /// True on iOS 14+ when the CN1WalletAppGroup Info.plist key is present. + native boolean isWalletExtensionSupported(); + + /// Removes all published pass entries from the iPhone (remote=false) or + /// Apple Watch (remote=true) list, including their card-art files. + native void walletExtensionClearPassEntries(boolean remote); + + /// Appends one pass entry to the shared App Group suite and writes its + /// card art PNG into the group container. + native void walletExtensionAddPassEntry(boolean remote, String identifier, String title, + String cardholderName, String accountSuffix, String network, String description, byte[] artPng); + + /// Sets the requires-authentication flag read by the extension's status callback. + native void walletExtensionSetRequiresAuthentication(boolean requiresAuthentication); + + /// Stores the auth token forwarded to the issuer endpoint; null removes it. + native void walletExtensionSetAuthToken(String token); + + /// Clears all wallet extension data from the App Group. + native void walletExtensionClear(); + // --- Biometrics (LocalAuthentication.framework) ------------------------- /** True when LAContext.canEvaluatePolicy(deviceOwnerAuthenticationWithBiometrics) succeeds. */ diff --git a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc index 89297d5940..8d7e5c07db 100644 --- a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc +++ b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc @@ -463,6 +463,39 @@ Only supported for App Store builds. See https://www.codenameone.com/developer-g |ios.onDeviceDebug.waitForAttach |Boolean true/false defaults to false. When `true`, the app blocks at startup until the proxy connects and the IDE tells the VM to continue. Useful when the breakpoint to investigate fires during app boot. Has no effect unless `ios.onDeviceDebug=true`. +|ios.wallet.extension +|Boolean true/false defaults to false. Generates an Apple Wallet issuer provisioning extension (the "From apps on your iPhone" flow in the Wallet app) and embeds it in the build. Requires `ios.wallet.appGroup` and `ios.wallet.issuerEndpoint`. See the <>. + +|ios.wallet.appGroup +|App Group id starting with `group.` shared by the app and the generated Wallet extensions. The app publishes pass entries into this group through `com.codename1.payment.WalletExtension` and the group is added to the app and extension entitlements automatically. Required when `ios.wallet.extension=true`. + +|ios.wallet.issuerEndpoint +|HTTPS URL of the issuer backend endpoint that produces the encrypted provisioning payload. The generated extension POSTs Apple's certificates/nonce plus the card identifier and auth token there as JSON. Required when `ios.wallet.extension=true`. + +|ios.wallet.includeUI +|Boolean true/false defaults to false. Also generates the Wallet authorization UI extension - a login form shown inside the Wallet app when the app reports that authentication is required. Requires `ios.wallet.authEndpoint`. + +|ios.wallet.authEndpoint +|HTTPS URL the generated login UI extension POSTs `{"username","password"}` to; the JSON response's `token` is stored in the App Group for the provisioning request. Required when `ios.wallet.includeUI=true`. + +|ios.wallet.nonuiExtensionName / ios.wallet.uiExtensionName +|Names of the generated extension targets, also used as the bundle id suffix (`.`). Default `WalletNonUIExtension` / `WalletUIExtension`. The matching App IDs must be registered with the payment-pass-provisioning entitlement and listed by the card network. + +|ios.wallet.nonuiProvisioningProfile / ios.wallet.uiProvisioningProfile +|Cloud device builds only. File name of the extension's `.mobileprovision` placed under `common/src/main/resources`. The profile must match the app's distribution certificate and carry the `com.apple.developer.payment-pass-provisioning` entitlement; the build keeps it out of the app bundle. + +|ios.wallet.nonuiProvisioningURL / ios.wallet.uiProvisioningURL +|Cloud device builds only. URL fallback for the extension provisioning profile when it isn't bundled in resources, mirroring `ios.notificationServiceExtensionProvisioningURL`. + +|ios.wallet.nonui.buildSettings.SETTING / ios.wallet.ui.buildSettings.SETTING +|Extra Xcode build settings applied to the generated extension targets, e.g. `ios.wallet.nonui.buildSettings.DEVELOPMENT_TEAM=ABCD123456`. Applied last so they override the generated defaults. + +|ios.wallet.nonuiImportsInject, ios.wallet.statusInject, ios.wallet.passEntriesInject, ios.wallet.remotePassEntriesInject, ios.wallet.generateRequestInject, ios.wallet.generateResponseInject, ios.wallet.uiImportsInject, ios.wallet.uiViewDidLoadInject, ios.wallet.uiAuthRequestInject, ios.wallet.uiAuthResponseInject +|Objective-C code injected at the matching marker comment in the generated Wallet extension sources, for custom behavior at each callback (e.g. adding fields to the issuer endpoint payload in `generateRequestInject`). See the <>. + +|ios.appext.NAME.provisioningURL +|Cloud device builds only. URL of the provisioning profile for a generic app extension dropped into `ios/app_extensions/NAME/`, used when the extension folder doesn't bundle a `.mobileprovision` itself. The profile is installed on the build machine and added to the export options per bundle id. + |codename1.mac.appid |Mac Native cloud builds only. The Mac bundle identifier registered in App Store Connect / Apple Developer. Distinct from `codename1.ios.appid` because Apple treats the iOS and Mac App Store records as separate products. Required for cloud Mac builds. diff --git a/docs/developer-guide/Apple-Wallet-Extension.asciidoc b/docs/developer-guide/Apple-Wallet-Extension.asciidoc new file mode 100644 index 0000000000..601b0a43a8 --- /dev/null +++ b/docs/developer-guide/Apple-Wallet-Extension.asciidoc @@ -0,0 +1,142 @@ +== Apple Wallet Extension + +Card issuers (banks, fintechs) can let users add a payment card to Apple Wallet from inside the Wallet app itself. The user opens Wallet, taps the add button and sees the issuer's cards listed under "From apps on your iPhone" - without ever launching the issuer's app. Apple implements this through a pair of app extensions called issuer provisioning extensions, and Codename One can generate them for you from build hints with no native code in your project. + +This is distinct from the in-app flow where your app shows an "Add to Apple Wallet" button. The extension flow starts inside the Wallet app, runs in a separate process and usually runs while your app isn't running at all. + +=== How the extension works + +Apple defines two extension points: + +* A *non-UI extension* that Wallet queries for status and the list of available cards, and that produces the encrypted provisioning payload when the user adds a card. Wallet gives it 100ms to answer the status query, so it can't perform network calls or heavy work there. +* An optional *authorization UI extension* that presents a login screen inside Wallet when the non-UI extension reports that authentication is required. + +Because the extensions run in a separate process, they can't call into your Java code. The Codename One integration bridges this in two ways: + +. Your app pre-publishes the card list, card art and an auth token through `com.codename1.payment.WalletExtension`. The data lands in a shared App Group container where the generated extension reads it instantly. +. The one step that genuinely needs the issuer's backend - producing `encryptedPassData` from the certificates and nonce that Apple hands the extension - is performed by an HTTPS endpoint you host. The generated extension POSTs the request there and relays the response to Wallet. + +=== Prerequisites from Apple + +Before any of this works you need approvals that only Apple and your card network can grant: + +* The `com.apple.developer.payment-pass-provisioning` entitlement is restricted. Apple grants it per app on request (see the In-App Provisioning documentation on the Apple developer site). After approval, enable it under the App ID's *Additional Capabilities* tab for the app *and for each extension App ID*. +* Each extension needs its own App ID (e.g. `com.mybank.app.WalletNonUIExtension`) and its own provisioning profile carrying the entitlement. +* The card network/PNO pass metadata must list the extension App IDs in `associatedApplicationIdentifiers`, otherwise Wallet never invokes the extensions. +* The extensions require iOS 14. The generated extension targets set their own deployment target, so your app can still target an older iOS version. + +=== Mode 1: generate the extensions from build hints + +Add these build hints to the iOS build: + +[source,properties] +---- +codename1.arg.ios.wallet.extension=true +codename1.arg.ios.wallet.appGroup=group.com.mybank.app +codename1.arg.ios.wallet.issuerEndpoint=https://api.mybank.com/wallet/provision +---- + +That generates the non-UI extension, wires it into the Xcode project as an embedded extension target and injects the App Group into the app and extension entitlements. To also generate the login UI extension add: + +[source,properties] +---- +codename1.arg.ios.wallet.includeUI=true +codename1.arg.ios.wallet.authEndpoint=https://api.mybank.com/wallet/login +---- + +In Java, publish the user's cards whenever they change (typically after login) and keep a fresh token published so Wallet can skip the login screen: + +[source,java] +---- +import com.codename1.payment.WalletExtension; +import com.codename1.payment.WalletPassEntry; + +if (WalletExtension.isSupported()) { + WalletExtension.setPassEntries(new WalletPassEntry[] { + new WalletPassEntry() + .identifier(card.getPrimaryAccountIdentifier()) + .title("My Bank Debit Card") + .cardholderName(user.getFullName()) + .primaryAccountSuffix(card.getLast4()) + .paymentNetwork("Visa") + .localizedDescription("My Bank Debit Card") + .artPng(cardArt.getImageData()) + }); + WalletExtension.setRemotePassEntries(sameEntries); // Apple Watch list + WalletExtension.setAuthToken(session.getToken()); + WalletExtension.setRequiresAuthentication(false); +} +---- + +Call `WalletExtension.clear()` on logout. The card art must be a PNG without personally identifiable information (Apple requires square corners and no full card number). The extension automatically filters out cards that are already provisioned on the device or the paired watch. + +==== The issuer endpoint + +When the user adds a card, the generated extension POSTs JSON to the `ios.wallet.issuerEndpoint` URL: + +[source,json] +---- +{ + "certificates": ["base64...", "base64..."], + "nonce": "base64...", + "nonceSignature": "base64...", + "cardIdentifier": "the WalletPassEntry identifier", + "authToken": "the token published via setAuthToken" +} +---- + +The token is also sent as an `Authorization: Bearer` header. Your backend performs the network-specific encryption (this always happens server side - the keys never live on the device) and responds with: + +[source,json] +---- +{ + "activationData": "base64...", + "encryptedPassData": "base64...", + "ephemeralPublicKey": "base64..." +} +---- + +For the RSA_V2 scheme return `wrappedKey` instead of `ephemeralPublicKey`. + +==== The login UI extension + +When you can't guarantee a fresh token, report `setRequiresAuthentication(true)` and enable the UI extension. It shows a minimal username/password form inside Wallet, POSTs `{"username", "password"}` to the `ios.wallet.authEndpoint` URL, expects `{"token"}` back and stores it where the non-UI extension picks it up. If the generated form doesn't fit your brand or login model, use the injection hints below or bring your own extension (Mode 2). + +==== Customizing the generated code + +The generated Objective-C contains marker comments at every interesting point, and each marker has a matching build hint that injects your snippet there: `ios.wallet.nonuiImportsInject`, `ios.wallet.statusInject`, `ios.wallet.passEntriesInject`, `ios.wallet.remotePassEntriesInject`, `ios.wallet.generateRequestInject` (runs before the POST - the mutable `payload` dictionary is in scope, so you can add fields your backend expects), `ios.wallet.generateResponseInject`, `ios.wallet.uiImportsInject`, `ios.wallet.uiViewDidLoadInject`, `ios.wallet.uiAuthRequestInject` and `ios.wallet.uiAuthResponseInject`. + +Xcode build settings of the extension targets can be overridden with `ios.wallet.nonui.buildSettings.SETTING=value` and `ios.wallet.ui.buildSettings.SETTING=value` - useful in local builds for `DEVELOPMENT_TEAM` or `CODE_SIGN_STYLE`. + +==== Provisioning profiles for cloud builds + +Cloud device builds sign each extension with its own profile. Either place the `.mobileprovision` files under `common/src/main/resources` and name them in build hints: + +[source,properties] +---- +codename1.arg.ios.wallet.nonuiProvisioningProfile=WalletNonUI.mobileprovision +codename1.arg.ios.wallet.uiProvisioningProfile=WalletUI.mobileprovision +---- + +Alternatively, host them at URLs the build server can reach and supply `ios.wallet.nonuiProvisioningURL` / `ios.wallet.uiProvisioningURL` instead. Profiles contain no private keys, so bundling them in resources is safe; the build keeps them out of the final app bundle. The build validates that each profile matches your distribution certificate and actually carries the payment-pass-provisioning entitlement, and fails with an actionable message when it doesn't. + +In local builds ("ios-source" target) no profile hints are needed - you sign the generated Xcode project in Xcode as usual. + +=== Mode 2: bring your own extension + +If you already have Xcode extension targets - from an issuer SDK vendor or an existing native app - skip the `ios.wallet.*` hints and drop each extension into the generic app extension mechanism instead. Create `ios/app_extensions/MyWalletExtension/` in your project containing: + +* The extension's source files (`.m`, `.h`, `.swift`) +* `Info.plist` with the `NSExtension` dictionary +* `MyWalletExtension.entitlements` +* Optional `buildSettings.properties` with Xcode build settings, one per line (set `IPHONEOS_DEPLOYMENT_TARGET=14.0` for Wallet extensions) +* Optional `.mobileprovision` for cloud device builds (or supply `ios.appext.MyWalletExtension.provisioningURL`) + +Each folder becomes an embedded extension target with bundle id `.`. This mechanism isn't Wallet-specific - it embeds any iOS app extension type. + +=== Testing + +Wallet extensions can't be exercised in the iOS simulator's Wallet app; testing the full flow requires a device, a provisioning profile with the entitlement and a card network sandbox. What you can verify earlier: + +* A local "ios-source" build with the hints produces the `WalletNonUIExtension`/`WalletUIExtension` targets - open the generated Xcode project and build. +* `WalletExtension.isSupported()` returns `false` in the simulator and on other platforms, and all publish calls are safe no-ops there, so the Java code needs no platform guards beyond the one shown above. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 80329b7fea..3d4577097b 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -87,6 +87,8 @@ include::Advertising.asciidoc[] include::Monetization.asciidoc[] +include::Apple-Wallet-Extension.asciidoc[] + include::Advanced-Topics-Under-The-Hood.asciidoc[] diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index fa5c9ce5cc..b2fd5549ea 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -22,6 +22,7 @@ */ package com.codename1.builders; +import com.codename1.util.IOSWalletExtensionBuilder; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; @@ -2393,18 +2394,38 @@ public void usesClassMethod(String cls, String method) { throw new BuildException("Failed to normalize iOS asset catalogs", ex); } stopwatch.split("Post-VM Setup"); - if (runPods) { + boolean walletExtensionEnabled = "true".equals(request.getArg("ios.wallet.extension", "false")); + if (walletExtensionEnabled) { + if (!request.getArg("ios.wallet.appGroup", "").startsWith("group.") + || request.getArg("ios.wallet.issuerEndpoint", "").length() == 0) { + log("The ios.wallet.extension build hint requires both of the following build hints:\n" + + " ios.wallet.appGroup={App Group id starting with 'group.' shared by the app and the Wallet extensions}\n" + + " ios.wallet.issuerEndpoint={HTTPS URL of the issuer endpoint that produces the encrypted pass payload}"); + return false; + } + if ("true".equals(request.getArg("ios.wallet.includeUI", "false")) + && request.getArg("ios.wallet.authEndpoint", "").length() == 0) { + log("The ios.wallet.includeUI build hint requires the ios.wallet.authEndpoint={HTTPS URL of the login endpoint} build hint"); + return false; + } + } + // Wallet extensions and .ios.appext archives mutate the Xcode project through the + // ruby xcodeproj gem even when CocoaPods isn't otherwise needed. + boolean needsXcodeProjectMutation = runPods || walletExtensionEnabled || hasAppExtensionArchives(resDir); + if (needsXcodeProjectMutation) { try { List podSpecFileList = new ArrayList(); - for (File podSpec : podSpecs.listFiles()) { - if (podSpec.getName().startsWith(".")) { - continue; - } - File distDir = new File(tmpFile, "dist"); - File targetF = new File(distDir, podSpec.getName()); - Files.move(podSpec.toPath(), targetF.toPath(), StandardCopyOption.REPLACE_EXISTING); - podSpecFileList.add(targetF); + if (runPods) { + for (File podSpec : podSpecs.listFiles()) { + if (podSpec.getName().startsWith(".")) { + continue; + } + File distDir = new File(tmpFile, "dist"); + File targetF = new File(distDir, podSpec.getName()); + Files.move(podSpec.toPath(), targetF.toPath(), StandardCopyOption.REPLACE_EXISTING); + podSpecFileList.add(targetF); + } } String deploymentTargetStr = ""; @@ -2519,7 +2540,10 @@ public void usesClassMethod(String cls, String method) { - sb.append("\nservice_target = xcproj.new_target(:app_extension, '" + extensionName + "', :ios, '10.0')\n" + // Guarded so the post-dependency re-run of fix_xcode_schemes.rb + // doesn't create duplicate extension targets. + sb.append("\nif xcproj.targets.find{|e| e.name=='" + extensionName + "'}.nil?\n" + + "service_target = xcproj.new_target(:app_extension, '" + extensionName + "', :ios, '10.0')\n" + "xcproj.targets.find{|e|e.name=='" + request.getMainClass() + "'}.build_configurations.each{|e| \n" + " e.build_settings['PROVISIONING_PROFILE']='$(APP_PROVISIONING_PROFILE)'\n" + " e.build_settings['CODE_SIGN_ENTITLEMENTS']='$(APP_CODE_SIGN_ENTITLEMENTS)'\n" @@ -2527,9 +2551,10 @@ public void usesClassMethod(String cls, String method) { //+ "service_target.frameworks_build_phase.add_file_reference(xcproj.files.find{|e|e.path.include? 'UserNotifications.framework'})\n" + "service_group = xcproj.new_group('" + extensionName + "')\n"); appendFilesToXcodeProjGroup(sb, appExtension, "service_group", "service_target", appExtension.getParentFile()); - sb.append("xcproj.targets.find{|e|e.name==main_class_name}.add_dependency(service_target)\n" + sb.append("main_app_target = xcproj.targets.find{|e| e.name==main_class_name}\n" + + "main_app_target.add_dependency(service_target)\n" + "fileref = xcproj.groups.find{|e| e.display_name=='Products'}.new_file('" + extensionName + ".appex', \"BUILT_PRODUCTS_DIR\")\n" - + "embed_phase=xcproj.targets.find{|e| e.name=='" + request.getMainClass() + "'}.new_copy_files_build_phase('Embed App Extensions')\n" + + "embed_phase = main_app_target.copy_files_build_phases.find{|p| p.name=='Embed App Extensions'} || main_app_target.new_copy_files_build_phase('Embed App Extensions')\n" + "embed_phase.build_action_mask = \"2147483647\"\n" + "embed_phase.dst_subfolder_spec = \"13\"\n" + "embed_phase.run_only_for_deployment_postprocessing=\"0\"\n" @@ -2539,6 +2564,7 @@ public void usesClassMethod(String cls, String method) { sb.append(" e.build_settings['" + buildSettingKey + "'] = \"" + buildSettingsMap.get(buildSettingKey) + "\"\n"); } sb.append("}\n"); + sb.append("end\n"); @@ -2548,6 +2574,10 @@ public void usesClassMethod(String cls, String method) { } } + if (walletExtensionEnabled) { + appendWalletExtensionTargets(appExtensionsBuilder, request, new File(tmpFile, "dist")); + } + String installLocalizedStrings = ""; if (installLocalizedStringsScript.length() > 0) { installLocalizedStrings = "begin\n"+ @@ -2677,10 +2707,11 @@ public void usesClassMethod(String cls, String method) { exec(hooksDir, "chmod", "0755", fixSchemesFile.getAbsolutePath()); exec(hooksDir, "echo", fixSchemesFile.getAbsolutePath()); if (!exec(hooksDir, fixSchemesFile.getAbsolutePath())) { - log("Failed to fix xcode project schemes. Make sure you have Cocoapods installed. "); + log("Failed to fix xcode project schemes. Make sure you have the xcodeproj ruby gem installed (gem install xcodeproj; it is also bundled with Cocoapods). "); return false; } + if (runPods) { if (!exec(new File(tmpFile, "dist"), podTimeout, pod, "init")) { log("Failed to run "+pod+" init. Make sure you have Cocoapods installed."); return false; @@ -2800,8 +2831,9 @@ public void usesClassMethod(String cls, String method) { } } } + } // end if (runPods) } catch (Exception ex) { - throw new BuildException("Failed to generate PodFile", ex); + throw new BuildException("Failed to update the generated Xcode project", ex); } stopwatch.split("CocoaPods"); } @@ -3060,7 +3092,8 @@ private void appendFilesToXcodeProjGroup(StringBuilder sb, File dir, String serv sb.append("fileref = ").append(serviceGroupVarName).append(".new_file(").append("'").append(f.getAbsolutePath().substring(basePathLen)).append("')\n"); if (f.getName().endsWith(".m") || f.getName().endsWith(".swift")) { sb.append(serviceTargetVarName).append(".add_file_references([fileref])\n"); - } else if (!f.getName().endsWith("Info.plist") && !f.getName().endsWith(".entitlements")){ + } else if (!f.getName().endsWith("Info.plist") && !f.getName().endsWith(".entitlements") + && !f.getName().endsWith(".h") && !f.getName().endsWith(".mobileprovision")){ sb.append(serviceTargetVarName).append(".add_resources([fileref])\n"); } } else { @@ -3296,6 +3329,105 @@ public void stop() { } } + private boolean hasAppExtensionArchives(File sourceDirectory) { + File[] children = sourceDirectory == null ? null : sourceDirectory.listFiles(); + if (children == null) { + return false; + } + for (File f : children) { + if (f.getName().endsWith(".ios.appext")) { + return true; + } + } + return false; + } + + private static final String[][] WALLET_INJECTION_HINTS = { + {"ios.wallet.nonuiImportsInject", IOSWalletExtensionBuilder.MARKER_NONUI_IMPORTS}, + {"ios.wallet.statusInject", IOSWalletExtensionBuilder.MARKER_STATUS}, + {"ios.wallet.passEntriesInject", IOSWalletExtensionBuilder.MARKER_PASS_ENTRIES}, + {"ios.wallet.remotePassEntriesInject", IOSWalletExtensionBuilder.MARKER_REMOTE_PASS_ENTRIES}, + {"ios.wallet.generateRequestInject", IOSWalletExtensionBuilder.MARKER_GENERATE_REQUEST}, + {"ios.wallet.generateResponseInject", IOSWalletExtensionBuilder.MARKER_GENERATE_RESPONSE}, + {"ios.wallet.uiImportsInject", IOSWalletExtensionBuilder.MARKER_UI_IMPORTS}, + {"ios.wallet.uiViewDidLoadInject", IOSWalletExtensionBuilder.MARKER_UI_VIEWDIDLOAD}, + {"ios.wallet.uiAuthRequestInject", IOSWalletExtensionBuilder.MARKER_UI_AUTH_REQUEST}, + {"ios.wallet.uiAuthResponseInject", IOSWalletExtensionBuilder.MARKER_UI_AUTH_RESPONSE}, + }; + + /** + * Generates the Apple Wallet issuer-provisioning extension folders under dist/ + * and appends the ruby that wires them into the generated Xcode project as + * app_extension targets. Driven by the ios.wallet.* build hints. + */ + private void appendWalletExtensionTargets(StringBuilder sb, BuildRequest request, File distDir) throws IOException { + IOSWalletExtensionBuilder walletBuilder = new IOSWalletExtensionBuilder() + .setAppGroupId(request.getArg("ios.wallet.appGroup", "")) + .setIssuerEndpoint(request.getArg("ios.wallet.issuerEndpoint", "")) + .setAuthEndpoint(request.getArg("ios.wallet.authEndpoint", "")) + .setNonUIExtensionName(request.getArg("ios.wallet.nonuiExtensionName", "WalletNonUIExtension")) + .setUIExtensionName(request.getArg("ios.wallet.uiExtensionName", "WalletUIExtension")); + for (String[] hintAndMarker : WALLET_INJECTION_HINTS) { + walletBuilder.setInjection(hintAndMarker[1], request.getArg(hintAndMarker[0], null)); + } + + String nonUIName = walletBuilder.getNonUIExtensionName(); + IOSWalletExtensionBuilder.writeFileMap(walletBuilder.buildNonUIFileMap(), new File(distDir, nonUIName)); + appendWalletExtensionRuby(sb, request, nonUIName, distDir, "ios.wallet.nonui.buildSettings."); + log("Adding Wallet issuer-provisioning extension target " + nonUIName); + + if ("true".equals(request.getArg("ios.wallet.includeUI", "false"))) { + String uiName = walletBuilder.getUIExtensionName(); + IOSWalletExtensionBuilder.writeFileMap(walletBuilder.buildUIFileMap(), new File(distDir, uiName)); + appendWalletExtensionRuby(sb, request, uiName, distDir, "ios.wallet.ui.buildSettings."); + log("Adding Wallet issuer-provisioning authorization UI extension target " + uiName); + } + sb.append("xcproj.save(project_file)\n"); + } + + private void appendWalletExtensionRuby(StringBuilder sb, BuildRequest request, String extensionName, File distDir, String buildSettingsHintPrefix) { + Map buildSettingsMap = new LinkedHashMap(); + buildSettingsMap.put("PRODUCT_BUNDLE_IDENTIFIER", request.getPackageName() + "." + extensionName); + buildSettingsMap.put("PRODUCT_NAME", "$(TARGET_NAME)"); + buildSettingsMap.put("INFOPLIST_FILE", extensionName + "/Info.plist"); + buildSettingsMap.put("CODE_SIGN_ENTITLEMENTS", extensionName + "/" + extensionName + ".entitlements"); + // PKIssuerProvisioningExtensionHandler requires iOS 14; the extension target + // keeps its own deployment target even when the app targets lower. + buildSettingsMap.put("IPHONEOS_DEPLOYMENT_TARGET", "14.0"); + buildSettingsMap.put("TARGETED_DEVICE_FAMILY", "1,2"); + buildSettingsMap.put("LD_RUNPATH_SEARCH_PATHS", "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"); + buildSettingsMap.put("SKIP_INSTALL", "YES"); + buildSettingsMap.put("CLANG_ENABLE_OBJC_ARC", "YES"); + buildSettingsMap.put("CLANG_ENABLE_MODULES", "YES"); + for (String key : request.getArgs()) { + if (key.startsWith(buildSettingsHintPrefix)) { + buildSettingsMap.put(key.substring(buildSettingsHintPrefix.length()), request.getArg(key, "")); + } + } + // The whole fragment is guarded so re-running the script (the build + // re-executes fix_xcode_schemes.rb after dependency integration) + // doesn't create duplicate targets. + sb.append("\nif xcproj.targets.find{|e| e.name=='" + extensionName + "'}.nil?\n" + + "service_target = xcproj.new_target(:app_extension, '" + extensionName + "', :ios, '14.0')\n" + + "service_target.add_system_framework('PassKit')\n" + + "service_group = xcproj.new_group('" + extensionName + "')\n"); + appendFilesToXcodeProjGroup(sb, new File(distDir, extensionName), "service_group", "service_target", distDir); + sb.append("main_app_target = xcproj.targets.find{|e| e.name==main_class_name}\n" + + "main_app_target.add_dependency(service_target)\n" + + "fileref = xcproj.groups.find{|e| e.display_name=='Products'}.new_file('" + extensionName + ".appex', \"BUILT_PRODUCTS_DIR\")\n" + + "embed_phase = main_app_target.copy_files_build_phases.find{|p| p.name=='Embed App Extensions'} || main_app_target.new_copy_files_build_phase('Embed App Extensions')\n" + + "embed_phase.build_action_mask = \"2147483647\"\n" + + "embed_phase.dst_subfolder_spec = \"13\"\n" + + "embed_phase.run_only_for_deployment_postprocessing=\"0\"\n" + + "embed_phase.add_file_reference(fileref)\n" + + "service_target.build_configurations.each{|e| \n"); + for (String buildSettingKey : buildSettingsMap.keySet()) { + sb.append(" e.build_settings['" + buildSettingKey + "'] = \"" + buildSettingsMap.get(buildSettingKey) + "\"\n"); + } + sb.append("}\n"); + sb.append("end\n"); + } + private File[] extractAppExtensions(File sourceDirectory, File targetDirectory) throws IOException { if (sourceDirectory == null || !sourceDirectory.isDirectory()) { throw new IllegalArgumentException("extractAppExtensions sourceDirectory must be an existing directory but received "+sourceDirectory); @@ -3654,6 +3786,14 @@ public boolean accept(File file, String string) { inject += "\nCN1ShareAppGroup" + shareAppGroup.trim() + ""; } + // Wallet issuer-provisioning: com.codename1.payment.WalletExtension reads this App Group + // suite to publish pass entries for the generated Wallet extensions. See ios.wallet.* hints. + String walletAppGroup = request.getArg("ios.wallet.appGroup", null); + if ("true".equals(request.getArg("ios.wallet.extension", "false")) + && walletAppGroup != null && walletAppGroup.trim().length() > 0 && !inject.contains("CN1WalletAppGroup")) { + inject += "\nCN1WalletAppGroup" + walletAppGroup.trim() + ""; + } + BufferedReader infoReader = new BufferedReader(new InputStreamReader( Files.newInputStream(infoPlist.toPath()), StandardCharsets.UTF_8)); StringBuilder b = new StringBuilder(); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSWalletExtensionBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSWalletExtensionBuilder.java new file mode 100644 index 0000000000..8f6a5740d0 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSWalletExtensionBuilder.java @@ -0,0 +1,695 @@ +/* + * 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.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Generates the Apple Wallet issuer-provisioning extension pair that the + * Codename One iOS build wires into the generated Xcode project when the + * {@code ios.wallet.extension} build hint is enabled. + * + *

An Apple Wallet ("In-App Provisioning") extension lets a card + * issuer's app surface its cards inside the Wallet app itself, under + * "From apps on your iPhone", so users can provision a card without + * launching the issuer's app. Apple defines two extension points:

+ * + *
    + *
  • A non-UI extension ({@code com.apple.PassKit.issuer-provisioning}) + * whose principal class subclasses {@code PKIssuerProvisioningExtensionHandler}. + * It must answer {@code status} within 100ms, list available passes, + * and produce the encrypted add-pass payload.
  • + *
  • An optional authorization UI extension + * ({@code com.apple.PassKit.issuer-provisioning.authorization}) that + * presents a login screen inside Wallet when the non-UI extension + * reports {@code requiresAuthentication}.
  • + *
+ * + *

The generated extensions are fixed Objective-C sources that contain no + * customer logic. They are driven entirely by data the Codename One app + * publishes through {@code com.codename1.payment.WalletExtension} into the + * shared App Group container (suite {@code NSUserDefaults} for entries, + * auth token and flags; PNG card art under {@code cn1wallet/} in the group + * container), plus two customer HTTPS endpoints:

+ * + *
    + *
  • The issuer endpoint receives a JSON POST + * {@code {certificates:[base64], nonce, nonceSignature, cardIdentifier, authToken}} + * and must answer {@code {activationData, encryptedPassData, ephemeralPublicKey}} + * (all base64; {@code wrappedKey} supported for RSA_V2) — this is the + * step that only the issuer's backend can perform.
  • + *
  • The auth endpoint (UI extension only) receives + * {@code {username, password}} and answers {@code {token}}.
  • + *
+ * + *

Customers needing custom behavior inject Objective-C snippets at the + * marker comments via the {@code ios.wallet.*Inject} build hints (see the + * {@code MARKER_*} constants); the markers survive injection so multiple + * passes remain possible.

+ * + *

Note that the {@code com.apple.developer.payment-pass-provisioning} + * entitlement written into the generated {@code .entitlements} files is + * restricted: Apple grants it per-app on request, and each extension needs + * its own App ID and provisioning profile carrying it.

+ */ +public class IOSWalletExtensionBuilder { + + /** Principal class name of the generated non-UI extension. */ + public static final String NONUI_PRINCIPAL_CLASS = "CN1WalletExtensionHandler"; + + /** Principal class name of the generated authorization UI extension. */ + public static final String UI_PRINCIPAL_CLASS = "CN1WalletAuthViewController"; + + /** Info.plist key holding the App Group id, read by extension and app alike. */ + public static final String APP_GROUP_PLIST_KEY = "CN1WalletAppGroup"; + + /** Info.plist key holding the issuer endpoint URL in the non-UI extension. */ + public static final String ISSUER_ENDPOINT_PLIST_KEY = "CN1WalletIssuerEndpoint"; + + /** Info.plist key holding the auth endpoint URL in the UI extension. */ + public static final String AUTH_ENDPOINT_PLIST_KEY = "CN1WalletAuthEndpoint"; + + // Suite/user-defaults keys shared with IOSNative.m (the app side). + public static final String PASS_ENTRIES_KEY = "cn1.wallet.passEntries"; + public static final String REMOTE_PASS_ENTRIES_KEY = "cn1.wallet.remotePassEntries"; + public static final String AUTH_TOKEN_KEY = "cn1.wallet.authToken"; + public static final String REQUIRES_AUTH_KEY = "cn1.wallet.requiresAuthentication"; + + /** Directory inside the App Group container holding card-art PNGs. */ + public static final String ART_DIR = "cn1wallet"; + + // Injection markers in the non-UI extension sources. + public static final String MARKER_NONUI_IMPORTS = "//CN1_WALLET_NONUI_IMPORTS_MARKER"; + public static final String MARKER_STATUS = "//CN1_WALLET_STATUS_MARKER"; + public static final String MARKER_PASS_ENTRIES = "//CN1_WALLET_PASS_ENTRIES_MARKER"; + public static final String MARKER_REMOTE_PASS_ENTRIES = "//CN1_WALLET_REMOTE_PASS_ENTRIES_MARKER"; + public static final String MARKER_GENERATE_REQUEST = "//CN1_WALLET_GENERATE_REQUEST_MARKER"; + public static final String MARKER_GENERATE_RESPONSE = "//CN1_WALLET_GENERATE_RESPONSE_MARKER"; + + // Injection markers in the UI extension sources. + public static final String MARKER_UI_IMPORTS = "//CN1_WALLET_UI_IMPORTS_MARKER"; + public static final String MARKER_UI_VIEWDIDLOAD = "//CN1_WALLET_UI_VIEWDIDLOAD_MARKER"; + public static final String MARKER_UI_AUTH_REQUEST = "//CN1_WALLET_UI_AUTH_REQUEST_MARKER"; + public static final String MARKER_UI_AUTH_RESPONSE = "//CN1_WALLET_UI_AUTH_RESPONSE_MARKER"; + + private String nonUIExtensionName = "WalletNonUIExtension"; + private String uiExtensionName = "WalletUIExtension"; + private String appGroupId; + private String issuerEndpoint; + private String authEndpoint; + private String applicationIdentifierPrefix; + private final Map injections = new LinkedHashMap(); + + /** Bare-bones constructor. Configure with the fluent setters. */ + public IOSWalletExtensionBuilder() {} + + /** + * Sets the non-UI extension target name (Xcode target, .appex bundle + * and bundle-id suffix). Must be an ASCII identifier. Defaults to + * {@code WalletNonUIExtension}. + */ + public IOSWalletExtensionBuilder setNonUIExtensionName(String name) { + this.nonUIExtensionName = name; + return this; + } + + /** + * Sets the authorization UI extension target name. Defaults to + * {@code WalletUIExtension}. + */ + public IOSWalletExtensionBuilder setUIExtensionName(String name) { + this.uiExtensionName = name; + return this; + } + + /** + * The App Group identifier shared between the host app and both + * extensions. Apple requires it to start with {@code group.}. Required. + */ + public IOSWalletExtensionBuilder setAppGroupId(String id) { + this.appGroupId = id; + return this; + } + + /** + * HTTPS endpoint POSTed by the non-UI extension's + * {@code generateAddPaymentPassRequest...} step. Required for the + * non-UI extension. + */ + public IOSWalletExtensionBuilder setIssuerEndpoint(String url) { + this.issuerEndpoint = url; + return this; + } + + /** + * HTTPS endpoint POSTed by the UI extension's login form. Required + * only when the UI extension is generated. + */ + public IOSWalletExtensionBuilder setAuthEndpoint(String url) { + this.authEndpoint = url; + return this; + } + + /** + * Optional application-identifier prefix (e.g. {@code TEAMID.com.example.app}); + * when set, each extension's entitlements include + * {@code application-identifier = .}. The cloud + * builder passes the build request's app id here; local builds omit it + * and let Xcode signing fill it in. + */ + public IOSWalletExtensionBuilder setApplicationIdentifierPrefix(String prefix) { + this.applicationIdentifierPrefix = prefix; + return this; + } + + /** + * Registers an Objective-C snippet to inject at one of the + * {@code MARKER_*} comments. The marker is preserved after the injected + * code so repeated processing stays possible. Unknown markers are + * rejected to catch build-hint typos early. + */ + public IOSWalletExtensionBuilder setInjection(String marker, String objcCode) { + if (!isKnownMarker(marker)) { + throw new IllegalArgumentException("Unknown wallet extension injection marker: " + marker); + } + if (objcCode != null && objcCode.length() > 0) { + injections.put(marker, objcCode); + } + return this; + } + + public String getNonUIExtensionName() { return nonUIExtensionName; } + public String getUIExtensionName() { return uiExtensionName; } + public String getAppGroupId() { return appGroupId; } + public String getIssuerEndpoint() { return issuerEndpoint; } + public String getAuthEndpoint() { return authEndpoint; } + public String getApplicationIdentifierPrefix() { return applicationIdentifierPrefix; } + + private static boolean isKnownMarker(String marker) { + return MARKER_NONUI_IMPORTS.equals(marker) + || MARKER_STATUS.equals(marker) + || MARKER_PASS_ENTRIES.equals(marker) + || MARKER_REMOTE_PASS_ENTRIES.equals(marker) + || MARKER_GENERATE_REQUEST.equals(marker) + || MARKER_GENERATE_RESPONSE.equals(marker) + || MARKER_UI_IMPORTS.equals(marker) + || MARKER_UI_VIEWDIDLOAD.equals(marker) + || MARKER_UI_AUTH_REQUEST.equals(marker) + || MARKER_UI_AUTH_RESPONSE.equals(marker); + } + + /** + * Builds the in-memory file map of the non-UI extension, keyed by + * relative path inside the extension folder. + */ + public Map buildNonUIFileMap() { + validateCommon(nonUIExtensionName); + if (issuerEndpoint == null || issuerEndpoint.length() == 0) { + throw new IllegalStateException("issuerEndpoint must be set (ios.wallet.issuerEndpoint build hint)"); + } + LinkedHashMap map = new LinkedHashMap(); + map.put("Info.plist", utf8(buildInfoPlist(nonUIExtensionName, + "com.apple.PassKit.issuer-provisioning", NONUI_PRINCIPAL_CLASS, + ISSUER_ENDPOINT_PLIST_KEY, issuerEndpoint))); + map.put(nonUIExtensionName + ".entitlements", utf8(buildEntitlements(nonUIExtensionName))); + map.put(NONUI_PRINCIPAL_CLASS + ".h", utf8(buildNonUIHeader())); + map.put(NONUI_PRINCIPAL_CLASS + ".m", utf8(inject(buildNonUISource()))); + return map; + } + + /** + * Builds the in-memory file map of the authorization UI extension, + * keyed by relative path inside the extension folder. + */ + public Map buildUIFileMap() { + validateCommon(uiExtensionName); + if (authEndpoint == null || authEndpoint.length() == 0) { + throw new IllegalStateException("authEndpoint must be set (ios.wallet.authEndpoint build hint)"); + } + LinkedHashMap map = new LinkedHashMap(); + map.put("Info.plist", utf8(buildInfoPlist(uiExtensionName, + "com.apple.PassKit.issuer-provisioning.authorization", UI_PRINCIPAL_CLASS, + AUTH_ENDPOINT_PLIST_KEY, authEndpoint))); + map.put(uiExtensionName + ".entitlements", utf8(buildEntitlements(uiExtensionName))); + map.put(UI_PRINCIPAL_CLASS + ".h", utf8(buildUIHeader())); + map.put(UI_PRINCIPAL_CLASS + ".m", utf8(inject(buildUISource()))); + return map; + } + + /** + * Writes a file map into {@code outputDir}, creating it if needed. + * Used by the IPhoneBuilders to materialize the extension folders + * under {@code dist/}. + */ + public static void writeFileMap(Map files, File outputDir) throws IOException { + if (!outputDir.exists() && !outputDir.mkdirs()) { + throw new IOException("Could not create " + outputDir); + } + for (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(); + } + } + } + + private void validateCommon(String extensionName) { + if (extensionName == null || !isIdentifier(extensionName)) { + throw new IllegalStateException( + "extension name must be ASCII letters/digits/_/- only: " + extensionName); + } + if (appGroupId == null || !appGroupId.startsWith("group.")) { + throw new IllegalStateException( + "appGroupId must start with 'group.' (Apple requirement, ios.wallet.appGroup build hint): " + appGroupId); + } + } + + 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; + } + + private String inject(String source) { + String result = source; + for (Map.Entry e : injections.entrySet()) { + int idx = result.indexOf(e.getKey()); + if (idx >= 0) { + result = result.substring(0, idx) + e.getValue() + "\n" + result.substring(idx); + } + } + return result; + } + + private static byte[] utf8(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + private String buildInfoPlist(String extensionName, String pointIdentifier, + String principalClass, String endpointKey, String endpointValue) { + // Apple's extension loader is intolerant of whitespace inside the + // NSExtension string values - every below must stay on a + // single line with no padding around the value. + StringBuilder sb = new StringBuilder(2048); + sb.append("\n"); + sb.append("\n"); + sb.append("\n"); + sb.append("\n"); + plistKeyString(sb, "CFBundleDevelopmentRegion", "$(DEVELOPMENT_LANGUAGE)"); + plistKeyString(sb, "CFBundleDisplayName", extensionName); + plistKeyString(sb, "CFBundleExecutable", "$(EXECUTABLE_NAME)"); + plistKeyString(sb, "CFBundleIdentifier", "$(PRODUCT_BUNDLE_IDENTIFIER)"); + plistKeyString(sb, "CFBundleInfoDictionaryVersion", "6.0"); + plistKeyString(sb, "CFBundleName", "$(PRODUCT_NAME)"); + plistKeyString(sb, "CFBundlePackageType", "XPC!"); + plistKeyString(sb, "CFBundleShortVersionString", "1.0"); + plistKeyString(sb, "CFBundleVersion", "1"); + plistKeyString(sb, APP_GROUP_PLIST_KEY, appGroupId); + plistKeyString(sb, endpointKey, endpointValue); + sb.append(" NSExtension\n"); + sb.append(" \n"); + sb.append(" NSExtensionPointIdentifier\n"); + sb.append(" ").append(pointIdentifier).append("\n"); + sb.append(" NSExtensionPrincipalClass\n"); + sb.append(" ").append(principalClass).append("\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(String extensionName) { + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append("\n"); + sb.append("\n"); + sb.append("\n"); + sb.append(" com.apple.developer.payment-pass-provisioning\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"); + if (applicationIdentifierPrefix != null && applicationIdentifierPrefix.length() > 0) { + sb.append(" application-identifier\n"); + sb.append(" ").append(escapeXml(applicationIdentifierPrefix)) + .append(".").append(escapeXml(extensionName)).append("\n"); + } + sb.append("\n"); + sb.append("\n"); + return sb.toString(); + } + + private String buildNonUIHeader() { + return "#import \n" + + "\n" + + "API_AVAILABLE(ios(14.0))\n" + + "@interface " + NONUI_PRINCIPAL_CLASS + " : PKIssuerProvisioningExtensionHandler\n" + + "@end\n"; + } + + private String buildNonUISource() { + StringBuilder sb = new StringBuilder(8192); + sb.append("#import \"").append(NONUI_PRINCIPAL_CLASS).append(".h\"\n"); + sb.append("#import \n"); + sb.append(MARKER_NONUI_IMPORTS).append("\n"); + sb.append("\n"); + sb.append("// Auto-generated by Codename One (ios.wallet.extension build hint).\n"); + sb.append("// Answers Wallet's issuer-provisioning callbacks from data the app\n"); + sb.append("// published into the App Group via com.codename1.payment.WalletExtension;\n"); + sb.append("// the encrypted pass payload is produced by the issuer endpoint configured\n"); + sb.append("// in the ios.wallet.issuerEndpoint build hint.\n"); + sb.append("\n"); + sb.append("static NSString *cn1WalletInfoString(NSString *key) {\n"); + sb.append(" id v = [[NSBundle mainBundle] objectForInfoDictionaryKey:key];\n"); + sb.append(" return [v isKindOfClass:[NSString class]] ? (NSString *)v : nil;\n"); + sb.append("}\n"); + sb.append("\n"); + sb.append("static NSUserDefaults *cn1WalletDefaults() {\n"); + sb.append(" NSString *group = cn1WalletInfoString(@\"").append(APP_GROUP_PLIST_KEY).append("\");\n"); + sb.append(" return group == nil ? nil : [[NSUserDefaults alloc] initWithSuiteName:group];\n"); + sb.append("}\n"); + sb.append("\n"); + sb.append("static NSURL *cn1WalletArtDir() {\n"); + sb.append(" NSString *group = cn1WalletInfoString(@\"").append(APP_GROUP_PLIST_KEY).append("\");\n"); + sb.append(" if (group == nil) return nil;\n"); + sb.append(" NSURL *container = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:group];\n"); + sb.append(" return [container URLByAppendingPathComponent:@\"").append(ART_DIR).append("\" isDirectory:YES];\n"); + sb.append("}\n"); + sb.append("\n"); + sb.append("static CGImageRef cn1WalletLoadArt(NSString *fileName) {\n"); + sb.append(" if (fileName == nil) return nil;\n"); + sb.append(" NSURL *url = [cn1WalletArtDir() URLByAppendingPathComponent:fileName];\n"); + sb.append(" if (url == nil) return nil;\n"); + sb.append(" UIImage *img = [UIImage imageWithContentsOfFile:url.path];\n"); + sb.append(" return img.CGImage;\n"); + sb.append("}\n"); + sb.append("\n"); + sb.append("@implementation ").append(NONUI_PRINCIPAL_CLASS).append("\n"); + sb.append("\n"); + // status: 100ms deadline - local suite reads only, no PKPassLibrary + // and no networking here. + sb.append("- (void)statusWithCompletion:(void (^)(PKIssuerProvisioningExtensionStatus *status))completion {\n"); + sb.append(" PKIssuerProvisioningExtensionStatus *status = [[PKIssuerProvisioningExtensionStatus alloc] init];\n"); + sb.append(" NSUserDefaults *d = cn1WalletDefaults();\n"); + sb.append(" status.passEntriesAvailable = [d arrayForKey:@\"").append(PASS_ENTRIES_KEY).append("\"].count > 0;\n"); + sb.append(" status.remotePassEntriesAvailable = [d arrayForKey:@\"").append(REMOTE_PASS_ENTRIES_KEY).append("\"].count > 0;\n"); + sb.append(" status.requiresAuthentication = [d boolForKey:@\"").append(REQUIRES_AUTH_KEY).append("\"];\n"); + sb.append(" ").append(MARKER_STATUS).append("\n"); + sb.append(" completion(status);\n"); + sb.append("}\n"); + sb.append("\n"); + sb.append("- (NSMutableArray *)cn1EntriesForKey:(NSString *)key remote:(BOOL)remote {\n"); + sb.append(" NSMutableArray *result = [NSMutableArray array];\n"); + sb.append(" NSUserDefaults *d = cn1WalletDefaults();\n"); + sb.append(" NSArray *stored = [d arrayForKey:key];\n"); + sb.append(" if (stored.count == 0) return result;\n"); + sb.append(" // Wallet requires filtering out cards that are already provisioned\n"); + sb.append(" // on this device (or the paired watch for remote entries).\n"); + sb.append(" NSMutableSet *existing = [NSMutableSet set];\n"); + sb.append(" PKPassLibrary *lib = [[PKPassLibrary alloc] init];\n"); + sb.append(" if (remote) {\n"); + sb.append(" for (PKSecureElementPass *p in [lib remoteSecureElementPasses]) {\n"); + sb.append(" if (p.primaryAccountIdentifier != nil) [existing addObject:p.primaryAccountIdentifier];\n"); + sb.append(" }\n"); + sb.append(" } else {\n"); + sb.append(" for (PKPass *p in [lib passesOfType:PKPassTypeSecureElement]) {\n"); + sb.append(" PKSecureElementPass *se = p.secureElementPass;\n"); + sb.append(" if (se.primaryAccountIdentifier != nil) [existing addObject:se.primaryAccountIdentifier];\n"); + sb.append(" }\n"); + sb.append(" }\n"); + sb.append(" for (NSDictionary *e in stored) {\n"); + sb.append(" if (![e isKindOfClass:[NSDictionary class]]) continue;\n"); + sb.append(" NSString *identifier = e[@\"identifier\"];\n"); + sb.append(" if (identifier == nil || [existing containsObject:identifier]) continue;\n"); + sb.append(" PKAddPaymentPassRequestConfiguration *config =\n"); + sb.append(" [[PKAddPaymentPassRequestConfiguration alloc] initWithEncryptionScheme:PKEncryptionSchemeECC_V2];\n"); + sb.append(" config.primaryAccountIdentifier = identifier;\n"); + sb.append(" config.cardholderName = e[@\"cardholderName\"];\n"); + sb.append(" config.primaryAccountSuffix = e[@\"accountSuffix\"];\n"); + sb.append(" config.localizedDescription = e[@\"description\"];\n"); + sb.append(" if (e[@\"network\"] != nil) {\n"); + sb.append(" config.paymentNetwork = e[@\"network\"];\n"); + sb.append(" }\n"); + sb.append(" CGImageRef art = cn1WalletLoadArt(e[@\"art\"]);\n"); + sb.append(" if (art == nil) continue;\n"); + sb.append(" PKIssuerProvisioningExtensionPaymentPassEntry *entry =\n"); + sb.append(" [[PKIssuerProvisioningExtensionPaymentPassEntry alloc] initWithIdentifier:identifier\n"); + sb.append(" title:(e[@\"title\"] != nil ? e[@\"title\"] : identifier)\n"); + sb.append(" art:art\n"); + sb.append(" addRequestConfiguration:config];\n"); + sb.append(" if (entry != nil) [result addObject:entry];\n"); + sb.append(" }\n"); + sb.append(" return result;\n"); + sb.append("}\n"); + sb.append("\n"); + sb.append("- (void)passEntriesWithCompletion:(void (^)(NSArray *entries))completion {\n"); + sb.append(" NSMutableArray *entries =\n"); + sb.append(" [self cn1EntriesForKey:@\"").append(PASS_ENTRIES_KEY).append("\" remote:NO];\n"); + sb.append(" ").append(MARKER_PASS_ENTRIES).append("\n"); + sb.append(" completion(entries);\n"); + sb.append("}\n"); + sb.append("\n"); + sb.append("- (void)remotePassEntriesWithCompletion:(void (^)(NSArray *entries))completion {\n"); + sb.append(" NSMutableArray *entries =\n"); + sb.append(" [self cn1EntriesForKey:@\"").append(REMOTE_PASS_ENTRIES_KEY).append("\" remote:YES];\n"); + sb.append(" ").append(MARKER_REMOTE_PASS_ENTRIES).append("\n"); + sb.append(" completion(entries);\n"); + sb.append("}\n"); + sb.append("\n"); + sb.append("- (void)generateAddPaymentPassRequestForPassEntryWithIdentifier:(NSString *)identifier\n"); + sb.append(" configuration:(PKAddPaymentPassRequestConfiguration *)configuration\n"); + sb.append(" certificateChain:(NSArray *)certificates\n"); + sb.append(" nonce:(NSData *)nonce\n"); + sb.append(" nonceSignature:(NSData *)nonceSignature\n"); + sb.append(" completionHandler:(void (^)(PKAddPaymentPassRequest *request))completion {\n"); + sb.append(" NSMutableArray *certStrings = [NSMutableArray array];\n"); + sb.append(" for (NSData *c in certificates) {\n"); + sb.append(" [certStrings addObject:[c base64EncodedStringWithOptions:0]];\n"); + sb.append(" }\n"); + sb.append(" NSMutableDictionary *payload = [NSMutableDictionary dictionary];\n"); + sb.append(" payload[@\"certificates\"] = certStrings;\n"); + sb.append(" payload[@\"nonce\"] = [nonce base64EncodedStringWithOptions:0];\n"); + sb.append(" payload[@\"nonceSignature\"] = [nonceSignature base64EncodedStringWithOptions:0];\n"); + sb.append(" payload[@\"cardIdentifier\"] = identifier;\n"); + sb.append(" NSString *token = [cn1WalletDefaults() stringForKey:@\"").append(AUTH_TOKEN_KEY).append("\"];\n"); + sb.append(" if (token != nil) payload[@\"authToken\"] = token;\n"); + sb.append(" NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:\n"); + sb.append(" [NSURL URLWithString:cn1WalletInfoString(@\"").append(ISSUER_ENDPOINT_PLIST_KEY).append("\")]];\n"); + sb.append(" req.HTTPMethod = @\"POST\";\n"); + sb.append(" [req setValue:@\"application/json\" forHTTPHeaderField:@\"Content-Type\"];\n"); + sb.append(" if (token != nil) {\n"); + sb.append(" [req setValue:[@\"Bearer \" stringByAppendingString:token] forHTTPHeaderField:@\"Authorization\"];\n"); + sb.append(" }\n"); + sb.append(" ").append(MARKER_GENERATE_REQUEST).append("\n"); + sb.append(" req.HTTPBody = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil];\n"); + sb.append(" [[[NSURLSession sharedSession] dataTaskWithRequest:req\n"); + sb.append(" completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {\n"); + sb.append(" PKAddPaymentPassRequest *passRequest = nil;\n"); + sb.append(" if (error == nil && data != nil) {\n"); + sb.append(" NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];\n"); + sb.append(" if ([json isKindOfClass:[NSDictionary class]]) {\n"); + sb.append(" passRequest = [[PKAddPaymentPassRequest alloc] init];\n"); + sb.append(" NSString *activationData = json[@\"activationData\"];\n"); + sb.append(" NSString *encryptedPassData = json[@\"encryptedPassData\"];\n"); + sb.append(" NSString *ephemeralPublicKey = json[@\"ephemeralPublicKey\"];\n"); + sb.append(" NSString *wrappedKey = json[@\"wrappedKey\"];\n"); + sb.append(" if (activationData != nil) {\n"); + sb.append(" passRequest.activationData = [[NSData alloc] initWithBase64EncodedString:activationData options:0];\n"); + sb.append(" }\n"); + sb.append(" if (encryptedPassData != nil) {\n"); + sb.append(" passRequest.encryptedPassData = [[NSData alloc] initWithBase64EncodedString:encryptedPassData options:0];\n"); + sb.append(" }\n"); + sb.append(" if (ephemeralPublicKey != nil) {\n"); + sb.append(" passRequest.ephemeralPublicKey = [[NSData alloc] initWithBase64EncodedString:ephemeralPublicKey options:0];\n"); + sb.append(" }\n"); + sb.append(" if (wrappedKey != nil) {\n"); + sb.append(" passRequest.wrappedKey = [[NSData alloc] initWithBase64EncodedString:wrappedKey options:0];\n"); + sb.append(" }\n"); + sb.append(" }\n"); + sb.append(" }\n"); + sb.append(" ").append(MARKER_GENERATE_RESPONSE).append("\n"); + sb.append(" completion(passRequest);\n"); + sb.append(" }] resume];\n"); + sb.append("}\n"); + sb.append("\n"); + sb.append("@end\n"); + return sb.toString(); + } + + private String buildUIHeader() { + return "#import \n" + + "#import \n" + + "\n" + + "API_AVAILABLE(ios(14.0))\n" + + "@interface " + UI_PRINCIPAL_CLASS + " : UIViewController \n" + + "@property (nonatomic, copy) void (^completionHandler)(PKIssuerProvisioningExtensionAuthorizationResult result);\n" + + "@end\n"; + } + + private String buildUISource() { + StringBuilder sb = new StringBuilder(8192); + sb.append("#import \"").append(UI_PRINCIPAL_CLASS).append(".h\"\n"); + sb.append(MARKER_UI_IMPORTS).append("\n"); + sb.append("\n"); + sb.append("// Auto-generated by Codename One (ios.wallet.includeUI build hint).\n"); + sb.append("// Presents a minimal login form inside Wallet; a successful POST to the\n"); + sb.append("// ios.wallet.authEndpoint URL stores the returned token in the App Group\n"); + sb.append("// so the non-UI extension can authorize the provisioning request.\n"); + sb.append("\n"); + sb.append("@interface ").append(UI_PRINCIPAL_CLASS).append(" ()\n"); + sb.append("@property (nonatomic, strong) UITextField *cn1UserField;\n"); + sb.append("@property (nonatomic, strong) UITextField *cn1PasswordField;\n"); + sb.append("@property (nonatomic, strong) UILabel *cn1ErrorLabel;\n"); + sb.append("@end\n"); + sb.append("\n"); + sb.append("@implementation ").append(UI_PRINCIPAL_CLASS).append("\n"); + sb.append("\n"); + sb.append("- (void)viewDidLoad {\n"); + sb.append(" [super viewDidLoad];\n"); + sb.append(" self.view.backgroundColor = [UIColor systemBackgroundColor];\n"); + sb.append("\n"); + sb.append(" self.cn1UserField = [[UITextField alloc] init];\n"); + sb.append(" self.cn1UserField.placeholder = @\"Username\";\n"); + sb.append(" self.cn1UserField.borderStyle = UITextBorderStyleRoundedRect;\n"); + sb.append(" self.cn1UserField.autocapitalizationType = UITextAutocapitalizationTypeNone;\n"); + sb.append(" self.cn1UserField.autocorrectionType = UITextAutocorrectionTypeNo;\n"); + sb.append("\n"); + sb.append(" self.cn1PasswordField = [[UITextField alloc] init];\n"); + sb.append(" self.cn1PasswordField.placeholder = @\"Password\";\n"); + sb.append(" self.cn1PasswordField.borderStyle = UITextBorderStyleRoundedRect;\n"); + sb.append(" self.cn1PasswordField.secureTextEntry = YES;\n"); + sb.append("\n"); + sb.append(" self.cn1ErrorLabel = [[UILabel alloc] init];\n"); + sb.append(" self.cn1ErrorLabel.textColor = [UIColor systemRedColor];\n"); + sb.append(" self.cn1ErrorLabel.font = [UIFont systemFontOfSize:13];\n"); + sb.append(" self.cn1ErrorLabel.numberOfLines = 0;\n"); + sb.append("\n"); + sb.append(" UIButton *signIn = [UIButton buttonWithType:UIButtonTypeSystem];\n"); + sb.append(" [signIn setTitle:@\"Sign In\" forState:UIControlStateNormal];\n"); + sb.append(" [signIn addTarget:self action:@selector(cn1SignIn) forControlEvents:UIControlEventTouchUpInside];\n"); + sb.append("\n"); + sb.append(" UIButton *cancel = [UIButton buttonWithType:UIButtonTypeSystem];\n"); + sb.append(" [cancel setTitle:@\"Cancel\" forState:UIControlStateNormal];\n"); + sb.append(" [cancel addTarget:self action:@selector(cn1Cancel) forControlEvents:UIControlEventTouchUpInside];\n"); + sb.append("\n"); + sb.append(" UIStackView *stack = [[UIStackView alloc] initWithArrangedSubviews:\n"); + sb.append(" @[self.cn1UserField, self.cn1PasswordField, self.cn1ErrorLabel, signIn, cancel]];\n"); + sb.append(" stack.axis = UILayoutConstraintAxisVertical;\n"); + sb.append(" stack.spacing = 12;\n"); + sb.append(" stack.translatesAutoresizingMaskIntoConstraints = NO;\n"); + sb.append(" [self.view addSubview:stack];\n"); + sb.append(" [NSLayoutConstraint activateConstraints:@[\n"); + sb.append(" [stack.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor],\n"); + sb.append(" [stack.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:32],\n"); + sb.append(" [stack.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-32]\n"); + sb.append(" ]];\n"); + sb.append(" ").append(MARKER_UI_VIEWDIDLOAD).append("\n"); + sb.append("}\n"); + sb.append("\n"); + sb.append("- (void)cn1Cancel {\n"); + sb.append(" if (self.completionHandler != nil) {\n"); + sb.append(" self.completionHandler(PKIssuerProvisioningExtensionAuthorizationResultCanceled);\n"); + sb.append(" }\n"); + sb.append("}\n"); + sb.append("\n"); + sb.append("- (void)cn1SignIn {\n"); + sb.append(" NSString *endpoint = [[NSBundle mainBundle] objectForInfoDictionaryKey:@\"").append(AUTH_ENDPOINT_PLIST_KEY).append("\"];\n"); + sb.append(" NSMutableDictionary *payload = [NSMutableDictionary dictionary];\n"); + sb.append(" payload[@\"username\"] = self.cn1UserField.text != nil ? self.cn1UserField.text : @\"\";\n"); + sb.append(" payload[@\"password\"] = self.cn1PasswordField.text != nil ? self.cn1PasswordField.text : @\"\";\n"); + sb.append(" NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:endpoint]];\n"); + sb.append(" req.HTTPMethod = @\"POST\";\n"); + sb.append(" [req setValue:@\"application/json\" forHTTPHeaderField:@\"Content-Type\"];\n"); + sb.append(" ").append(MARKER_UI_AUTH_REQUEST).append("\n"); + sb.append(" req.HTTPBody = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil];\n"); + sb.append(" __weak ").append(UI_PRINCIPAL_CLASS).append(" *weakSelf = self;\n"); + sb.append(" [[[NSURLSession sharedSession] dataTaskWithRequest:req\n"); + sb.append(" completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {\n"); + sb.append(" NSString *token = nil;\n"); + sb.append(" if (error == nil && data != nil) {\n"); + sb.append(" NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];\n"); + sb.append(" if ([json isKindOfClass:[NSDictionary class]]) {\n"); + sb.append(" token = json[@\"token\"] != nil ? json[@\"token\"] : json[@\"authToken\"];\n"); + sb.append(" }\n"); + sb.append(" }\n"); + sb.append(" ").append(MARKER_UI_AUTH_RESPONSE).append("\n"); + sb.append(" dispatch_async(dispatch_get_main_queue(), ^{\n"); + sb.append(" ").append(UI_PRINCIPAL_CLASS).append(" *strongSelf = weakSelf;\n"); + sb.append(" if (strongSelf == nil) return;\n"); + sb.append(" if (token != nil) {\n"); + sb.append(" NSString *group = [[NSBundle mainBundle] objectForInfoDictionaryKey:@\"").append(APP_GROUP_PLIST_KEY).append("\"];\n"); + sb.append(" NSUserDefaults *d = group != nil ? [[NSUserDefaults alloc] initWithSuiteName:group] : nil;\n"); + sb.append(" [d setObject:token forKey:@\"").append(AUTH_TOKEN_KEY).append("\"];\n"); + sb.append(" if (strongSelf.completionHandler != nil) {\n"); + sb.append(" strongSelf.completionHandler(PKIssuerProvisioningExtensionAuthorizationResultAuthorized);\n"); + sb.append(" }\n"); + sb.append(" } else {\n"); + sb.append(" strongSelf.cn1ErrorLabel.text = @\"Sign in failed. Please try again.\";\n"); + sb.append(" }\n"); + sb.append(" });\n"); + sb.append(" }] resume];\n"); + sb.append("}\n"); + sb.append("\n"); + sb.append("@end\n"); + return sb.toString(); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWalletExtensionBuilderTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWalletExtensionBuilderTest.java new file mode 100644 index 0000000000..35c2060d5b --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWalletExtensionBuilderTest.java @@ -0,0 +1,171 @@ +package com.codename1.util; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class IOSWalletExtensionBuilderTest { + + private IOSWalletExtensionBuilder validBuilder() { + return new IOSWalletExtensionBuilder() + .setAppGroupId("group.com.example.myapp.wallet") + .setIssuerEndpoint("https://issuer.example.com/provision") + .setAuthEndpoint("https://issuer.example.com/login"); + } + + private static String text(Map files, String name) { + byte[] data = files.get(name); + assertTrue(data != null, name + " must be present in file map"); + return new String(data, StandardCharsets.UTF_8); + } + + @Test + public void nonUIFileMap_producesExpectedFiles() { + Map files = validBuilder().buildNonUIFileMap(); + assertTrue(files.containsKey("Info.plist")); + assertTrue(files.containsKey("WalletNonUIExtension.entitlements"), + "entitlements file must be named after the extension"); + assertTrue(files.containsKey("CN1WalletExtensionHandler.h")); + assertTrue(files.containsKey("CN1WalletExtensionHandler.m")); + assertEquals(4, files.size()); + } + + @Test + public void uiFileMap_producesExpectedFiles() { + Map files = validBuilder().buildUIFileMap(); + assertTrue(files.containsKey("Info.plist")); + assertTrue(files.containsKey("WalletUIExtension.entitlements")); + assertTrue(files.containsKey("CN1WalletAuthViewController.h")); + assertTrue(files.containsKey("CN1WalletAuthViewController.m")); + assertEquals(4, files.size()); + } + + @Test + public void nonUIInfoPlist_hasExactExtensionPointAndPrincipalClass() { + String plist = text(validBuilder().buildNonUIFileMap(), "Info.plist"); + // Whitespace inside NSExtension string values breaks the extension; + // assert the exact single-line form. + assertTrue(plist.contains("com.apple.PassKit.issuer-provisioning"), + "exact non-UI extension point identifier missing"); + assertFalse(plist.contains("com.apple.PassKit.issuer-provisioning ")); + assertTrue(plist.contains("CN1WalletExtensionHandler"), + "principal class missing"); + assertTrue(plist.contains("CN1WalletAppGroup")); + assertTrue(plist.contains("group.com.example.myapp.wallet")); + assertTrue(plist.contains("CN1WalletIssuerEndpoint")); + assertTrue(plist.contains("https://issuer.example.com/provision")); + } + + @Test + public void uiInfoPlist_hasExactExtensionPointAndPrincipalClass() { + String plist = text(validBuilder().buildUIFileMap(), "Info.plist"); + assertTrue(plist.contains("com.apple.PassKit.issuer-provisioning.authorization"), + "exact UI extension point identifier missing"); + assertTrue(plist.contains("CN1WalletAuthViewController")); + assertTrue(plist.contains("CN1WalletAuthEndpoint")); + assertTrue(plist.contains("https://issuer.example.com/login")); + } + + @Test + public void entitlements_includePaymentPassProvisioningAndAppGroup() { + String ent = text(validBuilder().buildNonUIFileMap(), "WalletNonUIExtension.entitlements"); + assertTrue(ent.contains("com.apple.developer.payment-pass-provisioning")); + assertTrue(ent.contains("")); + assertTrue(ent.contains("group.com.example.myapp.wallet")); + assertFalse(ent.contains("application-identifier"), + "application-identifier must be omitted unless a prefix is set"); + } + + @Test + public void entitlements_includeApplicationIdentifierWhenPrefixSet() { + String ent = text(validBuilder() + .setApplicationIdentifierPrefix("TEAM123.com.example.myapp") + .buildNonUIFileMap(), "WalletNonUIExtension.entitlements"); + assertTrue(ent.contains("application-identifier")); + assertTrue(ent.contains("TEAM123.com.example.myapp.WalletNonUIExtension")); + } + + @Test + public void nonUISource_containsAllMarkers() { + String source = text(validBuilder().buildNonUIFileMap(), "CN1WalletExtensionHandler.m"); + assertTrue(source.contains(IOSWalletExtensionBuilder.MARKER_NONUI_IMPORTS)); + assertTrue(source.contains(IOSWalletExtensionBuilder.MARKER_STATUS)); + assertTrue(source.contains(IOSWalletExtensionBuilder.MARKER_PASS_ENTRIES)); + assertTrue(source.contains(IOSWalletExtensionBuilder.MARKER_REMOTE_PASS_ENTRIES)); + assertTrue(source.contains(IOSWalletExtensionBuilder.MARKER_GENERATE_REQUEST)); + assertTrue(source.contains(IOSWalletExtensionBuilder.MARKER_GENERATE_RESPONSE)); + } + + @Test + public void uiSource_containsAllMarkers() { + String source = text(validBuilder().buildUIFileMap(), "CN1WalletAuthViewController.m"); + assertTrue(source.contains(IOSWalletExtensionBuilder.MARKER_UI_IMPORTS)); + assertTrue(source.contains(IOSWalletExtensionBuilder.MARKER_UI_VIEWDIDLOAD)); + assertTrue(source.contains(IOSWalletExtensionBuilder.MARKER_UI_AUTH_REQUEST)); + assertTrue(source.contains(IOSWalletExtensionBuilder.MARKER_UI_AUTH_RESPONSE)); + } + + @Test + public void injection_insertsCodeAndPreservesMarker() { + String snippet = "NSLog(@\"custom status hook\");"; + String source = text(validBuilder() + .setInjection(IOSWalletExtensionBuilder.MARKER_STATUS, snippet) + .buildNonUIFileMap(), "CN1WalletExtensionHandler.m"); + int snippetIdx = source.indexOf(snippet); + int markerIdx = source.indexOf(IOSWalletExtensionBuilder.MARKER_STATUS); + assertTrue(snippetIdx >= 0, "injected snippet missing"); + assertTrue(markerIdx > snippetIdx, "marker must be preserved after the injected code"); + } + + @Test + public void injection_unknownMarkerRejected() { + assertThrows(IllegalArgumentException.class, + () -> validBuilder().setInjection("//NOT_A_MARKER", "code")); + } + + @Test + public void validation_rejectsMissingAppGroupPrefix() { + assertThrows(IllegalStateException.class, () -> new IOSWalletExtensionBuilder() + .setAppGroupId("com.example.nogroup") + .setIssuerEndpoint("https://x.example.com") + .buildNonUIFileMap()); + } + + @Test + public void validation_rejectsMissingIssuerEndpoint() { + assertThrows(IllegalStateException.class, () -> new IOSWalletExtensionBuilder() + .setAppGroupId("group.com.example.myapp") + .buildNonUIFileMap()); + } + + @Test + public void validation_rejectsMissingAuthEndpointForUI() { + assertThrows(IllegalStateException.class, () -> new IOSWalletExtensionBuilder() + .setAppGroupId("group.com.example.myapp") + .setIssuerEndpoint("https://x.example.com") + .buildUIFileMap()); + } + + @Test + public void validation_rejectsBadExtensionName() { + assertThrows(IllegalStateException.class, () -> validBuilder() + .setNonUIExtensionName("Bad Name!") + .buildNonUIFileMap()); + } + + @Test + public void customNames_flowIntoFileMapAndBundleArtifacts() { + Map files = validBuilder() + .setNonUIExtensionName("MyWalletExt") + .buildNonUIFileMap(); + assertTrue(files.containsKey("MyWalletExt.entitlements")); + String plist = text(files, "Info.plist"); + assertTrue(plist.contains("MyWalletExt"), "display name should use the custom name"); + } +} From 21c317853c7eb9e47aa233411e6b19bef06a4168 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:39:15 +0300 Subject: [PATCH 2/2] Compile wallet natives only when the app needs them (CN1_INCLUDE_WALLET) The Wallet issuer-provisioning natives in IOSNative.m are now gated behind CN1_INCLUDE_WALLET with no-op #else stubs, so unrelated apps carry no dormant wallet-looking code or cn1.wallet.* strings that could raise questions during Apple review. The build flips the define when the ios.wallet.extension hint is enabled or the class scan detects usage of com.codename1.payment.WalletExtension/WalletPassEntry (new usesWalletApi flag, same pattern as usesPurchaseAPI + CN1_INCLUDE_NOTIFICATIONS2). Verified both directions on hellocodenameone: a default build leaves the define commented, generates no extension targets, mentions no wallet symbols in the xcodebuild log and the stubs compile/link green on the arm64 simulator; a hinted build flips the define and generates the extension targets as before. Also: PMD ForLoopCanBeForeach fix in Display.walletExtensionSetPassEntries and Vale prose fixes in the new guide content. Co-Authored-By: Claude Fable 5 --- CodenameOne/src/com/codename1/ui/Display.java | 3 +- Ports/iOSPort/nativeSources/IOSNative.m | 36 +++++++++++++++++++ .../Advanced-Topics-Under-The-Hood.asciidoc | 4 +-- .../Apple-Wallet-Extension.asciidoc | 6 ++-- .../com/codename1/builders/IPhoneBuilder.java | 18 ++++++++++ 5 files changed, 60 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 0b4f224216..4784f56935 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -6126,8 +6126,7 @@ public boolean isWalletExtensionSupported() { public void walletExtensionSetPassEntries(boolean remote, com.codename1.payment.WalletPassEntry[] entries) { impl.walletExtensionClearPassEntries(remote); if (entries != null) { - for (int i = 0; i < entries.length; i++) { - com.codename1.payment.WalletPassEntry e = entries[i]; + for (com.codename1.payment.WalletPassEntry e : entries) { if (e == null) { continue; } diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 07663b8666..b8cf981f12 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -11668,6 +11668,15 @@ JAVA_OBJECT com_codename1_impl_ios_IOSNative_getPendingSharedContent___java_lang // the generated Wallet extensions (see the ios.wallet.* build hints) read // them. The group id comes from the CN1WalletAppGroup Info.plist key injected // by the build. +// +// The implementation is compiled in only when the build needs it - the +// ios.wallet.extension build hint is enabled or the app references +// com.codename1.payment.WalletExtension - because dormant wallet-looking +// code in unrelated apps can trigger questions during Apple review. The +// build flips the define below; the #else stubs keep the linker happy. +//#define CN1_INCLUDE_WALLET + +#ifdef CN1_INCLUDE_WALLET static NSString *cn1WalletGroupId() { id v = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CN1WalletAppGroup"]; @@ -11818,6 +11827,33 @@ void com_codename1_impl_ios_IOSNative_walletExtensionClear__(CN1_THREAD_STATE_MU } } +#else // CN1_INCLUDE_WALLET + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isWalletExtensionSupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isWalletExtensionSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} + +void com_codename1_impl_ios_IOSNative_walletExtensionClearPassEntries___boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_BOOLEAN remote) { +} + +void com_codename1_impl_ios_IOSNative_walletExtensionAddPassEntry___boolean_java_lang_String_java_lang_String_java_lang_String_java_lang_String_java_lang_String_java_lang_String_byte_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_BOOLEAN remote, JAVA_OBJECT identifier, JAVA_OBJECT title, JAVA_OBJECT cardholderName, JAVA_OBJECT accountSuffix, JAVA_OBJECT network, JAVA_OBJECT description, JAVA_OBJECT artPng) { +} + +void com_codename1_impl_ios_IOSNative_walletExtensionSetRequiresAuthentication___boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_BOOLEAN requiresAuthentication) { +} + +void com_codename1_impl_ios_IOSNative_walletExtensionSetAuthToken___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT token) { +} + +void com_codename1_impl_ios_IOSNative_walletExtensionClear__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +} + +#endif // CN1_INCLUDE_WALLET + // BEGIN IOSImplementation native code, this is used to optimize various "heavy" IOSImplementation methods #define DRAW_BGIMAGE_AT_GIVEN_POSITION_WITH_FILL_RECT(xpositionToDraw, ypositionToDraw) JAVA_BYTE bgTransparency = com_codename1_ui_plaf_Style_getBgTransparency___R_byte(threadStateData, s); \ diff --git a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc index 8d7e5c07db..2901dabfc2 100644 --- a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc +++ b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc @@ -488,10 +488,10 @@ Only supported for App Store builds. See https://www.codenameone.com/developer-g |Cloud device builds only. URL fallback for the extension provisioning profile when it isn't bundled in resources, mirroring `ios.notificationServiceExtensionProvisioningURL`. |ios.wallet.nonui.buildSettings.SETTING / ios.wallet.ui.buildSettings.SETTING -|Extra Xcode build settings applied to the generated extension targets, e.g. `ios.wallet.nonui.buildSettings.DEVELOPMENT_TEAM=ABCD123456`. Applied last so they override the generated defaults. +|Extra Xcode build settings applied to the generated extension targets, for example `ios.wallet.nonui.buildSettings.DEVELOPMENT_TEAM=ABCD123456`. Applied last so they override the generated defaults. |ios.wallet.nonuiImportsInject, ios.wallet.statusInject, ios.wallet.passEntriesInject, ios.wallet.remotePassEntriesInject, ios.wallet.generateRequestInject, ios.wallet.generateResponseInject, ios.wallet.uiImportsInject, ios.wallet.uiViewDidLoadInject, ios.wallet.uiAuthRequestInject, ios.wallet.uiAuthResponseInject -|Objective-C code injected at the matching marker comment in the generated Wallet extension sources, for custom behavior at each callback (e.g. adding fields to the issuer endpoint payload in `generateRequestInject`). See the <>. +|Objective-C code injected at the matching marker comment in the generated Wallet extension sources, for custom behavior at each callback (for example adding fields to the issuer endpoint payload in `generateRequestInject`). See the <>. |ios.appext.NAME.provisioningURL |Cloud device builds only. URL of the provisioning profile for a generic app extension dropped into `ios/app_extensions/NAME/`, used when the extension folder doesn't bundle a `.mobileprovision` itself. The profile is installed on the build machine and added to the export options per bundle id. diff --git a/docs/developer-guide/Apple-Wallet-Extension.asciidoc b/docs/developer-guide/Apple-Wallet-Extension.asciidoc index 601b0a43a8..6ee2f16768 100644 --- a/docs/developer-guide/Apple-Wallet-Extension.asciidoc +++ b/docs/developer-guide/Apple-Wallet-Extension.asciidoc @@ -21,11 +21,11 @@ Because the extensions run in a separate process, they can't call into your Java Before any of this works you need approvals that only Apple and your card network can grant: * The `com.apple.developer.payment-pass-provisioning` entitlement is restricted. Apple grants it per app on request (see the In-App Provisioning documentation on the Apple developer site). After approval, enable it under the App ID's *Additional Capabilities* tab for the app *and for each extension App ID*. -* Each extension needs its own App ID (e.g. `com.mybank.app.WalletNonUIExtension`) and its own provisioning profile carrying the entitlement. +* Each extension needs its own App ID (for example `com.mybank.app.WalletNonUIExtension`) and its own provisioning profile carrying the entitlement. * The card network/PNO pass metadata must list the extension App IDs in `associatedApplicationIdentifiers`, otherwise Wallet never invokes the extensions. * The extensions require iOS 14. The generated extension targets set their own deployment target, so your app can still target an older iOS version. -=== Mode 1: generate the extensions from build hints +=== Mode 1: Generate the extensions from build hints Add these build hints to the iOS build: @@ -122,7 +122,7 @@ Alternatively, host them at URLs the build server can reach and supply `ios.wall In local builds ("ios-source" target) no profile hints are needed - you sign the generated Xcode project in Xcode as usual. -=== Mode 2: bring your own extension +=== Mode 2: Bring your own extension If you already have Xcode extension targets - from an issuer SDK vendor or an existing native app - skip the `ios.wallet.*` hints and drop each extension into the generic app extension mechanism instead. Create `ios/app_extensions/MyWalletExtension/` in your project containing: diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index b2fd5549ea..c8d1324c82 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -94,6 +94,7 @@ public class IPhoneBuilder extends Executor { private String buildVersion; private boolean usesLocalNotifications; private boolean usesPurchaseAPI; + private boolean usesWalletApi; private boolean usesCryptoAPI; private boolean usesCryptoGcm; private boolean usesBiometrics; @@ -706,6 +707,12 @@ public void usesClass(String cls) { if (!usesPurchaseAPI && cls.indexOf("com/codename1/payment") == 0) { usesPurchaseAPI = true; } + // Wallet issuer-provisioning natives are only compiled in when + // the app actually references the API (or enables the extension + // via the ios.wallet.extension hint) - see CN1_INCLUDE_WALLET. + if (!usesWalletApi && cls.indexOf("com/codename1/payment/Wallet") == 0) { + usesWalletApi = true; + } if (cls.indexOf("com/codename1/security/") == 0) { // com.codename1.security contains two distinct API // families that toggle different bits of the iOS @@ -1666,6 +1673,17 @@ public void usesClassMethod(String cls, String method) { } } + // The Wallet issuer-provisioning natives in IOSNative.m stay dormant + // (#else stubs) unless the app needs them: unused wallet-looking code + // in the binary can trigger questions during Apple review. + if (usesWalletApi || "true".equals(request.getArg("ios.wallet.extension", "false"))) { + try { + replaceInFile(new File(buildinRes, "IOSNative.m"), "//#define CN1_INCLUDE_WALLET", "#define CN1_INCLUDE_WALLET"); + } catch (IOException ex) { + throw new BuildException("Failed to update Objective-C source files to activate the wallet flag", ex); + } + } + if(!(request.getPushCertificate() != null || includePush)) { try { // special workaround for issue Apple is having with push notification missing from