diff --git a/CodenameOne/src/com/codename1/analytics/Analytics.java b/CodenameOne/src/com/codename1/analytics/Analytics.java index 306746bf9be..e9b2c18341f 100644 --- a/CodenameOne/src/com/codename1/analytics/Analytics.java +++ b/CodenameOne/src/com/codename1/analytics/Analytics.java @@ -482,6 +482,72 @@ public static void flush() { } } + // The install-attribution hook. One observer, held here rather than in + // the invite package so that another part of the framework can listen + // without naming a class of that package: the builders read a reference + // to it as "this application uses invites" and add what invites need. + private static Runnable attributionObserver; + private static boolean attributionKnown; + private static boolean attributionObserverTold; + + /// Framework hook: registers the observer that is told when the invite + /// attribution of this install is known. It runs at most once per + /// registration -- at once when the attribution is already known, and + /// otherwise when it arrives. This is separate from the application's own + /// `InviteListener`, which it neither replaces nor delays. + /// + /// #### Parameters + /// + /// - `observer`: the observer, or null to remove it + /// + /// #### Deprecated + /// + /// Internal framework hook; an application listens with an + /// `InviteListener`. + public static void setAttributionObserver(Runnable observer) { + attributionObserver = observer; + attributionObserverTold = false; + if (attributionKnown) { + tellAttributionObserver(); + } + } + + /// Framework hook: called by the invite support when it knows whether + /// this install has an invite attribution. Tells the observer registered + /// with [#setAttributionObserver(Runnable)] the first time the answer is + /// yes. Never throws into the caller. + /// + /// #### Parameters + /// + /// - `available`: true when the install has an attribution, false when + /// it was forgotten + /// + /// #### Deprecated + /// + /// Internal framework hook; not intended for app use. + public static void attributionAvailable(boolean available) { + attributionKnown = available; + if (available) { + tellAttributionObserver(); + } else { + // A later attribution in this process is news to the observer. + attributionObserverTold = false; + } + } + + private static void tellAttributionObserver() { + Runnable observer = attributionObserver; + if (observer == null || attributionObserverTold) { + return; + } + attributionObserverTold = true; + try { + observer.run(); + } catch (Throwable t) { + Log.e(t); + } + } + /// The pseudonymous client id, generating and persisting one on first use. /// Not derived from any hardware identifier. /// diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 4db07e4a722..8111f4443fe 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1214,6 +1214,7 @@ private static void flush(boolean skipInFlight) { /// that left the referral dimensions behind would re-link the fresh /// identity to the same inviter. public static void reset() { + Analytics.attributionAvailable(false); if (!resetVerified()) { // The records did not go, and this method promised they would. // @@ -3890,7 +3891,23 @@ private static Map parseParams(String json) { // restart; deliveredThisRun covers the window between resolving and the // flag reaching the disk, so a failed write costs at most a duplicate // after a crash rather than one on every launch. + // Tells the framework's own observer (Analytics.setAttributionObserver) + // that this install has an attribution. It is not the application's + // listener and takes nothing from it: no delivered flag is read or + // written here, and Analytics tells its observer once however often this + // runs. + private static void announceAttribution() { + try { + if (getAttribution() != null) { + Analytics.attributionAvailable(true); + } + } catch (Throwable t) { + Log.e(t); + } + } + private static void deliverPending() { + announceAttribution(); if (listener == null || deliveredThisRun) { return; } diff --git a/CodenameOne/src/com/codename1/payment/CommerceManager.java b/CodenameOne/src/com/codename1/payment/CommerceManager.java index d1ac6fced83..0536cb0da6c 100644 --- a/CodenameOne/src/com/codename1/payment/CommerceManager.java +++ b/CodenameOne/src/com/codename1/payment/CommerceManager.java @@ -1,22 +1,51 @@ /* - * Codename One - cross-platform commerce (subscription/IAP validation) SDK. + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. * - * Optional client facade over the existing Purchase API that adds cloud-side - * receipt validation and store-agnostic entitlement checks backed by the - * Codename One Commerce service. The framework itself is free; this talks to - * the paid cloud service when the app is built with it enabled. + * 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.analytics.Analytics; +import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.ConsentMode; +import com.codename1.io.JSONParser; import com.codename1.io.Log; import com.codename1.io.Preferences; +import com.codename1.io.Storage; +import com.codename1.io.NetworkEvent; +import com.codename1.io.rest.ErrorCodeHandler; +import com.codename1.io.rest.RequestBuilder; import com.codename1.io.rest.Response; import com.codename1.io.rest.Rest; +import com.codename1.security.Ed25519; +import com.codename1.security.SecureRandom; import com.codename1.ui.Display; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.util.EventDispatcher; +import com.codename1.util.Base64; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Random; /// Client facade for the Codename One Commerce service. It does not replace /// the {@link Purchase} API -- it wraps it: purchases still go through the @@ -60,12 +89,88 @@ public final class CommerceManager { private static final String PREF_APP_USER_ID = "cn1$commerce$appUserId"; + // Display property holding the service's Ed25519 public key, base64 of + // the raw 32 bytes. It wins over DEFAULT_PUBLIC_KEY, so a self-hosted or + // staging service can be verified with its own key. + private static final String PROP_PUBLIC_KEY = "commerce.cloud.publicKey"; + + // The production key of the Codename One Commerce service, base64 of the + // raw 32 byte Ed25519 key. This constant is the one place it goes. With + // no key at all (a PROP_PUBLIC_KEY set to the empty string), no + // signed-envelope answer of the cloud is trusted: they are ignored, + // nothing is cached, the manager reports itself degraded and answers + // from the store directly. An unsigned answer is never taken, since + // anything between the device and the service could have written it. + private static final String DEFAULT_PUBLIC_KEY = "pIKY0AoLYMj6RbBSqTyDIlW7TQAlS6su/djHXTmoMP4="; + + // The key used when PROP_PUBLIC_KEY is unset or empty. Always + // DEFAULT_PUBLIC_KEY in an application; the unit tests clear it to + // exercise the no-key path, which no shipped build can reach any more. + static String defaultPublicKey = DEFAULT_PUBLIC_KEY; + + // Storage entry holding the last accepted customer payload, one per app + // user id. + private static final String STORAGE_CUSTOMER_PREFIX = "CN1CommerceCustomer_"; + + // Storage entry holding the last accepted offerings payload, one per app + // user id and placement. + private static final String STORAGE_OFFERINGS_PREFIX = "CN1CommerceOfferings_"; + + private static final String HEX = "0123456789abcdef"; + + // Seeded with the time, and with an object's identity so two installs + // started in the same millisecond still draw different ids. + private static final Random RANDOM = + new Random(System.currentTimeMillis() ^ ((long) new Object().hashCode() << 32)); + + /// The category of every analytics event commerce emits; the one + /// [Purchase] uses for `purchase` and `purchase_initiated`. + private static final String ANALYTICS_CATEGORY = "commerce"; + /// The experiment and variant last reported as an exposure. + private static final String PREF_EXPOSURE = "cn1$commerce$experimentExposure"; + /// How long the context of a started purchase waits for its receipt. A + /// receipt of the same product after this is taken to be another + /// purchase and is not labelled. + private static final long PURCHASE_CONTEXT_LIFETIME = 10L * 60L * 1000L; + private static final Runnable INVITE_OBSERVER = new Runnable() { + @Override + public void run() { + getInstance().inviteArrived(); + } + }; + private static CommerceManager instance; + // Offering id to {placement, experiment, variant} of the last offerings + // answer that carried it. Replaced as a whole, never changed in place. + private Map offeringContext = new HashMap(); + private PurchaseContext pendingPurchase; + private boolean invitesHooked; + private boolean hookingInvites; + private boolean refreshing; private final Map entitlementCache = new HashMap(); + private final EventDispatcher customerInfoListeners = new EventDispatcher(); + // Store products by SKU, as the store last listed them. + private final Map productCache = new HashMap(); + // Paywall events not sent yet, each a JSON object as text. Touched on the + // EDT only. + private final List pendingEvents = new ArrayList(); + private boolean eventFlushScheduled; private boolean degraded; + + // Set once the missing public key was logged, so it is said once. + private boolean noKeyLogged; + + // The billing portal last fetched, and the customer it was fetched for. + private String portalUrl; + private String portalUser; private String appUserId; + // The last customer the cloud answered with, or the stored one. + private CustomerInfo customerInfo = new CustomerInfo(null); + // The app user id customerInfo was loaded for; null before first use. + private String customerLoadedFor; + private CommerceManager() { appUserId = Preferences.get(PREF_APP_USER_ID, null); } @@ -112,13 +217,205 @@ public void setAppUserId(String id) { /// The current (explicit or generated-anonymous) app user id. public String getAppUserId() { if (appUserId == null) { + // The "anon-" prefix is a contract with the service: grants of an + // id that starts with it always move to the account the user + // later logs in to. The random part keeps two installs made in + // the same millisecond apart, which the identity hash of this + // object -- the same on every launch of some VMs -- did not. appUserId = "anon-" + Long.toString(System.currentTimeMillis(), 36) - + "-" + Integer.toString(hashCode() & 0x7fffffff, 36); + + "-" + Long.toString(RANDOM.nextLong() & Long.MAX_VALUE, 36); Preferences.set(PREF_APP_USER_ID, appUserId); } return appUserId; } + // ---- identity -------------------------------------------------------------- + + /// Tells the cloud that the current customer is now known as + /// `newAppUserId`, typically when the user signs in to the application's + /// own account. Purchases made under an anonymous id move to the account, + /// so a subscription bought before signing in, or on another platform + /// under the same account, unlocks here. + /// + /// Blocking network call -- invoke off the EDT. The id is switched only + /// when the cloud accepted the change; on failure the manager is + /// [degraded][#isDegraded()], the previous id stays, and the call can be + /// repeated. When the cloud is not enabled in this build the id is + /// switched locally and nothing else happens. + /// + /// This id is commerce's own. It is independent of the user id of + /// `Analytics.setUserId`, which stays whatever the application set: + /// logging in or out here never changes it, and an application that + /// wants its analytics under the same id sets it there as well. + /// + /// #### Parameters + /// + /// - `newAppUserId`: the application's stable id for the user + /// + /// #### Returns + /// + /// true if the id was switched + public boolean logIn(String newAppUserId) { + if (newAppUserId == null || newAppUserId.length() == 0) { + return false; + } + if (!isCloudEnabled()) { + setAppUserId(newAppUserId); + return true; + } + ensureCustomerLoaded(); + String previous = getAppUserId(); + if (newAppUserId.equals(previous)) { + return fetchCustomer(false); + } + StringBuilder extra = new StringBuilder(); + appendField(extra, "newAppUserId", newAppUserId); + SignedAnswer answer = postSignedAnswer("/identify", extra.toString(), newAppUserId); + Map payload = answer == null ? null : answer.payload; + if (payload == null || !accepted(payload)) { + degraded = true; + return false; + } + deleteStoredCustomer(previous); + setAppUserId(newAppUserId); + customerLoadedFor = newAppUserId; + adoptCustomer(answer, newAppUserId, true); + degraded = asBoolean(payload.get("degraded")); + return true; + } + + /// Forgets the current customer: a new anonymous id is generated and the + /// customer information becomes empty, so the next user of the device + /// does not inherit the previous one's entitlements. Listeners are told. + /// Nothing is sent to the cloud; call [#refresh()] afterwards to pick up + /// what the store account on the device still owns. + public void logOut() { + if (appUserId != null) { + deleteStoredCustomer(appUserId); + } + appUserId = null; + customerLoadedFor = getAppUserId(); + customerInfo = new CustomerInfo(null); + entitlementCache.clear(); + degraded = false; + fireCustomerInfoChanged(); + } + + /// Attaches attributes to the current customer in the cloud, where + /// targeting rules and the console can read them. A null value deletes + /// the attribute; any other value is sent as its string form. + /// + /// Blocking network call -- invoke off the EDT. Safe no-op when the cloud + /// is not enabled in this build. + /// + /// #### Parameters + /// + /// - `attributes`: attribute names to values + /// + /// #### Returns + /// + /// true if the cloud accepted the attributes + public boolean setAttributes(Map attributes) { + if (!isCloudEnabled() || attributes == null) { + return false; + } + String url = endpoint(); + StringBuilder extra = new StringBuilder(); + extra.append("\"attributes\":{"); + boolean first = true; + for (Object o : attributes.entrySet()) { + Map.Entry e = (Map.Entry) o; + if (e.getKey() == null) { + continue; + } + if (!first) { + extra.append(','); + } + first = false; + Object v = e.getValue(); + appendField(extra, String.valueOf(e.getKey()), v == null ? null : v.toString()); + } + extra.append('}'); + try { + Response resp = quiet(Rest.post(url + "/attributes")) + .body(baseRequest(newNonce(), extra.toString())).getAsString(); + return resp.getResponseCode() >= 200 && resp.getResponseCode() < 300; + } catch (Throwable t) { + Log.p("CommerceManager.setAttributes failed: " + t); + return false; + } + } + + /// Asks the store to restore the purchases of the store account on this + /// device, when the platform supports that. The restored receipts reach + /// the cloud through the receipt synchronization that follows, which + /// refreshes this manager by itself. + public void restore() { + Purchase p = Purchase.getInAppPurchase(); + if (p != null && p.isRestoreSupported()) { + p.restore(); + } + } + + // ---- customer information -------------------------------------------------- + + /// The last customer information the cloud answered with. Never null: + /// before any answer it is an empty customer. + /// + /// The last accepted answer is stored on the device, so this answers + /// after a cold start and with no network. A stored entitlement is + /// reported exactly as it was stored, even when its expiry date has + /// passed by the device's clock: the device clock is the user's to set, + /// so only the service's clock decides when an entitlement ends, and it + /// does so on the next [#refresh()]. + public CustomerInfo getCustomerInfo() { + ensureCustomerLoaded(); + return customerInfo; + } + + /// Adds a listener that is called on the EDT whenever the customer + /// information changes. The event's source is the new [CustomerInfo]. + public void addCustomerInfoListener(ActionListener l) { + customerInfoListeners.addListener(l); + } + + /// Removes a listener added with [#addCustomerInfoListener(ActionListener)]. + public void removeCustomerInfoListener(ActionListener l) { + customerInfoListeners.removeListener(l); + } + + private void fireCustomerInfoChanged() { + if (customerInfoListeners.hasListeners()) { + // The dispatcher moves the call to the EDT when this is another + // thread, which it normally is: refresh() blocks on the network. + customerInfoListeners.fireActionEvent(new ActionEvent(customerInfo)); + } + } + + /// Called by [Purchase] when a receipt synchronization completed + /// successfully: the stores' receipts have changed, so the cloud is + /// asked again. The refresh blocks on the network and therefore runs on + /// a background thread, never on the EDT this is called from. + static void receiptsSynchronized() { + final CommerceManager cm = getInstance(); + if (!cm.isCloudEnabled()) { + return; + } + Display.getInstance().scheduleBackgroundTask(new Runnable() { + @Override + public void run() { + cm.refresh(); + } + }); + } + + /// Drops the shared instance so a test starts from a cold one. + static synchronized void resetForTests() { + defaultPublicKey = DEFAULT_PUBLIC_KEY; + instance = null; + Analytics.setAttributionObserver(null); + } + // ---- purchase delegation (thin pass-through to Purchase) ---------------- public void purchase(String sku) { @@ -143,10 +440,17 @@ public boolean isEntitled(String entitlementId) { if (entitlementId == null) { return false; } + ensureCustomerLoaded(); + // A receipt validated after the last customer answer is newer than + // that answer; an accepted customer answer empties this cache. Object cached = entitlementCache.get(entitlementId); if (cached instanceof Boolean) { return ((Boolean) cached).booleanValue(); } + EntitlementInfo known = customerInfo.getEntitlement(entitlementId); + if (known != null) { + return known.isActive(); + } // Store-direct fallback: the platform's own receipt is authoritative // enough to keep a paying user unlocked when we have no cloud answer. try { @@ -158,6 +462,7 @@ public boolean isEntitled(String entitlementId) { /// Snapshot of the entitlement ids currently considered active. public List getActiveEntitlements() { + ensureCustomerLoaded(); List out = new ArrayList(); for (Object o : entitlementCache.entrySet()) { Map.Entry e = (Map.Entry) o; @@ -166,71 +471,1299 @@ public List getActiveEntitlements() { out.add(e.getKey()); } } + for (EntitlementInfo e : customerInfo.getActiveEntitlements()) { + if (!entitlementCache.containsKey(e.getId())) { + out.add(e.getId()); + } + } return out; } - // ---- cloud refresh ------------------------------------------------------- + // ---- offerings ---------------------------------------------------------------- - /// Validates the device's current receipts with the cloud and updates the - /// entitlement cache. Blocking network call -- invoke off the EDT. Safe - /// no-op when the cloud is not enabled in this build. On any failure the - /// manager flips to {@link #isDegraded() degraded} and leaves the previous - /// (or store-direct) answers in place. - public void refresh() { + /// The offerings the cloud configured for this customer. Same as + /// [#getOfferings(String)] with no placement. + /// + /// #### Returns + /// + /// the offerings; never null, and empty when there is nothing to answer + /// from + public Offerings getOfferings() { + return getOfferings(null); + } + + /// The offerings the cloud configured for this customer at a placement, + /// a named place in the application (`"onboarding"`, `"settings"`) that + /// the console can give an offering of its own. + /// + /// Blocking network call -- invoke off the EDT. The last accepted answer + /// is stored on the device and is what this returns when the cloud + /// cannot be reached. After a fresh answer the store is asked for the + /// products of the packages, when the platform lists products, and each + /// is attached to its [CommercePackage]. + /// + /// #### Parameters + /// + /// - `placement`: the placement, or null for the default offerings + /// + /// #### Returns + /// + /// the offerings; never null, and empty when the cloud is not enabled in + /// this build or there is nothing to answer from + public Offerings getOfferings(String placement) { if (!isCloudEnabled()) { + return new Offerings(null); + } + hookInvites(); + String key = storageKey(STORAGE_OFFERINGS_PREFIX, + getAppUserId() + "/" + (placement == null ? "" : placement)); + Display d = Display.getInstance(); + StringBuilder extra = new StringBuilder(); + appendField(extra, "platform", d.getPlatformName()); + String language = null; + String country = null; + try { + language = d.getLocalizationManager().getLanguage(); + country = d.getLocalizationManager().getLocale(); + } catch (Throwable t) { + // a port with no localization answers the default offerings + } + extra.append(','); + appendField(extra, "country", country); + extra.append(','); + appendField(extra, "appVersion", d.getProperty("AppVersion", null)); + extra.append(','); + String locale = language; + if (language != null && country != null && country.length() > 0) { + locale = language + "_" + country; + } + appendField(extra, "locale", locale); + if (placement != null) { + extra.append(','); + appendField(extra, "placement", placement); + } + SignedAnswer answer = postSignedAnswer("/offerings", extra.toString(), null); + Map payload = answer == null ? null : answer.payload; + Offerings result; + if (payload != null && accepted(payload)) { + result = new Offerings(payload); + try { + storeEnvelope(key, answer); + } catch (Throwable t) { + Log.p("CommerceManager: could not store the offerings: " + t); + } + fetchProducts(result); + } else { + result = new Offerings(null); + Map stored = loadEnvelope(key, OFFERINGS_ENDPOINTS, null); + if (stored != null) { + result = new Offerings(stored); + } + } + attachProducts(result); + rememberContext(result, placement); + reportExposure(result); + return result; + } + + /// Asks the store for the products of every package, when the platform + /// lists products. A store that fails to answer leaves the packages + /// without a product, and they are then shown with their own text. + private void fetchProducts(Offerings offerings) { + List skus = new ArrayList(); + for (Offering o : offerings.getAll()) { + for (CommercePackage p : o.getPackages()) { + if (p.getProductId() != null && !skus.contains(p.getProductId())) { + skus.add(p.getProductId()); + } + } + } + if (skus.isEmpty()) { return; } - List receipts; try { - receipts = Purchase.getInAppPurchase().getReceipts(); + Purchase store = Purchase.getInAppPurchase(); + if (store == null || !store.isItemListingSupported()) { + return; + } + String[] arr = new String[skus.size()]; + for (int i = 0; i < arr.length; i++) { + arr[i] = String.valueOf(skus.get(i)); + } + // A store that answers null lands in the catch below, like any + // other store failure. + for (Product p : store.getProducts(arr)) { + if (p != null && p.getSku() != null) { + productCache.put(p.getSku(), p); + } + } } catch (Throwable t) { - degraded = true; + Log.p("CommerceManager: the store did not list the products: " + t); + } + } + + private void attachProducts(Offerings offerings) { + for (Offering o : offerings.getAll()) { + for (CommercePackage p : o.getPackages()) { + Object product = productCache.get(p.getProductId()); + if (product instanceof Product) { + p.setProduct((Product) product); + } + } + } + } + + /// Buys a package through the platform store: a subscription for every + /// package that [is one][CommercePackage#isSubscription()], a one time + /// purchase otherwise. The outcome arrives the way every store purchase + /// does, and through the customer information listeners once the cloud + /// validated it. + /// + /// #### Parameters + /// + /// - `pkg`: the package to buy; null or a package with no product does + /// nothing + public void purchase(CommercePackage pkg) { + if (pkg == null) { + return; + } + if (isWebPurchase(pkg)) { + purchaseOnWeb(pkg); + return; + } + if (pkg.getProductId() == null) { + return; + } + // Read by Purchase when it reports purchase_initiated and purchase, + // so those events say which offering and package were bought. + pendingPurchase = new PurchaseContext(pkg.getProductId(), + contextParams(pkg.getOfferingId(), pkg.getId(), pkg.getProductId())); + if (pkg.isSubscription()) { + subscribe(pkg.getProductId()); + } else { + purchase(pkg.getProductId()); + } + } + + /// Whether [#purchase(CommercePackage)] sells a package through a web + /// checkout: the package has no product of this platform's store, and + /// the platform has no store to sell one -- the browser and the desktop, + /// where [Purchase] supports neither managed payments nor subscriptions. + /// + /// #### Parameters + /// + /// - `pkg`: the package asked about + /// + /// #### Returns + /// + /// true when buying the package opens a hosted checkout page + public boolean isWebPurchase(CommercePackage pkg) { + if (pkg == null || !isCloudEnabled()) { + return false; + } + String productId = pkg.getProductId(); + if (productId != null && productId.length() > 0) { + return false; + } + try { + Purchase store = Purchase.getInAppPurchase(); + return store == null || !(store.isManagedPaymentSupported() || store.isSubscriptionSupported()); + } catch (Throwable t) { + // a port with no store object at all has no native store + return true; + } + } + + /// Asks the cloud for a hosted checkout page that sells a package for + /// its web price. + /// + /// Blocking network call -- invoke off the EDT. + /// + /// #### Parameters + /// + /// - `pkg`: the package to sell + /// + /// - `successUrl`: where the checkout sends the customer after paying, + /// or null for the service's own page + /// + /// - `cancelUrl`: where the checkout sends a customer who gave up, or + /// null for the service's own page + /// + /// #### Returns + /// + /// the address of the checkout page, or null when the cloud is not + /// enabled in this build, could not be reached, or has no web price for + /// the package + public String getWebPurchaseUrl(CommercePackage pkg, String successUrl, String cancelUrl) { + if (pkg == null || !isCloudEnabled()) { + return null; + } + StringBuilder sb = new StringBuilder(); + appendField(sb, "offeringId", pkg.getOfferingId()); + sb.append(','); + appendField(sb, "packageId", pkg.getId()); + if (successUrl != null) { + sb.append(','); + appendField(sb, "successUrl", successUrl); + } + if (cancelUrl != null) { + sb.append(','); + appendField(sb, "cancelUrl", cancelUrl); + } + return urlOf(postSigned("/web-purchase", sb.toString())); + } + + /// Sells a package through a hosted checkout page, opened with + /// [Display#execute(String)]. The customer pays outside the + /// application, so nothing reports the outcome here: once the + /// application is back in the foreground the caller runs [#refresh()] + /// off the EDT, and the customer information listeners hear what was + /// bought. [com.codename1.payment.paywall.PaywallView] does that itself. + /// + /// May be called on the EDT, which keeps dispatching events while the + /// cloud is asked for the page. + /// + /// #### Parameters + /// + /// - `pkg`: the package to sell + /// + /// #### Returns + /// + /// true when a checkout page was opened, false when there was none + public boolean purchaseOnWeb(final CommercePackage pkg) { + final String[] url = new String[1]; + blocking(new Runnable() { + @Override + public void run() { + url[0] = getWebPurchaseUrl(pkg, null, null); + } + }); + if (url[0] == null) { + return false; + } + Display.getInstance().execute(url[0]); + if (pkg != null) { + // Purchase has no part in a web checkout, so its start is + // reported here, under the same name and category. + try { + Map params = contextParams(pkg.getOfferingId(), pkg.getId(), pkg.getProductId()); + params.put("store", "web"); + if (pkg.getProductId() != null) { + params.put("sku", pkg.getProductId()); + } + Analytics.autoEvent("purchase_initiated", ANALYTICS_CATEGORY, params); + } catch (Throwable t) { + Log.e(t); + } + } + return true; + } + + /// Buys a subscription under one of its App Store promotional offers. + /// The cloud signs the offer for this customer and the store is then + /// asked to sell the product with that signature, as + /// [Purchase#purchase(String, PromotionalOffer)] does. + /// + /// May be called on the EDT, which keeps dispatching events while the + /// cloud is asked for the signature. + /// + /// #### Parameters + /// + /// - `productId`: the subscription product + /// + /// - `offerId`: the promotional offer of that product, as App Store + /// Connect names it + /// + /// #### Returns + /// + /// true when the store was asked to sell the offer; false when no + /// signature is available -- the platform's store has no promotional + /// offers, the cloud is not enabled or it refused -- in which case + /// nothing was bought + public boolean purchaseWithPromotionalOffer(final String productId, final String offerId) { + if (productId == null || offerId == null || !isCloudEnabled() || !promotionalOffersSupported()) { + return false; + } + final ApplePromotionalOffer[] offer = new ApplePromotionalOffer[1]; + blocking(new Runnable() { + @Override + public void run() { + offer[0] = fetchPromotionalOffer(productId, offerId); + } + }); + if (offer[0] == null) { + return false; + } + try { + Purchase.getInAppPurchase().purchase(productId, offer[0]); + } catch (RuntimeException e) { + // thrown by a store that does not sell promotional offers + Log.p("CommerceManager: the store refused the promotional offer: " + e); + return false; + } + return true; + } + + /// Promotional offers are an App Store feature. + private static boolean promotionalOffersSupported() { + String platform = Display.getInstance().getPlatformName(); + return "ios".equalsIgnoreCase(platform) || "mac".equalsIgnoreCase(platform); + } + + /// The signed promotional offer of a product, or null when the cloud + /// gave no usable signature. Blocking. + private ApplePromotionalOffer fetchPromotionalOffer(String productId, String offerId) { + StringBuilder sb = new StringBuilder(); + appendField(sb, "productId", productId); + sb.append(','); + appendField(sb, "offerId", offerId); + Map payload = postSigned("/promo-offer-signature", sb.toString()); + if (payload == null || !asBoolean(payload.get("accepted"))) { + return null; + } + String keyId = CustomerInfo.asString(payload.get("keyId")); + String offerNonce = CustomerInfo.asString(payload.get("nonce")); + String signature = CustomerInfo.asString(payload.get("signature")); + if (keyId == null || offerNonce == null || signature == null + || keyId.length() == 0 || offerNonce.length() == 0 || signature.length() == 0) { + return null; + } + ApplePromotionalOffer offer = new ApplePromotionalOffer(); + offer.setOfferIdentifier(offerId); + offer.setKeyIdentifier(keyId); + offer.setNonce(offerNonce); + offer.setSignature(signature); + offer.setTimestamp(CustomerInfo.asLong(payload.get("timestamp"))); + return offer; + } + + /// Runs a network call to its end: through `invokeAndBlock` on the EDT, + /// directly on any other thread. + private static void blocking(Runnable r) { + Display d = Display.getInstance(); + if (d.isEdt()) { + d.invokeAndBlock(r); + } else { + r.run(); + } + } + + /// The `url` of an accepted answer, or null. + private static String urlOf(Map payload) { + if (payload == null || !asBoolean(payload.get("accepted"))) { + return null; + } + String url = CustomerInfo.asString(payload.get("url")); + return url == null || url.length() == 0 ? null : url; + } + + /// The page where the customer manages the subscription, or null when + /// there is none. Never touches the network: for a subscription bought + /// on the web it is the billing portal [#fetchManagementUrl()] last + /// answered, and otherwise -- or before that was ever asked -- the + /// store's page from the last + /// [customer information][#getCustomerInfo()]. + public String getManagementUrl() { + CustomerInfo info = getCustomerInfo(); + String user = getAppUserId(); + if (portalUrl != null && paidOnWeb(info) && user.equals(portalUser)) { + return portalUrl; + } + return info.getManagementUrl(); + } + + /// The page where the customer manages the subscription, asking the + /// cloud for a billing portal when the newest active entitlement -- the + /// one that expires last -- was bought on the web (`stripe` or + /// `paddle`). A portal address is short lived, so it is asked for when + /// it is about to be opened. For a store subscription this is + /// [#getManagementUrl()] and nothing is asked. + /// + /// Blocking network call -- invoke off the EDT. + /// + /// #### Returns + /// + /// the address, or null when there is none + public String fetchManagementUrl() { + CustomerInfo info = getCustomerInfo(); + if (isCloudEnabled() && paidOnWeb(info)) { + String url = urlOf(postSigned("/web-portal", null)); + if (url != null) { + portalUrl = url; + portalUser = getAppUserId(); + return url; + } + } + return getManagementUrl(); + } + + /// Whether the active entitlement that expires last was bought on the + /// web. One that never expires counts as the last only when no other is + /// active. + static boolean paidOnWeb(CustomerInfo info) { + EntitlementInfo newest = null; + for (EntitlementInfo e : info.getActiveEntitlements()) { + if (newest == null || e.getExpiresDate() > newest.getExpiresDate()) { + newest = e; + } + } + if (newest == null) { + return false; + } + String store = newest.getStore(); + return "stripe".equalsIgnoreCase(store) || "paddle".equalsIgnoreCase(store); + } + + /// The customer center the console configured for this application, as + /// parsed JSON: the `paths` to offer, the support address and the cancel + /// reasons. [com.codename1.payment.center.CustomerCenter] presents it. + /// + /// Blocking network call -- invoke off the EDT. + /// + /// #### Returns + /// + /// the configuration, or null when the cloud is not enabled in this + /// build, could not be reached or refused to answer + public Map getCustomerCenterConfiguration() { + if (!isCloudEnabled()) { + return null; + } + Map payload = postSigned("/customer-center", null); + if (payload == null || !asBoolean(payload.get("accepted"))) { + return null; + } + return payload; + } + + // ---- events -------------------------------------------------------------------- + + /// Reports a paywall event to the cloud, where the console charts the + /// funnel of each offering and paywall. Same as + /// [#logEvent(String, String, String, String, String)] with no reason. + /// + /// #### Parameters + /// + /// - `type`: the event, such as `IMPRESSION`, `PURCHASE_START`, + /// `PURCHASE` or `CLOSE` + /// + /// - `offeringId`: the offering shown, or null + /// + /// - `paywallId`: the paywall shown, or null + /// + /// - `packageId`: the package concerned, or null + public void logEvent(String type, String offeringId, String paywallId, String packageId) { + logEvent(type, offeringId, paywallId, packageId, null); + } + + /// Reports an event to the cloud. The call returns at once on any + /// thread: events are collected and sent together from a background + /// thread, and an event that cannot be sent is dropped. Reporting never + /// delays or fails a purchase. Safe no-op when the cloud is not enabled + /// in this build. + /// + /// #### Parameters + /// + /// - `type`: the event, such as `IMPRESSION` or `CANCEL_REASON` + /// + /// - `offeringId`: the offering shown, or null + /// + /// - `paywallId`: the paywall shown, or null + /// + /// - `packageId`: the package concerned, or null + /// + /// - `reason`: the id of the reason chosen, for a `CANCEL_REASON` event, + /// or null + public void logEvent(String type, String offeringId, String paywallId, String packageId, String reason) { + if (type == null || !isCloudEnabled()) { return; } - if (receipts == null || receipts.isEmpty()) { + StringBuilder sb = new StringBuilder(); + sb.append('{'); + appendField(sb, "type", type); + sb.append(','); + appendField(sb, "offeringId", offeringId); + sb.append(','); + appendField(sb, "paywallId", paywallId); + sb.append(','); + appendField(sb, "packageId", packageId); + if (reason != null) { + sb.append(','); + appendField(sb, "reason", reason); + } + sb.append(",\"time\":").append(System.currentTimeMillis()).append('}'); + final String event = sb.toString(); + // The queue belongs to the EDT, so no two threads ever touch it. + if (Display.getInstance().isEdt()) { + queueEvent(event); + } else { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + queueEvent(event); + } + }); + } + } + + /// Reports a commerce event to the analytics providers the application + /// registered with `Analytics`, in the category `commerce`. + /// [com.codename1.payment.paywall.PaywallView] + /// and the customer center call this for what they show; an application + /// that draws its own paywall can call it with the same names so its + /// reports read the same. + /// + /// The event carries the parameters `offering`, `package` and `product` + /// for the values that are not null, `placement` when the offering was + /// fetched for a placement, and `experiment` and `variant` when the + /// offering was chosen by an experiment. Nothing is sent without the + /// user's analytics consent, and the call does nothing at all when no + /// provider is registered. It never throws and never blocks. + /// + /// #### Parameters + /// + /// - `name`: the event name, such as `paywall_view` + /// + /// - `offeringId`: the offering concerned, or null + /// + /// - `packageId`: the package concerned, or null + /// + /// - `productId`: the store product concerned, or null + /// + /// - `key`: the name of one more parameter, or null + /// + /// - `value`: the value of that parameter, or null to leave it out + public void analyticsEvent(String name, String offeringId, String packageId, String productId, + String key, String value) { + if (name == null) { return; } + try { + Map params = contextParams(offeringId, packageId, productId); + if (key != null && value != null) { + params.put(key, value); + } + Analytics.autoEvent(name, ANALYTICS_CATEGORY, params); + } catch (Throwable t) { + Log.e(t); + } + } + + private Map contextParams(String offeringId, String packageId, String productId) { + Map params = new HashMap(); + putParam(params, "offering", offeringId); + putParam(params, "package", packageId); + putParam(params, "product", productId); + Object context = offeringId == null ? null : offeringContext.get(offeringId); + if (context instanceof String[]) { + String[] c = (String[]) context; + putParam(params, "placement", c[0]); + putParam(params, "experiment", c[1]); + putParam(params, "variant", c[2]); + } + return params; + } + + private static void putParam(Map params, String key, String value) { + if (value != null && value.length() > 0) { + params.put(key, value); + } + } + + /// Remembers which placement and experiment each offering of an answer + /// belongs to. The experiment chose the current offering, so only that + /// one is labelled with it. + private void rememberContext(Offerings result, String placement) { + Map next = new HashMap(offeringContext); + Offering current = result.getCurrent(); + for (Offering o : result.getAll()) { + if (o.getId() == null) { + continue; + } + boolean chosen = current != null && o.getId().equals(current.getId()); + String[] context = new String[3]; + context[0] = placement; + if (chosen) { + context[1] = result.getExperimentId(); + context[2] = result.getExperimentVariant(); + } + next.put(o.getId(), context); + } + offeringContext = next; + } + + /// Reports `experiment_exposure` the first time this install is seen in + /// an experiment's variant. Remembered only once it could be sent, so an + /// exposure met before the user consented is reported after. + private static void reportExposure(Offerings result) { + try { + String id = result.getExperimentId(); + if (id == null || !analyticsActive()) { + return; + } + String variant = result.getExperimentVariant(); + String pair = id + "/" + (variant == null ? "" : variant); + if (pair.equals(Preferences.get(PREF_EXPOSURE, null))) { + return; + } + Preferences.set(PREF_EXPOSURE, pair); + Map params = new HashMap(); + params.put("experiment", id); + putParam(params, "variant", variant); + Analytics.autoEvent("experiment_exposure", ANALYTICS_CATEGORY, params); + } catch (Throwable t) { + Log.e(t); + } + } + + /// Whether analytics would send an event now: a provider is registered + /// and the user's consent covers analytics. + private static boolean analyticsActive() { + AnalyticsConsent c = Analytics.getConsent(); + boolean allowed; + if (Analytics.getConsentMode() == ConsentMode.OPT_OUT) { + allowed = c == null || c.isAnalytics(); + } else { + allowed = c != null && c.isAnalytics(); + } + return allowed && !Analytics.getProviders().isEmpty(); + } + + /// The analytics client id of this install, which the cloud uses to join + /// a customer to the install's analytics and invite records. Null -- and + /// then no request carries one -- when the application registered no + /// analytics provider or the user's consent does not cover analytics, so + /// an id is never created for an application that does not use analytics + /// and never sent for a user who opted out. + static String analyticsClientId() { + try { + if (!analyticsActive()) { + return null; + } + String id = Analytics.clientId(); + return id == null || id.length() == 0 ? null : id; + } catch (Throwable t) { + return null; + } + } + + private static void appendClientId(StringBuilder sb) { + String id = analyticsClientId(); + if (id != null) { + sb.append(','); + appendField(sb, "analyticsClientId", id); + } + } + + /// Adds the context of a purchase started by + /// [#purchase(CommercePackage)] to the parameters of the analytics event + /// [Purchase] is about to send for the same product. A context is used + /// for one purchase: the completing event takes it, and it lapses by + /// itself so a later purchase of the product is not labelled with it. + /// + /// #### Parameters + /// + /// - `sku`: the product of the event + /// + /// - `params`: the parameters to add to + /// + /// - `consume`: true for the event that completes the purchase + static void addPurchaseContext(String sku, Map params, boolean consume) { + // No try block here: the loop below casts, and a cast must never + // sit under a handler that could be taken for its failure. + CommerceManager cm = instance; + if (cm == null) { + return; + } + PurchaseContext pending = cm.pendingPurchase; + if (pending == null || sku == null || params == null || !sku.equals(pending.sku)) { + return; + } + boolean lapsed = System.currentTimeMillis() - pending.time > PURCHASE_CONTEXT_LIFETIME; + if (consume || lapsed) { + cm.pendingPurchase = null; + } + if (lapsed) { + return; + } + for (Map.Entry e : pending.params.entrySet()) { + if (!params.containsKey(e.getKey())) { + params.put(e.getKey(), e.getValue()); + } + } + } + + /// Moves the start of the pending purchase context back in time. + static void agePurchaseContextForTests(long millis) { + CommerceManager cm = instance; + if (cm != null && cm.pendingPurchase != null) { + cm.pendingPurchase = new PurchaseContext(cm.pendingPurchase.sku, cm.pendingPurchase.params, + cm.pendingPurchase.time - millis); + } + } + + /// What [#purchase(CommercePackage)] knew about a purchase it started. + private static final class PurchaseContext { + final String sku; + final Map params; + final long time; + + PurchaseContext(String sku, Map params) { + this(sku, params, System.currentTimeMillis()); + } + + PurchaseContext(String sku, Map params, long time) { + this.sku = sku; + this.params = params; + this.time = time; + } + } + + /// Registers, once, for the invite attribution of this install. A + /// referral reward is granted by the cloud, so when the attribution is + /// known the customer is fetched again and the reward appears without the + /// application asking. The registration goes through `Analytics`, not + /// the invite package: it leaves the application's own invite listener + /// alone, and commerce does not make an application depend on invites. + private void hookInvites() { + if (invitesHooked) { + return; + } + invitesHooked = true; + hookingInvites = true; + try { + Analytics.setAttributionObserver(INVITE_OBSERVER); + } catch (Throwable t) { + Log.e(t); + } finally { + hookingInvites = false; + } + } + + private void inviteArrived() { + // Registering from inside a refresh answers at once when the + // attribution is already known, and that refresh is the fetch. + if (!isCloudEnabled() || (hookingInvites && refreshing)) { + return; + } + Display.getInstance().scheduleBackgroundTask(new Runnable() { + @Override + public void run() { + refresh(); + } + }); + } + + private void queueEvent(String event) { + pendingEvents.add(event); + if (eventFlushScheduled) { + return; + } + eventFlushScheduled = true; + // Sent on the next EDT cycle, so the events of one user action -- + // a purchase start right after an impression -- travel together. + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + final String body = drainEvents(); + if (body == null) { + return; + } + Display.getInstance().scheduleBackgroundTask(new Runnable() { + @Override + public void run() { + try { + quiet(Rest.post(endpoint() + "/events")).body(body).getAsString(); + } catch (Throwable t) { + // analytics only: dropped + } + } + }); + } + }); + } + + /// Empties the event queue into the body of one `/events` request. + /// + /// #### Returns + /// + /// the request body, or null when nothing is waiting + String drainEvents() { + eventFlushScheduled = false; + if (pendingEvents.isEmpty() || endpoint() == null) { + pendingEvents.clear(); + return null; + } + StringBuilder events = new StringBuilder("\"events\":["); + for (int i = 0; i < pendingEvents.size(); i++) { + if (i > 0) { + events.append(','); + } + events.append(pendingEvents.get(i)); + } + events.append(']'); + pendingEvents.clear(); + return baseRequest(newNonce(), events.toString()); + } + + // ---- cloud refresh ------------------------------------------------------- + + /// Validates the device's current receipts with the cloud, then asks the + /// cloud what the customer is entitled to and takes that answer as the + /// entitlement state. The customer is asked even when the device holds no + /// receipt at all, which is what unlocks a purchase made on another + /// platform or granted from the console. + /// + /// Blocking network call -- invoke off the EDT. Safe no-op when the cloud + /// is not enabled in this build. On any failure the manager flips to + /// {@link #isDegraded() degraded} and leaves the previous (or + /// store-direct) answers in place. + public void refresh() { + if (!isCloudEnabled()) { + return; + } + refreshing = true; + try { + refreshNow(); + } finally { + refreshing = false; + } + } + + private void refreshNow() { + ensureCustomerLoaded(); + List receipts = null; + boolean receiptsFailed = false; + try { + receipts = Purchase.getInAppPurchase().getReceipts(); + } catch (Throwable t) { + receiptsFailed = true; + } + boolean anyReceipt = false; boolean anySuccess = false; boolean anyDegraded = false; - for (Object o : receipts) { - Receipt r = (Receipt) o; - Boolean[] flags = validateReceipt(r); - if (flags != null) { - anySuccess = true; - if (flags[0].booleanValue()) { - anyDegraded = true; + if (receipts != null) { + for (Object o : receipts) { + if (!(o instanceof Receipt)) { + continue; + } + anyReceipt = true; + Boolean[] flags = validateReceipt((Receipt) o); + if (flags != null) { + anySuccess = true; + if (flags[0].booleanValue()) { + anyDegraded = true; + } } } } - degraded = anyDegraded || !anySuccess; + boolean customerOk = fetchCustomer(false); + degraded = !customerOk || degraded || receiptsFailed || anyDegraded || (anyReceipt && !anySuccess); } - /// Posts one receipt to the cloud /validate endpoint, folds the returned - /// entitlements into the cache, and returns {degraded} (or null on - /// failure). - private Boolean[] validateReceipt(Receipt r) { + /// Asks the cloud for the current customer and adopts the answer. Leaves + /// `degraded` as the answer itself reported it, or true when there was no + /// acceptable answer. + private boolean fetchCustomer(boolean forceEvent) { + // The id the request is for; it may change while the request is out. + String forUser = getAppUserId(); + SignedAnswer answer = postSignedAnswer("/customer", null, forUser); + Map payload = answer == null ? null : answer.payload; + if (payload == null || !accepted(payload)) { + degraded = true; + return false; + } + if (!forUser.equals(getAppUserId())) { + // The user changed while this was out; the answer is not theirs. + return false; + } + adoptCustomer(answer, forUser, forceEvent); + degraded = asBoolean(payload.get("degraded")); + return true; + } + + /// An answer the cloud explicitly refused to give (`accepted` false) is + /// not an entitlement state; one that does not say is taken as given. + private static boolean accepted(Map payload) { + Object a = payload.get("accepted"); + return a == null || asBoolean(a); + } + + private void adoptCustomer(SignedAnswer answer, String forUser, boolean forceEvent) { + CustomerInfo next = new CustomerInfo(answer.payload); + boolean changed = forceEvent || !next.sameState(customerInfo); + customerInfo = next; + // The customer answer is the entitlement state; what single receipts + // said before it is superseded. + entitlementCache.clear(); + try { + storeEnvelope(storageKey(STORAGE_CUSTOMER_PREFIX, forUser), answer); + } catch (Throwable t) { + Log.p("CommerceManager: could not store the customer: " + t); + } + if (changed) { + fireCustomerInfoChanged(); + } + } + + /// Loads the stored customer of the current app user id the first time + /// it is needed, and again after the id changed under it, so a cold + /// start answers from the last accepted answer before any network call. + /// + /// Nothing is expired here. An entitlement whose `expiresDate` is behind + /// the device clock is still reported as stored: the service's clock + /// decides, at the next refresh, and the device's is not trusted to. + private void ensureCustomerLoaded() { + if (isCloudEnabled()) { + hookInvites(); + } + String id = getAppUserId(); + if (id.equals(customerLoadedFor)) { + return; + } + boolean hadCustomer = customerLoadedFor != null; + customerLoadedFor = id; + customerInfo = new CustomerInfo(null); + if (hadCustomer) { + // Another user's receipts answered these. + entitlementCache.clear(); + } + Map stored = loadEnvelope(storageKey(STORAGE_CUSTOMER_PREFIX, id), CUSTOMER_ENDPOINTS, id); + if (stored != null) { + customerInfo = new CustomerInfo(stored); + } + } + + private void deleteStoredCustomer(String id) { + try { + Storage.getInstance().deleteStorageFile(storageKey(STORAGE_CUSTOMER_PREFIX, id)); + } catch (Throwable t) { + Log.p("CommerceManager: could not delete the stored customer: " + t); + } + } + + /// A storage entry name for an app user id. The id is the application's + /// and may hold characters a file name cannot, so those are replaced and + /// the id's hash keeps two ids that then look alike apart. + private static String storageKey(String prefix, String id) { + StringBuilder sb = new StringBuilder(prefix); + for (int i = 0; i < id.length(); i++) { + char c = id.charAt(i); + boolean safe = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '-'; + sb.append(safe ? c : '_'); + } + sb.append('_').append(Integer.toHexString(id.hashCode())); + return sb.toString(); + } + + // ---- signed envelope ------------------------------------------------------- + + /// Posts a request to one of the endpoints that answer with a signed + /// envelope, `{"payload": "", "signature": ""}`, and + /// returns the parsed payload, or null when there is no answer to trust. + /// + /// The signature is Ed25519 over the UTF-8 bytes of the request's nonce, + /// a line feed and the payload text, so an answer recorded earlier + /// cannot be replayed to a later request. An answer that is unsigned or + /// wrongly signed is ignored, and while no public key is known at all + /// every answer is: the request is not even sent. + private Map postSigned(String path, String extraFields) { + SignedAnswer a = postSignedAnswer(path, extraFields, null); + return a == null ? null : a.payload; + } + + /// A verified answer: its parsed payload and the envelope text that is + /// cached. Each call gets its own, because a request runs inside + /// invokeAndBlock and another commerce call can run while it is out. + static final class SignedAnswer { + final Map payload; + final String envelope; + + SignedAnswer(Map payload, String envelope) { + this.payload = payload; + this.envelope = envelope; + } + } + + // The endpoints whose answers may sit in each cache slot. + private static final String[] OFFERINGS_ENDPOINTS = {"/offerings"}; + private static final String[] CUSTOMER_ENDPOINTS = {"/customer", "/identify"}; + + /// [#postSigned(String, String)] keeping the envelope, and refusing an + /// answer bound to another request: one that names an `appUserId` other + /// than `forUser` (when it is not null), or a `packageName` or `endpoint` + /// other than this application's and `path`. A field the answer does not + /// carry is not checked, since older services do not send them. + private SignedAnswer postSignedAnswer(String path, String extraFields, String forUser) { String url = endpoint(); - if (url == null || r == null) { + if (url == null) { + return null; + } + byte[] key = publicKey(); + if (key == null) { + if (!noKeyLogged) { + noKeyLogged = true; + Log.p("CommerceManager: no public key for the commerce service is known (" + + PROP_PUBLIC_KEY + "), so its answers cannot be verified and are not used;" + + " answering from the store directly"); + } return null; } - String body = buildRequest(r); + String nonce = newNonce(); + Object data = postForJson(url + path, baseRequest(nonce, extraFields)); + // The casts stay outside any try block: a failed cast does not throw + // on every platform, so each type is tested and never caught. + if (!(data instanceof Map)) { + return null; + } + Map envelope = (Map) data; + Object payload = envelope.get("payload"); + if (!(payload instanceof String)) { + return null; + } + String text = (String) payload; + Object signature = envelope.get("signature"); + if (!(signature instanceof String) || !verify(nonce, text, (String) signature, key)) { + Log.p("CommerceManager: ignored an answer of " + path + " with a missing or bad signature"); + return null; + } + String sigText = (String) signature; + Object keyIdValue = envelope.get("keyId"); + String keyId = keyIdValue instanceof String ? (String) keyIdValue : null; try { - Response resp = Rest.post(url + "/validate").jsonContent().body(body).getAsJsonMap(); - if (resp.getResponseCode() < 200 || resp.getResponseCode() >= 300) { + Map parsed = JSONParser.parseJSON(text); + if (!boundTo(parsed, new String[] {path}, forUser, packageName())) { + Log.p("CommerceManager: ignored an answer of " + path + " bound to another request"); return null; } - Object data = resp.getResponseData(); - if (!(data instanceof Map)) { + StringBuilder sb = new StringBuilder("{"); + appendField(sb, "nonce", nonce); + sb.append(','); + appendField(sb, "payload", text); + sb.append(','); + appendField(sb, "signature", sigText); + if (keyId != null) { + sb.append(','); + appendField(sb, "keyId", keyId); + } + sb.append('}'); + return new SignedAnswer(parsed, sb.toString()); + } catch (Throwable t) { + Log.p("CommerceManager" + path + " answered an unreadable payload: " + t); + return null; + } + } + + /// True when the payload's binding fields, where present, match the + /// request: `appUserId` against `forUser` (skipped when null), + /// `packageName` against this application's, `endpoint` against one of + /// `paths`. + private static boolean boundTo(Map payload, String[] paths, String forUser, String pkg) { + Object user = payload.get("appUserId"); + if (forUser != null && user != null && !forUser.equals(String.valueOf(user))) { + return false; + } + Object p = payload.get("packageName"); + if (p != null && !String.valueOf(p).equals(pkg)) { + return false; + } + Object e = payload.get("endpoint"); + if (e != null) { + String ep = String.valueOf(e); + for (String path : paths) { + if (path.equals(ep)) { + return true; + } + } + return false; + } + return true; + } + + /// Stores the envelope of a verified answer under a storage entry name. + private void storeEnvelope(String name, SignedAnswer answer) { + if (answer == null || answer.envelope == null) { + return; + } + try { + Storage.getInstance().writeObject(name, answer.envelope); + } catch (Throwable t) { + Log.p("CommerceManager: could not store " + name + ": " + t); + } + } + + /// The payload of a stored envelope whose signature still verifies + /// against the current key, or null. An entry that does not verify, was + /// written in an older format, cannot be read, or is bound to another + /// user, application or endpoint (see `boundTo`) is deleted. + private Map loadEnvelope(String name, String[] paths, String forUser) { + Storage st = Storage.getInstance(); + if (!st.exists(name)) { + return null; + } + try { + Object stored = st.readObject(name); + byte[] key = publicKey(); + if (key != null && stored instanceof String) { + Map env = JSONParser.parseJSON((String) stored); + Object nonce = env.get("nonce"); + Object payload = env.get("payload"); + Object signature = env.get("signature"); + if (nonce instanceof String && payload instanceof String && signature instanceof String + && verify((String) nonce, (String) payload, (String) signature, key)) { + Map parsed = JSONParser.parseJSON((String) payload); + if (boundTo(parsed, paths, forUser, packageName())) { + return parsed; + } + Log.p("CommerceManager: dropped " + name + ", bound to another request"); + } + } + } catch (Throwable t) { + Log.p("CommerceManager: could not read " + name + ": " + t); + } + try { + st.deleteStorageFile(name); + } catch (Throwable t) { + Log.p("CommerceManager: could not delete " + name + ": " + t); + } + return null; + } + + /// Posts a JSON body and returns the parsed JSON answer, or null for an + /// error status or no answer at all. + private static Object postForJson(String url, String body) { + try { + Response resp = quiet(Rest.post(url)).body(body).getAsJsonMap(); + if (resp.getResponseCode() < 200 || resp.getResponseCode() >= 300) { return null; } - Map result = (Map) data; - applyEntitlements(result.get("entitlements")); - boolean deg = asBoolean(result.get("degraded")); - return new Boolean[] { deg ? Boolean.TRUE : Boolean.FALSE }; + return resp.getResponseData(); + } catch (Throwable t) { + Log.p("CommerceManager: " + url + " failed: " + t); + return null; + } + } + + private static boolean verify(String nonce, String payload, String signature, byte[] key) { + try { + if (key.length != Ed25519.PUBLIC_KEY_LENGTH) { + return false; + } + byte[] sig = Base64.decode(signature.getBytes("UTF-8")); + if (sig == null || sig.length != Ed25519.SIGNATURE_LENGTH) { + return false; + } + return Ed25519.verify((nonce + "\n" + payload).getBytes("UTF-8"), sig, key); + } catch (Throwable t) { + return false; + } + } + + /// The service's public key, or null when none is known. A key that is + /// configured but unreadable is returned as an empty array, which no + /// signature verifies against: a broken key must not quietly turn + /// verification off. + private static byte[] publicKey() { + String k = Display.getInstance().getProperty(PROP_PUBLIC_KEY, DEFAULT_PUBLIC_KEY); + if (k == null || k.length() == 0) { + k = defaultPublicKey; + } + if (k.length() == 0) { + return null; + } + try { + byte[] decoded = Base64.decode(k.getBytes("UTF-8")); + return decoded == null ? new byte[0] : decoded; } catch (Throwable t) { - Log.p("CommerceManager.validateReceipt failed: " + t); + return new byte[0]; + } + } + + /// A JSON request that reports failure through its response and nothing + /// else. Left alone, a request that meets an error status or a dead + /// connection puts the framework's "Retry / Cancel" dialog on screen, and + /// these run in the background where the user asked for nothing. + private static RequestBuilder quiet(RequestBuilder request) { + return request.jsonContent() + .onErrorCodeString(new ErrorCodeHandler() { + @Override + public void onError(Response errorData) { + // the caller reads the response code + } + }) + .onError(new ActionListener() { + @Override + public void actionPerformed(NetworkEvent evt) { + // the caller sees no response, or the exception + } + }); + } + + /// A fresh nonce for one request, 128 random bits in hex. + private static String newNonce() { + byte[] b; + try { + b = SecureRandom.bytes(16); + } catch (Throwable t) { + // A port with no secure source still gets a nonce that differs + // per request, which is what tells answers apart. + b = new byte[16]; + RANDOM.nextBytes(b); + } + StringBuilder sb = new StringBuilder(32); + for (byte v : b) { + sb.append(HEX.charAt((v >> 4) & 0xf)); + sb.append(HEX.charAt(v & 0xf)); + } + return sb.toString(); + } + + /// The fields every request of the newer endpoints carries, plus the + /// caller's own, as a JSON object. + private String baseRequest(String nonce, String extraFields) { + StringBuilder sb = new StringBuilder(); + sb.append('{'); + appendField(sb, "buildKey", Display.getInstance().getProperty(PROP_BUILD_KEY, "")); + sb.append(','); + appendField(sb, "packageName", packageName()); + sb.append(','); + appendField(sb, "appUserId", getAppUserId()); + sb.append(','); + appendField(sb, "nonce", nonce); + appendClientId(sb); + if (extraFields != null && extraFields.length() > 0) { + sb.append(',').append(extraFields); + } + sb.append('}'); + return sb.toString(); + } + + /// Posts one receipt to the cloud /validate endpoint, folds the returned + /// entitlements into the cache, and returns {degraded} (or null when there + /// is no answer to trust). + /// + /// With a public key the request carries a nonce and the answer is the + /// signed envelope [#postSigned(String, String)] verifies, so an answer + /// forged on the network applies nothing. Without a key the request is + /// still sent, so the service records the purchase and the revenue of an + /// invite, but its unsigned answer is not used: the store-direct fallback + /// answers instead. + private Boolean[] validateReceipt(Receipt r) { + String url = endpoint(); + if (url == null || r == null) { + return null; + } + if (publicKey() == null) { + postForJson(url + "/validate", buildRequest(r)); return null; } + Map result = postSigned("/validate", receiptFields(r)); + if (result == null) { + return null; + } + applyEntitlements(result.get("entitlements")); + boolean deg = asBoolean(result.get("degraded")); + return new Boolean[] { deg ? Boolean.TRUE : Boolean.FALSE }; } private void applyEntitlements(Object entitlements) { @@ -249,6 +1782,8 @@ private void applyEntitlements(Object entitlements) { } } + /// The unsigned request of a client that knows no public key: no nonce, + /// so the service answers in the old unsigned shape. private String buildRequest(Receipt r) { StringBuilder sb = new StringBuilder(); sb.append('{'); @@ -258,6 +1793,15 @@ private String buildRequest(Receipt r) { sb.append(','); appendField(sb, "appUserId", getAppUserId()); sb.append(','); + sb.append(receiptFields(r)); + appendClientId(sb); + sb.append('}'); + return sb.toString(); + } + + /// The fields that describe one receipt, without the enclosing braces. + private static String receiptFields(Receipt r) { + StringBuilder sb = new StringBuilder(); appendField(sb, "store", storeOf(r)); sb.append(','); // On StoreKit 2 the order data carries the signed transaction JWS; the @@ -265,18 +1809,26 @@ private String buildRequest(Receipt r) { appendField(sb, "signedTransaction", r.getOrderData()); sb.append(','); appendField(sb, "productId", r.getSku()); - sb.append('}'); return sb.toString(); } - private static String storeOf(Receipt r) { + /// The store name the service knows a receipt's store code by. Package + /// private so the mapping can be tested without a network round trip. + static String storeOf(Receipt r) { String code = r.getStoreCode(); if (Receipt.STORE_CODE_ITUNES.equals(code)) { - return "apple"; + // The Mac App Store reports the same store code and is a store + // of its own to the service, with its own products and keys. + return "mac".equalsIgnoreCase(Display.getInstance().getPlatformName()) ? "mac" : "apple"; } if (Receipt.STORE_CODE_PLAY.equals(code)) { return "google"; } + if (Receipt.STORE_CODE_AMAZON.equals(code)) { + // Named rather than left to the fall through below: the service + // routes on this word, so it must not move if the code ever does. + return "amazon"; + } return code == null ? "" : code; } @@ -311,7 +1863,15 @@ private static void appendField(StringBuilder sb, String key, String value) { case '\n': sb.append("\\n"); break; case '\r': sb.append("\\r"); break; case '\t': sb.append("\\t"); break; - default: sb.append(c); + default: + if (c < 0x20) { + // Any other control character is illegal raw in JSON. + sb.append('\\').append('u').append("00") + .append(HEX.charAt((c >> 4) & 0xf)) + .append(HEX.charAt(c & 0xf)); + } else { + sb.append(c); + } } } sb.append('"'); diff --git a/CodenameOne/src/com/codename1/payment/CommercePackage.java b/CodenameOne/src/com/codename1/payment/CommercePackage.java new file mode 100644 index 00000000000..6bb250bfc1b --- /dev/null +++ b/CodenameOne/src/com/codename1/payment/CommercePackage.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.payment; + +import java.util.HashMap; +import java.util.Map; + +/// One thing a customer can buy from an [Offering]: a store product together +/// with the role it plays in the offering (the monthly plan, the annual plan, +/// a lifetime unlock). +/// +/// Everything the Codename One Commerce service said about the package is +/// fixed when the instance is made. The store's own [Product], which carries +/// the localized price, is attached later, once the store has been asked. +public final class CommercePackage { + /// A one time purchase that never expires. + public static final String TYPE_LIFETIME = "LIFETIME"; + /// A subscription billed once a year. + public static final String TYPE_ANNUAL = "ANNUAL"; + /// A subscription billed every six months. + public static final String TYPE_SIX_MONTH = "SIX_MONTH"; + /// A subscription billed every three months. + public static final String TYPE_THREE_MONTH = "THREE_MONTH"; + /// A subscription billed every two months. + public static final String TYPE_TWO_MONTH = "TWO_MONTH"; + /// A subscription billed once a month. + public static final String TYPE_MONTHLY = "MONTHLY"; + /// A subscription billed once a week. + public static final String TYPE_WEEKLY = "WEEKLY"; + /// A package that is none of the standard ones. + public static final String TYPE_CUSTOM = "CUSTOM"; + + private final String id; + private final String type; + private final String productId; + private final String period; + private final String trialPeriod; + private final String introPrice; + private final String introPeriod; + private final boolean eligibleForIntro; + private Product product; + private String offeringId; + + /// Builds a package from the parsed JSON object of the service's + /// offerings payload. + CommercePackage(Map json) { + Map m = json == null ? new HashMap() : json; + id = CustomerInfo.asString(m.get("id")); + String t = CustomerInfo.asString(m.get("type")); + type = t == null || t.length() == 0 ? TYPE_CUSTOM : t; + productId = CustomerInfo.asString(m.get("productId")); + period = emptyAsNull(CustomerInfo.asString(m.get("period"))); + trialPeriod = emptyAsNull(CustomerInfo.asString(m.get("trialPeriod"))); + introPrice = emptyAsNull(CustomerInfo.asString(m.get("introPrice"))); + introPeriod = emptyAsNull(CustomerInfo.asString(m.get("introPeriod"))); + eligibleForIntro = CustomerInfo.asBoolean(m.get("eligibleForIntro")); + } + + private static String emptyAsNull(String s) { + return s == null || s.length() == 0 ? null : s; + } + + /// The id of the offering this package was listed in, or null for a + /// package that came from no offering. A web checkout names the package + /// by both ids. + public String getOfferingId() { + return offeringId; + } + + /// Set by the offering that lists this package. + void setOfferingId(String offeringId) { + this.offeringId = offeringId; + } + + /// The id of the package inside its offering. + public String getId() { + return id; + } + + /// The role of the package, one of the `TYPE_` constants. A package the + /// service gave no type is [#TYPE_CUSTOM]. + public String getType() { + return type; + } + + /// Whether the package is of the given type. Types are compared without + /// regard to case. + /// + /// #### Parameters + /// + /// - `packageType`: one of the `TYPE_` constants + public boolean isType(String packageType) { + return type.equalsIgnoreCase(packageType); + } + + /// The store product id (SKU) this package sells. + public String getProductId() { + return productId; + } + + /// The billing period as an ISO-8601 duration such as `P1M`, or null for + /// a package that is not billed periodically. + public String getPeriod() { + return period; + } + + /// The free trial as an ISO-8601 duration such as `P7D`, or null when + /// the package has none. + public String getTrialPeriod() { + return trialPeriod; + } + + /// The introductory price as text, or null when the package has none. + public String getIntroPrice() { + return introPrice; + } + + /// How long the introductory price lasts, as an ISO-8601 duration, or + /// null when the package has none. + public String getIntroPeriod() { + return introPeriod; + } + + /// Whether this customer may still take the free trial or introductory + /// price of the package, as far as the service knows. + public boolean isEligibleForIntro() { + return eligibleForIntro; + } + + /// Whether buying the package starts a subscription. Everything is a + /// subscription except a [#TYPE_LIFETIME] package and a [#TYPE_CUSTOM] + /// one with no billing period. + public boolean isSubscription() { + if (isType(TYPE_LIFETIME)) { + return false; + } + return !(isType(TYPE_CUSTOM) && period == null); + } + + /// The store's product for this package, which carries the localized + /// price and name. + /// + /// #### Returns + /// + /// the product, or null while the store has not been asked or does not + /// list products on this platform + public Product getProduct() { + return product; + } + + /// Attaches the store's product once the store answered. + void setProduct(Product product) { + this.product = product; + } + + /// This package as the JSON object it was built from, for storage. + Map toMap() { + Map out = new HashMap(); + CustomerInfo.putIfSet(out, "id", id); + CustomerInfo.putIfSet(out, "type", type); + CustomerInfo.putIfSet(out, "productId", productId); + CustomerInfo.putIfSet(out, "period", period); + CustomerInfo.putIfSet(out, "trialPeriod", trialPeriod); + CustomerInfo.putIfSet(out, "introPrice", introPrice); + CustomerInfo.putIfSet(out, "introPeriod", introPeriod); + out.put("eligibleForIntro", eligibleForIntro ? Boolean.TRUE : Boolean.FALSE); + return out; + } + + @Override + public String toString() { + return "CommercePackage{" + id + ", " + type + ", " + productId + "}"; + } +} diff --git a/CodenameOne/src/com/codename1/payment/CustomerInfo.java b/CodenameOne/src/com/codename1/payment/CustomerInfo.java new file mode 100644 index 00000000000..ec3c43b867f --- /dev/null +++ b/CodenameOne/src/com/codename1/payment/CustomerInfo.java @@ -0,0 +1,322 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.payment; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// What the Codename One Commerce service knows about one customer: the +/// entitlements held, the subscriptions that are active and the attributes +/// the application attached. +/// +/// Instances are immutable. [CommerceManager#getCustomerInfo()] returns the +/// last one the service answered with, and an empty one before any answer. +public final class CustomerInfo { + private final String appUserId; + private final String originalAppUserId; + private final String managementUrl; + private final long serverTime; + private final Map entitlements = new HashMap(); + private final List activeSubscriptions = new ArrayList(); + private final Map attributes = new HashMap(); + private final Map currencies = new HashMap(); + private final List referralRewards = new ArrayList(); + + /// Builds a customer from the parsed JSON object of the service's + /// customer payload. + /// + /// #### Parameters + /// + /// - `json`: the parsed payload; null makes an empty customer + CustomerInfo(Map json) { + Map m = json == null ? new HashMap() : json; + appUserId = asString(m.get("appUserId")); + originalAppUserId = asString(m.get("originalAppUserId")); + managementUrl = asString(m.get("managementUrl")); + serverTime = asLong(m.get("serverTime")); + Object ents = m.get("entitlements"); + if (ents instanceof Map) { + for (Object o : ((Map) ents).entrySet()) { + Map.Entry e = (Map.Entry) o; + if (e.getKey() == null) { + continue; + } + String id = String.valueOf(e.getKey()); + Object v = e.getValue(); + entitlements.put(id, new EntitlementInfo(id, v instanceof Map ? (Map) v : null)); + } + } + Object subs = m.get("activeSubscriptions"); + if (subs instanceof List) { + for (Object o : (List) subs) { + if (o != null) { + activeSubscriptions.add(String.valueOf(o)); + } + } + } + Object attrs = m.get("attributes"); + if (attrs instanceof Map) { + for (Object o : ((Map) attrs).entrySet()) { + Map.Entry e = (Map.Entry) o; + if (e.getKey() != null && e.getValue() != null) { + attributes.put(String.valueOf(e.getKey()), String.valueOf(e.getValue())); + } + } + } + Object cur = m.get("currencies"); + if (cur instanceof Map) { + for (Object o : ((Map) cur).entrySet()) { + Map.Entry e = (Map.Entry) o; + if (e.getKey() != null) { + currencies.put(String.valueOf(e.getKey()), Long.valueOf(asLong(e.getValue()))); + } + } + } + Object rewards = m.get("rewards"); + if (rewards instanceof List) { + for (Object o : (List) rewards) { + if (o instanceof Map) { + referralRewards.add(new ReferralReward((Map) o)); + } + } + } + } + + /// The id the service knows this customer by, or null for the empty + /// customer. + public String getAppUserId() { + return appUserId; + } + + /// The first id this customer was known by, when a later one replaced it + /// (an anonymous id that became an account), or null. + public String getOriginalAppUserId() { + return originalAppUserId; + } + + /// The page where the customer manages the subscription with the store, + /// or null when there is none. + public String getManagementUrl() { + return managementUrl; + } + + /// The service's clock when it answered, in milliseconds since the + /// epoch, or 0 for the empty customer. + public long getServerTime() { + return serverTime; + } + + /// The entitlement with this id, active or not. + /// + /// #### Returns + /// + /// the entitlement, or null when the service reported none by that id + public EntitlementInfo getEntitlement(String entitlementId) { + if (entitlementId == null) { + return null; + } + return entitlements.get(entitlementId); + } + + /// Whether the service reported this entitlement as active. + public boolean isEntitled(String entitlementId) { + EntitlementInfo e = getEntitlement(entitlementId); + return e != null && e.isActive(); + } + + /// Every entitlement the service reported, active or not, by id. + /// + /// #### Returns + /// + /// a new map, which the caller may change + public Map getEntitlements() { + return new HashMap(entitlements); + } + + /// The entitlements the service reported as active. + /// + /// #### Returns + /// + /// a new list, which the caller may change + public List getActiveEntitlements() { + List out = new ArrayList(); + for (EntitlementInfo e : entitlements.values()) { + if (e.isActive()) { + out.add(e); + } + } + return out; + } + + /// The store product ids of the subscriptions that are active. + /// + /// #### Returns + /// + /// a new list, which the caller may change + public List getActiveSubscriptions() { + return new ArrayList(activeSubscriptions); + } + + /// The attributes attached to this customer. + /// + /// #### Returns + /// + /// a new map, which the caller may change + public Map getAttributes() { + return new HashMap(attributes); + } + + /// The customer's balance of a virtual currency. + /// + /// #### Parameters + /// + /// - `code`: the code of the currency, as defined in the console + /// + /// #### Returns + /// + /// the balance, or 0 when the customer holds none of that currency + public long getCurrencyBalance(String code) { + if (code == null) { + return 0; + } + Long balance = currencies.get(code); + return balance == null ? 0 : balance.longValue(); + } + + /// The customer's virtual currency balances, by currency code. + /// + /// #### Returns + /// + /// a new map, which the caller may change + public Map getCurrencies() { + return new HashMap(currencies); + } + + /// The rewards the service granted this customer for referrals: an + /// entitlement or an amount of a virtual currency each, as configured in + /// the console. A reward that is an entitlement is also among + /// [#getEntitlements()], and a currency reward is already part of + /// [#getCurrencyBalance(String)]; this list says where they came from. + /// + /// #### Returns + /// + /// a new list, which the caller may change; empty when the service + /// reported no reward + public List getReferralRewards() { + return new ArrayList(referralRewards); + } + + /// This customer as the JSON object it was built from, for storage. + /// Passing the result to the constructor gives an equal customer. + /// + /// #### Returns + /// + /// a new map, which the caller may change + public Map toMap() { + Map out = new HashMap(); + putIfSet(out, "appUserId", appUserId); + putIfSet(out, "originalAppUserId", originalAppUserId); + putIfSet(out, "managementUrl", managementUrl); + out.put("serverTime", Long.valueOf(serverTime)); + Map ents = new HashMap(); + for (Map.Entry e : entitlements.entrySet()) { + ents.put(e.getKey(), e.getValue().toMap()); + } + out.put("entitlements", ents); + out.put("activeSubscriptions", new ArrayList(activeSubscriptions)); + out.put("attributes", new HashMap(attributes)); + out.put("currencies", new HashMap(currencies)); + if (!referralRewards.isEmpty()) { + List rewards = new ArrayList(); + for (ReferralReward r : referralRewards) { + rewards.add(r.toMap()); + } + out.put("rewards", rewards); + } + return out; + } + + /// Whether the other customer says the same thing as this one. The + /// service's clock is left out: it differs in every answer, and a + /// listener is told about a change of state, not about a new answer. + boolean sameState(CustomerInfo other) { + if (other == null) { + return false; + } + Map a = toMap(); + Map b = other.toMap(); + a.remove("serverTime"); + b.remove("serverTime"); + return a.equals(b); + } + + @Override + public String toString() { + return "CustomerInfo{" + appUserId + ", entitlements=" + entitlements.values() + "}"; + } + + static void putIfSet(Map out, String key, String value) { + if (value != null) { + out.put(key, value); + } + } + + static String asString(Object o) { + return o == null ? null : o.toString(); + } + + static boolean asBoolean(Object o) { + if (o instanceof Boolean) { + return ((Boolean) o).booleanValue(); + } + return o != null && "true".equalsIgnoreCase(o.toString()); + } + + /// A JSON number as a long. The parser hands numbers over as `Double` or + /// `Long` depending on its settings, and a hand-edited value may be a + /// string; anything else is 0. + static long asLong(Object o) { + if (o instanceof Number) { + return ((Number) o).longValue(); + } + if (o instanceof String) { + String s = (String) o; + if (s.length() == 0) { + return 0; + } + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if ((c < '0' || c > '9') && !(i == 0 && c == '-' && s.length() > 1)) { + return 0; + } + } + if (s.length() > 18) { + return 0; + } + return Long.parseLong(s); + } + return 0; + } +} diff --git a/CodenameOne/src/com/codename1/payment/EntitlementInfo.java b/CodenameOne/src/com/codename1/payment/EntitlementInfo.java new file mode 100644 index 00000000000..831dae4ce58 --- /dev/null +++ b/CodenameOne/src/com/codename1/payment/EntitlementInfo.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.payment; + +import java.util.HashMap; +import java.util.Map; + +/// One entitlement of a customer, as the Codename One Commerce service +/// reported it. An entitlement is what the application checks ("pro"), and is +/// independent of the store and of the product that granted it. +/// +/// Instances are immutable. They are created by [CustomerInfo] from the +/// service's answer and never by the application. +public final class EntitlementInfo { + private final String id; + private final boolean active; + private final long expiresDate; + private final String productId; + private final String store; + private final String periodType; + private final String ownershipType; + private final String billingStage; + private final boolean willRenew; + private final String provenance; + + /// Builds an entitlement from its parsed JSON object. + /// + /// #### Parameters + /// + /// - `id`: the entitlement id, used when the object does not carry one + /// + /// - `json`: the parsed object, may be null + EntitlementInfo(String id, Map json) { + Map m = json == null ? new HashMap() : json; + String own = CustomerInfo.asString(m.get("entitlementId")); + this.id = own == null ? id : own; + this.active = CustomerInfo.asBoolean(m.get("active")); + this.expiresDate = CustomerInfo.asLong(m.get("expiresDate")); + this.productId = CustomerInfo.asString(m.get("productId")); + this.store = CustomerInfo.asString(m.get("store")); + this.periodType = CustomerInfo.asString(m.get("periodType")); + this.ownershipType = CustomerInfo.asString(m.get("ownershipType")); + this.billingStage = CustomerInfo.asString(m.get("billingStage")); + this.willRenew = CustomerInfo.asBoolean(m.get("willRenew")); + this.provenance = CustomerInfo.asString(m.get("provenance")); + } + + /// The entitlement id, the name the application checks. + public String getId() { + return id; + } + + /// Whether the service considers the entitlement held. This is the + /// service's decision, made with its own clock; it is not recomputed on + /// the device from [#getExpiresDate()]. + public boolean isActive() { + return active; + } + + /// When the entitlement ends, in milliseconds since the epoch, or 0 when + /// it has no end (a lifetime purchase) or the service did not say. + public long getExpiresDate() { + return expiresDate; + } + + /// The store product that granted the entitlement, or null. + public String getProductId() { + return productId; + } + + /// The store the grant came from, as the service names it, or null. + public String getStore() { + return store; + } + + /// The kind of period the customer is in (a trial, an introductory + /// period, the normal one), as the service names it, or null. + public String getPeriodType() { + return periodType; + } + + /// Whether the customer bought the product or received it through family + /// sharing, as the service names it, or null. + public String getOwnershipType() { + return ownershipType; + } + + /// Where the subscription is in its billing life (in a grace period, in + /// billing retry and so on), as the service names it, or null. + public String getBillingStage() { + return billingStage; + } + + /// Whether the subscription behind the entitlement is set to renew. + public boolean isWillRenew() { + return willRenew; + } + + /// How the grant came to be (a purchase, a promotional grant, a + /// transfer), as the service names it, or null. + public String getProvenance() { + return provenance; + } + + /// This entitlement as the JSON object it was built from, for storage. + /// + /// #### Returns + /// + /// a new map, which the caller may change + public Map toMap() { + Map out = new HashMap(); + CustomerInfo.putIfSet(out, "entitlementId", id); + out.put("active", active ? Boolean.TRUE : Boolean.FALSE); + out.put("expiresDate", Long.valueOf(expiresDate)); + CustomerInfo.putIfSet(out, "productId", productId); + CustomerInfo.putIfSet(out, "store", store); + CustomerInfo.putIfSet(out, "periodType", periodType); + CustomerInfo.putIfSet(out, "ownershipType", ownershipType); + CustomerInfo.putIfSet(out, "billingStage", billingStage); + out.put("willRenew", willRenew ? Boolean.TRUE : Boolean.FALSE); + CustomerInfo.putIfSet(out, "provenance", provenance); + return out; + } + + @Override + public String toString() { + return "EntitlementInfo{" + id + ", active=" + active + ", expiresDate=" + expiresDate + "}"; + } +} diff --git a/CodenameOne/src/com/codename1/payment/Offering.java b/CodenameOne/src/com/codename1/payment/Offering.java new file mode 100644 index 00000000000..1e8db3395f7 --- /dev/null +++ b/CodenameOne/src/com/codename1/payment/Offering.java @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.payment; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// A set of packages the application offers a customer together, and +/// optionally the paywall that presents them. Which offering a customer sees +/// is decided by the Codename One Commerce service, so it can be changed, or +/// tested against another, without a new build of the application. +/// +/// Instances are immutable. +public final class Offering { + private final String id; + private final String description; + private final Map metadata = new HashMap(); + private final List packages = new ArrayList(); + private final Map paywall; + + /// Builds an offering from the parsed JSON object of the service's + /// offerings payload. + Offering(Map json) { + Map m = json == null ? new HashMap() : json; + id = CustomerInfo.asString(m.get("id")); + description = CustomerInfo.asString(m.get("description")); + Object meta = m.get("metadata"); + if (meta instanceof Map) { + for (Object o : ((Map) meta).entrySet()) { + Map.Entry e = (Map.Entry) o; + if (e.getKey() != null) { + metadata.put(String.valueOf(e.getKey()), copy(e.getValue())); + } + } + } + Object pkgs = m.get("packages"); + if (pkgs instanceof List) { + for (Object o : (List) pkgs) { + if (o instanceof Map) { + CommercePackage pkg = new CommercePackage((Map) o); + pkg.setOfferingId(CustomerInfo.asString(m.get("id"))); + packages.add(pkg); + } + } + } + Object pw = m.get("paywall"); + Object pwCopy = pw instanceof Map ? copy(pw) : null; + paywall = pwCopy instanceof Map ? (Map) pwCopy : null; + } + + /// The id of the offering. + public String getId() { + return id; + } + + /// The description given to the offering in the console, or null. + public String getDescription() { + return description; + } + + /// The free form metadata attached to the offering in the console. + /// + /// #### Returns + /// + /// a new map, which the caller may change + public Map getMetadata() { + Map out = new HashMap(); + for (Map.Entry e : metadata.entrySet()) { + out.put(e.getKey(), copy(e.getValue())); + } + return out; + } + + /// The packages of the offering, in the order the service listed them. + /// + /// #### Returns + /// + /// a new list, which the caller may change + public List getPackages() { + return new ArrayList(packages); + } + + /// The paywall document designed for this offering, as parsed JSON. + /// + /// #### Returns + /// + /// a new map, which the caller may change, or null when the offering + /// has no paywall + public Map getPaywall() { + if (paywall == null) { + return null; + } + Object c = copy(paywall); + return c instanceof Map ? (Map) c : null; + } + + /// The package with the given id. + /// + /// #### Returns + /// + /// the package, or null when the offering has none by that id + public CommercePackage getPackage(String packageId) { + if (packageId == null) { + return null; + } + for (CommercePackage p : packages) { + if (packageId.equals(p.getId())) { + return p; + } + } + return null; + } + + /// The first package billed monthly, or null. + public CommercePackage getMonthly() { + return firstOfType(CommercePackage.TYPE_MONTHLY); + } + + /// The first package billed annually, or null. + public CommercePackage getAnnual() { + return firstOfType(CommercePackage.TYPE_ANNUAL); + } + + /// The first lifetime package, or null. + public CommercePackage getLifetime() { + return firstOfType(CommercePackage.TYPE_LIFETIME); + } + + /// The first package billed weekly, or null. + public CommercePackage getWeekly() { + return firstOfType(CommercePackage.TYPE_WEEKLY); + } + + private CommercePackage firstOfType(String type) { + for (CommercePackage p : packages) { + if (p.isType(type)) { + return p; + } + } + return null; + } + + /// This offering as the JSON object it was built from, for storage. + Map toMap() { + Map out = new HashMap(); + CustomerInfo.putIfSet(out, "id", id); + CustomerInfo.putIfSet(out, "description", description); + out.put("metadata", getMetadata()); + List pkgs = new ArrayList(); + for (CommercePackage p : packages) { + pkgs.add(p.toMap()); + } + out.put("packages", pkgs); + if (paywall != null) { + out.put("paywall", copy(paywall)); + } + return out; + } + + /// A deep copy of a parsed JSON value. Maps and lists are copied, and + /// everything else in parsed JSON is immutable. `Object.clone()` is not + /// used: it answers null on a device. + static Object copy(Object value) { + if (value instanceof Map) { + Map out = new HashMap(); + for (Object o : ((Map) value).entrySet()) { + Map.Entry e = (Map.Entry) o; + out.put(e.getKey(), copy(e.getValue())); + } + return out; + } + if (value instanceof List) { + List out = new ArrayList(); + for (Object o : (List) value) { + out.add(copy(o)); + } + return out; + } + return value; + } + + @Override + public String toString() { + return "Offering{" + id + ", packages=" + packages + "}"; + } +} diff --git a/CodenameOne/src/com/codename1/payment/Offerings.java b/CodenameOne/src/com/codename1/payment/Offerings.java new file mode 100644 index 00000000000..dfb7013183f --- /dev/null +++ b/CodenameOne/src/com/codename1/payment/Offerings.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.payment; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The offerings the Codename One Commerce service configured for this +/// customer: the one to present now, and every other one by id. +/// +/// Instances are immutable. [CommerceManager#getOfferings()] returns the last +/// one the service answered with, and an empty one before any answer. +public final class Offerings { + private final String currentOfferingId; + private final String experimentId; + private final String experimentVariant; + private final List offerings = new ArrayList(); + + /// Builds the offerings from the parsed JSON object of the service's + /// offerings payload. + /// + /// #### Parameters + /// + /// - `json`: the parsed payload; null makes empty offerings + Offerings(Map json) { + Map m = json == null ? new HashMap() : json; + currentOfferingId = CustomerInfo.asString(m.get("currentOfferingId")); + Object exp = m.get("experiment"); + if (exp instanceof Map) { + experimentId = CustomerInfo.asString(((Map) exp).get("id")); + experimentVariant = CustomerInfo.asString(((Map) exp).get("variant")); + } else { + experimentId = null; + experimentVariant = null; + } + Object all = m.get("offerings"); + if (all instanceof List) { + for (Object o : (List) all) { + if (o instanceof Map) { + offerings.add(new Offering((Map) o)); + } + } + } + } + + /// The offering the service chose for this customer. + /// + /// #### Returns + /// + /// the offering, or null when the service named none or there was no + /// answer yet + public Offering getCurrent() { + return getOffering(currentOfferingId); + } + + /// Every offering of the application. + /// + /// #### Returns + /// + /// a new list, which the caller may change + public List getAll() { + return new ArrayList(offerings); + } + + /// The offering with the given id. + /// + /// #### Returns + /// + /// the offering, or null when there is none by that id + public Offering getOffering(String offeringId) { + if (offeringId == null) { + return null; + } + for (Offering o : offerings) { + if (offeringId.equals(o.getId())) { + return o; + } + } + return null; + } + + /// The id of the experiment this customer takes part in, or null when + /// the current offering was not chosen by an experiment. + public String getExperimentId() { + return experimentId; + } + + /// The variant of the experiment this customer was assigned, or null. + public String getExperimentVariant() { + return experimentVariant; + } + + /// Whether there is no offering at all. + public boolean isEmpty() { + return offerings.isEmpty(); + } + + /// These offerings as the JSON object they were built from, for storage. + Map toMap() { + Map out = new HashMap(); + CustomerInfo.putIfSet(out, "currentOfferingId", currentOfferingId); + if (experimentId != null) { + Map exp = new HashMap(); + exp.put("id", experimentId); + CustomerInfo.putIfSet(exp, "variant", experimentVariant); + out.put("experiment", exp); + } + List all = new ArrayList(); + for (Offering o : offerings) { + all.add(o.toMap()); + } + out.put("offerings", all); + return out; + } + + @Override + public String toString() { + return "Offerings{current=" + currentOfferingId + ", " + offerings + "}"; + } +} diff --git a/CodenameOne/src/com/codename1/payment/Purchase.java b/CodenameOne/src/com/codename1/payment/Purchase.java index a98c55dc049..52a9146e0bd 100644 --- a/CodenameOne/src/com/codename1/payment/Purchase.java +++ b/CodenameOne/src/com/codename1/payment/Purchase.java @@ -118,6 +118,9 @@ public static void postReceipt(String storeCode, String sku, String transactionI // provider is registered, and it never throws into the caller. java.util.Map params = new java.util.HashMap(); params.put("sku", sku); + // The offering, package and experiment of a purchase that + // CommerceManager.purchase(CommercePackage) started; used up here. + CommerceManager.addPurchaseContext(sku, params, true); Analytics.autoEvent("purchase", "commerce", params); } @@ -417,6 +420,7 @@ protected void purchaseImpl(String sku) { private static void fireAnalyticsPurchaseInitiated(String sku) { java.util.Map params = new java.util.HashMap(); params.put("sku", sku); + CommerceManager.addPurchaseContext(sku, params, false); Analytics.autoEvent("purchase_initiated", "commerce", params); } @@ -817,6 +821,12 @@ public void run() { synchronizeReceipts(0, null); return; } + if (Boolean.TRUE.equals(fetchSucceeded)) { + // The receipts are settled: every pending one was submitted and + // the list was fetched. Cloud commerce asks its service again, on + // a background thread of its own; a build without it does nothing. + CommerceManager.receiptsSynchronized(); + } fireSynchronizeReceiptsCallbacks(Boolean.TRUE.equals(fetchSucceeded)); } diff --git a/CodenameOne/src/com/codename1/payment/Receipt.java b/CodenameOne/src/com/codename1/payment/Receipt.java index 6044a9c7ce9..fccbf9c3af9 100644 --- a/CodenameOne/src/com/codename1/payment/Receipt.java +++ b/CodenameOne/src/com/codename1/payment/Receipt.java @@ -37,6 +37,13 @@ public class Receipt implements Externalizable { public static final String STORE_CODE_ITUNES = "itunes"; public static final String STORE_CODE_PLAY = "play"; + + /// The Amazon Appstore, for an Android application built with the + /// `android.billing.store=amazon` build hint. The transaction id of such a + /// receipt is Amazon's receipt id, and `#getOrderData()` is a JSON object + /// with the `receiptId`, `userId` and `sku` that Amazon's receipt + /// verification service asks for. + public static final String STORE_CODE_AMAZON = "amazon"; public static final String STORE_CODE_WINDOWS = "windows"; public static final String STORE_CODE_SIMULATOR = "simulator"; private static boolean externalizableRegistered; @@ -76,7 +83,7 @@ public class Receipt implements Externalizable { private String orderData; /// The store code from which this receipt originated. Will usually be one of - /// `#STORE_CODE_ITUNES`, `#STORE_CODE_PLAY`, `#STORE_CODE_WINDOWS`, + /// `#STORE_CODE_ITUNES`, `#STORE_CODE_PLAY`, `#STORE_CODE_AMAZON`, `#STORE_CODE_WINDOWS`, /// or `#STORE_CODE_SIMULATOR`, but developers could also use custom codes /// if they use a different store, or no store at all. This code can be used /// for validating the receipt. @@ -336,7 +343,7 @@ public void setOrderData(String orderData) { } /// The store code from which this receipt originated. Will usually be one of - /// `#STORE_CODE_ITUNES`, `#STORE_CODE_PLAY`, `#STORE_CODE_WINDOWS`, + /// `#STORE_CODE_ITUNES`, `#STORE_CODE_PLAY`, `#STORE_CODE_AMAZON`, `#STORE_CODE_WINDOWS`, /// or `#STORE_CODE_SIMULATOR`, but developers could also use custom codes /// if they use a different store, or no store at all. This code can be used /// for validating the receipt. diff --git a/CodenameOne/src/com/codename1/payment/ReferralReward.java b/CodenameOne/src/com/codename1/payment/ReferralReward.java new file mode 100644 index 00000000000..da19f715ee9 --- /dev/null +++ b/CodenameOne/src/com/codename1/payment/ReferralReward.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.payment; + +import java.util.HashMap; +import java.util.Map; + +/// A reward the service granted this customer for a referral: either an +/// entitlement or an amount of a virtual currency. Rewards are configured in +/// the console and listed by [CustomerInfo#getReferralRewards()]. Immutable. +public final class ReferralReward { + private final String entitlementId; + private final String currencyCode; + private final long amount; + private final long expiresDate; + private final String reason; + + /// Builds a reward from one entry of the customer payload's `rewards`. + /// + /// #### Parameters + /// + /// - `json`: the parsed entry; null makes an empty reward + ReferralReward(Map json) { + Map m = json == null ? new HashMap() : json; + entitlementId = CustomerInfo.asString(m.get("entitlementId")); + currencyCode = CustomerInfo.asString(m.get("currencyCode")); + amount = CustomerInfo.asLong(m.get("amount")); + expiresDate = CustomerInfo.asLong(m.get("expiresDate")); + reason = CustomerInfo.asString(m.get("reason")); + } + + /// The entitlement this reward grants, or null when it is a currency + /// reward. + /// + /// #### Returns + /// + /// the entitlement id or null + public String getEntitlementId() { + return entitlementId; + } + + /// The code of the virtual currency this reward adds to, or null when it + /// is an entitlement reward. + /// + /// #### Returns + /// + /// the currency code or null + public String getCurrencyCode() { + return currencyCode; + } + + /// Whether this reward is an amount of a virtual currency rather than an + /// entitlement. + /// + /// #### Returns + /// + /// true for a currency reward + public boolean isCurrency() { + return currencyCode != null; + } + + /// The amount of currency granted; 0 for an entitlement reward. + /// + /// #### Returns + /// + /// the amount + public long getAmount() { + return amount; + } + + /// When the reward expires, in milliseconds since the epoch, or 0 when it + /// does not expire. + /// + /// #### Returns + /// + /// the expiry time or 0 + public long getExpiresDate() { + return expiresDate; + } + + /// Why the reward was granted, as the service names it -- for example + /// that a user this customer invited made a purchase -- or null. + /// + /// #### Returns + /// + /// the reason or null + public String getReason() { + return reason; + } + + /// This reward as the JSON object it was built from, for storage. + Map toMap() { + Map out = new HashMap(); + CustomerInfo.putIfSet(out, "entitlementId", entitlementId); + CustomerInfo.putIfSet(out, "currencyCode", currencyCode); + out.put("amount", Long.valueOf(amount)); + out.put("expiresDate", Long.valueOf(expiresDate)); + CustomerInfo.putIfSet(out, "reason", reason); + return out; + } + + @Override + public String toString() { + return "ReferralReward{" + (currencyCode == null ? entitlementId : currencyCode + " x" + amount) + + ", reason=" + reason + "}"; + } +} diff --git a/CodenameOne/src/com/codename1/payment/center/CustomerCenter.java b/CodenameOne/src/com/codename1/payment/center/CustomerCenter.java new file mode 100644 index 00000000000..2d807b4ab06 --- /dev/null +++ b/CodenameOne/src/com/codename1/payment/center/CustomerCenter.java @@ -0,0 +1,388 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.payment.center; + +import com.codename1.io.Log; +import com.codename1.payment.CommerceManager; +import com.codename1.payment.CustomerInfo; +import com.codename1.payment.Purchase; +import com.codename1.ui.Button; +import com.codename1.ui.Command; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BoxLayout; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The customer center: a form listing the customer's active subscriptions +/// and what can be done about them. +/// +/// The console configures which paths the form offers: +/// +/// - `MANAGE` opens the store's own subscription management, or the +/// customer's management page where the platform has none +/// - `RESTORE` restores the purchases of the store account +/// - `REFUND` asks the store for a refund, one row per subscription the +/// store can refund +/// - `CANCEL` asks why, reports the answer, shows the retention offer +/// configured for that reason if there is one, and otherwise continues to +/// the store's subscription management. Offered while a subscription is +/// active. +/// - `SUPPORT` opens the support page, or an email to the support address. +/// Offered when one of the two is configured. +/// +/// When the configuration cannot be fetched the form offers `MANAGE` and +/// `RESTORE`. +/// +/// #### Styling +/// +/// - `CustomerCenterForm`: the form +/// - `CustomerCenterHeader`: the heading above the subscriptions +/// - `CustomerCenterSubscription`: one active subscription +/// - `CustomerCenterEmpty`: the line shown when no subscription is active +/// - `CustomerCenterRow`: the button of one path +/// - `CustomerCenterReason`: one cancel reason +public final class CustomerCenter { + /// The path that opens subscription management. + public static final String PATH_MANAGE = "MANAGE"; + /// The path that restores purchases. + public static final String PATH_RESTORE = "RESTORE"; + /// The path that asks for a refund. + public static final String PATH_REFUND = "REFUND"; + /// The path that cancels a subscription. + public static final String PATH_CANCEL = "CANCEL"; + /// The path that reaches support. + public static final String PATH_SUPPORT = "SUPPORT"; + + private CustomerCenter() { + } + + /// One thing the customer center offers to do. + static final class Row { + final String path; + final String label; + final String sku; + + Row(String path, String label, String sku) { + this.path = path; + this.label = label; + this.sku = sku; + } + } + + /// Fetches the customer center configuration and shows the form over + /// the current one; going back returns there. May be called on any + /// thread: the form is shown on the EDT, which is not blocked while the + /// configuration is fetched. + public static void show() { + Display d = Display.getInstance(); + if (!d.isEdt()) { + d.callSerially(new Runnable() { + @Override + public void run() { + show(); + } + }); + return; + } + final Map[] fetched = new Map[1]; + d.invokeAndBlock(new Runnable() { + @Override + public void run() { + try { + fetched[0] = CommerceManager.getInstance().getCustomerCenterConfiguration(); + } catch (Throwable t) { + Log.p("CustomerCenter: no configuration: " + t); + } + } + }); + Map config = fetched[0] == null ? defaultConfiguration() : fetched[0]; + CommerceManager.getInstance().analyticsEvent("customer_center_view", null, null, null, null, null); + createForm(config, CommerceManager.getInstance().getCustomerInfo(), + Purchase.getInAppPurchase(), d.getCurrent()).show(); + } + + /// The configuration used when the cloud's cannot be fetched. + static Map defaultConfiguration() { + Map config = new HashMap(); + List paths = new ArrayList(); + paths.add(PATH_MANAGE); + paths.add(PATH_RESTORE); + config.put("paths", paths); + return config; + } + + /// The rows of a configuration for a customer, in the configured order. + /// A path the client does not know is skipped. + static List buildRows(Map config, CustomerInfo info, Purchase store) { + List rows = new ArrayList(); + List active = info == null ? new ArrayList() : info.getActiveSubscriptions(); + String firstSku = active.isEmpty() ? null : active.get(0); + Object paths = config == null ? null : config.get("paths"); + if (!(paths instanceof List)) { + return rows; + } + for (Object o : (List) paths) { + String path = o == null ? "" : o.toString(); + if (PATH_MANAGE.equalsIgnoreCase(path)) { + rows.add(new Row(PATH_MANAGE, string(config, "path.MANAGE", "Manage subscription"), firstSku)); + } else if (PATH_RESTORE.equalsIgnoreCase(path)) { + rows.add(new Row(PATH_RESTORE, string(config, "path.RESTORE", "Restore purchases"), null)); + } else if (PATH_REFUND.equalsIgnoreCase(path)) { + String label = string(config, "path.REFUND", "Request a refund"); + for (String sku : active) { + if (store != null && store.isRefundable(sku)) { + rows.add(new Row(PATH_REFUND, active.size() > 1 ? label + " (" + sku + ")" : label, sku)); + } + } + } else if (PATH_CANCEL.equalsIgnoreCase(path)) { + if (firstSku != null) { + rows.add(new Row(PATH_CANCEL, string(config, "path.CANCEL", "Cancel subscription"), firstSku)); + } + } else if (PATH_SUPPORT.equalsIgnoreCase(path)) { + if (supportAddress(config) != null) { + rows.add(new Row(PATH_SUPPORT, string(config, "path.SUPPORT", "Contact support"), null)); + } + } + } + return rows; + } + + /// What the support row opens: the support page, else an email to the + /// support address, else null. + static String supportAddress(Map config) { + String url = text(config.get("supportUrl")); + if (url != null) { + return url; + } + String email = text(config.get("supportEmail")); + return email == null ? null : "mailto:" + email; + } + + /// Builds the form of a configuration without showing it. + static Form createForm(final Map config, CustomerInfo info, final Purchase store, final Form previous) { + final Form form = new Form(string(config, "title", "Subscriptions"), BoxLayout.y()); + form.setUIID("CustomerCenterForm"); + if (previous != null) { + form.setBackCommand(new Command("") { + @Override + public void actionPerformed(ActionEvent evt) { + previous.showBack(); + } + }); + } + Label header = new Label(string(config, "subscriptions", "Active subscriptions")); + header.setUIID("CustomerCenterHeader"); + form.add(header); + List active = info == null ? new ArrayList() : info.getActiveSubscriptions(); + for (String sku : active) { + Label l = new Label(sku); + l.setUIID("CustomerCenterSubscription"); + form.add(l); + } + if (active.isEmpty()) { + Label none = new Label(string(config, "none", "No active subscription")); + none.setUIID("CustomerCenterEmpty"); + form.add(none); + } + for (final Row row : buildRows(config, info, store)) { + Button b = new Button(row.label); + b.setUIID("CustomerCenterRow"); + b.setName("CustomerCenterRow_" + row.path); + b.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + perform(row, config, store, form); + } + }); + form.add(b); + } + return form; + } + + static void perform(Row row, Map config, Purchase store, Form form) { + CommerceManager.getInstance().analyticsEvent("customer_center_action", null, null, row.sku, + "action", row.path); + if (PATH_MANAGE.equals(row.path)) { + manage(store, row.sku); + } else if (PATH_RESTORE.equals(row.path)) { + CommerceManager.getInstance().restore(); + } else if (PATH_REFUND.equals(row.path)) { + if (store != null) { + store.refund(row.sku); + } + } else if (PATH_CANCEL.equals(row.path)) { + cancel(config, store, row.sku, form); + } else if (PATH_SUPPORT.equals(row.path)) { + String address = supportAddress(config); + if (address != null) { + Display.getInstance().execute(address); + } + } + } + + /// Opens the store's subscription management, or the customer's + /// management page on a platform with none. + private static void manage(Purchase store, String sku) { + if (store != null && store.isManageSubscriptionsSupported()) { + store.manageSubscriptions(sku); + return; + } + // A web subscription is managed in a billing portal the cloud has + // to be asked for, which must not happen on the EDT. + final String[] url = new String[1]; + Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + url[0] = CommerceManager.getInstance().fetchManagementUrl(); + } + }); + if (url[0] != null && url[0].length() > 0) { + Display.getInstance().execute(url[0]); + } + } + + /// The cancel reasons of a configuration that have an id, each as its + /// parsed JSON object. + static List cancelReasons(Map config) { + List out = new ArrayList(); + Object reasons = config.get("cancelReasons"); + if (reasons instanceof List) { + for (Object o : (List) reasons) { + if (o instanceof Map && text(((Map) o).get("id")) != null) { + out.add((Map) o); + } + } + } + return out; + } + + private static void cancel(final Map config, final Purchase store, final String sku, final Form center) { + List reasons = cancelReasons(config); + if (reasons.isEmpty()) { + manage(store, sku); + return; + } + Form picker = new Form(string(config, "cancel.title", "Why are you leaving?"), BoxLayout.y()); + picker.setUIID("CustomerCenterForm"); + picker.setBackCommand(new Command("") { + @Override + public void actionPerformed(ActionEvent evt) { + center.showBack(); + } + }); + for (final Map reason : reasons) { + String id = text(reason.get("id")); + String label = text(reason.get("label")); + Button b = new Button(string(config, "reason." + id, label == null ? id : label)); + b.setUIID("CustomerCenterReason"); + b.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + reasonChosen(config, reason, store, sku); + center.showBack(); + } + }); + picker.add(b); + } + picker.show(); + } + + static void reasonChosen(Map config, Map reason, Purchase store, String sku) { + CommerceManager cm = CommerceManager.getInstance(); + cm.logEvent("CANCEL_REASON", null, null, null, text(reason.get("id"))); + cm.analyticsEvent("subscription_cancel_reason", null, null, sku, "reason", text(reason.get("id"))); + Object offer = reason.get("offer"); + if (offer instanceof Map) { + String productId = text(((Map) offer).get("productId")); + String offerText = text(((Map) offer).get("text")); + if (productId != null && offerText != null) { + boolean accepted = Dialog.show(string(config, "offer.title", "Before you go"), offerText, + string(config, "offer.accept", "Accept offer"), + string(config, "offer.decline", "No thanks")); + if (accepted) { + // An offer that names a promotional offer is sold under + // it where the store has such offers and the cloud signs + // it; anywhere else the product is sold as it is. + String offerId = text(((Map) offer).get("offerId")); + if (offerId == null || !cm.purchaseWithPromotionalOffer(productId, offerId)) { + cm.subscribe(productId); + } + return; + } + } + } + manage(store, sku); + } + + /// A string of the configuration by key: the device's language and + /// country, then its language, then English, then the fallback. + static String string(Map config, String key, String fallback) { + Object strings = config == null ? null : config.get("strings"); + if (!(strings instanceof Map)) { + return fallback; + } + Map all = (Map) strings; + String language = null; + String country = null; + try { + language = Display.getInstance().getLocalizationManager().getLanguage(); + country = Display.getInstance().getLocalizationManager().getLocale(); + } catch (Throwable t) { + // no localization on this port: English + Log.p("CustomerCenter: no locale: " + t); + } + String found = null; + if (language != null && country != null) { + found = entry(all.get(language + "_" + country), key); + } + if (found == null && language != null) { + found = entry(all.get(language), key); + } + if (found == null) { + found = entry(all.get("en"), key); + } + return found == null ? fallback : found; + } + + private static String entry(Object table, String key) { + if (!(table instanceof Map)) { + return null; + } + return text(((Map) table).get(key)); + } + + private static String text(Object o) { + if (o == null) { + return null; + } + String s = o.toString(); + return s.length() == 0 ? null : s; + } +} diff --git a/CodenameOne/src/com/codename1/payment/center/package-info.java b/CodenameOne/src/com/codename1/payment/center/package-info.java new file mode 100644 index 00000000000..c43368f0785 --- /dev/null +++ b/CodenameOne/src/com/codename1/payment/center/package-info.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// The customer center of the Codename One Commerce service: one screen where +/// a customer sees the active subscriptions and manages them, restores +/// purchases, asks for a refund, cancels or reaches support. +/// +/// Which of those the screen offers, the cancel reasons it asks for and the +/// retention offers it answers them with are configured in the console. +/// [com.codename1.payment.center.CustomerCenter] fetches that configuration +/// and shows the screen. +package com.codename1.payment.center; diff --git a/CodenameOne/src/com/codename1/payment/paywall/Paywall.java b/CodenameOne/src/com/codename1/payment/paywall/Paywall.java new file mode 100644 index 00000000000..8f26e711f65 --- /dev/null +++ b/CodenameOne/src/com/codename1/payment/paywall/Paywall.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.payment.paywall; + +import com.codename1.payment.CommercePackage; +import com.codename1.payment.Offering; +import com.codename1.ui.Command; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.layouts.BorderLayout; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Shows the paywall of an offering as a form of its own. +/// +/// ```java +/// Offering offering = CommerceManager.getInstance().getOfferings().getCurrent(); +/// Paywall.show(offering, new PaywallListener() { +/// public void purchaseStarted(CommercePackage pkg) { +/// } +/// public void purchaseCompleted(CustomerInfo info) { +/// Dialog.show("Thanks", "Your purchase is active", "OK", null); +/// } +/// public void purchaseCancelled() { +/// } +/// public void restoreCompleted(CustomerInfo info) { +/// } +/// public void closed() { +/// } +/// }); +/// ``` +public final class Paywall { + private Paywall() { + } + + /// Shows the paywall of the offering in a new form over the current one. + /// Closing the paywall, or going back, returns to the form that was + /// showing, and so does a purchase or a restore that completes: the + /// paywall returns to that form first and the listener hears + /// `purchaseCompleted` or `restoreCompleted`, then `closed`, over it. An offering with no paywall document is shown with + /// [the built-in one][#createDefault(Offering)]. The form's UIID is + /// `PaywallForm`. + /// + /// Call on the EDT. + /// + /// #### Parameters + /// + /// - `offering`: the offering to sell + /// + /// - `listener`: told what happens on the paywall, or null + /// + /// #### Returns + /// + /// the view that was shown + public static PaywallView show(Offering offering, PaywallListener listener) { + final Form previous = Display.getInstance().getCurrent(); + final PaywallView view = new PaywallView(offering); + view.setPaywallListener(listener); + Form form = new Form(new BorderLayout()); + form.setUIID("PaywallForm"); + form.add(BorderLayout.CENTER, view); + view.setCloseHandler(new Runnable() { + @Override + public void run() { + if (previous != null) { + previous.showBack(); + } + } + }); + view.setCloseWhenDone(true); + form.setBackCommand(new Command("") { + @Override + public void actionPerformed(ActionEvent evt) { + view.close(); + } + }); + form.show(); + return view; + } + + /// The built-in paywall document: a close button, a title, the packages + /// of the offering as a list, a purchase button, a restore button and a + /// footer. It names no colors, so the theme decides them all. + /// + /// #### Parameters + /// + /// - `offering`: the offering the document is for; may be null + /// + /// #### Returns + /// + /// a new paywall document, which the caller may change + public static Map createDefault(Offering offering) { + List children = new ArrayList(); + children.add(node("closeButton")); + Map title = node("text"); + title.put("text", "Choose your plan"); + title.put("style", "title"); + title.put("align", "center"); + children.add(title); + Map packages = node("packages"); + packages.put("style", "list"); + if (offering != null) { + List all = offering.getPackages(); + if (!all.isEmpty() && all.get(0).getId() != null) { + packages.put("selected", all.get(0).getId()); + } + } + children.add(packages); + Map buy = node("purchaseButton"); + buy.put("text", "Continue"); + children.add(buy); + Map restore = node("restoreButton"); + restore.put("text", "Restore purchases"); + children.add(restore); + children.add(node("footer")); + + Map root = node("stack"); + root.put("axis", "y"); + root.put("gap", Integer.valueOf(2)); + root.put("children", children); + + Map doc = new HashMap(); + doc.put("version", Integer.valueOf(1)); + doc.put("id", "default"); + doc.put("defaultLocale", "en"); + doc.put("root", root); + return doc; + } + + private static Map node(String type) { + Map n = new HashMap(); + n.put("type", type); + return n; + } +} diff --git a/CodenameOne/src/com/codename1/payment/paywall/PaywallListener.java b/CodenameOne/src/com/codename1/payment/paywall/PaywallListener.java new file mode 100644 index 00000000000..76b6def0e23 --- /dev/null +++ b/CodenameOne/src/com/codename1/payment/paywall/PaywallListener.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.payment.paywall; + +import com.codename1.payment.CommercePackage; +import com.codename1.payment.CustomerInfo; + +/// Receives what happens on a paywall. Every method is called on the EDT. +public interface PaywallListener { + /// The customer pressed the purchase button and the store was asked to + /// sell the package. + /// + /// #### Parameters + /// + /// - `pkg`: the package being bought + void purchaseStarted(CommercePackage pkg); + + /// The cloud confirmed the purchase: the customer now owns the product + /// of the package. + /// A paywall shown by [Paywall#show(Offering, PaywallListener)] has + /// already returned to the form it was shown over; a [PaywallView] in a + /// form of the application is still showing. + /// + /// #### Parameters + /// + /// - `info`: the customer information that includes the purchase + void purchaseCompleted(CustomerInfo info); + + /// The paywall was closed after a purchase was started and before the + /// cloud confirmed it. + void purchaseCancelled(); + + /// The customer information changed after the customer asked to restore + /// purchases. + /// A paywall shown by [Paywall#show(Offering, PaywallListener)] has + /// already returned to the form it was shown over. + /// + /// #### Parameters + /// + /// - `info`: the customer information after the restore + void restoreCompleted(CustomerInfo info); + + /// The paywall was closed: by the customer, or by itself after a + /// completed purchase or restore when [Paywall#show(Offering, PaywallListener)] + /// showed it. + void closed(); +} diff --git a/CodenameOne/src/com/codename1/payment/paywall/PaywallView.java b/CodenameOne/src/com/codename1/payment/paywall/PaywallView.java new file mode 100644 index 00000000000..e27599f9bd7 --- /dev/null +++ b/CodenameOne/src/com/codename1/payment/paywall/PaywallView.java @@ -0,0 +1,1079 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.payment.paywall; + +import com.codename1.components.SpanLabel; +import com.codename1.io.Log; +import com.codename1.payment.CommerceManager; +import com.codename1.payment.CommercePackage; +import com.codename1.payment.CustomerInfo; +import com.codename1.payment.EntitlementInfo; +import com.codename1.payment.Offering; +import com.codename1.payment.Product; +import com.codename1.ui.Button; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.EncodedImage; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Image; +import com.codename1.ui.Label; +import com.codename1.ui.Tabs; +import com.codename1.ui.URLImage; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.plaf.Style; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// A paywall built from a paywall document and the offering it sells. +/// +/// The document is a tree of nodes, each a JSON object with a `type`: +/// `stack`, `text`, `image`, `icon`, `features`, `packages`, +/// `purchaseButton`, `restoreButton`, `closeButton`, `carousel`, `footer` +/// and `spacer`. A node of any other type is skipped, so a document made for +/// a newer client still shows. Sizes in the document are millimetres. +/// +/// A text is first looked up in the document's `strings`, by the device's +/// locale and then the document's default locale, and used as written when +/// it is not a key there. Then `{{price}}`, `{{period}}`, `{{trialPeriod}}`, +/// `{{introPrice}}`, `{{productName}}` and `{{packageId}}` are replaced from +/// the selected package, again whenever the selection changes. The price is +/// the store's localized one when the store listed the product. +/// +/// #### Styling +/// +/// Every element has a UIID, so a theme can restyle the paywall. The colors +/// the document names -- its `dark` set when the device is in dark mode, its +/// `light` set otherwise -- are applied on top of the theme. +/// +/// - `Paywall`: the view itself +/// - `PaywallStack`: a `stack` node +/// - `PaywallTitle`, `PaywallHeadline`, `PaywallBody`, `PaywallCaption`: the +/// text of a `text` node, by its style +/// - `PaywallImage`, `PaywallIcon`: an `image` and an `icon` node +/// - `PaywallFeatures`, `PaywallFeature`, `PaywallFeatureIcon`, +/// `PaywallFeatureText`: a `features` node, one of its rows and the row's +/// parts +/// - `PaywallPackages`: a `packages` node +/// - `PaywallPackage`, `PaywallPackageTitle`, `PaywallPackagePrice`: a +/// package and its two lines +/// - `PaywallPackageSelected`, `PaywallPackageTitleSelected`, +/// `PaywallPackagePriceSelected`: the same for the selected package +/// - `PaywallPurchaseButton`, `PaywallRestoreButton`, `PaywallCloseButton` +/// - `PaywallCloseBar`: the row holding the close button +/// - `PaywallCarousel`: a `carousel` node +/// - `PaywallFooter`, `PaywallFooterLink`: a `footer` node and its links +/// - `PaywallSpacer`: a `spacer` node, and the gap between a stack's children +/// +/// #### Events +/// +/// The view reports `IMPRESSION` when it is first shown, `PURCHASE_START`, +/// `PURCHASE` and `CLOSE` through +/// [CommerceManager#logEvent(String, String, String, String)]. +public class PaywallView extends Container { + private static final String[] ICON_NAMES = { + "check", "check_circle", "done", "star", "lock", "favorite", "cloud", "bolt", "sync", + "security", "block", "speed", "support", "person", "group", "notifications", "download", + "info", "verified", "all_inclusive", "close" + }; + private static final char[] ICON_CHARS = { + FontImage.MATERIAL_CHECK, FontImage.MATERIAL_CHECK_CIRCLE, FontImage.MATERIAL_DONE, + FontImage.MATERIAL_STAR, FontImage.MATERIAL_LOCK, FontImage.MATERIAL_FAVORITE, + FontImage.MATERIAL_CLOUD, FontImage.MATERIAL_BOLT, FontImage.MATERIAL_SYNC, + FontImage.MATERIAL_SECURITY, FontImage.MATERIAL_BLOCK, FontImage.MATERIAL_SPEED, + FontImage.MATERIAL_SUPPORT, FontImage.MATERIAL_PERSON, FontImage.MATERIAL_GROUP, + FontImage.MATERIAL_NOTIFICATIONS, FontImage.MATERIAL_DOWNLOAD, FontImage.MATERIAL_INFO, + FontImage.MATERIAL_VERIFIED, FontImage.MATERIAL_ALL_INCLUSIVE, FontImage.MATERIAL_CLOSE + }; + + private final Offering offering; + private final Map document; + private final String paywallId; + private final List stringTables = new ArrayList(); + private final Map colors; + private final List packageRows = new ArrayList(); + private final List rowPackages = new ArrayList(); + private final List textComponents = new ArrayList(); + private final List textSources = new ArrayList(); + private CommercePackage selected; + private CommercePackage pending; + private boolean restoring; + private boolean impressionLogged; + private boolean closed; + private PaywallListener listener; + private Runnable closeHandler; + private boolean closeWhenDone; + private ActionListener customerListener; + private boolean webPurchaseStarted; + private Form shownForm; + private final ActionListener showListener = new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + formShown(); + } + }; + + /// Builds the paywall of an offering from the offering's own document, + /// or from [the built-in one][Paywall#createDefault(Offering)] when it + /// has none. + /// + /// #### Parameters + /// + /// - `offering`: the offering to sell + public PaywallView(Offering offering) { + this(offering, offering == null ? null : offering.getPaywall()); + } + + /// Builds a paywall from a document. + /// + /// #### Parameters + /// + /// - `offering`: the offering to sell + /// + /// - `paywallDocument`: the parsed paywall document; null uses + /// [the built-in one][Paywall#createDefault(Offering)] + public PaywallView(Offering offering, Map paywallDocument) { + super(BoxLayout.y()); + this.offering = offering; + document = paywallDocument == null ? Paywall.createDefault(offering) : paywallDocument; + Object id = document.get("id"); + paywallId = id == null ? null : id.toString(); + setUIID("Paywall"); + setScrollableY(true); + loadStrings(); + colors = chooseColors(); + int bg = color("background"); + if (bg >= 0) { + getAllStyles().setBgColor(bg); + getAllStyles().setBgTransparency(255); + } + Object root = document.get("root"); + if (root instanceof Map) { + Component c = build((Map) root); + if (c != null) { + add(c); + } + } + if (selected == null && !rowPackages.isEmpty()) { + selected = rowPackages.get(0); + } + if (selected == null && offering != null && !offering.getPackages().isEmpty()) { + selected = offering.getPackages().get(0); + } + applySelection(); + } + + // ---- public surface ------------------------------------------------------ + + /// Sets the listener that is told what happens on this paywall. + /// + /// #### Parameters + /// + /// - `listener`: the listener, or null for none + public void setPaywallListener(PaywallListener listener) { + this.listener = listener; + } + + /// The offering this paywall sells. + public Offering getOffering() { + return offering; + } + + /// The id of the paywall document, or null when it has none. + public String getPaywallId() { + return paywallId; + } + + /// The package that is selected, which the purchase button buys. + /// + /// #### Returns + /// + /// the package, or null when the offering has no package + public CommercePackage getSelectedPackage() { + return selected; + } + + /// Selects a package, as tapping it does. + /// + /// #### Parameters + /// + /// - `packageId`: the id of a package of the offering; any other value + /// is ignored + public void selectPackage(String packageId) { + CommercePackage pkg = offering == null ? null : offering.getPackage(packageId); + if (pkg == null) { + return; + } + selected = pkg; + applySelection(); + track("paywall_package_selected", pkg); + if (getComponentForm() != null) { + revalidate(); + } + } + + /// Buys the selected package, as the purchase button does. The listener + /// hears `purchaseStarted` now and `purchaseCompleted` once the cloud + /// reports the customer as owning the product. + public void purchase() { + CommercePackage pkg = selected; + if (pkg == null) { + return; + } + pending = pkg; + log("PURCHASE_START", pkg.getId()); + if (listener != null) { + listener.purchaseStarted(pkg); + } + watchCustomer(); + CommerceManager cm = CommerceManager.getInstance(); + if (cm.isWebPurchase(pkg)) { + // The customer pays in the browser and nothing reports back: + // the cloud is asked again when this form is next shown. + if (cm.purchaseOnWeb(pkg)) { + webPurchaseStarted = true; + } + return; + } + cm.purchase(pkg); + } + + /// Restores the purchases of the store account, as the restore button + /// does. The listener hears `restoreCompleted` when the customer + /// information changes afterwards. + public void restore() { + restoring = true; + track("paywall_restore", selected); + watchCustomer(); + CommerceManager.getInstance().restore(); + } + + /// Closes the paywall, as the close button does. A purchase that was + /// started and not confirmed yet is reported as cancelled. + public void close() { + if (closed) { + return; + } + closed = true; + boolean cancelled = pending != null; + pending = null; + restoring = false; + stopWatchingCustomer(); + log("CLOSE", null); + if (listener != null) { + if (cancelled) { + listener.purchaseCancelled(); + } + listener.closed(); + } + if (closeHandler != null) { + closeHandler.run(); + } + } + + /// Sets what closing the paywall does to the screen, after the listener + /// was told. [Paywall#show(Offering, PaywallListener)] sets it to return + /// to the previous form; an application that embeds the view in a form + /// of its own sets it to whatever removes the view. + /// + /// #### Parameters + /// + /// - `closeHandler`: run on the EDT when the paywall closes, or null + public void setCloseHandler(Runnable closeHandler) { + this.closeHandler = closeHandler; + } + + @Override + protected void initComponent() { + super.initComponent(); + if (!impressionLogged) { + impressionLogged = true; + log("IMPRESSION", null); + } + Form form = getComponentForm(); + if (form != null && shownForm != form) { //NOPMD CompareObjectsWithEquals - the same form instance, not an equal one + if (shownForm != null) { + shownForm.removeShowListener(showListener); + } + shownForm = form; + form.addShowListener(showListener); + } + } + + /// Asks the cloud again once the form is back after a web checkout was + /// opened, which is how a purchase made in the browser is learned of. + /// The refresh blocks on the network, so it runs in the background; the + /// customer information listener hears the answer. + void formShown() { + if (!webPurchaseStarted || closed) { + return; + } + Display.getInstance().scheduleBackgroundTask(new RefreshTask()); + } + + /// Asks the cloud for the customer again, off the EDT. + private static final class RefreshTask implements Runnable { + @Override + public void run() { + CommerceManager.getInstance().refresh(); + } + } + + // ---- purchase tracking --------------------------------------------------- + + private void watchCustomer() { + if (customerListener != null) { + return; + } + customerListener = new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + Object source = evt.getSource(); + if (source instanceof CustomerInfo) { + customerChanged((CustomerInfo) source); + } + } + }; + CommerceManager.getInstance().addCustomerInfoListener(customerListener); + } + + private void stopWatchingCustomer() { + if (customerListener != null) { + CommerceManager.getInstance().removeCustomerInfoListener(customerListener); + customerListener = null; + } + } + + private void customerChanged(CustomerInfo info) { + boolean bought = pending != null && owns(info, pending.getProductId()); + boolean restored = restoring; + if (!bought && !restored) { + if (pending == null) { + stopWatchingCustomer(); + } + return; + } + if (bought) { + log("PURCHASE", pending.getId()); + pending = null; + } + restoring = false; + boolean left = false; + if (pending == null) { + stopWatchingCustomer(); + left = leaveWhenDone(); + } + if (listener != null) { + if (bought) { + listener.purchaseCompleted(info); + } + if (restored) { + listener.restoreCompleted(info); + } + if (left) { + listener.closed(); + } + } + } + + /// A paywall that is a screen of its own has nothing left to offer once + /// the purchase or the restore went through, so it leaves before the + /// listener hears of it: the listener then runs over the form the + /// customer came from and may show one of its own. A view the + /// application embedded stays where it is; only the application knows + /// what its form should show next. + private boolean leaveWhenDone() { + if (!closeWhenDone || closed) { + return false; + } + closed = true; + log("CLOSE", null); + if (closeHandler != null) { + closeHandler.run(); + } + return true; + } + + /// Makes the view close itself, through its close handler, once a + /// purchase or a restore completes. [Paywall#show(Offering, PaywallListener)] + /// turns it on for the form it shows. + void setCloseWhenDone(boolean closeWhenDone) { + this.closeWhenDone = closeWhenDone; + } + + private static boolean owns(CustomerInfo info, String productId) { + if (productId == null) { + return false; + } + if (info.getActiveSubscriptions().contains(productId)) { + return true; + } + for (EntitlementInfo e : info.getActiveEntitlements()) { + if (productId.equals(e.getProductId())) { + return true; + } + } + return false; + } + + private void log(String type, String packageId) { + CommerceManager.getInstance().logEvent(type, + offering == null ? null : offering.getId(), paywallId, packageId); + // The same moments, through the application's analytics providers. + // A purchase is not repeated here: Purchase reports it. + if ("IMPRESSION".equals(type)) { + track("paywall_view", null); + } else if ("CLOSE".equals(type)) { + track("paywall_close", null); + } + } + + private void track(String name, CommercePackage pkg) { + CommerceManager.getInstance().analyticsEvent(name, + offering == null ? null : offering.getId(), + pkg == null ? null : pkg.getId(), + pkg == null ? null : pkg.getProductId(), null, null); + } + + // ---- building ------------------------------------------------------------ + + /// Builds the component of one node, or null for a node to skip. + private Component build(Map node) { + String type = str(node.get("type")); + if (type == null) { + return null; + } + if ("stack".equalsIgnoreCase(type)) { + return buildStack(node); + } + if ("text".equalsIgnoreCase(type)) { + return buildText(node); + } + if ("image".equalsIgnoreCase(type)) { + return buildImage(node); + } + if ("icon".equalsIgnoreCase(type)) { + Label icon = new Label(); + icon.setUIID("PaywallIcon"); + setIcon(icon, str(node.get("name"))); + tint(icon.getAllStyles(), "accent"); + return icon; + } + if ("features".equalsIgnoreCase(type)) { + return buildFeatures(node); + } + if ("packages".equalsIgnoreCase(type)) { + return buildPackages(node); + } + if ("purchaseButton".equalsIgnoreCase(type)) { + Button b = new Button(); + b.setUIID("PaywallPurchaseButton"); + int accent = color("accent"); + if (accent >= 0) { + b.getAllStyles().setBgColor(accent); + b.getAllStyles().setBgTransparency(255); + } + tint(b.getAllStyles(), "accentText"); + registerText(b, textOr(node, "Continue")); + b.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + purchase(); + } + }); + return b; + } + if ("restoreButton".equalsIgnoreCase(type)) { + Button b = new Button(); + b.setUIID("PaywallRestoreButton"); + tint(b.getAllStyles(), "muted"); + registerText(b, textOr(node, "Restore purchases")); + b.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + restore(); + } + }); + return b; + } + if ("closeButton".equalsIgnoreCase(type)) { + Button b = new Button(); + b.setUIID("PaywallCloseButton"); + setIcon(b, "close"); + tint(b.getAllStyles(), "muted"); + b.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + close(); + } + }); + Container bar = new Container(new FlowLayout(Component.RIGHT)); + bar.setUIID("PaywallCloseBar"); + bar.add(b); + return bar; + } + if ("carousel".equalsIgnoreCase(type)) { + Tabs tabs = new Tabs(); + tabs.setUIID("PaywallCarousel"); + tabs.hideTabs(); + tabs.setSwipeActivated(true); + for (Component c : buildChildren(node)) { + tabs.addTab("", (Image) null, c); + } + return tabs; + } + if ("footer".equalsIgnoreCase(type)) { + Container footer = new Container(new FlowLayout(Component.CENTER)); + footer.setUIID("PaywallFooter"); + addLink(footer, str(node.get("termsUrl")), "footer.terms", "Terms of Service"); + addLink(footer, str(node.get("privacyUrl")), "footer.privacy", "Privacy Policy"); + return footer; + } + if ("spacer".equalsIgnoreCase(type)) { + return gap(0, mm(node.get("size"), 2)); + } + return null; + } + + private List buildChildren(Map node) { + List out = new ArrayList(); + Object children = node.get("children"); + if (children instanceof List) { + for (Object o : (List) children) { + if (o instanceof Map) { + Component c = build((Map) o); + if (c != null) { + out.add(c); + } + } + } + } + return out; + } + + private Component buildStack(Map node) { + boolean horizontal = "x".equalsIgnoreCase(str(node.get("axis"))); + Container stack = new Container(horizontal ? BoxLayout.x() : BoxLayout.y()); + stack.setUIID("PaywallStack"); + int gap = mm(node.get("gap"), 0); + boolean first = true; + for (Component c : buildChildren(node)) { + if (!first && gap > 0) { + stack.add(horizontal ? gap(gap, 0) : gap(0, gap)); + } + first = false; + stack.add(c); + } + if (node.get("padding") != null) { + int p = mm(node.get("padding"), 0); + Style s = stack.getAllStyles(); + s.setPaddingUnit(Style.UNIT_TYPE_PIXELS, Style.UNIT_TYPE_PIXELS, + Style.UNIT_TYPE_PIXELS, Style.UNIT_TYPE_PIXELS); + s.setPadding(p, p, p, p); + } + int align = alignment(str(node.get("align"))); + if (horizontal && align != Component.LEFT) { + // A box layout fills its axis; a flow layout places the row. + Container holder = new Container(new FlowLayout(align)); + holder.setUIID("PaywallStack"); + holder.add(stack); + return holder; + } + return stack; + } + + private Component buildText(Map node) { + String style = str(node.get("style")); + String uiid = "PaywallBody"; + String colorKey = "text"; + if ("title".equalsIgnoreCase(style)) { + uiid = "PaywallTitle"; + } else if ("headline".equalsIgnoreCase(style)) { + uiid = "PaywallHeadline"; + } else if ("caption".equalsIgnoreCase(style)) { + uiid = "PaywallCaption"; + colorKey = "muted"; + } + SpanLabel label = new SpanLabel(); + label.setTextUIID(uiid); + tint(label.getTextAllStyles(), colorKey); + if (node.get("align") != null) { + int align = alignment(str(node.get("align"))); + label.getTextAllStyles().setAlignment(align); + label.setTextBlockAlign(align); + } + registerText(label, textOr(node, "")); + return label; + } + + private Component buildImage(Map node) { + Label label = new Label(); + label.setUIID("PaywallImage"); + String url = str(node.get("url")); + if (url == null || url.length() == 0) { + return label; + } + int height = mm(node.get("height"), 40); + int width = Math.max(1, Display.getInstance().getDisplayWidth()); + try { + EncodedImage placeholder = EncodedImage.createFromImage( + Image.createImage(width, Math.max(1, height), 0), false); + label.setIcon(URLImage.createToStorage(placeholder, + "CN1Paywall_" + Integer.toHexString(url.hashCode()), url, + URLImage.RESIZE_SCALE_TO_FILL)); + } catch (Throwable t) { + // A paywall without its picture still sells. + Log.p("PaywallView: could not load " + url + ": " + t); + } + return label; + } + + private Component buildFeatures(Map node) { + Container features = new Container(BoxLayout.y()); + features.setUIID("PaywallFeatures"); + Object items = node.get("items"); + if (items instanceof List) { + for (Object o : (List) items) { + if (!(o instanceof Map)) { + continue; + } + Map item = (Map) o; + Container row = new Container(BoxLayout.x()); + row.setUIID("PaywallFeature"); + Label icon = new Label(); + icon.setUIID("PaywallFeatureIcon"); + setIcon(icon, str(item.get("icon"))); + tint(icon.getAllStyles(), "accent"); + SpanLabel text = new SpanLabel(); + text.setTextUIID("PaywallFeatureText"); + tint(text.getTextAllStyles(), "text"); + registerText(text, textOr(item, "")); + row.add(icon); + row.add(text); + features.add(row); + } + } + return features; + } + + private Component buildPackages(Map node) { + List all = offering == null + ? new ArrayList() : offering.getPackages(); + boolean tiles = "tiles".equalsIgnoreCase(str(node.get("style"))); + Container holder = new Container(tiles && !all.isEmpty() + ? new GridLayout(1, all.size()) : BoxLayout.y()); + holder.setUIID("PaywallPackages"); + String preselected = str(node.get("selected")); + for (final CommercePackage pkg : all) { + Container row = new Container(BoxLayout.y()); + row.setName("PaywallPackage_" + pkg.getId()); + Button title = new Button(packageTitle(pkg)); + Label price = new Label(packagePrice(pkg)); + title.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + selectPackage(pkg.getId()); + } + }); + row.add(title); + row.add(price); + // The whole row acts as the one button inside it. + row.setLeadComponent(title); + holder.add(row); + packageRows.add(row); + rowPackages.add(pkg); + if (selected == null && preselected != null && preselected.equals(pkg.getId())) { + selected = pkg; + } + } + return holder; + } + + /// Gives every package row the UIIDs of its state, which also drops the + /// colors a previous state painted on it, then fills in the texts that + /// name the selected package. + private void applySelection() { + for (int i = 0; i < packageRows.size(); i++) { + Container row = packageRows.get(i); + boolean on = rowPackages.get(i) == selected; //NOPMD CompareObjectsWithEquals - the selection is one of these instances + String suffix = on ? "Selected" : ""; + row.setUIID("PaywallPackage" + suffix); + row.getComponentAt(0).setUIID("PaywallPackageTitle" + suffix); + row.getComponentAt(1).setUIID("PaywallPackagePrice" + suffix); + if (on) { + int accent = color("accent"); + if (accent >= 0) { + row.getAllStyles().setBgColor(accent); + row.getAllStyles().setBgTransparency(255); + } + tint(row.getComponentAt(0).getAllStyles(), "accentText"); + tint(row.getComponentAt(1).getAllStyles(), "accentText"); + } else { + tint(row.getComponentAt(0).getAllStyles(), "text"); + tint(row.getComponentAt(1).getAllStyles(), "muted"); + } + } + for (int i = 0; i < textComponents.size(); i++) { + Component c = textComponents.get(i); + String text = substitute(lookup(textSources.get(i))); + if (c instanceof SpanLabel) { + ((SpanLabel) c).setText(text); + } else if (c instanceof Label) { + ((Label) c).setText(text); + } + } + } + + private void addLink(Container footer, String url, String key, String fallback) { + if (url == null || url.length() == 0) { + return; + } + String text = lookup(key); + Button link = new Button(key.equals(text) ? fallback : text); + link.setUIID("PaywallFooterLink"); + tint(link.getAllStyles(), "muted"); + link.addActionListener(new OpenUrl(url)); + footer.add(link); + } + + /// Opens a page of the footer in the platform's browser. + private static final class OpenUrl implements ActionListener { + private final String url; + + OpenUrl(String url) { + this.url = url; + } + + @Override + public void actionPerformed(ActionEvent evt) { + Display.getInstance().execute(url); + } + } + + private void registerText(Component c, String source) { + textComponents.add(c); + textSources.add(source); + } + + private static String textOr(Map node, String fallback) { + String t = str(node.get("text")); + return t == null ? fallback : t; + } + + private static Component gap(int width, int height) { + Component c = new Gap(width, height); + c.setUIID("PaywallSpacer"); + return c; + } + + /// An empty component of a fixed size. + private static final class Gap extends Component { + private final int width; + private final int height; + + Gap(int width, int height) { + this.width = width; + this.height = height; + } + + @Override + protected Dimension calcPreferredSize() { + return new Dimension(width, height); + } + } + + private static void setIcon(Label label, String name) { + char icon = FontImage.MATERIAL_CHECK; + if (name != null) { + for (int i = 0; i < ICON_NAMES.length; i++) { + if (ICON_NAMES[i].equalsIgnoreCase(name)) { + icon = ICON_CHARS[i]; + break; + } + } + } + try { + FontImage.setMaterialIcon(label, icon); + } catch (Throwable t) { + // a port with no icon font shows the element without its icon + Log.p("PaywallView: no material icon font: " + t); + } + } + + // ---- package texts ------------------------------------------------------- + + private String packageTitle(CommercePackage pkg) { + String key = "package." + pkg.getId(); + String text = lookup(key); + if (!key.equals(text)) { + return text; + } + Product product = pkg.getProduct(); + if (product != null && product.getDisplayName() != null && product.getDisplayName().length() > 0) { + return product.getDisplayName(); + } + if (pkg.isType(CommercePackage.TYPE_LIFETIME)) { + return "Lifetime"; + } + if (pkg.isType(CommercePackage.TYPE_ANNUAL)) { + return "Annual"; + } + if (pkg.isType(CommercePackage.TYPE_SIX_MONTH)) { + return "6 months"; + } + if (pkg.isType(CommercePackage.TYPE_THREE_MONTH)) { + return "3 months"; + } + if (pkg.isType(CommercePackage.TYPE_TWO_MONTH)) { + return "2 months"; + } + if (pkg.isType(CommercePackage.TYPE_MONTHLY)) { + return "Monthly"; + } + if (pkg.isType(CommercePackage.TYPE_WEEKLY)) { + return "Weekly"; + } + return pkg.getId() == null ? "" : pkg.getId(); + } + + /// The second line of a package: the store's localized price and the + /// billing period, or whichever of them is known. + private static String packagePrice(CommercePackage pkg) { + String price = price(pkg); + String period = periodText(pkg.getPeriod()); + if (price.length() > 0 && period.length() > 0) { + return price + " / " + period; + } + return price.length() > 0 ? price : period; + } + + private static String price(CommercePackage pkg) { + Product product = pkg.getProduct(); + if (product != null && product.getLocalizedPrice() != null) { + return product.getLocalizedPrice(); + } + return ""; + } + + /// An ISO-8601 duration of one unit, such as `P1M` or `P7D`, in words. + /// Anything else is shown as it is written. + static String periodText(String iso) { + if (iso == null || iso.length() < 3 || (iso.charAt(0) != 'P' && iso.charAt(0) != 'p')) { + return iso == null ? "" : iso; + } + int count = 0; + for (int i = 1; i < iso.length() - 1; i++) { + char c = iso.charAt(i); + if (c < '0' || c > '9' || count > 100000) { + return iso; + } + count = count * 10 + (c - '0'); + } + String unit; + switch (iso.charAt(iso.length() - 1)) { + case 'Y': case 'y': unit = "year"; break; + case 'M': case 'm': unit = "month"; break; + case 'W': case 'w': unit = "week"; break; + case 'D': case 'd': unit = "day"; break; + default: return iso; + } + return count == 1 ? unit : count + " " + unit + "s"; + } + + // ---- strings, variables, colors, sizes ----------------------------------- + + /// Collects the string tables to search, most specific first: the + /// device's language and country, its language, the document's default. + private void loadStrings() { + Object strings = document.get("strings"); + if (!(strings instanceof Map)) { + return; + } + Map all = (Map) strings; + String language = null; + String country = null; + try { + language = Display.getInstance().getLocalizationManager().getLanguage(); + country = Display.getInstance().getLocalizationManager().getLocale(); + } catch (Throwable t) { + // no localization on this port: the default locale's strings + Log.p("PaywallView: no locale: " + t); + } + if (language != null && country != null) { + addTable(all.get(language + "_" + country)); + addTable(all.get(language + "-" + country)); + } + if (language != null) { + addTable(all.get(language)); + } + Object def = document.get("defaultLocale"); + if (def != null) { + addTable(all.get(def.toString())); + } + } + + private void addTable(Object table) { + if (table instanceof Map) { + stringTables.add((Map) table); + } + } + + /// The string a key names, or the key itself when no table has it. + private String lookup(String key) { + if (key == null) { + return ""; + } + for (Map table : stringTables) { + Object v = table.get(key); + if (v != null) { + return v.toString(); + } + } + return key; + } + + /// Replaces the `{{variables}}` of a text from the selected package. A + /// variable with no value becomes empty, and an unknown one stays as it + /// is written. + private String substitute(String text) { + if (text.indexOf("{{") < 0) { + return text; + } + Map vars = new HashMap(); + CommercePackage p = selected; + vars.put("price", p == null ? "" : price(p)); + vars.put("period", p == null ? "" : periodText(p.getPeriod())); + vars.put("trialPeriod", p == null ? "" : periodText(p.getTrialPeriod())); + vars.put("introPrice", p == null || p.getIntroPrice() == null ? "" : p.getIntroPrice()); + vars.put("productName", p == null ? "" : packageTitle(p)); + vars.put("packageId", p == null || p.getId() == null ? "" : p.getId()); + StringBuilder out = new StringBuilder(); + int pos = 0; + while (pos < text.length()) { + int open = text.indexOf("{{", pos); + int end = open < 0 ? -1 : text.indexOf("}}", open + 2); + if (end < 0) { + out.append(text.substring(pos)); + break; + } + out.append(text.substring(pos, open)); + String value = vars.get(text.substring(open + 2, end).trim()); + if (value == null) { + out.append(text.substring(open, end + 2)); + } else { + out.append(value); + } + pos = end + 2; + } + return out.toString(); + } + + private Map chooseColors() { + Object all = document.get("colors"); + if (!(all instanceof Map)) { + return null; + } + Boolean dark = Display.getInstance().isDarkMode(); + Object set = null; + if (dark != null && dark.booleanValue()) { + set = ((Map) all).get("dark"); + } + if (!(set instanceof Map)) { + set = ((Map) all).get("light"); + } + return set instanceof Map ? (Map) set : null; + } + + /// A color of the chosen set as RGB, or -1 when the document does not + /// name it or it is not `#rrggbb`. + private int color(String key) { + if (colors == null) { + return -1; + } + Object v = colors.get(key); + if (v == null) { + return -1; + } + String s = v.toString(); + if (s.length() != 7 || s.charAt(0) != '#') { + return -1; + } + int rgb = 0; + for (int i = 1; i < 7; i++) { + char c = s.charAt(i); + int digit; + if (c >= '0' && c <= '9') { + digit = c - '0'; + } else if (c >= 'a' && c <= 'f') { + digit = c - 'a' + 10; + } else if (c >= 'A' && c <= 'F') { + digit = c - 'A' + 10; + } else { + return -1; + } + rgb = (rgb << 4) | digit; + } + return rgb; + } + + private void tint(Style style, String key) { + int c = color(key); + if (c >= 0) { + style.setFgColor(c); + } + } + + private static int alignment(String align) { + if ("center".equalsIgnoreCase(align)) { + return Component.CENTER; + } + if ("end".equalsIgnoreCase(align) || "right".equalsIgnoreCase(align)) { + return Component.RIGHT; + } + return Component.LEFT; + } + + /// A size of the document, in millimetres, as pixels. + private static int mm(Object value, float fallback) { + float v = fallback; + if (value instanceof Number) { + v = ((Number) value).floatValue(); + } + if (v <= 0) { + return 0; + } + return Display.getInstance().convertToPixels(v); + } + + private static String str(Object o) { + return o == null ? null : o.toString(); + } +} diff --git a/CodenameOne/src/com/codename1/payment/paywall/package-info.java b/CodenameOne/src/com/codename1/payment/paywall/package-info.java new file mode 100644 index 00000000000..3eef7c47380 --- /dev/null +++ b/CodenameOne/src/com/codename1/payment/paywall/package-info.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// Paywalls for the Codename One Commerce service: the screen that presents +/// the packages of an [com.codename1.payment.Offering] and sells one. +/// +/// A paywall is a JSON document designed in the console and delivered with +/// the offering, so its content can change without a new build. +/// [com.codename1.payment.paywall.PaywallView] builds it from standard +/// components with documented UIIDs, which a theme can restyle, and +/// [com.codename1.payment.paywall.Paywall] shows it as a form of its own. An +/// offering with no document gets a built-in one. +package com.codename1.payment.paywall; diff --git a/Ports/Android/build.xml b/Ports/Android/build.xml index d81fed57d2a..2a487e9d4a5 100644 --- a/Ports/Android/build.xml +++ b/Ports/Android/build.xml @@ -130,7 +130,7 @@ a package to one file is not adding it. --> + excludes="com/codename1/impl/android/ar/**,com/codename1/impl/android/ai/**,com/codename1/impl/android/cipher/**,com/codename1/impl/android/nearby/**,com/codename1/impl/android/webrtc/**,com/codename1/impl/android/unifiedpush/**,com/codename1/impl/android/referrer/**,com/codename1/impl/android/biometrics/**,com/codename1/impl/android/BillingSupport.java,com/codename1/impl/android/AmazonBillingSupport.java"> diff --git a/Ports/Android/nbproject/project.properties b/Ports/Android/nbproject/project.properties index acabc30cc96..ea11bec8fec 100644 --- a/Ports/Android/nbproject/project.properties +++ b/Ports/Android/nbproject/project.properties @@ -36,7 +36,7 @@ endorsed.classpath= # excludes= attribute in build.xml. The list lives in three files; the Maven # build is green with only one of them updated, so adding a package here alone # is not adding it. -excludes=com/codename1/impl/android/ar/**,com/codename1/impl/android/ai/**,com/codename1/impl/android/cipher/**,com/codename1/impl/android/nearby/**,com/codename1/impl/android/webrtc/**,com/codename1/impl/android/unifiedpush/**,com/codename1/impl/android/referrer/**,com/codename1/impl/android/biometrics/**,com/codename1/impl/android/BillingSupport.java +excludes=com/codename1/impl/android/ar/**,com/codename1/impl/android/ai/**,com/codename1/impl/android/cipher/**,com/codename1/impl/android/nearby/**,com/codename1/impl/android/webrtc/**,com/codename1/impl/android/unifiedpush/**,com/codename1/impl/android/referrer/**,com/codename1/impl/android/biometrics/**,com/codename1/impl/android/BillingSupport.java,com/codename1/impl/android/AmazonBillingSupport.java file.reference.android-support-v7-appcompat.jar=../../../cn1-binaries/android/android-support-v7-appcompat.jar file.reference.android-support-v7-cardview.jar=../../../cn1-binaries/android/android-support-v7-cardview.jar file.reference.android-support-v7-gridlayout.jar=../../../cn1-binaries/android/android-support-v7-gridlayout.jar diff --git a/Ports/Android/src/com/codename1/impl/android/AmazonBillingSupport.java b/Ports/Android/src/com/codename1/impl/android/AmazonBillingSupport.java new file mode 100644 index 00000000000..9d292965efa --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AmazonBillingSupport.java @@ -0,0 +1,620 @@ +/* + * 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.impl.android; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import com.amazon.device.iap.PurchasingListener; +import com.amazon.device.iap.PurchasingService; +import com.amazon.device.iap.model.FulfillmentResult; +import com.amazon.device.iap.model.ProductDataResponse; +import com.amazon.device.iap.model.ProductType; +import com.amazon.device.iap.model.PurchaseResponse; +import com.amazon.device.iap.model.PurchaseUpdatesResponse; +import com.amazon.device.iap.model.RequestId; +import com.amazon.device.iap.model.UserData; +import com.amazon.device.iap.model.UserDataResponse; +import com.codename1.payment.PendingPurchaseCallback; +import com.codename1.payment.Product; +import com.codename1.payment.PurchaseCallback; +import com.codename1.payment.Receipt; +import com.codename1.ui.CN; +import com.codename1.ui.Display; + +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.json.JSONException; +import org.json.JSONObject; + +/** + * In-app purchase over the Amazon Appstore SDK, for an application built with the + * {@code android.billing.store=amazon} build hint. + * + *

This is the Amazon counterpart of {@link BillingSupport} and is selected the + * same way: the builder generates the application stub's + * {@link CodenameOneActivity#createBillingSupport()} to construct one or the + * other, by name, so no class is looked up at run time and obfuscation cannot + * break the choice. Like {@code BillingSupport} it ships as source and is + * compiled only inside an application build that asked for it -- the builder + * deletes it everywhere else, because the Amazon SDK is not on any other + * build's class path.

+ * + *

Differences from Play that shape the code:

+ *
    + *
  • There is no connection to open. The SDK answers every request through the + * one registered {@link PurchasingListener}, always on the main thread.
  • + *
  • One {@code purchase} call covers consumables, entitlements and + * subscriptions; the product's type is Amazon's to know.
  • + *
  • A receipt is settled with {@code notifyFulfillment}, which is what stops + * Amazon from delivering it again. It is sent only after the receipt reached + * the application, so a process that dies in between gets the receipt again on + * its next launch instead of losing the purchase.
  • + *
  • {@code getPurchaseUpdates(true)} returns every entitlement and + * subscription the customer has, cancelled ones included, on every launch. + * The receipt ids already handed to the application are remembered so that a + * launch does not replay old purchases as new ones.
  • + *
+ */ +public class AmazonBillingSupport implements IBillingSupport { + private static final String TAG = "Codename One"; + + /** Preferences holding the receipt ids already reported to the application. */ + private static final String PREFS = "cn1_amazon_iap"; + private static final String KEY_DELIVERED = "delivered"; + private static final String KEY_CANCELED = "canceled"; + + /** + * Amazon documents a ceiling of 100 SKUs per product data request and fails + * the whole request above it. + */ + private static final int MAX_SKUS_PER_REQUEST = 100; + + /** + * How long a product listing waits for Amazon. A device without the Appstore + * client may never answer, and the caller is blocked until this returns. + */ + private static final long PRODUCT_TIMEOUT_MILLIS = 20000; + + private final CodenameOneActivity activity; + private final Object lock = new Object(); + + // All guarded by lock: the listener runs on the main thread, the + // application asks from the EDT. + private final Map products = new HashMap(); + private final Map productTypes = new HashMap(); + private final Set owned = new HashSet(); + private final Set pendingProductRequests = new HashSet(); + private final Map purchaseRequests = new HashMap(); + private String userId; + + private boolean registered; + /** The first query of a process asks for everything, later ones for what changed. */ + private boolean fullQueryDone; + + private final PurchasingListener listener = new PurchasingListener() { + @Override + public void onUserDataResponse(UserDataResponse response) { + if (response != null + && response.getRequestStatus() == UserDataResponse.RequestStatus.SUCCESSFUL) { + rememberUser(response.getUserData()); + } + } + + @Override + public void onProductDataResponse(ProductDataResponse response) { + handleProductData(response); + } + + @Override + public void onPurchaseResponse(PurchaseResponse response) { + handlePurchaseResponse(response); + } + + @Override + public void onPurchaseUpdatesResponse(PurchaseUpdatesResponse response) { + handlePurchaseUpdates(response); + } + }; + + public AmazonBillingSupport(CodenameOneActivity activity) { + this.activity = activity; + } + + @Override + public void initBilling() { + if (!activity.isBillingEnabled() || registered) { + return; + } + // The listener has to be registered before any other call, and from the + // main thread; initBilling runs in the activity's onCreate. + PurchasingService.registerListener(activity.getApplicationContext(), listener); + registered = true; + } + + /** + * Called on every resume. On Amazon there is nothing to acknowledge up + * front; this is where the user and the purchases that happened elsewhere + * (another device, a purchase interrupted by a crash, a refund) are picked + * up, as Amazon's own guidance asks of {@code onResume}. + */ + @Override + public void consumeAndAcknowlegePurchases() { + if (!registered) { + return; + } + boolean reset; + synchronized (lock) { + reset = !fullQueryDone; + } + PurchasingService.getUserData(); + PurchasingService.getPurchaseUpdates(reset); + } + + @Override + public void purchase(final String item) { + if (!registered) { + reportError(item, "In-app purchase is not available"); + return; + } + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + RequestId id = PurchasingService.purchase(item); + if (id != null) { + synchronized (lock) { + // A failed response carries no receipt, so this is the + // only way to tell the application which SKU failed. + purchaseRequests.put(id.toString(), item); + } + } + } + }); + } + + /** Amazon has one purchase call; a subscription is a product like any other. */ + @Override + public void subscribe(String item) { + purchase(item); + } + + @Override + public boolean wasPurchased(String item) { + synchronized (lock) { + return owned.contains(item); + } + } + + @Override + public boolean isConsumable(String item) { + ProductType type; + synchronized (lock) { + type = productTypes.get(item); + } + if (type != null) { + return type == ProductType.CONSUMABLE; + } + // Not listed yet: the naming rule the Play implementation applies. + return !item.endsWith("nonconsume"); + } + + @Override + public Product[] getProducts(String[] skus, boolean fromCacheOnly) { + if (!fromCacheOnly && registered) { + final Set missing = new HashSet(); + synchronized (lock) { + for (int i = 0; i < skus.length; i++) { + if (!products.containsKey(skus[i])) { + missing.add(skus[i]); + } + } + } + if (!missing.isEmpty()) { + loadProducts(missing); + } + } + List out = new ArrayList(); + synchronized (lock) { + for (int i = 0; i < skus.length; i++) { + Product p = products.get(skus[i]); + if (p != null) { + out.add(p); + } + } + } + return out.toArray(new Product[out.size()]); + } + + @Override + public void onDestroy() { + // The SDK holds the listener by application context and offers no + // unregister; there is no connection to close. + } + + private void loadProducts(Set missing) { + final List> batches = new ArrayList>(); + Set batch = new HashSet(); + for (String sku : missing) { + if (batch.size() == MAX_SKUS_PER_REQUEST) { + batches.add(batch); + batch = new HashSet(); + } + batch.add(sku); + } + batches.add(batch); + + final Set mine = new HashSet(); + final boolean[] sent = new boolean[1]; + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + synchronized (lock) { + for (Set b : batches) { + RequestId id = PurchasingService.getProductData(b); + if (id != null) { + mine.add(id.toString()); + pendingProductRequests.add(id.toString()); + } + } + sent[0] = true; + lock.notifyAll(); + } + } + }); + + final long deadline = System.currentTimeMillis() + PRODUCT_TIMEOUT_MILLIS; + Runnable wait = new Runnable() { + @Override + public void run() { + synchronized (lock) { + while (!sent[0] || outstanding(mine)) { + long left = deadline - System.currentTimeMillis(); + if (left <= 0) { + return; + } + try { + lock.wait(left); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return; + } + } + } + } + }; + if (CN.isEdt()) { + // Keeps the EDT alive while Amazon answers, as the Play path does. + CN.invokeAndBlock(wait); + } else { + wait.run(); + } + synchronized (lock) { + pendingProductRequests.removeAll(mine); + } + } + + /** Callers hold lock. */ + private boolean outstanding(Set requestIds) { + for (String id : requestIds) { + if (pendingProductRequests.contains(id)) { + return true; + } + } + return false; + } + + private void handleProductData(ProductDataResponse response) { + if (response == null) { + return; + } + synchronized (lock) { + if (response.getRequestStatus() == ProductDataResponse.RequestStatus.SUCCESSFUL + && response.getProductData() != null) { + for (Map.Entry e + : response.getProductData().entrySet()) { + com.amazon.device.iap.model.Product src = e.getValue(); + if (src == null) { + continue; + } + Product p = new Product(); + p.setSku(src.getSku()); + p.setDisplayName(src.getTitle()); + p.setDescription(src.getDescription()); + // Already formatted for the customer's marketplace. + p.setLocalizedPrice(src.getPrice()); + products.put(e.getKey(), p); + if (src.getProductType() != null) { + productTypes.put(e.getKey(), src.getProductType()); + } + } + } + if (response.getRequestId() != null) { + pendingProductRequests.remove(response.getRequestId().toString()); + } + lock.notifyAll(); + } + } + + private void handlePurchaseResponse(PurchaseResponse response) { + if (response == null) { + return; + } + String requested = null; + if (response.getRequestId() != null) { + synchronized (lock) { + requested = purchaseRequests.remove(response.getRequestId().toString()); + } + } + rememberUser(response.getUserData()); + PurchaseResponse.RequestStatus status = response.getRequestStatus(); + com.amazon.device.iap.model.Receipt receipt = response.getReceipt(); + if (status == PurchaseResponse.RequestStatus.SUCCESSFUL && receipt != null) { + // The application asked for this SKU by the name it knows; for a + // subscription that is the term SKU, which is what it hears back. + deliver(receipt, requested != null ? requested : skuOf(receipt), true); + return; + } + final String sku = requested != null ? requested + : (receipt != null ? skuOf(receipt) : ""); + if (status == PurchaseResponse.RequestStatus.PENDING) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + PurchaseCallback pc = getPurchaseCallback(); + if (pc instanceof PendingPurchaseCallback) { + ((PendingPurchaseCallback) pc).itemPurchasePending(sku); + } + } + }); + return; + } + if (status == PurchaseResponse.RequestStatus.ALREADY_PURCHASED) { + // Amazon sends no receipt with this answer; the full query brings + // it, and delivers it if this installation never saw it. + synchronized (lock) { + owned.add(sku); + } + PurchasingService.getPurchaseUpdates(true); + reportError(sku, "Item already purchased"); + return; + } + reportError(sku, "Purchase failed: " + (status == null ? "unknown" : status.name())); + } + + private void handlePurchaseUpdates(PurchaseUpdatesResponse response) { + if (response == null + || response.getRequestStatus() != PurchaseUpdatesResponse.RequestStatus.SUCCESSFUL) { + return; + } + rememberUser(response.getUserData()); + List receipts = response.getReceipts(); + if (receipts != null) { + for (com.amazon.device.iap.model.Receipt receipt : receipts) { + if (receipt == null) { + continue; + } + if (receipt.isCanceled()) { + revoke(receipt); + } else { + deliver(receipt, skuOf(receipt), false); + } + } + } + if (response.hasMore()) { + // The next page continues from the SDK's own cursor. + PurchasingService.getPurchaseUpdates(false); + } else { + synchronized (lock) { + fullQueryDone = true; + } + } + } + + /** + * Hands a receipt to the application and then tells Amazon it arrived. + * + * @param fresh true for the answer to a purchase the application just made, + * which is always reported; a receipt found by a query is reported only if + * this installation has not reported it before + */ + private void deliver(final com.amazon.device.iap.model.Receipt receipt, final String sku, + final boolean fresh) { + final String receiptId = receipt.getReceiptId(); + final boolean consumable = receipt.getProductType() == ProductType.CONSUMABLE; + synchronized (lock) { + if (receipt.getProductType() != null) { + productTypes.put(sku, receipt.getProductType()); + } + if (!consumable) { + // A fulfilled consumable is spent; the others are owned for + // as long as Amazon keeps listing them. + owned.add(sku); + owned.add(receipt.getSku()); + } + } + if (receiptId == null) { + return; + } + final String user; + synchronized (lock) { + user = userId; + } + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + if (activity.getApp() == null) { + // The application is not up yet (a query answered during + // launch). Nothing is recorded and nothing is fulfilled, + // so the next resume or launch brings the receipt again. + return; + } + if (!fresh && wasReported(KEY_DELIVERED, receiptId)) { + fulfill(receiptId); + return; + } + Date purchased = receipt.getPurchaseDate(); + com.codename1.payment.Purchase.postReceipt(Receipt.STORE_CODE_AMAZON, sku, + receiptId, purchased == null ? System.currentTimeMillis() : purchased.getTime(), + orderData(receiptId, user, sku)); + markReported(KEY_DELIVERED, receiptId); + PurchaseCallback pc = getPurchaseCallback(); + if (pc != null) { + pc.itemPurchased(sku); + } + // Last, so a receipt Amazon stops sending is one the + // application already has. + fulfill(receiptId); + } + }); + } + + /** + * A cancelled receipt: Amazon revoked the entitlement (a refund) or the + * subscription ended. Reported once per receipt, since a full query lists + * it again on every launch. + */ + private void revoke(com.amazon.device.iap.model.Receipt receipt) { + final String sku = skuOf(receipt); + final String receiptId = receipt.getReceiptId(); + final boolean subscription = receipt.getProductType() == ProductType.SUBSCRIPTION; + synchronized (lock) { + owned.remove(sku); + owned.remove(receipt.getSku()); + } + if (receiptId == null) { + return; + } + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + if (activity.getApp() == null || wasReported(KEY_CANCELED, receiptId)) { + return; + } + markReported(KEY_CANCELED, receiptId); + PurchaseCallback pc = getPurchaseCallback(); + if (pc == null) { + return; + } + if (subscription) { + pc.subscriptionCanceled(sku); + } else { + pc.itemRefunded(sku); + } + } + }); + } + + private void fulfill(final String receiptId) { + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + try { + PurchasingService.notifyFulfillment(receiptId, FulfillmentResult.FULFILLED); + } catch (RuntimeException ex) { + // Not fatal: an unfulfilled receipt is delivered again and + // recognised by its id. + Log.e(TAG, "Amazon notifyFulfillment failed for " + receiptId, ex); + } + } + }); + } + + private void reportError(final String sku, final String message) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + PurchaseCallback pc = getPurchaseCallback(); + if (pc != null) { + pc.itemPurchaseError(sku, message); + } + } + }); + } + + private void rememberUser(UserData data) { + if (data != null && data.getUserId() != null) { + synchronized (lock) { + userId = data.getUserId(); + } + } + } + + /** + * The SKU the application purchased. For a subscription Amazon's receipt + * names the parent in {@code getSku} and the purchased term separately. + */ + private static String skuOf(com.amazon.device.iap.model.Receipt receipt) { + String term = receipt.getTermSku(); + return term != null && term.length() > 0 ? term : receipt.getSku(); + } + + /** + * The three values Amazon's receipt verification needs, as the JSON object + * the receipt carries to a server. + */ + static String orderData(String receiptId, String user, String sku) { + try { + JSONObject json = new JSONObject(); + json.put("receiptId", receiptId); + json.put("userId", user == null ? "" : user); + json.put("sku", sku == null ? "" : sku); + return json.toString(); + } catch (JSONException ex) { + Log.e(TAG, "Failed to encode the Amazon receipt " + receiptId, ex); + return ""; + } + } + + private SharedPreferences prefs() { + return activity.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + private boolean wasReported(String key, String receiptId) { + Set ids = prefs().getStringSet(key, null); + return ids != null && ids.contains(receiptId); + } + + private void markReported(String key, String receiptId) { + SharedPreferences p = prefs(); + // The set getStringSet returns must not be modified; store a copy. + Set ids = new HashSet(); + Set stored = p.getStringSet(key, null); + if (stored != null) { + ids.addAll(stored); + } + ids.add(receiptId); + p.edit().putStringSet(key, ids).apply(); + } + + public PurchaseCallback getPurchaseCallback() { + Object app = activity.getApp(); + return app instanceof PurchaseCallback ? (PurchaseCallback) app : null; + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 7de582ec16d..df31245fb06 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -16807,6 +16807,17 @@ public String getProperty(String key, String defaultValue) { } return defaultValue; } + if ("commerce.cloud.enabled".equals(key) || "commerce.cloud.endpoint".equals(key) + || "commerce.cloud.publicKey".equals(key)) { + // A builder stamps these into the device stub; nothing does for the simulator, so + // the hints are resolved here and a project pointed at its own service can be run. + // Display.setProperty still wins: Display answers its own properties first. + String commerce = buildHint(key); + if (commerce != null && commerce.trim().length() > 0) { + return commerce.trim(); + } + return defaultValue; + } if(key.equalsIgnoreCase("cn1_push_prefix") || key.equalsIgnoreCase("cellId") || key.equalsIgnoreCase("IMEI") diff --git a/Ports/MacPort/nativeSources/CN1MacAppDelegate.m b/Ports/MacPort/nativeSources/CN1MacAppDelegate.m index b8c45fa1345..8ea3358894e 100644 --- a/Ports/MacPort/nativeSources/CN1MacAppDelegate.m +++ b/Ports/MacPort/nativeSources/CN1MacAppDelegate.m @@ -659,11 +659,15 @@ @implementation CN1MacAppDelegate // isAppSuspended is set whether or not Java is up: it is C state the shared // paint path reads, and it has to be right from the first frame. +/// CN1MacViewController.m. Empty unless the build enabled in-app purchase. +extern void CN1MacInstallStoreKitObserver(void); + - (void)applicationDidFinishLaunching:(NSNotification *)notification { // Normally a no-op: CN1MacBuildMainWindowBeforeRun released it before // [NSApp run]. This is the backstop for a generated main that never calls // that, so held menu commands cannot stay held for good. CN1MacReleaseStartupMenu(); + CN1MacInstallStoreKitObserver(); // Installed unconditionally, and this early. A scheduled notification is // delivered through this delegate whether it fires while the application is // frontmost or the user opens it from Notification Center, so without it a diff --git a/Ports/MacPort/nativeSources/CN1MacViewController.m b/Ports/MacPort/nativeSources/CN1MacViewController.m index e9c23bed706..5f5ed6e2cbb 100644 --- a/Ports/MacPort/nativeSources/CN1MacViewController.m +++ b/Ports/MacPort/nativeSources/CN1MacViewController.m @@ -302,4 +302,17 @@ - (void)dealloc { @end +/// Starts observing the payment queue. Called from applicationDidFinishLaunching, +/// which is when StoreKit asks for the observer: a transaction left unfinished by +/// the last run, or one bought outside the application, is delivered as soon as +/// an observer exists and is lost to this launch if none does. A function here +/// rather than a line in the delegate because CN1_USE_STOREKIT is switched in +/// CodenameOne_GLViewController.h, which the delegate does not import; without +/// the define this is empty and names nothing from StoreKit. +void CN1MacInstallStoreKitObserver(void) { +#ifdef CN1_USE_STOREKIT + [[SKPaymentQueue defaultQueue] addTransactionObserver:[CodenameOne_GLViewController instance]]; +#endif +} + #endif diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index 37601ff3695..e8e08eb70b0 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -673,9 +673,6 @@ UIImagePickerControllerDelegate, MFMailComposeViewControllerDelegate, #endif UIScrollViewDelegate, -#ifdef CN1_USE_STOREKIT -SKProductsRequestDelegate, SKPaymentTransactionObserver, -#endif #if !TARGET_OS_WATCH && !TARGET_OS_TV MFMessageComposeViewControllerDelegate, UIActionSheetDelegate, UIPopoverControllerDelegate, #endif @@ -793,3 +790,12 @@ CLLocationManagerDelegate, AVAudioRecorderDelegate -(void)updateCanvas:(BOOL)animated; @end #endif // TARGET_OS_WATCH (GL view controller interface variant) + +// Declared on a category rather than in the protocol list of either interface +// above, because the delegate methods are implemented in one +// (CodenameOne_GLViewController.m) that both the UIKit controller and the +// native macOS render driver pick up. See the category's implementation. +#if defined(CN1_USE_STOREKIT) && !TARGET_OS_WATCH +@interface CodenameOne_GLViewController (CN1StoreKit) +@end +#endif diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m index 775041e59f1..680a975fc5f 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m @@ -6125,134 +6125,6 @@ -(void) messageComposeViewController:(MFMessageComposeViewController*)controller #endif #endif // !TARGET_OS_WATCH && !TARGET_OS_TV (MessageUI delegates) -extern JAVA_OBJECT productsArrayPending; - -#ifdef CN1_USE_STOREKIT -- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response { -// UIKit-only helper. AppKit's equivalent is a different API rather than a -// renamed one, so this is inert on the native macOS port until it is ported. -#if TARGET_OS_OSX -#else - POOL_BEGIN(); - if(productsArrayPending != nil) { -#ifndef NEW_CODENAME_ONE_VM - org_xmlvm_runtime_XMLVMArray* pArray = productsArrayPending; - JAVA_ARRAY_OBJECT* data = (JAVA_ARRAY_OBJECT*)pArray->fields.org_xmlvm_runtime_XMLVMArray.array_; -#else - JAVA_ARRAY pArray = (JAVA_ARRAY)productsArrayPending; - JAVA_ARRAY_OBJECT* data = (JAVA_ARRAY_OBJECT*)CN1_ARRAY_DATA(pArray); -#endif - NSArray* arr = response.products; - int count = arr.count; - for(int iter = 0 ; iter < count ; iter++) { - SKProduct* prod = [arr objectAtIndex:iter]; - NSString* sku = [prod productIdentifier]; - com_codename1_payment_Product_setSku___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG data[iter], fromNSString(CN1_THREAD_GET_STATE_PASS_ARG sku)); - - NSNumberFormatter *numberFormatter = [[NSNumberFormatter alloc] init]; - [numberFormatter setFormatterBehavior:NSNumberFormatterBehavior10_4]; - [numberFormatter setNumberStyle:NSNumberFormatterCurrencyStyle]; - [numberFormatter setLocale:prod.priceLocale]; - NSString *formattedString = [numberFormatter stringFromNumber:prod.price]; - com_codename1_payment_Product_setLocalizedPrice___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG data[iter], fromNSString(CN1_THREAD_GET_STATE_PASS_ARG formattedString)); - - - com_codename1_payment_Product_setDescription___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG data[iter], fromNSString(CN1_THREAD_GET_STATE_PASS_ARG [prod localizedDescription])); - com_codename1_payment_Product_setDisplayName___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG data[iter], fromNSString(CN1_THREAD_GET_STATE_PASS_ARG [prod localizedTitle])); - } - } - productsArrayPending = nil; - com_codename1_impl_ios_ZoozPurchase_fetchProductsComplete__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); - POOL_END(); -#endif -} - - --(void)request:(SKRequest *)request didFailWithError:(NSError *)error -{ -// UIKit-only helper. AppKit's equivalent is a different API rather than a -// renamed one, so this is inert on the native macOS port until it is ported. -#if TARGET_OS_OSX -#else - POOL_BEGIN(); - com_codename1_impl_ios_ZoozPurchase_fetchProductsCanceledOrFailed___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG fromNSString(CN1_THREAD_GET_STATE_PASS_ARG [error localizedDescription])); - - POOL_END(); -#endif -} - -extern SKPayment *paymentInstance; -- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions -{ -// UIKit-only helper. AppKit's equivalent is a different API rather than a -// renamed one, so this is inert on the native macOS port until it is ported. -#if TARGET_OS_OSX -#else - for (SKPaymentTransaction *transaction in transactions) - { - switch (transaction.transactionState) - { - case SKPaymentTransactionStatePurchased: - - [[SKPaymentQueue defaultQueue] finishTransaction: transaction]; - NSData *receipt = nil; - if (isIOS7()) { - NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL]; - receipt = [NSData dataWithContentsOfURL : receiptURL]; - } else { - receipt = transaction.transactionReceipt; - } - - // Post the receipt - com_codename1_payment_Purchase_postReceipt___java_lang_String_java_lang_String_java_lang_String_long_java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG - get_static_com_codename1_payment_Receipt_STORE_CODE_ITUNES(), - fromNSString(CN1_THREAD_GET_STATE_PASS_ARG transaction.payment.productIdentifier), - fromNSString(CN1_THREAD_GET_STATE_PASS_ARG transaction.transactionIdentifier), - (JAVA_LONG)[transaction.transactionDate timeIntervalSince1970] * 1000, - receipt ? fromNSString(CN1_THREAD_GET_STATE_PASS_ARG [receipt base64EncodedStringWithOptions:0]) : JAVA_NULL - ); - - com_codename1_impl_ios_IOSImplementation_itemPurchased___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG fromNSString(CN1_THREAD_GET_STATE_PASS_ARG transaction.payment.productIdentifier)); - continue; - case SKPaymentTransactionStateFailed: - if (transaction.error.code != SKErrorPaymentCancelled) { - CN1Log(@"Transaction error %@", transaction.error); - com_codename1_impl_ios_IOSImplementation_itemPurchaseError___java_lang_String_java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG fromNSString(CN1_THREAD_GET_STATE_PASS_ARG transaction.payment.productIdentifier), fromNSString(CN1_THREAD_GET_STATE_PASS_ARG transaction.error.localizedDescription)); - } - [[SKPaymentQueue defaultQueue] finishTransaction: transaction]; - continue; - - case SKPaymentTransactionStateRestored: - CN1Log(@"Transaction restored SKPaymentTransactionStateRestored %@", transaction.originalTransaction.payment.productIdentifier); - - [[SKPaymentQueue defaultQueue] finishTransaction: transaction]; - com_codename1_impl_ios_IOSImplementation_itemRestored___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG fromNSString(CN1_THREAD_GET_STATE_PASS_ARG transaction.originalTransaction.payment.productIdentifier)); - continue; - case SKPaymentTransactionStatePurchasing: - CN1Log(@"SKPaymentTransactionStatePurchasing"); - continue; - default: - CN1Log(@"Transaction error %i", transaction.transactionState); - [[SKPaymentQueue defaultQueue] finishTransaction: transaction]; - continue; - } - } -#endif -} - --(void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue -{ - CN1Log(@"Restore transactions finished"); - com_codename1_impl_ios_IOSImplementation_restoreRequestComplete__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); -} --(void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error -{ - CN1Log(@"Restore error"); - com_codename1_impl_ios_IOSImplementation_restoreRequestError___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG fromNSString(CN1_THREAD_GET_STATE_PASS_ARG error.localizedDescription)); -} - -#endif - - (void)audioRecorderDidFinishRecording:(AVAudioRecorder *)recorder successfully:(BOOL)flag { CN1Log(@"audioRecorderDidFinishRecording: %i", (int)flag); } @@ -6681,3 +6553,134 @@ - (UIViewController *) documentInteractionControllerViewControllerForPreview: (U @end #endif #endif + +// The StoreKit delegate lives in a category, outside the UIKit controller's +// @implementation above, because that implementation is compiled out on the +// native macOS port (CN1MacViewController.m supplies the class there). A +// category attaches to whichever of the two is in the build, so iOS, tvOS and +// macOS run the same product listing, transaction and receipt code. Nothing in +// it is UIKit: StoreKit 1 and the app receipt are the same API on all three. +// watchOS is left out, as it always was -- the watch driver never observed the +// payment queue. +#if defined(CN1_USE_STOREKIT) && !TARGET_OS_WATCH +extern JAVA_OBJECT productsArrayPending; + +@implementation CodenameOne_GLViewController (CN1StoreKit) +- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response { + POOL_BEGIN(); + if(productsArrayPending != nil) { +#ifndef NEW_CODENAME_ONE_VM + org_xmlvm_runtime_XMLVMArray* pArray = productsArrayPending; + JAVA_ARRAY_OBJECT* data = (JAVA_ARRAY_OBJECT*)pArray->fields.org_xmlvm_runtime_XMLVMArray.array_; +#else + JAVA_ARRAY pArray = (JAVA_ARRAY)productsArrayPending; + JAVA_ARRAY_OBJECT* data = (JAVA_ARRAY_OBJECT*)CN1_ARRAY_DATA(pArray); +#endif + NSArray* arr = response.products; + int count = arr.count; + for(int iter = 0 ; iter < count ; iter++) { + SKProduct* prod = [arr objectAtIndex:iter]; + NSString* sku = [prod productIdentifier]; + com_codename1_payment_Product_setSku___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG data[iter], fromNSString(CN1_THREAD_GET_STATE_PASS_ARG sku)); + + NSNumberFormatter *numberFormatter = [[NSNumberFormatter alloc] init]; + [numberFormatter setFormatterBehavior:NSNumberFormatterBehavior10_4]; + [numberFormatter setNumberStyle:NSNumberFormatterCurrencyStyle]; + [numberFormatter setLocale:prod.priceLocale]; + NSString *formattedString = [numberFormatter stringFromNumber:prod.price]; + com_codename1_payment_Product_setLocalizedPrice___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG data[iter], fromNSString(CN1_THREAD_GET_STATE_PASS_ARG formattedString)); + + + com_codename1_payment_Product_setDescription___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG data[iter], fromNSString(CN1_THREAD_GET_STATE_PASS_ARG [prod localizedDescription])); + com_codename1_payment_Product_setDisplayName___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG data[iter], fromNSString(CN1_THREAD_GET_STATE_PASS_ARG [prod localizedTitle])); + } + } + productsArrayPending = nil; + com_codename1_impl_ios_ZoozPurchase_fetchProductsComplete__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); + POOL_END(); +} + + +-(void)request:(SKRequest *)request didFailWithError:(NSError *)error +{ + POOL_BEGIN(); + com_codename1_impl_ios_ZoozPurchase_fetchProductsCanceledOrFailed___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG fromNSString(CN1_THREAD_GET_STATE_PASS_ARG [error localizedDescription])); + + POOL_END(); +} + +extern SKPayment *paymentInstance; +- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions +{ + for (SKPaymentTransaction *transaction in transactions) + { + switch (transaction.transactionState) + { + case SKPaymentTransactionStatePurchased: + + [[SKPaymentQueue defaultQueue] finishTransaction: transaction]; + NSData *receipt = nil; +#if TARGET_OS_OSX + // macOS never had the per-transaction receipt: the app receipt + // (Contents/_MASReceipt/receipt) is the only one, and it is the + // same payload the iOS arm posts. + NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL]; + receipt = receiptURL ? [NSData dataWithContentsOfURL : receiptURL] : nil; +#else + if (isIOS7()) { + NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL]; + receipt = [NSData dataWithContentsOfURL : receiptURL]; + } else { + receipt = transaction.transactionReceipt; + } +#endif + + // Post the receipt + com_codename1_payment_Purchase_postReceipt___java_lang_String_java_lang_String_java_lang_String_long_java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG + get_static_com_codename1_payment_Receipt_STORE_CODE_ITUNES(), + fromNSString(CN1_THREAD_GET_STATE_PASS_ARG transaction.payment.productIdentifier), + fromNSString(CN1_THREAD_GET_STATE_PASS_ARG transaction.transactionIdentifier), + (JAVA_LONG)[transaction.transactionDate timeIntervalSince1970] * 1000, + receipt ? fromNSString(CN1_THREAD_GET_STATE_PASS_ARG [receipt base64EncodedStringWithOptions:0]) : JAVA_NULL + ); + + com_codename1_impl_ios_IOSImplementation_itemPurchased___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG fromNSString(CN1_THREAD_GET_STATE_PASS_ARG transaction.payment.productIdentifier)); + continue; + case SKPaymentTransactionStateFailed: + if (transaction.error.code != SKErrorPaymentCancelled) { + CN1Log(@"Transaction error %@", transaction.error); + com_codename1_impl_ios_IOSImplementation_itemPurchaseError___java_lang_String_java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG fromNSString(CN1_THREAD_GET_STATE_PASS_ARG transaction.payment.productIdentifier), fromNSString(CN1_THREAD_GET_STATE_PASS_ARG transaction.error.localizedDescription)); + } + [[SKPaymentQueue defaultQueue] finishTransaction: transaction]; + continue; + + case SKPaymentTransactionStateRestored: + CN1Log(@"Transaction restored SKPaymentTransactionStateRestored %@", transaction.originalTransaction.payment.productIdentifier); + + [[SKPaymentQueue defaultQueue] finishTransaction: transaction]; + com_codename1_impl_ios_IOSImplementation_itemRestored___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG fromNSString(CN1_THREAD_GET_STATE_PASS_ARG transaction.originalTransaction.payment.productIdentifier)); + continue; + case SKPaymentTransactionStatePurchasing: + CN1Log(@"SKPaymentTransactionStatePurchasing"); + continue; + default: + CN1Log(@"Transaction error %i", (int)transaction.transactionState); + [[SKPaymentQueue defaultQueue] finishTransaction: transaction]; + continue; + } + } +} + +-(void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue +{ + CN1Log(@"Restore transactions finished"); + com_codename1_impl_ios_IOSImplementation_restoreRequestComplete__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); +} +-(void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error +{ + CN1Log(@"Restore error"); + com_codename1_impl_ios_IOSImplementation_restoreRequestError___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG fromNSString(CN1_THREAD_GET_STATE_PASS_ARG error.localizedDescription)); +} + +@end +#endif diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 5aec42bdaef..993c36228d8 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -13836,8 +13836,9 @@ void com_codename1_impl_ios_IOSNative_fetchProducts___java_lang_String_1ARRAY_co NSObject *paymentDiscountInstance = nil; #endif -#if defined(CN1_USE_STOREKIT) && TARGET_OS_TV +#if defined(CN1_USE_STOREKIT) && (TARGET_OS_TV || TARGET_OS_OSX) /* + * Shared by the native macOS port, where the SDK marks the same selector unavailable. * tvOS has no +[SKPayment paymentWithProductIdentifier:]: a payment can only be built from an * SKProduct, so the product is looked up first. A product the store does not return is reported * through the same itemPurchaseError callback a failed transaction uses. The request keeps this @@ -13892,7 +13893,7 @@ CN1_THREAD_GET_STATE_PASS_ARG fromNSString(CN1_THREAD_GET_STATE_PASS_ARG sku), } SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:product]; if (discount != nil) { - if (@available(tvOS 12.2, *)) { + if (@available(tvOS 12.2, macOS 10.14.4, *)) { payment.paymentDiscount = (SKPaymentDiscount *)discount; } } @@ -13932,7 +13933,7 @@ void com_codename1_impl_ios_IOSNative_purchase___java_lang_String(CN1_THREAD_STA } nsSku = [dict valueForKey:@"sku"]; NSDictionary *promoDict = [dict valueForKey: @"promotionalOffer"]; - if (@available(iOS 12.2, *)) { + if (@available(iOS 12.2, macOS 10.14.4, *)) { for (NSString* stringKey in @[@"offerIdentifier", @"keyIdentifier", @"signature", @"nonce"]) { if (!promoDict[stringKey] || ! [promoDict[stringKey] isKindOfClass:[NSString class]] ) { JAVA_OBJECT ex = __NEW_java_lang_RuntimeException(CN1_THREAD_STATE_PASS_SINGLE_ARG); @@ -13961,7 +13962,7 @@ void com_codename1_impl_ios_IOSNative_purchase___java_lang_String(CN1_THREAD_STA } } -#if TARGET_OS_TV +#if TARGET_OS_TV || TARGET_OS_OSX NSString *tvSku = nsSku; NSObject *tvDiscount = paymentDiscountInstance; dispatch_async(dispatch_get_main_queue(), ^{ @@ -13973,7 +13974,7 @@ void com_codename1_impl_ios_IOSNative_purchase___java_lang_String(CN1_THREAD_STA dispatch_async(dispatch_get_main_queue(), ^{ if (paymentDiscountInstance != nil) { paymentInstance = [SKMutablePayment paymentWithProductIdentifier:nsSku]; - if (@available(iOS 12.2, *)) { + if (@available(iOS 12.2, macOS 10.14.4, *)) { ((SKMutablePayment*)paymentInstance).paymentDiscount = (SKPaymentDiscount*) paymentDiscountInstance; } else { // Fallback on earlier versions diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/ZoozPurchase.java b/Ports/iOSPort/src/com/codename1/impl/ios/ZoozPurchase.java index c381a56590c..5849820dadc 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/ZoozPurchase.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/ZoozPurchase.java @@ -240,6 +240,13 @@ public boolean isManageSubscriptionsSupported() { @Override public void manageSubscriptions(String sku) { + if ("mac".equals(Display.getInstance().getPlatformName())) { + // The native macOS port. StoreKit has no manage-subscriptions sheet + // there and itms-apps is an iOS scheme; the web address is the one + // macOS routes to the App Store's subscriptions page. + CN.execute("https://apps.apple.com/account/subscriptions"); + return; + } CN.execute("itms-apps://apps.apple.com/account/subscriptions"); } diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java new file mode 100644 index 00000000000..ea56c4f2c9d --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java @@ -0,0 +1,406 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets; + +import com.codename1.payment.CommerceManager; +import com.codename1.payment.CommercePackage; +import com.codename1.payment.CustomerInfo; +import com.codename1.payment.EntitlementInfo; +import com.codename1.payment.Offering; +import com.codename1.payment.Offerings; +import com.codename1.payment.ReferralReward; +import com.codename1.payment.center.CustomerCenter; +import com.codename1.payment.paywall.Paywall; +import com.codename1.payment.paywall.PaywallListener; +import com.codename1.payment.paywall.PaywallView; +import com.codename1.ui.CN; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; +import java.util.HashMap; +import java.util.Map; + +/** Compiled source snippets for the Commerce chapter of the developer guide. */ +public class CommerceSnippets { + + /** Check an entitlement and show a paywall. */ + public void quickStart() { + // tag::commerce-quick-start[] + CommerceManager cm = CommerceManager.getInstance(); + if (cm.isEntitled("pro")) { + // unlock the paid features here + } else { + // getOfferings() blocks on the network, so it runs on a thread of + // its own and the paywall is shown back on the EDT. + new Thread(() -> { + Offering current = cm.getOfferings().getCurrent(); + if (current != null) { + CN.callSerially(() -> Paywall.show(current, null)); + } + }).start(); + } + // end::commerce-quick-start[] + } + + /** Read the details of an entitlement. */ + public void customerInfo() { + // tag::commerce-customer-info[] + CustomerInfo info = CommerceManager.getInstance().getCustomerInfo(); + EntitlementInfo pro = info.getEntitlement("pro"); + if (pro != null && pro.isActive()) { + String product = pro.getProductId(); // the product that granted it + String store = pro.getStore(); // where it was bought + long expires = pro.getExpiresDate(); // 0 for a lifetime purchase + boolean renews = pro.isWillRenew(); + String period = pro.getPeriodType(); // trial, introductory or normal + } + for (String sku : info.getActiveSubscriptions()) { + // one line per subscription on an account screen + } + // end::commerce-customer-info[] + } + + /** Follow entitlement changes. */ + public void listener() { + // tag::commerce-listener[] + CommerceManager.getInstance().addCustomerInfoListener(evt -> { + // Called on the EDT. The source is the new customer information. + CustomerInfo info = (CustomerInfo) evt.getSource(); + boolean pro = info.isEntitled("pro"); + // show or hide the paid features to match + }); + // end::commerce-listener[] + } + + /** Refresh on start and tell a missing answer from a denial. */ + public void refresh() { + // tag::commerce-refresh[] + CommerceManager cm = CommerceManager.getInstance(); + // refresh() blocks on the network, so it runs off the EDT. + new Thread(() -> { + cm.refresh(); + CN.callSerially(() -> { + if (cm.isDegraded()) { + // The service gave no answer. isEntitled() is answering + // from the stored answer and the store's own receipts. + } + boolean pro = cm.isEntitled("pro"); + }); + }).start(); + // end::commerce-refresh[] + } + + /** Sign in, sign out and restore. */ + public void identity() { + // tag::commerce-log-in[] + CommerceManager cm = CommerceManager.getInstance(); + String accountId = "account-1234"; // your own id for the signed-in user + // logIn() blocks on the network, so it runs off the EDT. + new Thread(() -> { + if (cm.logIn(accountId)) { + // Purchases made before signing in now belong to the account, + // and the account's purchases from other platforms unlock here. + cm.refresh(); + } else { + // The service did not accept the change. The previous id is + // still current and the call can be repeated. + } + }).start(); + // end::commerce-log-in[] + } + + /** Sign out. */ + public void logOut() { + // tag::commerce-log-out[] + CommerceManager cm = CommerceManager.getInstance(); + cm.logOut(); + // The device may still hold purchases of its store account. Refreshing + // attaches them to the new anonymous customer. + new Thread(() -> cm.refresh()).start(); + // end::commerce-log-out[] + } + + /** Fetch offerings and read packages. */ + public void offerings() { + // tag::commerce-offerings[] + CommerceManager cm = CommerceManager.getInstance(); + new Thread(() -> { + // Pass null, or call getOfferings(), for the default offerings. + Offerings offerings = cm.getOfferings("onboarding"); + Offering current = offerings.getCurrent(); + if (current == null) { + return; + } + CommercePackage monthly = current.getMonthly(); + CommercePackage annual = current.getAnnual(); + for (CommercePackage pkg : current.getPackages()) { + String period = pkg.getPeriod(); // "P1M", "P1Y" + if (pkg.isEligibleForIntro() && pkg.getTrialPeriod() != null) { + // this customer can still take the free trial + } + if (pkg.getProduct() != null) { + // the store's localized price + String price = pkg.getProduct().getLocalizedPrice(); + } + } + }).start(); + // end::commerce-offerings[] + } + + /** Buy a package. */ + public void purchasePackage() { + // tag::commerce-purchase-package[] + CommerceManager cm = CommerceManager.getInstance(); + cm.addCustomerInfoListener(evt -> { + if (((CustomerInfo) evt.getSource()).isEntitled("pro")) { + // the purchase was validated: unlock the paid features + } + }); + new Thread(() -> { + Offering current = cm.getOfferings().getCurrent(); + if (current != null && current.getAnnual() != null) { + CN.callSerially(() -> cm.purchase(current.getAnnual())); + } + }).start(); + // end::commerce-purchase-package[] + } + + /** Read the experiment a customer is in. */ + public void experiment() { + // tag::commerce-experiment[] + new Thread(() -> { + Offerings offerings = CommerceManager.getInstance().getOfferings(); + String experiment = offerings.getExperimentId(); // null when not in one + String variant = offerings.getExperimentVariant(); + }).start(); + // end::commerce-experiment[] + } + + /** Show a paywall and listen to it. */ + public void paywall() { + // tag::commerce-paywall[] + new Thread(() -> { + Offering offering = CommerceManager.getInstance().getOfferings().getCurrent(); + if (offering == null) { + return; + } + CN.callSerially(() -> Paywall.show(offering, new PaywallListener() { + @Override + public void purchaseStarted(CommercePackage pkg) { + // the store's purchase sheet is up + } + + @Override + public void purchaseCompleted(CustomerInfo info) { + // the service confirmed the purchase: unlock the features + } + + @Override + public void purchaseCancelled() { + // closed before the purchase was confirmed + } + + @Override + public void restoreCompleted(CustomerInfo info) { + // check info.isEntitled("pro") after a restore + } + + @Override + public void closed() { + // the previous form is showing again + } + })); + }).start(); + // end::commerce-paywall[] + } + + /** Embed a paywall in a form of the application. */ + public void paywallView() { + // tag::commerce-paywall-view[] + new Thread(() -> { + Offering offering = CommerceManager.getInstance().getOfferings().getCurrent(); + CN.callSerially(() -> { + Form previous = Display.getInstance().getCurrent(); + // Start from the built-in document and change it, or pass a + // document of your own as a parsed JSON map. + Map document = Paywall.createDefault(offering); + PaywallView view = new PaywallView(offering, document); + Form upgrade = new Form("Upgrade", new BorderLayout()); + upgrade.add(BorderLayout.CENTER, view); + view.setCloseHandler(() -> previous.showBack()); + upgrade.show(); + }); + }).start(); + // end::commerce-paywall-view[] + } + + /** Open the customer center. */ + public void customerCenter() { + // tag::commerce-customer-center[] + // Safe on any thread: the configuration is fetched in the background + // and the form is shown on the EDT. + CustomerCenter.show(); + // end::commerce-customer-center[] + } + + /** Buy under a promotional offer. */ + public void promotionalOffer() { + // tag::commerce-promotional-offer[] + CommerceManager cm = CommerceManager.getInstance(); + // The ids are the ones App Store Connect shows for the offer. + if (!cm.purchaseWithPromotionalOffer("pro_monthly", "winback_50")) { + // No signature was available, so nothing was bought. Sell the + // product at its regular price instead. + cm.subscribe("pro_monthly"); + } + // end::commerce-promotional-offer[] + } + + /** Set subscriber attributes. */ + public void attributes() { + // tag::commerce-attributes[] + CommerceManager cm = CommerceManager.getInstance(); + Map attributes = new HashMap(); + attributes.put("plan_interest", "annual"); + attributes.put("$email", "user@example.com"); + attributes.put("campaign", null); // a null value deletes the attribute + // setAttributes() blocks on the network, so it runs off the EDT. + new Thread(() -> cm.setAttributes(attributes)).start(); + // end::commerce-attributes[] + } + + /** Read a virtual currency balance. */ + public void currency() { + // tag::commerce-currency[] + CustomerInfo info = CommerceManager.getInstance().getCustomerInfo(); + long gems = info.getCurrencyBalance("GEMS"); // 0 when the customer holds none + Map all = info.getCurrencies(); + // end::commerce-currency[] + } + + /** Sell a package through a web checkout. */ + public void webPurchase() { + // tag::commerce-web-purchase[] + CommerceManager cm = CommerceManager.getInstance(); + new Thread(() -> { + Offering current = cm.getOfferings().getCurrent(); + if (current == null || current.getMonthly() == null) { + return; + } + CommercePackage pkg = current.getMonthly(); + CN.callSerially(() -> { + if (cm.isWebPurchase(pkg)) { + // No store on this platform: purchase(pkg) opens the + // hosted checkout itself, as purchaseOnWeb(pkg) does. + } + cm.purchase(pkg); + }); + }).start(); + // end::commerce-web-purchase[] + } + + /** Ask for a checkout address to open in a page of the application's own. */ + public void webPurchaseUrl() { + // tag::commerce-web-purchase-url[] + CommerceManager cm = CommerceManager.getInstance(); + new Thread(() -> { + Offering current = cm.getOfferings().getCurrent(); + if (current == null || current.getAnnual() == null) { + return; + } + // Blocking. Null return addresses use the service's own pages. + String url = cm.getWebPurchaseUrl(current.getAnnual(), + "https://example.com/thanks", "https://example.com/pricing"); + if (url != null) { + CN.callSerially(() -> CN.execute(url)); + } + }).start(); + // end::commerce-web-purchase-url[] + } + + /** Open the page where the customer manages a subscription. */ + public void managementUrl() { + // tag::commerce-management-url[] + CommerceManager cm = CommerceManager.getInstance(); + // fetchManagementUrl() asks for a billing portal when the subscription + // was bought on the web, so it blocks and runs off the EDT. + new Thread(() -> { + String url = cm.fetchManagementUrl(); + if (url != null) { + CN.callSerially(() -> CN.execute(url)); + } + }).start(); + // end::commerce-management-url[] + } + + /** Report the events of a paywall the app draws itself. */ + public void analyticsEvents() { + // tag::commerce-analytics-event[] + CommerceManager cm = CommerceManager.getInstance(); + new Thread(() -> { + Offering offering = cm.getOfferings("onboarding").getCurrent(); + if (offering == null || offering.getAnnual() == null) { + return; + } + CommercePackage annual = offering.getAnnual(); + CN.callSerially(() -> { + // The names the built-in paywall uses, so the reports read the same. + cm.analyticsEvent("paywall_view", offering.getId(), null, null, null, null); + cm.analyticsEvent("paywall_package_selected", offering.getId(), + annual.getId(), annual.getProductId(), null, null); + // purchase(CommercePackage) labels the purchase events by itself. + cm.purchase(annual); + }); + }).start(); + // end::commerce-analytics-event[] + } + + /** List the rewards a customer earned by inviting. */ + public void referralRewards() { + // tag::commerce-referral-rewards[] + CustomerInfo info = CommerceManager.getInstance().getCustomerInfo(); + for (ReferralReward reward : info.getReferralRewards()) { + String earned = reward.isCurrency() + ? reward.getAmount() + " " + reward.getCurrencyCode() + : reward.getEntitlementId(); + Dialog.show("Thank you", "Your invite earned you " + earned, "OK", null); + } + // The reward itself is already in effect: an entitlement answers + // isEntitled(), and a currency is part of the balance. + boolean pro = info.isEntitled("pro"); + long coins = info.getCurrencyBalance("coins"); + // end::commerce-referral-rewards[] + } + + /** Point the simulator at a service. */ + public void simulator() { + // tag::commerce-simulator[] + // In init(), before the first CommerceManager call, and only in the + // simulator: a device build carries its own build key. + if (CN.isSimulator()) { + Display.getInstance().setProperty("build_key", "the-build-key-of-your-account"); + } + // end::commerce-simulator[] + } +} diff --git a/docs/demos/common/src/main/snippets/developer-guide/commerce.json b/docs/demos/common/src/main/snippets/developer-guide/commerce.json new file mode 100644 index 00000000000..a85a80bc648 --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/commerce.json @@ -0,0 +1,61 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::commerce-json-001[] +{ + "version": 1, + "id": "spring_sale", + "defaultLocale": "en", + "strings": { + "en": { + "title": "Go Pro", + "cta": "Continue for {{price}} per {{period}}" + }, + "de": { + "title": "Pro freischalten", + "cta": "Weiter mit {{price}} pro {{period}}" + } + }, + "colors": { + "light": { + "background": "#ffffff", + "text": "#111111", + "muted": "#666666", + "accent": "#0a84ff", + "accentText": "#ffffff" + }, + "dark": { + "background": "#101014", + "text": "#f5f5f7", + "muted": "#9a9aa0", + "accent": "#0a84ff", + "accentText": "#ffffff" + } + }, + "root": { + "type": "stack", + "axis": "y", + "gap": 3, + "padding": 4, + "children": [ + { "type": "closeButton" }, + { "type": "image", "url": "https://example.com/hero.png", "height": 40 }, + { "type": "text", "text": "title", "style": "title", "align": "center" }, + { + "type": "features", + "items": [ + { "icon": "cloud_sync", "text": "Sync across all your devices" }, + { "icon": "block", "text": "No ads" } + ] + }, + { "type": "packages", "style": "list", "selected": "annual" }, + { "type": "purchaseButton", "text": "cta" }, + { "type": "restoreButton", "text": "Restore purchases" }, + { + "type": "footer", + "termsUrl": "https://example.com/terms", + "privacyUrl": "https://example.com/privacy" + } + ] + } +} +// end::commerce-json-001[] diff --git a/docs/demos/common/src/main/snippets/developer-guide/commerce.properties b/docs/demos/common/src/main/snippets/developer-guide/commerce.properties new file mode 100644 index 00000000000..01999f36a55 --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/commerce.properties @@ -0,0 +1,14 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::commerce-properties-001[] +codename1.arg.android.billing.store=amazon +// end::commerce-properties-001[] + +// tag::commerce-properties-002[] +codename1.arg.commerce.cloud.endpoint=https://commerce.example.com/api/v2/commerce +codename1.arg.commerce.cloud.publicKey= +// end::commerce-properties-002[] + +// tag::commerce-properties-003[] +codename1.arg.commerce.cloud.endpoint=http://localhost:8080/api/v2/commerce +// end::commerce-properties-003[] diff --git a/docs/demos/common/src/main/snippets/developer-guide/commerce.sh b/docs/demos/common/src/main/snippets/developer-guide/commerce.sh new file mode 100644 index 00000000000..9ff1302c74d --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/commerce.sh @@ -0,0 +1,9 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::commerce-bash-001[] +curl -X POST \ + -H "Authorization: Bearer sk_your_secret_key" \ + -H "Content-Type: application/json" \ + -d '{"expiresDate": 1798761600000, "reason": "support ticket 4411"}' \ + https://cloud.codenameone.com/api/v2/commerce/v1/subscribers/account-1234/entitlements/pro/grant +// end::commerce-bash-001[] diff --git a/docs/developer-guide/Commerce.asciidoc b/docs/developer-guide/Commerce.asciidoc index e5d21bf9d54..2506d4e9c2b 100644 --- a/docs/developer-guide/Commerce.asciidoc +++ b/docs/developer-guide/Commerce.asciidoc @@ -1,128 +1,1040 @@ == Commerce -Commerce is an optional service that validates your in-app purchases and subscriptions server-side and gives your app a single, store-agnostic question to ask: _does this user have this entitlement right now?_ It wraps the existing Purchase API (see the _Monetization_ chapter) -- purchases still go through the platform store -- and adds cloud receipt validation, a normalized subscription state machine across Apple and Google, lifecycle webhooks to your backend, and a revenue console. +Commerce is an optional service that validates in-app purchases and subscriptions server-side and gives your app a single, store-agnostic question to ask: _does this user have this entitlement right now?_ It answers that question for a customer on every platform at once, so a subscription bought on an iPhone unlocks the same account on Android, on the desktop and on the web. On top of entitlements it adds offerings you configure remotely, paywalls and a customer center you design in the console, targeting and experiments, lifecycle webhooks, integrations and revenue reports. -Codename One doesn't process payments and never touches your money -- the stores (and, for physical goods, your own payment processor) do. Commerce is infrastructure that sits beside the money flow, exactly like the third-party services it replaces. +Commerce wraps the `Purchase` API (see the <>) and doesn't replace it. Purchases still go through the platform store, and code that talks to `Purchase` directly keeps working unchanged. When the service can't answer -- commerce is switched off in the build, the device is offline, or the account is over its plan -- the app falls back to the store's own receipts, so a paying user keeps access. -=== Entitlements, not SKUs +Codename One doesn't process payments and never touches your money. The stores, or your own Stripe or Paddle account for web purchases, do. Commerce is infrastructure that sits beside the money flow. -The core idea is the *entitlement*: an abstract access right such as `pro` or `remove_ads`. You map one or more store products to an entitlement, and your code only ever checks the entitlement: +=== Quick start + +Every cloud build is ready to use the service: there is nothing to add to the build. Three steps take you from nothing to a working paywall. + +. In the console, open *Commerce*. On the *Products* page, create an entitlement such as `pro` and attach your store products to it. Then, on the *Offerings* page, create an offering with a package for each product you want to sell. +. Install a receipt store in your `init` method, as the next section shows. +. Check the entitlement, and show the paywall when the customer doesn't have it: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-quick-start,indent=0] +---- + +The paywall sells the packages of the offering and the service validates the purchase. From then on `isEntitled("pro")` returns `true`, on this device and on every other one the customer signs in to. Pass a `PaywallListener` instead of `null` to react the moment the purchase is confirmed, as the paywall section shows. + +=== Entitlements and products + +The core idea is the *entitlement*: an abstract access right such as `pro` or `remove_ads`. Your code checks the entitlement and never a store product: [source,java] ---- include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/CommerceJava001Snippet.java[tag=commerce-java-001,indent=0] ---- -`isEntitled` reads the answer the cloud last cached, so it's only as fresh as your last `refresh()`: before one completes the cache is empty and a paying subscriber reads as unentitled. It falls back to asking the store for a subscription named after the entitlement, and since the entitlement-to-SKU mapping lives in the cloud rather than on the device, that only matches when a product happens to share the id. Where the cloud can't answer -- commerce not enabled in the build, an offline start, a degraded account -- check the granting SKU yourself, as the post-purchase example below does. Don't do it when the cloud answered no: a refunded or revoked receipt still looks active on the device, and treating that as a grant unlocks the app after the server denied it. +==== Mapping products to entitlements -An entitlement is active when *any* of its granting products is active. This matters: a promotional grant and a paid subscription, or a sandbox and a production purchase, can both map to `pro` without one hiding the other. Access is decided by a single authoritative rule -- the latest expiry over the active grants, evaluated against a server timestamp -- so a tampered device clock can't extend it. Everything else (will-renew, billing stage, period type, family sharing) is advisory and never removes access on its own. +The *Products* page of the console holds the mapping. Each row names a store, a product id in that store and the entitlement the product grants. One entitlement usually has several products: a monthly and an annual subscription in each store, and perhaps a lifetime purchase. Changing the entitlement of a product moves the product's existing grants with it, so customers who already bought it follow the new mapping. -=== Client usage +An entitlement is active when *any* of its granting products is active. A promotional grant and a paid subscription, or a sandbox and a production purchase, can both map to `pro` without one hiding the other. Access is decided by a single rule -- the latest expiry over the active grants, evaluated against the service's clock -- so a device clock set back can't extend it. Everything else (will-renew, billing stage, period type, family sharing) is advisory and never removes access by itself. -In your `Lifecycle.init` (or wherever you set up purchases). Installing a receipt store is part of the setup, not an optional extra: `Purchase` submits pending purchases and reloads its receipt list only when one is installed, so without it `refresh()` finds no receipts and no entitlement is ever granted. On iOS `isSubscriptionSupported()` returns false until one is installed, so subscriptions don't start at all. <> builds a store you can use here. +==== Setting up the client + +Installing a receipt store is part of the setup. `Purchase` submits pending purchases and reloads its receipt list only when one is installed, so without it the service is never handed a receipt from this device. On iOS `isSubscriptionSupported()` returns false until one is installed, so subscriptions don't start at all. <> builds a store you can use here. [source,java] ---- include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/CommerceJava002Snippet.java[tag=commerce-java-002,indent=0] ---- -Drive purchases through the manager (these delegate to the `Purchase` API): +You can still drive purchases by product id through the manager. These calls delegate to the `Purchase` API: [source,java] ---- include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/CommerceJava003Snippet.java[tag=commerce-java-003,indent=0] ---- -After a purchase, or on app start, validate the device's receipts with the cloud and refresh the entitlement cache. `refresh()` validates the receipts already stored rather than waiting for one in flight, so chain it off receipt synchronization after a purchase -- otherwise it runs before the new receipt has been submitted and nothing retries it. It's a blocking network call, so keep it off the EDT: +==== Refreshing + +`refresh()` validates the device's receipts with the service and then asks the service what the customer is entitled to. The customer is asked even when the device holds no receipt at all, which is what unlocks a purchase made on another platform or an entitlement granted from the console. It's a blocking network call, so keep it off the EDT. Call it when the app starts and when it returns to the foreground: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-refresh,indent=0] +---- + +You don't need to call `refresh()` after a purchase. When `Purchase` finishes synchronizing receipts, the manager refreshes by itself in the background and the customer-info listeners hear the result. If you synchronize receipts yourself and want to act as soon as the answer is in, chain the refresh off the synchronization: [source,java] ---- include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/CommerceJava004Snippet.java[tag=commerce-java-004,indent=0] ---- -`isEntitled` returns the last cloud-validated answer when one is available, and otherwise falls back to the platform's own receipt (treating the entitlement id as a subscription SKU) so a paying user is never locked out while the network is down. +==== Customer information -=== How the app finds the service +`getCustomerInfo()` returns everything the service last said about the customer. It never returns null and never touches the network: -Commerce reuses the standard `build_key` that every Codename One cloud build already carries, the same token the analytics provider sends -- so there is *nothing to wire into the build*. `CommerceManager.isCloudEnabled()` is true on any cloud build and false in the simulator or a local build (where every call defers to `Purchase`). Two optional build hints tune it: +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-customer-info,indent=0] +---- -[cols="2,1,4"] +Each `EntitlementInfo` carries these fields: + +[cols="2,5"] |=== -|Build hint |Default |Description +|Method |What it tells you -|`commerce.cloud.enabled` -|`true` -|Set to `false` to switch the cloud path off in a cloud build; every call then defers to the platform `Purchase` API. +|`getId()` +|The entitlement id, the name your code checks. -|`commerce.cloud.endpoint` -|_(Codename One cloud)_ -|Base URL of the commerce service. Override only if you self-host. +|`isActive()` +|Whether the service considers the entitlement held. This is the service's decision, made with its own clock. + +|`getExpiresDate()` +|When the entitlement ends, in milliseconds since the epoch. `0` for a lifetime purchase. + +|`getProductId()` +|The store product that granted it. + +|`getStore()` +|The store the grant came from. + +|`getPeriodType()` +|Whether the customer is in a trial, an introductory period or the normal one. + +|`getOwnershipType()` +|Whether the customer bought the product or received it through family sharing. + +|`getBillingStage()` +|Where the subscription is in its billing life, such as a grace period or billing retry. + +|`isWillRenew()` +|Whether the subscription is set to renew. + +|`getProvenance()` +|How the grant came to be: a purchase, a promotional grant or a transfer. +|=== + +`CustomerInfo` also lists the active subscriptions by product id, the customer's attributes, the virtual currency balances and a management address for the subscription. + +To follow changes, add a listener. It's called on the EDT whenever the customer information changes: after a refresh, a purchase, a restore, a sign-in or a sign-out. + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-listener,indent=0] +---- + +==== The stored answer and degraded mode + +The last answer the service gave is stored on the device, per customer. After a cold start `isEntitled` and `getCustomerInfo` answer from it at once, before any network call and with no network at all. A stored entitlement is reported exactly as it was stored, even when its expiry date has passed by the device's clock. The device clock is the user's to set, so only the service decides when an entitlement ends, and it does so on the next `refresh()`. + +`isDegraded()` is true after a call that couldn't get an answer the app can trust: the service was unreachable, the response failed its signature check, or the account is over the validated volume of its plan. While it's true, the stored answer stays in place, and `isEntitled` also accepts the store's own receipt, treating the entitlement id as a subscription product id. A paying user is therefore not locked out while the network is down. + +When commerce isn't enabled in the build, or the manager is degraded, you can check the granting product yourself, as the receipt synchronization example does. Don't do it when the service answered no: a refunded or revoked receipt still looks active on the device, and treating that as a grant unlocks the app after the service denied it. + +=== Identity + +The service keys everything on an _app user id_. Until you set one, the manager generates an anonymous id and persists it, so entitlements survive restarts on the device. + +When the user signs in to your own account system, call `logIn` with your stable id for that user: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-log-in,indent=0] +---- + +Signing in moves purchases from the device's own anonymous id to the signed-in id, and only from there. It never moves purchases from one signed-in account to another, so logging in with someone else's id doesn't take their purchases. Pick app user ids that can't be guessed, such as a random identifier your account system issues, rather than an email address or a counter. The original anonymous id stays available through `CustomerInfo.getOriginalAppUserId()`. `logIn` returns `false` and leaves the previous id in place when the service didn't accept the change, and you can call it again. + +`setAppUserId` sets the id without telling the service anything. Use it when the id is known before the first purchase, for example when your app requires an account before it shows anything. Use `logIn` whenever an anonymous customer might already own something. + +==== Transfer policies + +A store purchase is tied to the store account on the device, not to your app user id. When a purchase is restored or validated under a different id than the one that owns it, for example after another user signs in on the same device and restores, the transfer policy of the app decides who keeps it. Set it on the *App settings* page of the console: + +[cols="2,5"] +|=== +|Policy |Behavior + +|`TRANSFER` +|The purchase moves to the id that restored or validated it. This is the default, and it matches what the stores do when a device changes hands. + +|`KEEP_WITH_ORIGINAL` +|The purchase stays with the id that owns it. The other id gets nothing from it. + +|`TRANSFER_IF_NO_ACTIVE` +|The purchase moves only when the id that owns it has no active entitlement from it. +|=== + +Every move is reported to your backend as a `TRANSFER` webhook event. + +==== Signing out and restoring + +`logOut()` forgets the current customer. A new anonymous id is generated and the customer information becomes empty, so the next user of the device doesn't inherit the previous user's entitlements. Nothing is sent to the service. Refresh afterward to pick up what the store account on the device still owns: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-log-out,indent=0] +---- + +`restore()` asks the store to restore the purchases of the store account on the device, on platforms that support it. The restored receipts reach the service through the receipt synchronization that follows, and the manager refreshes by itself. The customer-info listeners hear the result. The built-in paywall and the customer center both have a restore button that calls it. + +=== Offerings and packages + +An *offering* is the set of things you're selling at one moment: "the standard plans," "the holiday sale." A *package* is one entry of an offering -- a store product together with the role it plays, such as the monthly plan. Because offerings live in the console, you change what the app sells, and at what price points, without shipping an update. + +==== Console setup + +On the *Offerings* page, create an offering and add packages to it. Each package has: + +- An id that's unique inside the offering. +- A type: `LIFETIME`, `ANNUAL`, `SIX_MONTH`, `THREE_MONTH`, `TWO_MONTH`, `MONTHLY`, `WEEKLY` or `CUSTOM`. +- A product id for each store the app ships to. The service hands each device the product of its own store. +- The billing period, and the free trial and introductory price if the product has them. Enter these as you configured them in the store. Periods are ISO-8601 durations such as `P1M` or `P7D`. + +Mark one offering as the current one. That's the offering `getCurrent()` returns unless a targeting rule or an experiment selects another. + +==== Reading offerings + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-offerings,indent=0] +---- + +`getOfferings` is a blocking network call. The last answer is stored on the device and is what the call returns when the service can't be reached, so a paywall still opens offline. It never returns null. The result is empty, and `getCurrent()` is null, when commerce isn't enabled in the build or there has never been an answer. + +After a fresh answer the manager asks the store for the products of the packages, where the platform lists products, and attaches each to its package. `getProduct()` then carries the store's localized price and name. On a platform whose store doesn't list products it stays null. + +`Offering` has shortcuts for the standard types -- `getMonthly()`, `getAnnual()`, `getWeekly()` and `getLifetime()` -- and `getPackage(id)` for any other. `getMetadata()` returns the free-form values you attached to the offering in the console, which is a convenient place for a headline or a feature flag that should change with the offering. + +==== Placements + +A _placement_ is a named place in your app that sells something: `"onboarding"`, `"settings"`, `"export_limit"`. Pass it to `getOfferings(String)`. A targeting rule in the console can give a placement an offering of its own, so the upsell after onboarding and the one behind a locked feature can differ without a code change. A placement with no rule gets the current offering. + +==== Trial and introductory eligibility + +`isEligibleForIntro()` tells you whether this customer may still take the free trial or introductory price of a package. The service computes it from the customer's purchase history across platforms, so a customer who used a trial on one device isn't promised another one elsewhere. Use it to decide whether a paywall says "Start your free week" or "Subscribe." The built-in paywall variables described below follow the same rule. + +==== Buying a package + +`purchase(CommercePackage)` starts a subscription for every package that's one and a one-time purchase otherwise. The outcome arrives through the customer-info listeners once the service has validated the purchase: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-purchase-package,indent=0] +---- + +=== Targeting and experiments + +Targeting and experiments decide which offering `getCurrent()` returns for a given customer. Both are evaluated by the service on every `getOfferings` call, so they take effect without an app update. + +==== Targeting rules + +A targeting rule on the *Targeting* page names an offering and the conditions under which a customer gets it. A rule can match on: + +- *Country* -- one or more store countries. +- *Platform* -- the platform the app is running on. +- *App version* -- a minimum version, a maximum version, or both. +- *Attribute* -- a subscriber attribute with a given value. +- *Placement* -- the placement the app asked for. + +A condition you leave empty matches everyone. Rules are tried in priority order, lowest number first, and the first rule whose conditions all match wins. When no rule matches, the customer gets the current offering. + +==== Experiments + +An experiment splits customers between two or more offerings so that you can compare them. On the *Experiments* page, give the experiment an id, optionally limit it to a placement, and add variants. Each variant names an offering and a weight. Start the experiment when it's ready and stop it when you have an answer. + +Assignment is deterministic. The service hashes the experiment id together with the app user id and maps the result onto the variant weights, so a customer always sees the same variant, on every device and after every restart, and nothing about the assignment needs to be stored. A customer who logs in moves from the anonymous id to the account id and is assigned by the account id from then on. + +The app can read which variant it was given, for example to tag its own analytics: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-experiment,indent=0] +---- + +The *Experiments* page reports, for each variant, the number of customers who saw a paywall of its offering, the number who went on to buy, and the conversion rate between the two. The counts come from the paywall events described in the next section, so an experiment needs the built-in paywall or your own `logEvent` calls to produce results. + +=== Paywalls + +A paywall is the screen that sells an offering. You design it in the console as a document attached to the offering, and the app draws it with native components. Changing the paywall doesn't require an app update. + +==== Showing a paywall + +`Paywall.show` opens the paywall of an offering in a form of its own, over the current form. Closing the paywall or going back returns to the form that was showing. When a purchase or a restore completes, the paywall also returns to that form by itself and then tells the listener, so `purchaseCompleted` is the place to unlock the feature or show a form of your own. Call `Paywall.show` on the EDT: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-paywall,indent=0] +---- + +Every listener method is called on the EDT: + +[cols="2,5"] +|=== +|Method |When it's called + +|`purchaseStarted(CommercePackage)` +|The customer pressed the purchase button and the store was asked to sell the package. + +|`purchaseCompleted(CustomerInfo)` +|The service confirmed the purchase. The customer now owns the product of the package. A paywall opened with `Paywall.show` has already returned to the previous form. + +|`purchaseCancelled()` +|The paywall was closed after a purchase was started and before the service confirmed it. + +|`restoreCompleted(CustomerInfo)` +|The customer information changed after the customer asked to restore purchases. A paywall opened with `Paywall.show` has already returned to the previous form. + +|`closed()` +|The paywall was closed: by the customer, or by itself after a completed purchase or restore. +|=== + +==== The built-in paywall + +An offering with no paywall document is shown with the built-in one: a close button, a title, the packages of the offering as a list, a purchase button, a restore button and a footer. It names no colors, so your theme decides them all. `Paywall.createDefault(offering)` returns that document as a map you can change before you hand it to a `PaywallView`. + +`PaywallView` is the component behind `Paywall.show`. Use it directly to embed a paywall in a form of your own, and tell it what closing means there. An embedded view never navigates by itself: after a completed purchase or restore it stays where it's and the listener decides what your form shows next. + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-paywall-view,indent=0] +---- + +The view reports `IMPRESSION` when it's first shown, then `PURCHASE_START`, `PURCHASE` and `CLOSE`, through `CommerceManager.logEvent`. The console charts these as a funnel for each offering and paywall, and experiments count them. If you build a purchase screen of your own instead, call `logEvent` with the same event names to get the same reports. Events are queued and sent from a background thread, and reporting never delays or fails a purchase. + +==== Theming + +Every element of a paywall has a UIID, so your theme can restyle it. The colors a document names are applied on top of the theme. + +[cols="3,4"] |=== +|UIID |Element + +|`PaywallForm` +|The form `Paywall.show` creates. + +|`Paywall` +|The view itself. + +|`PaywallStack` +|A `stack` node. + +|`PaywallTitle`, `PaywallHeadline`, `PaywallBody`, `PaywallCaption` +|The text of a `text` node, by its style. -=== Server notifications +|`PaywallImage`, `PaywallIcon` +|An `image` node and an `icon` node. -For the entitlement state to stay correct after the initial purchase -- renewals, cancellations, refunds, billing retries, grace periods, holds -- point the stores' server notifications at the service: +|`PaywallFeatures`, `PaywallFeature`, `PaywallFeatureIcon`, `PaywallFeatureText` +|A `features` node, one of its rows, and the two parts of a row. -. *Apple* -- in App Store Connect, set the App Store Server Notifications V2 URL to `https://cloud.codenameone.com/api/v2/commerce/notifications/apple`. -. *Google* -- in the Play Console, set Real-time developer notifications and route the Pub/Sub topic to `https://cloud.codenameone.com/api/v2/commerce/notifications/google`. +|`PaywallPackages` +|A `packages` node. -Notifications are idempotent (deduplicated on Apple's `notificationUUID` / Google's Pub/Sub `messageId`), so a redelivery never double-counts. +|`PaywallPackage`, `PaywallPackageTitle`, `PaywallPackagePrice` +|A package and its two lines. + +|`PaywallPackageSelected`, `PaywallPackageTitleSelected`, `PaywallPackagePriceSelected` +|The same three for the selected package. + +|`PaywallPurchaseButton`, `PaywallRestoreButton`, `PaywallCloseButton` +|The three buttons. + +|`PaywallCloseBar` +|The row that holds the close button. + +|`PaywallCarousel` +|A `carousel` node. + +|`PaywallFooter`, `PaywallFooterLink` +|A `footer` node and its links. + +|`PaywallSpacer` +|A `spacer` node, and the gap between the children of a stack. +|=== -=== Server-side validation (connect your store credentials) +==== The paywall document + +A paywall document is a JSON object. The *Paywalls* page of the console edits it with a live preview that uses the same `PaywallView` class the device does. + +[cols="2,5"] +|=== +|Field |Meaning + +|`version` +|The format version, `1`. + +|`id` +|The id of the paywall, reported with every paywall event. + +|`defaultLocale` +|The locale of `strings` to use when the device's locale has no entry. + +|`strings` +|Translations: an object per locale, each mapping a key to its text. + +|`colors` +|A `light` and a `dark` set, each with `background`, `text`, `muted`, `accent` and `accentText` as `#rrggbb`. The `dark` set is used when the device is in dark mode. + +|`root` +|The node the paywall starts from, normally a `stack`. +|=== + +A node is an object with a `type`. A node of a type the app doesn't know is skipped, so a document that uses a newer node type still shows on an older build. Sizes are millimeters. + +[cols="2,3,4"] +|=== +|Node type |Fields |What it draws -By default the service validates the store-signed receipt the device presents -- enough to gate features, and it works with no setup. For *server-authoritative* validation -- where the service calls Apple's and Google's own server APIs to confirm the real subscription state -- provide your store credentials in the console *Secrets* page: +|`stack` +|`axis` (`y` or `x`), `gap`, `padding`, `align`, `children` +|Its children, one after another along the axis. -- *Apple* -- an App Store Connect API key, as three secrets: `commerce.apple.keyId`, `commerce.apple.issuerId`, and `commerce.apple.privateKey` (the contents of the `.p8` file). -- *Google* -- a service account with Android Publisher access, as `commerce.google.serviceAccountJson` (the full JSON key). +|`text` +|`text`, `style` (`title`, `headline`, `body` or `caption`), `align` +|A block of text. -Secrets are stored AES-256-GCM encrypted in the BuildCloud vault; they're decrypted only server-side at call time and are never shown in the console again. Once set: +|`image` +|`url`, `height` +|An image loaded from an address. -- Entitlements are confirmed against Apple's `Get All Subscription Statuses` and Google's `subscriptionsv2.get`, so a grant is server-verified rather than device-trusted, and -- Subscription *renewals* signalled by store notifications are recorded at their real amount (the renewal price lives only in the store's server response). +|`icon` +|`name` +|A material icon, by name. -Without credentials the service stays in device-trusted mode -- everything still works, you just don't get the server cross-check or accurate renewal revenue. +|`features` +|`items`, each with `icon` and `text` +|A list of selling points. -=== Lifecycle webhooks to your backend +|`packages` +|`style` (`list` or `tiles`), `selected` +|The packages of the offering. `selected` is the id of the package that starts selected. + +|`purchaseButton` +|`text` +|The button that buys the selected package. + +|`restoreButton` +|`text` +|The button that restores purchases. + +|`closeButton` +| +|The button that closes the paywall. + +|`carousel` +|`children` +|Its children as swipeable pages. + +|`footer` +|`termsUrl`, `privacyUrl` +|Links to your terms and privacy policy. + +|`spacer` +|`size` +|Empty space. +|=== + +A text is first looked up in `strings`, by the device's locale and then by the default locale. When it isn't a key there, it's used as written. Then the variables in it are replaced from the selected package, and replaced again whenever the customer selects another one: + +[cols="2,5"] +|=== +|Variable |Replaced with + +|`{{price}}` +|The price. This is the store's localized price when the store listed the product. + +|`{{period}}` +|The billing period, in words. + +|`{{trialPeriod}}` +|The length of the free trial. + +|`{{introPrice}}` +|The introductory price. + +|`{{productName}}` +|The store's name for the product. + +|`{{packageId}}` +|The id of the package. +|=== + +A variable with no value becomes empty. A complete document: + +[source,json] +---- +include::../demos/common/src/main/snippets/developer-guide/commerce.json[tag=commerce-json-001,indent=0] +---- + +=== Customer center + +The customer center is a ready-made form where a customer sees their active subscriptions and acts on them. One call shows it: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-customer-center,indent=0] +---- + +The *Customer center* page of the console decides which paths the form offers and in what order: + +[cols="1,5"] +|=== +|Path |What it does + +|`MANAGE` +|Opens the store's own subscription management. On a platform with none, it opens the customer's management page, which is the billing portal for a subscription bought on the web. + +|`RESTORE` +|Restores the purchases of the store account. + +|`REFUND` +|Asks the store for a refund. There is one row for each subscription the store can refund. + +|`CANCEL` +|Asks the customer why, reports the answer, shows the retention offer for that reason if there is one, and otherwise continues to the store's subscription management. Offered while a subscription is active. + +|`SUPPORT` +|Opens your support page, or an email to your support address. Offered when one of the two is configured. +|=== + +When the configuration can't be fetched, the form offers `MANAGE` and `RESTORE`. The form's texts can be translated per locale in the console. Its UIIDs are `CustomerCenterForm`, `CustomerCenterHeader`, `CustomerCenterSubscription`, `CustomerCenterEmpty`, `CustomerCenterRow` and `CustomerCenterReason`. + +==== Cancel reasons and retention offers + +For the `CANCEL` path you define a list of reasons, such as "Too expensive" or "Not using it enough." The reason a customer picks is reported to the service, and the console charts the answers. + +Each reason can carry a retention offer: a product, a line of text, and optionally the id of a promotional offer. A customer who picks that reason is shown the text and can accept or decline. Accepting buys the product, under the promotional offer when one is named and the store supports it. Declining continues to the store's subscription management, where the customer cancels. + +==== Promotional offers + +A promotional offer is an App Store discount for existing or lapsed subscribers that the store sells only when it comes with a signature made by the developer's key. The service makes that signature for you. `purchaseWithPromotionalOffer` asks for it and then asks the store to sell the product under the offer: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-promotional-offer,indent=0] +---- + +It may be called on the EDT. It returns `false`, and buys nothing, when no signature is available: the platform's store has no promotional offers, commerce isn't enabled in the build, or the service couldn't sign. Signing needs the `commerce.apple.keyId` and `commerce.apple.privateKey` secrets described under <>, and the key must be one App Store Connect accepts for subscription offers. Promotional offers are an App Store feature, on iOS and on the Mac. A retention offer on any other store sells its product at the regular price. + +[[commerce-analytics-invites]] +=== Analytics and invites + +Commerce reports what happens around a purchase to the analytics providers the app registered (see the <>), and it joins a customer to the invite that brought them in (see <>). Neither needs code. An app with no provider sends nothing, and nothing is sent for a user whose consent doesn't cover analytics. + +==== Events + +Every event is in the category `commerce`. The built-in paywall and the customer center send theirs as the customer uses them: + +[cols="2,3,3",options="header"] +|=== +| Event | Sent when | Parameters -Commerce can forward every normalized state change to your own backend. Configure a URL and a signing secret on the *Commerce* page of the console. Each event is HMAC-SHA256 signed (header `CN1-Commerce-Signature`) and carries: +| `paywall_view` +| A paywall appears +| `offering` -- A *per-customer monotonic `sequence`* -- so your consumer can reject stale events and never has to reorder defensively, -- The grant's *`state_version`* -- reconcile against this, -- An always-present *`effective_expiration_at`*, and -- The event `id` for idempotent dedup. +| `paywall_package_selected` +| The customer picks a package +| `offering`, `package`, `product` -Delivery is at-least-once, in sequence order per customer, with retries that back off exponentially. Event types mirror the familiar set: `INITIAL_PURCHASE`, `RENEWAL`, `CANCELLATION`, `UNCANCELLATION`, `EXPIRATION`, `BILLING_ISSUE`, `PRODUCT_CHANGE`, `SUBSCRIPTION_PAUSED`, `REFUND_REVERSED`, `TRANSFER`. +| `paywall_restore` +| The customer restores purchases from a paywall +| `offering`, and `package` and `product` of the selected package -=== The console +| `paywall_close` +| The paywall goes away +| `offering` -The *Commerce* section in the console at https://cloud.codenameone.com/console/index.html is account-level by default, with an app filter to narrow to one app: +| `purchase_initiated` +| The store, or a web checkout, is asked to sell +| `sku`, `offering`, `package`, `product`, and `store` with the value `web` for a web checkout + +| `purchase` +| The store reports the purchase +| `sku`, `offering`, `package`, `product` + +| `customer_center_view` +| The customer center opens +| None + +| `customer_center_action` +| The customer picks a row of the customer center +| `action`, which is `MANAGE`, `RESTORE`, `REFUND`, `CANCEL` or `SUPPORT`, and `product` + +| `subscription_cancel_reason` +| The customer picks a cancel reason +| `reason`, the id of the reason, and `product` + +| `experiment_exposure` +| The app is first given a variant of an experiment +| `experiment`, `variant` +|=== + +An event about an offering also carries `placement` when the offering was read for a placement, and `experiment` and `variant` when an experiment chose it. A parameter with no value is left out. + +`purchase_initiated` and `purchase` are the events `Purchase` has always sent, each still sent once. They gain the offering, package and experiment when the purchase was started with `purchase(CommercePackage)`, which is what the built-in paywall does. A purchase started with a bare SKU carries the `sku` alone. + +A paywall you draw yourself can send the same events, so that it shows up in the same reports: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-analytics-event,indent=0] +---- + +==== Segmenting by experiment + +Because the paywall and purchase events carry `experiment` and `variant`, any analytics report can be split by variant: how many customers of each variant opened the paywall, picked the annual package, or bought. `experiment_exposure` is sent once for each experiment and variant an installation is given, which gives every variant its denominator even for a customer who never opens a paywall. + +==== Purchases by invited users + +The service knows which install belongs to which customer, so a purchase by a customer who arrived through an invitation is attributed to the inviter. The revenue it reports for that invite is verified: it's the amount of the purchase the service validated with the store, and not a value the app claimed. You don't call `Invites.conversion` for a purchase. Keep it for the conversions the service can't see, such as finishing a tutorial. + +When the user logs in with `logIn`, the attribution follows the customer to the account. The app user id of commerce and the user id of analytics stay separate, and logging in here doesn't change the one you set with `Analytics.setUserId`. + +==== Referral rewards + +A referral reward gives something to the inviter, the invited user, or both. Configure it on the *Referral rewards* page of the console. Each of the two rewards grants either an entitlement for a number of days or an amount of a virtual currency. Entitlement days stack: a second reward extends the first rather than replacing it. + +The trigger decides when the rewards are granted: + +[cols="2,5"] +|=== +|Trigger |Behavior + +|First purchase +|The default. The rewards are granted when the invited user makes a first purchase. + +|Claim +|The rewards are granted when the invitation is matched to the installation. This trigger requires a cap on the number of rewards per inviter, and it skips invited people who were already users of the app. +|=== + +Each reward is granted once. The inviter's reward reaches the inviter's device once that device has used analytics, which ties it to the inviter's install. A refund of the invited user's purchase doesn't take a reward back. + +The service grants the reward, so there's nothing to deliver from the app. An entitlement reward answers `isEntitled` like a purchased one, and a currency reward is part of the balance. When an invitation is matched to the installation, commerce asks the service for the customer again in the background, and the customer information listener hears about the reward. `getReferralRewards()` lists what was granted and why, for example to thank the user: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-referral-rewards,indent=0] +---- + +A reward has an entitlement id or a currency code, an amount for a currency, an expiry date of 0 when it doesn't expire, and the reason the service recorded. The list is empty for a customer with no rewards. + +=== Subscriber attributes and virtual currency + +==== Attributes + +Attributes are name and value pairs you attach to a customer. Targeting rules read them, the console shows them on the *Customers* page, and integrations use some of them to identify the customer in another system. + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-attributes,indent=0] +---- + +`setAttributes` is a blocking network call that returns `true` when the service accepted the attributes. A null value deletes an attribute, and any other value is stored as its string form. The attributes come back with the customer information, in `CustomerInfo.getAttributes()`. Names that start with `$` are reserved for the uses this chapter lists, such as `$appsflyerId`. + +==== Virtual currency + +A virtual currency is a balance the service keeps for each customer: gems, credits, tokens. Define a currency on the *Currencies* page with a code and a name, and list the products that grant it with the amount each grants. When the service validates a purchase of such a product, it adds the amount to the customer's balance. A consumable product that should be bought again and again is the usual choice. + +The app reads balances from the customer information: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-currency,indent=0] +---- + +Spending is a server-side operation, so that a modified app can't grant itself currency. Your backend adjusts a balance through the REST API described below, and the app sees the new balance after its next `refresh()`. + +=== Stores + +==== Apple App Store, on iOS and on the Mac + +iOS purchases work with no setup beyond the receipt store. A native Mac build sells through the Mac App Store with the same code, and the service validates its receipts with the same Apple credentials. If the Mac app's bundle id differs from the package name of the project, enter it as the Mac bundle id on the *App settings* page of the console so that its receipts are accepted. + +Point App Store Server Notifications V2 at `https://cloud.codenameone.com/api/v2/commerce/notifications/apple` in App Store Connect, so that renewals, cancellations, refunds and billing problems reach the service when they happen. + +==== Google Play + +Android builds sell through Google Play by default. In the Play Console, set up Real-time developer notifications and route the Pub/Sub topic to `https://cloud.codenameone.com/api/v2/commerce/notifications/google`. Google purchases are validated with Google's own server API, so the service account described under <> is required for them. Without it the service accepts the purchase in degraded mode and the app relies on the device's receipt. + +==== Amazon Appstore + +An Android app is built for one store. To build for the Amazon Appstore, set the build hint: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/commerce.properties[tag=commerce-properties-001,indent=0] +---- + +The build then uses the Amazon Appstore SDK instead of the Play Billing Library, declares the receiver Amazon's client answers through, and leaves out the `com.android.vending.BILLING` permission, which the Amazon Appstore doesn't accept. The Google Play settings `android.licenseKey` and `android.billingclient.version` aren't used. Your code doesn't change: `Purchase` and `CommerceManager` behave the same, and receipts carry the store code `Receipt.STORE_CODE_AMAZON`. + +For validation, enter your Amazon shared secret (`commerce.amazon.sharedSecret`) on the *Stores* page of the console, and set the Amazon SNS topic ARN in the Amazon store settings there. The same page shows the notification address to register with Amazon. It ends in a token that identifies your app, so copy it as shown. + +==== Web billing with Stripe and Paddle + +On platforms with no store -- the browser and the desktop -- the service sells through a hosted checkout page in your own Stripe or Paddle account. The same package can therefore be bought in the App Store on a phone and with a card on the web, and both grant the same entitlement. + +To set it up: + +. Enter the credentials of your account on the *Stores* page of the console: `commerce.stripe.secretKey` and `commerce.stripe.webhookSecret` for Stripe, or `commerce.paddle.apiKey` and `commerce.paddle.notificationSecret` for Paddle. +. Register the notification address the *Stores* page shows for your app as a webhook in the Stripe or Paddle dashboard. It ends in a token that identifies your app. +. On each package that should be sold on the web, enter its Stripe price id or its Paddle price id. +. On the *App settings* page, enter the web URLs of your app that the checkout returns the customer to. + +In the app, `purchase(CommercePackage)` does the right thing on every platform. Where the platform has a store it sells the store product. Where it has none, and the package has a web price, it opens the hosted checkout in the browser. `isWebPurchase` tells you which of the two will happen, and `purchaseOnWeb` opens the checkout explicitly: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-web-purchase,indent=0] +---- + +The customer pays outside the app, so nothing reports the outcome to the call. Once the app is back in the foreground, run `refresh()` off the EDT, and the customer-info listeners hear what was bought. The paywall does that by itself. + +To send the customer back to pages of your own after the checkout, ask for the address and open it yourself. `getWebPurchaseUrl` is a blocking call that returns null when there is no web price for the package: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-web-purchase-url,indent=0] +---- + +Check the terms of each app store before you offer a web checkout inside an app that's distributed through that store. + +===== Hosted purchase links + +Every package with a web price also has a purchase link you can put on a website or in an email, with no app involved: + +---- +https://cloud.codenameone.com/api/v2/commerce/buy///?user= +---- + +The console shows the link, including your app's token, next to the package. Opening it redirects to the checkout for the customer named in `user`. Use the same id the app passes to `logIn`, and the purchase unlocks in the app on its next refresh. + +===== The management portal + +A customer who subscribed on the web manages the subscription in the billing portal of Stripe or Paddle. `fetchManagementUrl()` returns the right address for the customer's newest active entitlement: a fresh portal address when it was bought on the web, and the store's subscription page otherwise. A portal address is short-lived, so ask for it when you're about to open it: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-management-url,indent=0] +---- + +`getManagementUrl()` is the variant that never touches the network. It returns the portal address `fetchManagementUrl()` last answered with, or the store's page from the last customer information. + +[[commerce-store-credentials]] +==== Store credentials + +By default the service validates the store-signed receipt the device presents. For Apple that's enough to gate features and it works with no setup. For _server-authoritative_ validation, where the service calls the stores' own server APIs to confirm the real subscription state, provide your credentials in the console. Apple and Google credentials are entered on the *Setup* page; Amazon, Stripe and Paddle credentials on the *Stores* page: + +[cols="2,3,4"] +|=== +|Store |Secrets |Used for + +|Apple +|`commerce.apple.keyId`, `commerce.apple.issuerId`, `commerce.apple.privateKey` (the contents of the `.p8` file) +|Confirming subscription status for iOS and Mac purchases, recording renewals at their real amount, and signing promotional offers. + +|Google +|`commerce.google.serviceAccountJson` (the full JSON key of a service account with Android Publisher access) +|Validating every Google Play purchase. + +|Amazon +|`commerce.amazon.sharedSecret` +|Validating Amazon Appstore receipts. + +|Stripe +|`commerce.stripe.secretKey`, `commerce.stripe.webhookSecret` +|Creating checkouts and portals, and verifying Stripe's notifications. + +|Paddle +|`commerce.paddle.apiKey`, `commerce.paddle.notificationSecret` +|Creating checkouts and portals, and verifying Paddle's notifications. +|=== + +Secrets are stored AES-256-GCM encrypted. They're decrypted only server-side at call time and are never shown in the console again. + +Store notifications are idempotent. Each is deduplicated on the id the store gives it, so a redelivery never double-counts, and a store transaction is counted once however many devices present its receipt. + +=== Server side + +==== Lifecycle webhooks to your backend + +Commerce can forward every normalized state change to your own backend. Configure a URL and a signing secret on the *Webhook* page of the console. Each event is HMAC-SHA256 signed (header `CN1-Commerce-Signature`) and carries: + +- A *per-customer monotonic `sequence`*, so your consumer can reject stale events and never has to reorder defensively. +- The grant's *`state_version`*. Reconcile against this. +- An always-present *`effective_expiration_at`*. +- The event `id` for idempotent deduplication. +- The *`event_type`*. + +Delivery is at-least-once, in sequence order per customer, with retries that back off exponentially. The *Webhook* page lists recent deliveries and their outcome. + +[cols="2,5"] +|=== +|Event type |Sent when + +|`INITIAL_PURCHASE` +|A customer buys a subscription for the first time. + +|`RENEWAL` +|A subscription renews. + +|`NON_RENEWING_PURCHASE` +|A one-time product is bought: a lifetime unlock or a consumable. + +|`CANCELLATION` +|The customer turns renewal off. Access continues until the period ends. + +|`UNCANCELLATION` +|The customer turns renewal back on. + +|`EXPIRATION` +|A subscription ends. + +|`BILLING_ISSUE` +|A renewal payment failed. + +|`PRODUCT_CHANGE` +|The customer moved to another product of the same subscription group. + +|`SUBSCRIPTION_PAUSED` +|The subscription was paused. + +|`SUBSCRIPTION_EXTENDED` +|The store extended the renewal date. + +|`REFUND` +|A purchase was refunded. + +|`REFUND_REVERSED` +|A refund was reversed. + +|`TRANSFER` +|Purchases moved from one app user id to another. + +|`TEMPORARY_ENTITLEMENT_GRANT` +|Access was granted for a short time while a purchase couldn't be validated. + +|`PROMOTIONAL_GRANT` +|An entitlement was granted from the console or the REST API. + +|`PROMOTIONAL_REVOKE` +|Such a grant was revoked. +|=== + +==== Integrations + +Integrations forward the same lifecycle events to other services, each in the format that service expects. Enable them on the *Integrations* page, where each takes its own credentials and a choice of event types to forward. A test button sends a sample event. + +[cols="2,3,4"] +|=== +|Destination |Settings |Customer identity + +|Slack +|`webhookUrl` +|None. A message is posted for each event. + +|Amplitude +|`apiKey` +|The app user id. + +|Mixpanel +|`token` +|The app user id. + +|Segment +|`writeKey` +|The app user id. + +|Firebase (Google Analytics) +|`measurementId`, `apiSecret` +|The `$firebaseAppInstanceId` attribute. Required. + +|AppsFlyer +|`appId`, `devKey` +|The `$appsflyerId` attribute. Required. + +|Adjust +|`appToken`, and an event token for each kind of event: `purchaseEventToken`, `renewalEventToken`, `trialEventToken`, `cancelEventToken` +|The `$adjustId` attribute. Required. + +|Branch +|`branchKey` +|The `$branchId` attribute, or the app user id when it isn't set. +|=== + +The attribution services identify a device by their own id, which only their SDK on the device knows. Read it from the SDK when the app starts and store it with `setAttributes` under the reserved name in the table. An event for a customer who lacks a required attribute isn't sent to that destination. The other destinations still receive it. + +==== The REST API + +Your backend can read and change customers through a REST API. Create a secret key on the *API keys* page. A key starts with `sk_`, belongs to one app, and is shown once when you create it, so store it then. Send it as a bearer token to `https://cloud.codenameone.com/api/v2/commerce/v1`: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/commerce.sh[tag=commerce-bash-001,indent=0] +---- + +[cols="1,4,5"] +|=== +|Method |Path |What it does + +|`GET` +|`/subscribers/{appUserId}` +|Returns the customer: `serverTime`, `appUserId`, `originalAppUserId`, `managementUrl`, `activeSubscriptions`, `entitlements`, `attributes` and `currencies`. + +|`DELETE` +|`/subscribers/{appUserId}` +|Deletes the customer with their grants, attributes and currency history. + +|`POST` +|`/subscribers/{appUserId}/attributes` +|Sets attributes. The body is `{"attributes": {"name": "value"}}`, and a null value deletes an attribute. + +|`POST` +|`/subscribers/{appUserId}/entitlements/{entitlementId}/grant` +|Grants an entitlement. The optional body fields are `expiresDate`, in milliseconds since the epoch, and `reason`. Without an expiry the grant doesn't end. + +|`POST` +|`/subscribers/{appUserId}/entitlements/{entitlementId}/revoke` +|Revokes a grant made this way. Purchases aren't affected. + +|`POST` +|`/receipts` +|Validates a receipt your backend holds. The body names `appUserId`, `store` and `productId`, and carries the receipt as `signedTransaction` for Apple, `purchaseToken` for Google, or `signedTransaction` with `amazonUserId` for Amazon. + +|`POST` +|`/receipts/import` +|Imports receipts in bulk from a CSV body. + +|`GET` +|`/offerings` +|Lists the offerings of the app. + +|`GET` +|`/subscribers/{appUserId}/currency` +|Returns the customer's virtual currency balances. + +|`POST` +|`/subscribers/{appUserId}/currency` +|Changes a balance. The body is `code`, `delta`, and optionally `reason` and `reference`. +|=== + +A currency change with a negative `delta` spends. When the balance is too low the call answers `409` with the error `insufficient_balance` and changes nothing. `reference` makes the call safe to repeat: a second call with the same reference returns the first result, marked `replayed`, and doesn't change the balance again. Use your own order id as the reference. + +An unknown customer answers `404`, and a request the service can't read answers `400`. + +==== Granting entitlements from the console + +The *Customers* page of the console has the same controls to grant and revoke entitlements as the REST API. Pick an entitlement, optionally an expiry, and grant it. This is the way to give a customer access as a goodwill gesture, to unlock an account for a reviewer, or to give your team the paid features. A granted entitlement has the provenance `PROMOTIONAL`, is excluded from revenue, and reaches the app on its next `refresh()`. + +==== Export and import + +The *Export* page downloads two CSV files on demand, and can also deliver both every day to an Amazon S3 or Google Cloud Storage bucket you name. + +[cols="1,5"] +|=== +|File |Columns + +|Transactions +|`createdAt`, `appUserId`, `store`, `environment`, `productId`, `eventType`, `transactionId`, `originalTransactionId`, `price`, `currency`, `usd`, `validationState` + +|Subscribers +|`appUserId`, `entitlementId`, `active`, `productId`, `store`, `expiresDate`, `willRenew`, `provenance` +|=== + +For a scheduled export, choose the destination, the bucket and a prefix, and for S3 the region. S3 takes an access key id and a secret access key, and Google Cloud Storage takes a service account key. The files are named `transactions-.csv` and `subscribers-.csv` under the prefix. + +Import brings existing customers into the service, for example when you add Commerce to an app that already has subscribers. Upload a CSV file in the console, or post it to `/receipts/import`. The first line must be this header: + +---- +app_user_id,store,product_id,receipt +---- + +Each row is validated exactly as a receipt from a device is, so an imported subscription is checked with the store and continues to renew, expire and report events like any other. The answer lists the rows that weren't imported, with the reason for each. + +==== The console + +The *Commerce* section in the console at https://cloud.codenameone.com/console/index.html is account-level by default, with an app filter to narrow to one app. Beside the configuration pages this chapter has described, it reports on the business: - *Overview* -- the subscription dashboard: headline KPIs (MRR, ARR, ARPU, active subscriptions, active trials, churn, trial conversion, customers) and trend charts for MRR, active subscriptions and revenue. -- *Insights* -- MRR movement (new vs churned), MRR forecast, realized-LTV curve, active subscriptions by store and by product, cohort-retention, and period-over-period MRR. -- *Customers* -- each app user and their active entitlements. +- *Insights* -- MRR movement (new versus churned), MRR forecast, realized-LTV curve, active subscriptions by store and by product, cohort retention, trials started, conversion to paying, refund rate over time and subscription status. +- *Customers* -- each app user with their active entitlements, attributes and balances. - *Transactions* -- the recent validated-event ledger. -- *Webhook* -- your delivery endpoint and signing secret. -=== Analytics +Commerce normalizes every active subscription to a monthly figure (MRR) and reconstructs the trends from the subscription history. Every chart pairs a graph with its underlying data table for export-friendly inspection. All money is shown in your reporting currency, and transactions in other currencies are normalized to USD through daily exchange rates. -Commerce normalizes every active subscription to a monthly figure (MRR) and reconstructs the trends from the subscription history, so the dashboard matches what dedicated subscription-analytics tools provide: +=== Build hints -- *MRR / ARR / ARPU* -- normalized recurring revenue, annualized, and per active subscription. -- *Churn* and *trial conversion* rates, *refund rate*. -- *Cohort retention* -- monthly acquisition cohorts and the percentage still active each subsequent month. -- *Realized LTV* -- average cumulative revenue per paying customer by months since acquisition. -- *Forecast* -- projected MRR for the coming months from the current run-rate and recent growth. -- *Breakdowns* by store and by product, and *period-over-period* comparison. +Commerce reuses the standard `build_key` that every Codename One cloud build already carries, so a cloud build needs no build hint to use the service. `CommerceManager.isCloudEnabled()` is true on any cloud build. These hints change the defaults: + +[cols="2,1,4"] +|=== +|Build hint |Default |Description + +|`commerce.cloud.enabled` +|`true` +|Set to `false` to keep every decision on the device, from the store's own receipts. Every `CommerceManager` call then defers to the `Purchase` API. + +|`commerce.cloud.endpoint` +|_(Codename One cloud)_ +|The base URL of the commerce service, for example `\https://commerce.example.com/api/v2/commerce`. It must be an `https` URL. Plain `http` is accepted only for a service on a loopback address -- `localhost`, `127.0.0.1`, `[::1]`, or `10.0.2.2`, the host machine as the Android emulator sees it -- for testing on your own machine. A build with any other value fails. + +|`commerce.cloud.publicKey` +|_(Codename One cloud)_ +|The base64 encoded Ed25519 public key that responses of the service are verified against. A self-hosted service needs it: set it together with `commerce.cloud.endpoint`. While the app knows no public key, it uses no answer of the service. + +|`android.billing.store` +|`play` +|The store an Android app is built for: `play` or `amazon`. +|=== + +==== Response signing and self-hosting + +The answers that decide what a customer owns -- customer information, purchase validation, offerings, the customer center and promotional offer signatures -- are signed by the service with an Ed25519 key. Each request carries a fresh random nonce, and the signature covers that nonce together with the response. An answer recorded earlier can't be replayed to a later request, and an answer forged by something between the device and the service doesn't verify. + +An answer is trusted only when it's signed and the signature verifies against the service's public key. An answer that's unsigned or wrongly signed is ignored, and so is every answer while the app knows no public key at all. The manager then becomes degraded. With a key, the app falls back to the stored answer and the store's receipts, exactly as it does when the network is down. With no key, nothing was ever stored, so the app answers from the store's own receipts alone. + +A signed answer must also be the answer to this request: one that names another app user id, another application's package name or another endpoint is ignored, and a stored answer is checked the same way when it's loaded. + +The stored answer is kept as the service signed it, and its signature is checked again every time the app starts. A stored answer that no longer verifies, for example after the key changed, is deleted rather than used. + +The service address must use `https`. Plain `http` is accepted only on a loopback address -- `localhost`, `127.0.0.1`, `[::1]` or `10.0.2.2` -- for testing on your own machine. + +To run against a service of your own, set both hints. A self-hosted service needs `commerce.cloud.publicKey`; without it the app trusts none of its answers: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/commerce.properties[tag=commerce-properties-002,indent=0] +---- + +Use the public half of the key pair your service signs with. A key that's set but can't be read verifies nothing, so every answer is ignored and the app runs degraded. That's deliberate: a broken key must not turn verification off. + +=== Testing in the simulator + +The simulator reads the three `commerce.cloud` hints from the project's `codenameone_settings.properties`, so you can point it at a staging service or at one running on your own machine: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/commerce.properties[tag=commerce-properties-003,indent=0] +---- + +The build key is stamped by the build server, and the simulator has none. Until one is set, `isCloudEnabled()` is false there and every call defers to the simulated `Purchase` API, which is enough to exercise your purchase flow and the built-in paywall. To talk to the service from the simulator, set the build key property before the first `CommerceManager` call: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/CommerceSnippets.java[tag=commerce-simulator,indent=0] +---- -Every chart pairs a graph with its underlying data table (one tab-flip away) for export-friendly inspection. All money is shown in your reporting currency; transactions in other currencies are normalized to USD via daily exchange rates. +With that in place the simulator fetches offerings, paywalls and the customer center from the service, and the purchases you make in the simulated store are sent for validation as `Receipt.STORE_CODE_SIMULATOR` receipts. You can also grant the test customer an entitlement on the *Customers* page of the console and watch `refresh()` pick it up. `getAppUserId()` returns the id to look for. === Privacy -Commerce stores entitlement state and transaction metadata (store, product id, event type, amount, validation state) keyed by your app-user id. It never stores card numbers or payment credentials -- those stay with Apple, Google, or your processor. Family shared purchases grant access but are excluded from revenue. The minimum lookup state needed to resolve the next notification is kept; grants are retained as the durable record, while the high-churn ledger and delivered webhook rows are purged on a rolling window. +Commerce stores entitlement state and transaction metadata (store, product id, event type, amount, validation state) keyed by your app user id, together with the attributes you set. It never stores card numbers or payment credentials. Those stay with Apple, Google, Amazon, Stripe, Paddle or your processor. Family shared purchases grant access but are excluded from revenue. Deleting a customer through the REST API removes their grants, attributes and currency history. === Setup checklist -. Map your store products to entitlements in the console. -. In the app, call `CommerceManager.getInstance().subscribe(...)` / `.purchase(...)`, then `.refresh()` off the EDT, and gate features with `.isEntitled(...)`. -. Point Apple App Store Server Notifications V2 and Google RTDN at the service notification URLs. -. (Optional) Set a webhook URL + secret on the *Commerce* console page to receive lifecycle events on your backend. +. Map your store products to entitlements on the *Products* page of the console, and create an offering with a package for each product you sell on the *Offerings* page. +. In the app, install a receipt store, call `refresh()` off the EDT on start, and gate features with `isEntitled(...)`. +. Show the offering with `Paywall.show(...)`, and add `CustomerCenter.show()` to your settings screen. +. Call `logIn(...)` when the user signs in to your account system and `logOut()` when they sign out. +. Point the stores' server notifications at the service, and enter your store credentials on the *Setup* and *Stores* pages. +. Optionally, set a webhook URL and secret to receive lifecycle events on your backend, and enable the integrations you use. diff --git a/maven/android/pom.xml b/maven/android/pom.xml index 7d94497ea0d..16a0236a3f2 100644 --- a/maven/android/pom.xml +++ b/maven/android/pom.xml @@ -162,6 +162,15 @@ rather than letting javac fail in the generated project. --> com/codename1/impl/android/BillingSupport.java + + com/codename1/impl/android/AmazonBillingSupport.java diff --git a/maven/build-engine/src/main/java/com/codename1/builders/AmazonBillingFragments.java b/maven/build-engine/src/main/java/com/codename1/builders/AmazonBillingFragments.java new file mode 100644 index 00000000000..e8680b991fa --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/builders/AmazonBillingFragments.java @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +/** + * What the {@code android.billing.store} build hint changes in a generated + * Android project, as pure text so it can be unit tested. + * + *

An application that uses the purchase API is built for exactly one store. + * The two cannot share a build: Google Play rejects nothing about the Amazon + * SDK, but the Amazon Appstore's review does reject an application that + * requests {@code com.android.vending.BILLING}, and the port's two billing + * implementations each compile against a library the other build does not + * carry. So every method here answers for one store and says nothing of the + * other.

+ * + *

The Amazon SDK is a plain jar with no manifest of its own, which is why + * the receiver is written here: nothing merges it in, and without it Amazon's + * client has nowhere to send an answer -- every request then goes unanswered + * on a build that looks complete.

+ */ +public final class AmazonBillingFragments { + + /** The value of {@code android.billing.store} that selects Google Play. */ + public static final String STORE_PLAY = "play"; + + /** The value of {@code android.billing.store} that selects the Amazon Appstore. */ + public static final String STORE_AMAZON = "amazon"; + + /** Group and artifact of the Amazon Appstore SDK on Maven Central. */ + public static final String SDK_COORDINATE = "com.amazon.device:amazon-appstore-sdk"; + + /** + * The Amazon Appstore SDK the port's {@code AmazonBillingSupport} is + * compiled against. + */ + public static final String SDK_VERSION = "3.0.9"; + + /** The receiver Amazon's client delivers every in-app purchase answer to. */ + static final String RECEIVER_CLASS = "com.amazon.device.iap.ResponseReceiver"; + + private static final String PLAY_PERMISSION = "com.android.vending.BILLING"; + + private AmazonBillingFragments() { + } + + /** + * Explains why a store value cannot be built, or returns null when it can. + * + *

A misspelt store must not fall back to Play: the application would be + * submitted to Amazon with Play's billing in it and fail there, long after + * the build said nothing.

+ * + * @param store the {@code android.billing.store} value + * @return a sentence for the build log, or null + */ + public static String refusalFor(String store) { + if (store == null || store.trim().length() == 0 + || STORE_PLAY.equalsIgnoreCase(store.trim()) + || STORE_AMAZON.equalsIgnoreCase(store.trim())) { + return null; + } + return "android.billing.store must be \"" + STORE_PLAY + "\" or \"" + STORE_AMAZON + + "\", but is \"" + store + "\"."; + } + + /** + * Whether the build targets the Amazon Appstore. + * + * @param store the {@code android.billing.store} value; null and empty mean Play + * @return true for {@code amazon}, in any case + */ + public static boolean isAmazon(String store) { + // Compared without folding: toLowerCase() is locale sensitive. + return store != null && STORE_AMAZON.equalsIgnoreCase(store.trim()); + } + + /** + * The gradle dependency line of the selected store's billing library. + * + * @param amazon the Amazon Appstore is selected + * @param playBillingVersion the resolved Play Billing version; unused for Amazon + * @return one dependency line, newline terminated + */ + public static String dependency(boolean amazon, String playBillingVersion) { + if (amazon) { + return " implementation '" + SDK_COORDINATE + ":" + SDK_VERSION + "'\n"; + } + return " implementation 'com.android.billingclient:billing:" + playBillingVersion + "'\n"; + } + + /** + * The permission the selected store's billing needs. Amazon needs none of + * the application; its receiver is guarded by a permission Amazon's own + * client holds. + * + * @param amazon the Amazon Appstore is selected + * @return the element to append, possibly empty + */ + public static String permissions(boolean amazon) { + if (amazon) { + return ""; + } + return " \n"; + } + + /** + * The {@code } Amazon's client answers through, or an empty string + * for Play or when the project's own {@code android.xapplication} already + * declares it (a second declaration fails the manifest merge). + * + * @param amazon the Amazon Appstore is selected + * @param xApplication the project's {@code android.xapplication} fragment + * @return the element to append, possibly empty + */ + public static String receiver(boolean amazon, String xApplication) { + if (!amazon || (xApplication != null && xApplication.contains(RECEIVER_CLASS))) { + return ""; + } + // exported, because the sender is another application; the permission + // is what keeps every application but Amazon's client from sending. + return " \n" + + " \n" + + " \n" + + " \n" + + " \n"; + } + + /** + * The port class the generated activity constructs. It is named in + * generated source rather than looked up at run time, so the reference + * survives obfuscation. + * + * @param amazon the Amazon Appstore is selected + * @return a fully qualified class name + */ + public static String billingSupportClass(boolean amazon) { + return amazon ? "com.codename1.impl.android.AmazonBillingSupport" + : "com.codename1.impl.android.BillingSupport"; + } + + /** + * The port's billing sources this build must not compile, as file names in + * {@code com/codename1/impl/android}. Each is written against a library + * only its own store's build has on the class path. + * + * @param purchase the application uses the purchase API + * @param amazon the Amazon Appstore is selected + * @return the file names to delete from the generated project + */ + public static String[] sourcesToRemove(boolean purchase, boolean amazon) { + if (!purchase) { + return new String[] {"BillingSupport.java", "AmazonBillingSupport.java"}; + } + return new String[] {amazon ? "BillingSupport.java" : "AmazonBillingSupport.java"}; + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 18b36a36462..f11a6713545 100644 --- a/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -5681,13 +5681,23 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { throw new BuildException("Failed to generate icon files", ex); } + // Which store the application's purchases go through. Read before anything + // billing related is resolved, because the two stores share nothing: each + // has its own library, its own manifest entries and its own port source. + String billingStore = request.getArg("android.billing.store", AmazonBillingFragments.STORE_PLAY); + String billingStoreRefusal = AmazonBillingFragments.refusalFor(billingStore); + if (billingStoreRefusal != null) { + throw new BuildException(billingStoreRefusal); + } + final boolean amazonBilling = AmazonBillingFragments.isAmazon(billingStore); + // The Play Billing version, resolved here rather than beside the gradle // dependency it feeds, because it also moves minSdk and the manifest that // carries minSdkVersion is written further down this method. Resolving it // late put the raised floor in build.gradle and left the manifest saying 19, // which is the merge failure it was meant to prevent. String billingClientVersion = null; - if (purchasePermissions) { + if (purchasePermissions && !amazonBilling) { billingClientVersion = request.getArg("android.billingclient.version", PlayBillingVersions.DEFAULT_VERSION); // The port's BillingSupport is written against the ProductDetails API, which @@ -5712,8 +5722,10 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { minSDK = billingRaisedMinSdk; } - if (!purchasePermissions) { - File billingSupport = new File(srcDir, path("com", "codename1", "impl", "android", "BillingSupport.java")); + // Each billing source compiles only against its own store's library, so the + // one this build has no library for is removed -- both, without purchases. + for (String unused : AmazonBillingFragments.sourcesToRemove(purchasePermissions, amazonBilling)) { + File billingSupport = new File(srcDir, path("com", "codename1", "impl", "android", unused)); if (billingSupport.exists()) { billingSupport.delete(); } @@ -6420,7 +6432,8 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { if (purchasePermissions) { String k = request.getArg("android.licenseKey", null); //if the android.licenseKey is not defined abort the build - if (k == null) { + // The key is Google Play's; an Amazon Appstore build has none to give. + if (k == null && !amazonBilling) { throw new BuildException("android.licenseKey must be defined in the build hints, grab the key from the \"Monetization setup\" section in the android dev portal" + ", then paste the Base64-encoded RSA public key into the android.licenseKey build hint.\n\n"); } @@ -6446,13 +6459,15 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { consumable = "\"" + cons + "\""; } } - permissions += " \n"; + permissions += AmazonBillingFragments.permissions(amazonBilling); + billingServiceData = AmazonBillingFragments.receiver(amazonBilling, + request.getArg("android.xapplication", "")); activityBillingSource = " protected boolean isBillingEnabled() {\n" + " return true;\n" + " }\n\n" + " protected com.codename1.impl.android.IBillingSupport createBillingSupport() {\n" - + " return new com.codename1.impl.android.BillingSupport(this);\n" + + " return new " + AmazonBillingFragments.billingSupportClass(amazonBilling) + "(this);\n" + " }\n\n"; } @@ -7447,6 +7462,9 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { + " firstTime = false;\n" + firebaseRegisterInstall + inviteDomainProperty + // Commerce reads its switch, endpoint and signing key through + // Display.getProperty; this is the only thing that puts them there. + + CommerceBuildHints.stubProperties(request) + inviteRegisterInstall + svgRegistryInstall + " i.init(this);\n" @@ -8234,7 +8252,7 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { // Resolved earlier, with the minSdk raise, because the manifest that carries // minSdkVersion is written before this point. - additionalDependencies += " implementation 'com.android.billingclient:billing:"+billingClientVersion+"'\n"; + additionalDependencies += AmazonBillingFragments.dependency(amazonBilling, billingClientVersion); } // Play In-App Review library, added only when the app references the diff --git a/maven/build-engine/src/main/java/com/codename1/builders/CommerceBuildHints.java b/maven/build-engine/src/main/java/com/codename1/builders/CommerceBuildHints.java new file mode 100644 index 00000000000..5ffa5d7eeac --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/builders/CommerceBuildHints.java @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +/** + * The Codename One Commerce build hints, validated and rendered as the lines + * of generated startup code that hand them to the client. + * + *

{@code CommerceManager} reads its configuration with + * {@code Display.getProperty}, and a build hint only becomes a display + * property when a builder writes a {@code setProperty} call into the + * generated stub. Until this class existed nothing did, so + * {@code commerce.cloud.enabled=false} and a self-hosted + * {@code commerce.cloud.endpoint} were accepted by every build and read by + * none.

+ * + *

The values are spliced into a Java string literal, so each one is held + * to the alphabet its meaning allows rather than escaped: an endpoint with a + * quote in it is not a url the client could have used anyway, and refusing + * the build names the hint, where a compile error in generated code would + * not.

+ * + *

The two build pipelines are separate codebases that must generate the + * same stub, so keep this file in sync with + * {@code com.codename1.build.daemon.CommerceBuildHints} in the BuildDaemon + * repository. Its unit tests live in both repos for the same reason.

+ */ +class CommerceBuildHints { + + static final String ENABLED = "commerce.cloud.enabled"; + static final String ENDPOINT = "commerce.cloud.endpoint"; + static final String PUBLIC_KEY = "commerce.cloud.publicKey"; + + private CommerceBuildHints() { + } + + /** + * The generated startup lines for every commerce hint the build sets. + * + *

An absent hint emits nothing, which leaves the client on its own + * default. That matters for the endpoint and the key: stamping the + * default here would freeze it into the binary of an application that + * never asked to be pinned to it.

+ * + * @param request the build request + * @return zero or more {@code setProperty} lines, each newline terminated + * @throws IllegalArgumentException when a hint cannot be used as written + */ + static String stubProperties(BuildRequest request) { + StringBuilder out = new StringBuilder(); + String enabled = trimmed(request, ENABLED); + if (enabled.length() > 0) { + if (!"true".equalsIgnoreCase(enabled) && !"false".equalsIgnoreCase(enabled)) { + throw new IllegalArgumentException(ENABLED + "=\"" + enabled + + "\" must be true or false."); + } + line(out, ENABLED, "true".equalsIgnoreCase(enabled) ? "true" : "false"); + } + String endpoint = trimmed(request, ENDPOINT); + if (endpoint.length() > 0) { + if (!isUsableEndpoint(endpoint)) { + throw new IllegalArgumentException(ENDPOINT + "=\"" + endpoint + + "\" is not a usable endpoint. It must be an https url (http only for localhost, " + + "127.0.0.1, [::1] or 10.0.2.2) with no " + + "spaces, quotes or backslashes, for example " + + "https://commerce.example.com/api/v2/commerce"); + } + // The client appends "/validate", "/customer" and so on, so a + // trailing slash would produce "//customer", which some proxies + // route differently from the path the service maps. + while (endpoint.endsWith("/")) { + endpoint = endpoint.substring(0, endpoint.length() - 1); + } + line(out, ENDPOINT, endpoint); + } + String key = trimmed(request, PUBLIC_KEY); + if (key.length() > 0) { + if (!isBase64(key)) { + throw new IllegalArgumentException(PUBLIC_KEY + " must be the base64 encoding " + + "of the service's 32 byte Ed25519 public key."); + } + line(out, PUBLIC_KEY, key); + } + return out.toString(); + } + + private static String trimmed(BuildRequest request, String name) { + String value = request.getArg(name, ""); + return value == null ? "" : value.trim(); + } + + private static void line(StringBuilder out, String name, String value) { + out.append(" Display.getInstance().setProperty(\"").append(name) + .append("\", \"").append(value).append("\");\n"); + } + + /** + * Whether the value is an http(s) url that is safe inside a string literal. + * + * @param value the trimmed hint, never empty + * @return true when the client can use it as written + */ + static boolean isUsableEndpoint(String value) { + // regionMatches, not toLowerCase: a scheme is ASCII by specification + // and a locale-sensitive fold breaks "HTTPS" on a Turkish build host. + boolean http = value.regionMatches(true, 0, "http://", 0, 7) && value.length() > 7; + boolean https = value.regionMatches(true, 0, "https://", 0, 8) && value.length() > 8; + if (!http && !https) { + return false; + } + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c <= ' ' || c >= 127 || c == '"' || c == '\\' || c == '\'') { + return false; + } + } + return !http || isLoopbackAuthority(value.substring(7)); + } + + /** + * Whether the authority at the start of an http url names this machine, + * or the host machine as the Android emulator sees it (10.0.2.2). Plain + * http is only for testing against a service on the developer's own + * machine; anywhere else, anything on the path could rewrite answers. + * A user-info part is refused, since "localhost@host" names "host". + * + * @param rest the url after "http://" + * @return true for localhost, 127.0.0.1, [::1] or 10.0.2.2, any port + */ + static boolean isLoopbackAuthority(String rest) { + int end = rest.length(); + for (int i = 0; i < rest.length(); i++) { + char c = rest.charAt(i); + if (c == '/' || c == '?' || c == '#') { + end = i; + break; + } + } + String authority = rest.substring(0, end); + if (authority.indexOf('@') >= 0) { + return false; + } + String host = authority; + if (host.startsWith("[")) { + int close = host.indexOf(']'); + if (close < 0) { + return false; + } + String tail = host.substring(close + 1); + if (tail.length() > 0 && !tail.startsWith(":")) { + return false; + } + host = host.substring(0, close + 1); + } else { + int colon = host.indexOf(':'); + if (colon >= 0) { + host = host.substring(0, colon); + } + } + return host.equalsIgnoreCase("localhost") || host.equals("127.0.0.1") + || host.equals("[::1]") || host.equals("10.0.2.2"); + } + + /** + * Whether the value is plain or url-safe base64 text. + * + *

The length is not checked against 32 bytes here: the client refuses + * a key of the wrong size by failing every signature, and a self-hosting + * developer who pasted the wrong value learns that from the first + * refresh rather than from a builder that has to track key formats.

+ * + * @param value the trimmed hint, never empty + * @return true when every character belongs to a base64 alphabet + */ + static boolean isBase64(String value) { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + boolean ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') + || c == '+' || c == '/' || c == '=' || c == '-' || c == '_'; + if (!ok) { + return false; + } + } + return true; + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java index 33b4e4258ce..811818b3043 100644 --- a/maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4202,6 +4202,9 @@ public void usesClassMethod(String cls, String method) { + newStorage + disableScreenshots + inviteDomainProperty + // Commerce reads its switch, endpoint and signing key through + // Display.getProperty; this is the only thing that puts them there. + + CommerceBuildHints.stubProperties(request) + dbLegacy + desktopTitleBar + adPadding diff --git a/maven/build-engine/src/main/java/com/codename1/builders/JavaScriptBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/JavaScriptBuilder.java index f3d6a46c465..b0faed8fcc3 100644 --- a/maven/build-engine/src/main/java/com/codename1/builders/JavaScriptBuilder.java +++ b/maven/build-engine/src/main/java/com/codename1/builders/JavaScriptBuilder.java @@ -635,6 +635,9 @@ private File writeLauncher(File workDir, String launcherName, String packageName pw.println(" ParparVMBootstrap.bootstrap(new " + mainClass + "(), new Runnable() {"); pw.println(" public void run() {"); pw.print(hardeningRuntimeProperties(request)); + // The commerce switch, endpoint and signing key, which CommerceManager reads + // through Display.getProperty and nothing else puts there. + pw.print(CommerceBuildHints.stubProperties(request)); // The theme, title-bar and text-selection hints, read by the port through // Display.getProperty. The native theme is installed from the lifecycle's init, // which runs after this. diff --git a/maven/build-engine/src/main/java/com/codename1/builders/LinuxNativeBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/LinuxNativeBuilder.java index 59fd03b260c..e8c42bc4839 100644 --- a/maven/build-engine/src/main/java/com/codename1/builders/LinuxNativeBuilder.java +++ b/maven/build-engine/src/main/java/com/codename1/builders/LinuxNativeBuilder.java @@ -868,6 +868,9 @@ private void writeBootstrapStub(BuildRequest request, File classesDir, File stub // Stamp the hardening metadata so Hardening.isHardened() and crash reports carry the // mapping id / level on this port too (parity with iOS and Android). src.append(hardeningRuntimeProperties(request)); + // The commerce switch, endpoint and signing key, which CommerceManager reads through + // Display.getProperty and nothing else puts there. + src.append(CommerceBuildHints.stubProperties(request)); // And the database compatibility switch, before the lifecycle below starts, so it is in // force by the time anything opens a database. src.append(databaseLegacyStubProperty(request, usesDatabase)); diff --git a/maven/build-engine/src/main/java/com/codename1/builders/MacOSNativeBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/MacOSNativeBuilder.java index 65a5c9c61a8..914cc090732 100644 --- a/maven/build-engine/src/main/java/com/codename1/builders/MacOSNativeBuilder.java +++ b/maven/build-engine/src/main/java/com/codename1/builders/MacOSNativeBuilder.java @@ -330,10 +330,11 @@ public String get(String key, String defaultValue) { final boolean[] usesPush = {false}; final boolean[] usesLocation = {false}; final boolean[] usesAppReview = {false}; + final boolean[] usesPurchase = {false}; try { scanClassesForPermissions(classesDir, new NativeFeatureScanner(usesCrypto, usesLocalNotifications, usesMicrophone, usesBluetooth, usesCalendar, usesPush, - usesLocation, usesAppReview)); + usesLocation, usesAppReview, usesPurchase)); // btres too, for the reason the capability scan reads it: unzip routes a // submitted cn1lib's jar there rather than unpacking it beside the loose // classes. A library that is the only thing calling SecureRandom left @@ -342,7 +343,7 @@ public String get(String key, String defaultValue) { // returns a constant is the worst failure in this file. scanClassesForPermissions(buildinRes, new NativeFeatureScanner(usesCrypto, usesLocalNotifications, usesMicrophone, usesBluetooth, usesCalendar, usesPush, - usesLocation, usesAppReview)); + usesLocation, usesAppReview, usesPurchase)); } catch (IOException ex) { throw new BuildException("Failed to scan the application for native feature usage", ex); } @@ -433,6 +434,7 @@ public String get(String key, String defaultValue) { boolean calendarEnabled = usesCalendar[0]; boolean locationEnabled = usesLocation[0]; boolean appReviewEnabled = usesAppReview[0]; + boolean purchaseEnabled = usesPurchase[0]; // Unconditional, for the reason the capability scan above explains at // length: com/codename1/camera/Camera#open is invoked by the framework's // own Camera class, so no scan can tell an application using the low @@ -522,6 +524,27 @@ public String get(String key, String defaultValue) { } log("App review " + (appReviewEnabled ? "enabled" : "disabled")); + // In-app purchase. The StoreKit natives are the iOS port's own, shared: + // without the define every one of them is an empty body, so + // Purchase.getInAppPurchase() answers "payments unavailable" and a + // purchase call returns without a callback. + if (purchaseEnabled) { + File controllerHeader = new File(nativeSources, "CodenameOne_GLViewController.h"); + if (!controllerHeader.exists()) { + throw new BuildException("The application uses com.codename1.payment but " + + "CodenameOne_GLViewController.h is missing from the staged native " + + "sources at " + controllerHeader.getAbsolutePath()); + } + try { + replaceInFile(controllerHeader, "//#define CN1_USE_STOREKIT", + "#define CN1_USE_STOREKIT"); + } catch (Exception ex) { + throw new BuildException("Failed to enable in-app purchase in " + + "CodenameOne_GLViewController.h", ex); + } + } + log("In-app purchase " + (purchaseEnabled ? "enabled" : "disabled")); + if (locationEnabled) { File controllerHeader = new File(nativeSources, "CodenameOne_GLViewController.h"); if (!controllerHeader.exists()) { @@ -612,6 +635,15 @@ public String get(String key, String defaultValue) { extraFrameworks.add("CoreBluetooth.framework"); } log("Bluetooth " + (bluetoothEnabled ? "enabled" : "disabled")); + // Declared, never left to an autolink hint. Both defines make the + // natives import StoreKit, and with modules on that import links the + // framework by itself -- so the review prompt linked on the builds + // that had modules and nothing said why. A project that named it in + // its own libraries already has it. + if ((purchaseEnabled || appReviewEnabled) + && !extraFrameworks.contains("StoreKit.framework")) { + extraFrameworks.add("StoreKit.framework"); + } if (calendarEnabled) { File iosNative = new File(nativeSources, "IOSNative.m"); if (!iosNative.exists()) { @@ -850,6 +882,9 @@ void writeStub(BuildRequest request, File stubSource, File classesDir, // makes the uploaded mapping unreachable -- the failure is a // stack trace nobody can retrace, months later. + hardeningRuntimeProperties(request) + // The commerce switch, endpoint and signing key, which CommerceManager reads + // through Display.getProperty and nothing else puts there. + + CommerceBuildHints.stubProperties(request) + desktopTitleBarStubProperty(request) + svgRegistryInstall + " i.init(this);\n" @@ -1593,10 +1628,12 @@ private static final class NativeFeatureScanner implements Executor.ClassScanner private final boolean[] usesPush; private final boolean[] usesLocation; private final boolean[] usesAppReview; + private final boolean[] usesPurchase; NativeFeatureScanner(boolean[] usesCrypto, boolean[] usesLocalNotifications, boolean[] usesMicrophone, boolean[] usesBluetooth, boolean[] usesCalendar, - boolean[] usesPush, boolean[] usesLocation, boolean[] usesAppReview) { + boolean[] usesPush, boolean[] usesLocation, boolean[] usesAppReview, + boolean[] usesPurchase) { this.usesCrypto = usesCrypto; this.usesLocalNotifications = usesLocalNotifications; this.usesMicrophone = usesMicrophone; @@ -1605,6 +1642,7 @@ private static final class NativeFeatureScanner implements Executor.ClassScanner this.usesPush = usesPush; this.usesLocation = usesLocation; this.usesAppReview = usesAppReview; + this.usesPurchase = usesPurchase; } @Override @@ -1664,6 +1702,9 @@ public void usesClass(String cls) { if (cls.startsWith("com/codename1/appreview")) { usesAppReview[0] = true; } + if (usesPurchaseApi(cls)) { + usesPurchase[0] = true; + } if (!cls.startsWith("com/codename1/security/")) { return; } @@ -1713,6 +1754,17 @@ public void usesClassMethod(String cls, String method) { } } + /** + * Whether a referenced class belongs to the in-app purchase API. + * + *

The test IPhoneBuilder applies, so one application enables StoreKit + * on both targets or on neither. By package, with the separator: the + * prefix alone would also match an unrelated {@code com.codename1.paymentsx}.

+ */ + static boolean usesPurchaseApi(String cls) { + return cls != null && cls.startsWith("com/codename1/payment/"); + } + /** * Whether an invoked method is the low-level in-app review entry point. * diff --git a/maven/build-engine/src/main/java/com/codename1/builders/WatchNativeBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/WatchNativeBuilder.java index 4a649d4995c..199d9c3884d 100644 --- a/maven/build-engine/src/main/java/com/codename1/builders/WatchNativeBuilder.java +++ b/maven/build-engine/src/main/java/com/codename1/builders/WatchNativeBuilder.java @@ -534,6 +534,9 @@ void writeWatchStubSource(BuildRequest request, File stubSource, String buildVer + " Display.getInstance().setProperty(\"package_name\", PACKAGE_NAME);\n" + " Display.getInstance().setProperty(\"AppVersion\", APPLICATION_VERSION);\n" + " Display.getInstance().setProperty(\"AppName\", APPLICATION_NAME);\n" + // The commerce switch, endpoint and signing key, which CommerceManager reads + // through Display.getProperty and nothing else puts there. + + CommerceBuildHints.stubProperties(request) + " if(!initialized) {\n" + " initialized = true;\n" // The SAME registry installs the phone stub emits. diff --git a/maven/build-engine/src/main/java/com/codename1/builders/WindowsNativeBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/WindowsNativeBuilder.java index 5f535740010..192d68efccb 100644 --- a/maven/build-engine/src/main/java/com/codename1/builders/WindowsNativeBuilder.java +++ b/maven/build-engine/src/main/java/com/codename1/builders/WindowsNativeBuilder.java @@ -1531,6 +1531,9 @@ private void writeBootstrapStub(BuildRequest request, File classesDir, File stub // Stamp the hardening metadata so Hardening.isHardened() and crash reports carry the // mapping id / level on this port too (parity with iOS and Android). src.append(hardeningRuntimeProperties(request)); + // The commerce switch, endpoint and signing key, which CommerceManager reads through + // Display.getProperty and nothing else puts there. + src.append(CommerceBuildHints.stubProperties(request)); // And the database compatibility switch, before the lifecycle below starts, so it is in // force by the time anything opens a database. src.append(databaseLegacyStubProperty(request, usesDatabase)); diff --git a/maven/build-engine/src/test/java/com/codename1/builders/AmazonBillingFragmentsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AmazonBillingFragmentsTest.java new file mode 100644 index 00000000000..c20b3891921 --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/builders/AmazonBillingFragmentsTest.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// What `android.billing.store` puts into a generated project. +/// +/// Every assertion is about a failure nothing in a build reports: an Amazon +/// build that Amazon's client cannot answer, or one that still carries Play's +/// billing and is turned away at Appstore review. +class AmazonBillingFragmentsTest { + + @Test + public void playIsTheDefaultAndAmazonIsMatchedWithoutCase() { + assertFalse(AmazonBillingFragments.isAmazon(null)); + assertFalse(AmazonBillingFragments.isAmazon("")); + assertFalse(AmazonBillingFragments.isAmazon("play")); + assertTrue(AmazonBillingFragments.isAmazon("amazon")); + // An upper case I is the letter a locale-sensitive fold gets wrong. + assertTrue(AmazonBillingFragments.isAmazon(" AMAZON ")); + } + + @Test + public void anUnknownStoreIsRefusedRatherThanBuiltForPlay() { + assertNull(AmazonBillingFragments.refusalFor(null)); + assertNull(AmazonBillingFragments.refusalFor("play")); + assertNull(AmazonBillingFragments.refusalFor("Amazon")); + String refusal = AmazonBillingFragments.refusalFor("amazn"); + assertNotNull(refusal); + assertTrue(refusal.contains("android.billing.store") && refusal.contains("amazn"), + "the sentence names the hint and the value"); + } + + @Test + public void anAmazonBuildDependsOnTheAmazonSdkAndNotOnPlayBilling() { + String out = AmazonBillingFragments.dependency(true, "8.0.0"); + assertEquals(" implementation 'com.amazon.device:amazon-appstore-sdk:" + + AmazonBillingFragments.SDK_VERSION + "'\n", out); + assertFalse(out.contains("billingclient"), + "Play Billing has no place in an Appstore build"); + } + + @Test + public void anAmazonBuildDoesNotRequestThePlayBillingPermission() { + assertEquals("", AmazonBillingFragments.permissions(true)); + } + + @Test + public void anAmazonBuildDeclaresTheReceiverAmazonAnswersThrough() { + String out = AmazonBillingFragments.receiver(true, ""); + assertTrue(out.contains("")); + } + + @Test + public void aProjectThatDeclaresTheReceiverItselfIsNotGivenASecond() { + assertEquals("", AmazonBillingFragments.receiver(true, + "")); + } + + @Test + public void aPlayBuildIsWhatItWasBeforeTheHintExisted() { + assertEquals(" implementation 'com.android.billingclient:billing:8.0.0'\n", + AmazonBillingFragments.dependency(false, "8.0.0")); + assertEquals(" \n", AmazonBillingFragments.permissions(false)); + assertEquals("", AmazonBillingFragments.receiver(false, "")); + assertEquals("com.codename1.impl.android.BillingSupport", + AmazonBillingFragments.billingSupportClass(false)); + } + + @Test + public void eachBuildCompilesOnlyTheImplementationItHasALibraryFor() { + assertEquals("com.codename1.impl.android.AmazonBillingSupport", + AmazonBillingFragments.billingSupportClass(true)); + assertEquals(Arrays.asList("BillingSupport.java"), + Arrays.asList(AmazonBillingFragments.sourcesToRemove(true, true))); + assertEquals(Arrays.asList("AmazonBillingSupport.java"), + Arrays.asList(AmazonBillingFragments.sourcesToRemove(true, false))); + // No purchase API, no billing library of either kind on the class path. + assertEquals(Arrays.asList("BillingSupport.java", "AmazonBillingSupport.java"), + Arrays.asList(AmazonBillingFragments.sourcesToRemove(false, false))); + } +} diff --git a/maven/build-engine/src/test/java/com/codename1/builders/AndroidDependencyCoordinates.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidDependencyCoordinates.java index 9a1502ae1c7..f9fc9501c7c 100644 --- a/maven/build-engine/src/test/java/com/codename1/builders/AndroidDependencyCoordinates.java +++ b/maven/build-engine/src/test/java/com/codename1/builders/AndroidDependencyCoordinates.java @@ -101,6 +101,7 @@ public final class AndroidDependencyCoordinates { INJECTED.put("androidx.wear.tiles:tiles", "1.4.1"); INJECTED.put("androidx.wear.watchface:watchface-complications-data-source", "1.2.1"); INJECTED.put("androidx.window:window", "1.3.0"); + INJECTED.put(AmazonBillingFragments.SDK_COORDINATE, AmazonBillingFragments.SDK_VERSION); INJECTED.put("com.android.billingclient:billing", PlayBillingVersions.DEFAULT_VERSION); INJECTED.put("com.android.support:appcompat-v7", "28.0.0"); INJECTED.put("com.android.support:multidex", "1.0.3"); diff --git a/maven/build-engine/src/test/java/com/codename1/builders/CommerceBuildHintsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/CommerceBuildHintsTest.java new file mode 100644 index 00000000000..4078351abe5 --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/builders/CommerceBuildHintsTest.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class CommerceBuildHintsTest { + + private static BuildRequest request(String... pairs) { + BuildRequest r = new BuildRequest(); + for (int i = 0; i < pairs.length; i += 2) { + r.putArgument(pairs[i], pairs[i + 1]); + } + return r; + } + + private static String line(String name, String value) { + return " Display.getInstance().setProperty(\"" + name + "\", \"" + value + "\");\n"; + } + + @Test + public void aBuildWithNoCommerceHintsStampsNothing() { + assertEquals("", CommerceBuildHints.stubProperties(request())); + assertEquals("", CommerceBuildHints.stubProperties(request("commerce.cloud.endpoint", " "))); + } + + @Test + public void theSwitchIsNormalised() { + assertEquals(line("commerce.cloud.enabled", "false"), + CommerceBuildHints.stubProperties(request("commerce.cloud.enabled", " FALSE "))); + assertEquals(line("commerce.cloud.enabled", "true"), + CommerceBuildHints.stubProperties(request("commerce.cloud.enabled", "True"))); + } + + @Test + public void aSwitchThatIsNotABooleanFailsTheBuild() { + assertThrows(IllegalArgumentException.class, + () -> CommerceBuildHints.stubProperties(request("commerce.cloud.enabled", "yes"))); + } + + @Test + public void theEndpointLosesItsTrailingSlash() { + assertEquals(line("commerce.cloud.endpoint", "https://c.example.com/api/v2/commerce"), + CommerceBuildHints.stubProperties( + request("commerce.cloud.endpoint", " https://c.example.com/api/v2/commerce// "))); + } + + @Test + public void anEndpointThatCouldBreakTheGeneratedSourceFailsTheBuild() { + String[] bad = {"c.example.com", "ftp://c.example.com", "https://", "https://c.example.com/\"x", + "https://c.example.com/a b", "https://c.example.com/\\"}; + for (String value : bad) { + assertThrows(IllegalArgumentException.class, + () -> CommerceBuildHints.stubProperties(request("commerce.cloud.endpoint", value)), value); + } + } + + @Test + public void thePublicKeyIsStampedAsWritten() { + String key = "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo="; + assertEquals(line("commerce.cloud.publicKey", key), + CommerceBuildHints.stubProperties(request("commerce.cloud.publicKey", key))); + assertThrows(IllegalArgumentException.class, + () -> CommerceBuildHints.stubProperties(request("commerce.cloud.publicKey", "not a key\""))); + } + + @Test + public void everyHintSetIsStampedInAFixedOrder() { + assertEquals(line("commerce.cloud.enabled", "true") + + line("commerce.cloud.endpoint", "http://localhost:8080/api/v2/commerce"), + CommerceBuildHints.stubProperties(request( + "commerce.cloud.endpoint", "http://localhost:8080/api/v2/commerce", + "commerce.cloud.enabled", "true"))); + } + + @Test + void plainHttpIsOnlyForLoopback() { + String[] ok = {"http://localhost/api", "http://LOCALHOST:8080/api", "http://127.0.0.1:9000", + "http://10.0.2.2:8080/api", "http://[::1]:8080/api", "https://commerce.example.com/api"}; + for (String u : ok) { + assertTrue(CommerceBuildHints.isUsableEndpoint(u), u); + } + String[] bad = {"http://commerce.example.com/api", "http://localhost.evil.com/api", + "http://localhost@evil.com/api", "http://127.0.0.2/api", "http://10.0.2.3/api", + "http://[::1]evil/api"}; + for (String u : bad) { + assertFalse(CommerceBuildHints.isUsableEndpoint(u), u); + assertThrows(IllegalArgumentException.class, + () -> CommerceBuildHints.stubProperties(request("commerce.cloud.endpoint", u))); + } + } +} diff --git a/maven/build-engine/src/test/java/com/codename1/builders/MacOSNativeBuilderScanTest.java b/maven/build-engine/src/test/java/com/codename1/builders/MacOSNativeBuilderScanTest.java index 484c9b0b6f1..eecf3821bde 100644 --- a/maven/build-engine/src/test/java/com/codename1/builders/MacOSNativeBuilderScanTest.java +++ b/maven/build-engine/src/test/java/com/codename1/builders/MacOSNativeBuilderScanTest.java @@ -421,6 +421,20 @@ public void aTemplateWithoutTheSettingYieldsNothing() { assertTrue(MacOSNativeBuilder.deploymentTargetAssignments(null).isEmpty()); } + /** + * Any class of the purchase API switches the StoreKit natives on, the + * commerce facade included; a package that merely starts with the same + * letters does not. + */ + @Test + public void thePaymentPackageEnablesPurchase() { + assertTrue(MacOSNativeBuilder.usesPurchaseApi("com/codename1/payment/Purchase")); + assertTrue(MacOSNativeBuilder.usesPurchaseApi("com/codename1/payment/CommerceManager")); + assertFalse(MacOSNativeBuilder.usesPurchaseApi("com/codename1/paymentsx/Thing")); + assertFalse(MacOSNativeBuilder.usesPurchaseApi("com/example/payment/Purchase")); + assertFalse(MacOSNativeBuilder.usesPurchaseApi(null)); + } + /** * An application is free to call the review API without the * com.codename1.appreview facade, and both spellings are public API. The diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java index 18d83ec0222..28cb1d4cdbe 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java @@ -260,6 +260,25 @@ static void register(List h) { .def("false") .platform("android")); + h.add(new Hint("android.billing.store") + .group(HintGroup.ANDROID) + .type(HintType.STRING) + .def("play") + .platform("android") + .doc("The store an in-app-purchase app is built for: `play` (the default) or " + + "`amazon`. An app is built for one store. `amazon` builds against the " + + "Amazon Appstore SDK instead of the Play Billing Library, declares the " + + "receiver Amazon's client answers through, and leaves out the " + + "`com.android.vending.BILLING` permission, which the Amazon Appstore " + + "doesn't accept. `android.licenseKey` and " + + "`android.billingclient.version` are Google Play settings and aren't " + + "used. Receipts carry the store code `amazon`, the Amazon receipt id " + + "as the transaction id, and the receipt id, user id and SKU as order " + + "data for server-side verification. The Appstore SDK also requires " + + "the app's public key from the Amazon developer console, " + + "`AppstoreAuthenticationKey.pem`, among the app's Android assets. Any " + + "other value fails the build.")); + h.add(new Hint("android.billingclient.version") .group(HintGroup.ANDROID) .type(HintType.VERSION) diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsGeneral.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsGeneral.java index 9ce0f0bc136..e4b61f3a6f4 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsGeneral.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsGeneral.java @@ -52,6 +52,33 @@ static void register(List h) { .doc("Whether video calls are offered, on both platforms. `ios.call.video` and " + "`android.call.video` override it per platform.")); + h.add(new Hint("commerce.cloud.enabled") + .group(HintGroup.GENERAL) + .type(HintType.BOOLEAN) + .def("true") + .platform("general") + .doc("Whether `CommerceManager` asks the Codename One Commerce service for " + + "entitlements. Set it to `false` to keep every decision on the device, " + + "from the store's own receipts.")); + + h.add(new Hint("commerce.cloud.endpoint") + .group(HintGroup.GENERAL) + .type(HintType.STRING) + .platform("general") + .doc("The base url of the Commerce service `CommerceManager` talks to, for a " + + "self-hosted or staging service, for example " + + "`+https://commerce.example.com/api/v2/commerce+`. Must be an `http` " + + "or `https` url; a build with any other value fails.")); + + h.add(new Hint("commerce.cloud.publicKey") + .group(HintGroup.GENERAL) + .type(HintType.STRING) + .platform("general") + .doc("The base64 encoded Ed25519 public key that Commerce responses are " + + "verified against. Set it together with `commerce.cloud.endpoint` when " + + "the service signs with its own key; a response that fails the check " + + "is ignored and the app falls back to the store's receipts.")); + h.add(new Hint("invite.domain") .group(HintGroup.GENERAL) .type(HintType.STRING) diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteObserverTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteObserverTest.java new file mode 100644 index 00000000000..2660e7dc409 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteObserverTest.java @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.analytics.invite; + +import com.codename1.analytics.Analytics; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import org.junit.jupiter.api.AfterEach; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/// The framework's attribution observer beside the application's listener: +/// both hear a resolved attribution, and neither takes the other's place. +class InviteObserverTest extends UITestBase { + private static final String CODE = "ABC123xxxxxxxxxxxxxxxx"; + + @AfterEach + void cleanUp() { + Analytics.setAttributionObserver(null); + InviteTestSupport.tearDown(); + } + + private static final class Capture implements InviteListener { + final List received = new ArrayList(); + + public void inviteReceived(InviteAttribution attribution) { + received.add(attribution); + } + + public void attributionUnavailable(String reason) { + // not under test + } + } + + @FormTest + void theObserverHearsAnAttributionOnceAndTheListenerStillHearsIt() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + final int[] told = new int[1]; + Analytics.setAttributionObserver(new Runnable() { + @Override + public void run() { + told[0]++; + } + }); + Capture capture = new Capture(); + Invites.setInviteListener(capture); + assertEquals(0, told[0]); + + Invites.handleResolution(InviteTestSupport.resolvedJson(CODE, "spring", "sms"), + Invites.MATCH_REFERRER, true); + assertEquals(1, told[0]); + assertEquals(1, capture.received.size()); + assertSame(capture, Invites.getInviteListener()); + + Invites.checkForInvite(); + Invites.setInviteListener(capture); + assertEquals(1, told[0], "the observer was told twice"); + assertEquals(1, capture.received.size()); + } + + @FormTest + void anObserverRegisteredLaterHearsWhatIsAlreadyKnownAndNeedsNoListener() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + // No listener at all: the observer does not depend on one. + Invites.handleResolution(InviteTestSupport.resolvedJson(CODE, "spring", "sms"), + Invites.MATCH_REFERRER, true); + final int[] told = new int[1]; + Analytics.setAttributionObserver(new Runnable() { + @Override + public void run() { + told[0]++; + } + }); + assertEquals(1, told[0]); + + // The application's listener is still owed its one delivery. + Capture capture = new Capture(); + Invites.setInviteListener(capture); + assertEquals(1, capture.received.size()); + + // Forgetting the attribution is forgetting it here too. + Invites.reset(); + told[0] = 0; + Analytics.setAttributionObserver(new Runnable() { + @Override + public void run() { + told[0]++; + } + }); + assertEquals(0, told[0]); + } + + @FormTest + void observerIsToldAgainAfterAttributionWasCleared() { + final int[] told = new int[1]; + Analytics.setAttributionObserver(new Runnable() { + @Override + public void run() { + told[0]++; + } + }); + Analytics.attributionAvailable(true); + assertEquals(1, told[0]); + Analytics.attributionAvailable(true); + assertEquals(1, told[0]); + Analytics.attributionAvailable(false); + Analytics.attributionAvailable(true); + assertEquals(2, told[0]); + Analytics.attributionAvailable(false); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/payment/CommerceAnalyticsTest.java b/maven/core-unittests/src/test/java/com/codename1/payment/CommerceAnalyticsTest.java new file mode 100644 index 00000000000..f935d5f9755 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/payment/CommerceAnalyticsTest.java @@ -0,0 +1,312 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.payment; + +import com.codename1.analytics.Analytics; +import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.AnalyticsEvent; +import com.codename1.analytics.invite.InviteAttribution; +import com.codename1.analytics.invite.InviteListener; +import com.codename1.analytics.invite.Invites; +import com.codename1.io.JSONParser; +import com.codename1.junit.FormTest; +import com.codename1.payment.paywall.PaywallView; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BoxLayout; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/// Commerce joined to analytics and invites: the client id in requests, the +/// events of a paywall, the context of a purchase, experiment exposure, and +/// the refresh an invite attribution causes. +class CommerceAnalyticsTest extends CommerceTestBase { + private static final String OFFERINGS = + "{\"accepted\":true,\"currentOfferingId\":\"main\"," + + "\"experiment\":{\"id\":\"exp1\",\"variant\":\"B\"}," + + "\"offerings\":[{\"id\":\"main\",\"packages\":[" + + "{\"id\":\"forever\",\"type\":\"LIFETIME\",\"productId\":\"pro_forever\"}," + + "{\"id\":\"annual\",\"type\":\"ANNUAL\",\"productId\":\"pro_annual\",\"period\":\"P1Y\"}]}," + + "{\"id\":\"other\",\"packages\":[" + + "{\"id\":\"monthly\",\"type\":\"MONTHLY\",\"productId\":\"pro_monthly\",\"period\":\"P1M\"}]}]}"; + + private static final String REWARDED_CUSTOMER = + "{\"accepted\":true,\"degraded\":false,\"serverTime\":1700000000000,\"appUserId\":\"u1\"," + + "\"activeSubscriptions\":[],\"entitlements\":{}," + + "\"rewards\":[{\"currencyCode\":\"coins\",\"amount\":50,\"expiresDate\":0," + + "\"reason\":\"invite_purchase\"}," + + "{\"entitlementId\":\"pro\",\"expiresDate\":1900000000000,\"reason\":\"invited\"}," + + "\"not a reward\"]}"; + + /// The body of an `/events` request made now. + private String eventsBody() { + final CommerceManager cm = CommerceManager.getInstance(); + final String[] body = new String[1]; + Runnable r = new Runnable() { + @Override + public void run() { + cm.logEvent("IMPRESSION", "main", "pw1", null); + body[0] = cm.drainEvents(); + } + }; + if (Display.getInstance().isEdt()) { + r.run(); + } else { + Display.getInstance().callSerially(r); + flushSerialCalls(); + } + assertNotNull(body[0]); + return body[0]; + } + + @FormTest + void theClientIdTravelsWithRequestsAndStopsAfterOptOut() { + // No provider: the application does not use analytics, and no id is + // made for it. + assertNull(CommerceManager.analyticsClientId()); + assertFalse(eventsBody().contains("analyticsClientId")); + + analyticsOn(); + String id = Analytics.clientId(); + assertEquals(id, CommerceManager.analyticsClientId()); + assertTrue(eventsBody().contains("\"analyticsClientId\":\"" + id + "\"")); + + Analytics.setConsent(AnalyticsConsent.denied()); + assertNull(CommerceManager.analyticsClientId()); + assertFalse(eventsBody().contains("analyticsClientId")); + + Analytics.setConsent(null); + assertFalse(eventsBody().contains("analyticsClientId"), "opt-in with no choice on record sends nothing"); + } + + @FormTest + void aPaywallReportsItsEventsWithPlacementAndExperiment() { + AnalyticsRecorder recorder = analyticsOn(); + CommerceManager cm = CommerceManager.getInstance(); + mock("/offerings", OFFERINGS); + Offerings offerings = cm.getOfferings("onboarding"); + + assertEquals(1, recorder.count("experiment_exposure")); + assertEquals("commerce", recorder.last("experiment_exposure").getCategory()); + assertEquals("exp1", recorder.param("experiment_exposure", "experiment")); + assertEquals("B", recorder.param("experiment_exposure", "variant")); + mock("/offerings", OFFERINGS); + cm.getOfferings("onboarding"); + assertEquals(1, recorder.count("experiment_exposure"), "an exposure is reported once"); + mock("/offerings", OFFERINGS.replace("\"variant\":\"B\"", "\"variant\":\"C\"")); + offerings = cm.getOfferings("onboarding"); + assertEquals(2, recorder.count("experiment_exposure"), "another variant is another exposure"); + assertEquals("C", recorder.param("experiment_exposure", "variant")); + + PaywallView view = new PaywallView(offerings.getCurrent()); + Form form = new Form("Paywall", BoxLayout.y()); + form.add(view); + form.show(); + flushSerialCalls(); + assertEquals(1, recorder.count("paywall_view")); + AnalyticsEvent shown = recorder.last("paywall_view"); + assertEquals("commerce", shown.getCategory()); + assertEquals("main", shown.getParameters().get("offering")); + assertEquals("onboarding", shown.getParameters().get("placement")); + assertEquals("exp1", shown.getParameters().get("experiment")); + assertEquals("C", shown.getParameters().get("variant")); + assertFalse(shown.getParameters().containsKey("package")); + + view.selectPackage("annual"); + assertEquals(1, recorder.count("paywall_package_selected")); + assertEquals("annual", recorder.param("paywall_package_selected", "package")); + assertEquals("pro_annual", recorder.param("paywall_package_selected", "product")); + assertEquals("main", recorder.param("paywall_package_selected", "offering")); + assertEquals("C", recorder.param("paywall_package_selected", "variant")); + + view.restore(); + assertEquals(1, recorder.count("paywall_restore")); + assertEquals("main", recorder.param("paywall_restore", "offering")); + + view.close(); + assertEquals(1, recorder.count("paywall_close")); + assertEquals("onboarding", recorder.param("paywall_close", "placement")); + + // The experiment chose the current offering and labels no other. + PaywallView other = new PaywallView(offerings.getOffering("other")); + other.selectPackage("monthly"); + assertEquals("other", recorder.param("paywall_package_selected", "offering")); + assertNull(recorder.param("paywall_package_selected", "experiment")); + assertEquals("onboarding", recorder.param("paywall_package_selected", "placement")); + } + + @FormTest + void withNoProviderNothingIsReportedAndNoExposureIsSpent() { + CommerceManager cm = CommerceManager.getInstance(); + mock("/offerings", OFFERINGS); + Offerings offerings = cm.getOfferings(); + new PaywallView(offerings.getCurrent()).selectPackage("annual"); + cm.analyticsEvent(null, null, null, null, null, null); + + // The exposure nobody could hear is reported once somebody can. + AnalyticsRecorder recorder = analyticsOn(); + mock("/offerings", OFFERINGS); + cm.getOfferings(); + assertEquals(1, recorder.count("experiment_exposure")); + } + + @FormTest + void thePurchaseEventsCarryThePackageAndTheContextIsUsedOnce() { + implementation.setInAppPurchase(new Purchase() { + @Override + protected void purchaseImpl(String sku) { + storeCalls.add("purchase:" + sku); + } + }); + AnalyticsRecorder recorder = analyticsOn(); + CommerceManager cm = CommerceManager.getInstance(); + mock("/offerings", OFFERINGS); + CommercePackage forever = cm.getOfferings("settings").getCurrent().getPackage("forever"); + + cm.purchase(forever); + assertEquals(1, storeCalls.size()); + assertEquals(1, recorder.count("purchase_initiated")); + assertEquals("pro_forever", recorder.param("purchase_initiated", "sku")); + assertEquals("main", recorder.param("purchase_initiated", "offering")); + assertEquals("forever", recorder.param("purchase_initiated", "package")); + assertEquals("pro_forever", recorder.param("purchase_initiated", "product")); + assertEquals("settings", recorder.param("purchase_initiated", "placement")); + assertEquals("exp1", recorder.param("purchase_initiated", "experiment")); + assertEquals("B", recorder.param("purchase_initiated", "variant")); + + // The receipt of another product leaves the context where it is. + Purchase.postReceipt(Receipt.STORE_CODE_ITUNES, "pro_other", "t0", 0, "data"); + assertNull(recorder.param("purchase", "offering")); + Purchase.postReceipt(Receipt.STORE_CODE_ITUNES, "pro_forever", "t1", 0, "data"); + assertEquals(2, recorder.count("purchase")); + assertEquals("commerce", recorder.last("purchase").getCategory()); + assertEquals("main", recorder.param("purchase", "offering")); + assertEquals("forever", recorder.param("purchase", "package")); + assertEquals("B", recorder.param("purchase", "variant")); + + // Used up: a later receipt of the same product is not labelled. + Purchase.postReceipt(Receipt.STORE_CODE_ITUNES, "pro_forever", "t2", 0, "data"); + assertEquals("pro_forever", recorder.param("purchase", "sku")); + assertNull(recorder.param("purchase", "offering")); + + // And a purchase nobody completed lapses. + cm.purchase(forever); + CommerceManager.agePurchaseContextForTests(11L * 60L * 1000L); + Purchase.postReceipt(Receipt.STORE_CODE_ITUNES, "pro_forever", "t3", 0, "data"); + assertNull(recorder.param("purchase", "offering")); + } + + @FormTest + void aWebPurchaseReportsItsOwnStart() { + AnalyticsRecorder recorder = analyticsOn(); + CommerceManager cm = CommerceManager.getInstance(); + mock("/offerings", OFFERINGS); + CommercePackage annual = cm.getOfferings().getCurrent().getPackage("annual"); + mock("/web-purchase", "{\"accepted\":false}"); + assertFalse(cm.purchaseOnWeb(annual)); + assertEquals(0, recorder.count("purchase_initiated")); + mock("/web-purchase", "{\"accepted\":true,\"url\":\"https://pay.test/c/1\"}"); + assertTrue(cm.purchaseOnWeb(annual)); + assertEquals(1, recorder.count("purchase_initiated")); + assertEquals("web", recorder.param("purchase_initiated", "store")); + assertEquals("pro_annual", recorder.param("purchase_initiated", "sku")); + assertEquals("annual", recorder.param("purchase_initiated", "package")); + assertEquals("exp1", recorder.param("purchase_initiated", "experiment")); + } + + @FormTest + void anInviteAttributionRefreshesTheCustomerAndLeavesTheListenerAlone() throws Exception { + InviteListener own = new InviteListener() { + @Override + public void inviteReceived(InviteAttribution attribution) { + // the application's + } + + @Override + public void attributionUnavailable(String reason) { + // the application's + } + }; + try { + Invites.setInviteListener(own); + CommerceManager cm = CommerceManager.getInstance(); + cm.setAppUserId("u1"); + // First use registers for the attribution; nothing is known yet. + assertTrue(cm.getCustomerInfo().getReferralRewards().isEmpty()); + mock("/customer", REWARDED_CUSTOMER); + + Analytics.attributionAvailable(true); + // The refresh runs on a background thread; the EDT keeps + // dispatching while this waits for it. + long end = System.currentTimeMillis() + 3000L; + while (cm.getCustomerInfo().getReferralRewards().isEmpty() && System.currentTimeMillis() < end) { + Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }); + } + assertEquals(2, cm.getCustomerInfo().getReferralRewards().size(), + "the reward the cloud granted arrives without the application asking"); + assertSame(own, Invites.getInviteListener()); + } finally { + Invites.setInviteListener(null); + } + } + + @FormTest + void referralRewardsAreParsedStoredAndOptional() throws Exception { + CustomerInfo info = new CustomerInfo(JSONParser.parseJSON(REWARDED_CUSTOMER)); + List rewards = info.getReferralRewards(); + assertEquals(2, rewards.size()); + assertTrue(rewards.get(0).isCurrency()); + assertEquals("coins", rewards.get(0).getCurrencyCode()); + assertNull(rewards.get(0).getEntitlementId()); + assertEquals(50L, rewards.get(0).getAmount()); + assertEquals(0L, rewards.get(0).getExpiresDate()); + assertEquals("invite_purchase", rewards.get(0).getReason()); + assertFalse(rewards.get(1).isCurrency()); + assertEquals("pro", rewards.get(1).getEntitlementId()); + assertEquals(1900000000000L, rewards.get(1).getExpiresDate()); + assertNotNull(rewards.get(1).toString()); + + // The list is the caller's, and the stored form gives it back. + rewards.clear(); + assertEquals(2, info.getReferralRewards().size()); + CustomerInfo again = new CustomerInfo(info.toMap()); + assertEquals(2, again.getReferralRewards().size()); + assertTrue(info.sameState(again)); + + assertTrue(new CustomerInfo(null).getReferralRewards().isEmpty()); + assertTrue(new CustomerInfo(JSONParser.parseJSON("{\"appUserId\":\"u1\",\"rewards\":\"none\"}")) + .getReferralRewards().isEmpty()); + assertFalse(new CustomerInfo(null).toMap().containsKey("rewards")); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/payment/CommerceManagerTest.java b/maven/core-unittests/src/test/java/com/codename1/payment/CommerceManagerTest.java new file mode 100644 index 00000000000..f03a25f6a07 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/payment/CommerceManagerTest.java @@ -0,0 +1,671 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.payment; + +import com.codename1.io.JSONParser; +import com.codename1.io.Preferences; +import com.codename1.io.Storage; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.security.Ed25519; +import com.codename1.testing.TestCodenameOneImplementation; +import com.codename1.ui.Display; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.util.Base64; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class CommerceManagerTest extends UITestBase { + private static final String ENDPOINT = "http://commerce.test/api"; + + /// A customer with one active entitlement that ended long ago by any + /// device clock: only the server decides when it stops being active. + private static final String PRO_CUSTOMER = + "{\"accepted\":true,\"degraded\":false,\"serverTime\":1700000000000," + + "\"appUserId\":\"USER\",\"originalAppUserId\":\"anon-1\"," + + "\"managementUrl\":\"https://store.test/manage\"," + + "\"activeSubscriptions\":[\"pro_monthly\"]," + + "\"entitlements\":{\"pro\":{\"entitlementId\":\"pro\",\"active\":true," + + "\"expiresDate\":1000,\"productId\":\"pro_monthly\",\"store\":\"apple\"," + + "\"periodType\":\"NORMAL\",\"ownershipType\":\"PURCHASED\"," + + "\"billingStage\":\"ACTIVE\",\"willRenew\":true,\"provenance\":\"PURCHASE\"}," + + "\"old\":{\"entitlementId\":\"old\",\"active\":false,\"expiresDate\":5}}," + + "\"attributes\":{\"plan\":\"gold\"}}"; + + @BeforeEach + void setUpCommerce() throws Exception { + Storage.setStorageInstance(null); + Storage.getInstance().clearStorage(); + Storage.getInstance().clearCache(); + Preferences.clearAll(); + implementation.clearNetworkMocks(); + implementation.setInAppPurchase(new Purchase() { + }); + Display d = Display.getInstance(); + d.setProperty("build_key", "test-build-key"); + d.setProperty("package_name", "com.example.app"); + d.setProperty("commerce.cloud.endpoint", ENDPOINT); + d.setProperty("commerce.cloud.enabled", "true"); + d.setProperty("commerce.cloud.publicKey", CommerceTestBase.testPublicKey()); + CommerceTestBase.signAnswers(implementation); + CommerceManager.resetForTests(); + } + + @AfterEach + void tearDownCommerce() { + Display d = Display.getInstance(); + d.setProperty("build_key", ""); + d.setProperty("commerce.cloud.publicKey", ""); + implementation.clearNetworkMocks(); + implementation.setInAppPurchase(null); + CommerceManager.resetForTests(); + } + + @FormTest + void customerLookupWithNoReceiptsGrantsEntitlement() { + mock("/customer", envelope(PRO_CUSTOMER.replace("USER", "u1"), null)); + CommerceManager cm = CommerceManager.getInstance(); + cm.setAppUserId("u1"); + assertFalse(cm.isEntitled("pro")); + assertNotNull(cm.getCustomerInfo()); + assertNull(cm.getCustomerInfo().getAppUserId()); + + cm.refresh(); + + assertFalse(cm.isDegraded()); + assertTrue(cm.isEntitled("pro")); + assertFalse(cm.isEntitled("old")); + assertEquals(1, cm.getActiveEntitlements().size()); + assertEquals("pro", cm.getActiveEntitlements().get(0)); + CustomerInfo info = cm.getCustomerInfo(); + assertEquals("u1", info.getAppUserId()); + assertEquals("anon-1", info.getOriginalAppUserId()); + assertEquals("https://store.test/manage", info.getManagementUrl()); + assertEquals(1700000000000L, info.getServerTime()); + assertEquals("pro_monthly", info.getActiveSubscriptions().get(0)); + assertEquals("gold", info.getAttributes().get("plan")); + assertEquals(1, info.getActiveEntitlements().size()); + EntitlementInfo pro = info.getEntitlement("pro"); + assertEquals("pro", pro.getId()); + assertEquals(1000L, pro.getExpiresDate()); + assertEquals("pro_monthly", pro.getProductId()); + assertEquals("apple", pro.getStore()); + assertEquals("NORMAL", pro.getPeriodType()); + assertEquals("PURCHASED", pro.getOwnershipType()); + assertEquals("ACTIVE", pro.getBillingStage()); + assertTrue(pro.isWillRenew()); + assertEquals("PURCHASE", pro.getProvenance()); + assertNull(info.getEntitlement("missing")); + } + + private static final String VALIDATE_ANSWER = + "{\"degraded\":false,\"entitlements\":{\"vip\":{\"active\":true}}}"; + + /// Records the body of every request to /validate and signs answers as + /// the faked cloud does. + private List recordValidate() { + final List bodies = new ArrayList(); + implementation.setNetworkMockHandler(new TestCodenameOneImplementation.NetworkMockHandler() { + @Override + public void handle(TestCodenameOneImplementation.TestConnection connection) { + if (connection.getUrl().endsWith("/validate") && connection.getOutputData() != null) { + bodies.add(new String(connection.getOutputData(), StandardCharsets.UTF_8)); + } + CommerceTestBase.signConnection(connection); + } + }); + return bodies; + } + + private void validateOneReceipt() { + // No customer answer, so what /validate said is what isEntitled reads. + implementation.addNetworkMockResponse(ENDPOINT + "/customer", 500, "Error", new byte[0]); + Receipt r = new Receipt(); + r.setStoreCode(Receipt.STORE_CODE_ITUNES); + r.setSku("vip_monthly"); + r.setTransactionId("tx-v"); + r.setOrderData("jws"); + List receipts = new ArrayList(); + receipts.add(r); + Receipt.registerExternalizable(); + // The store the device's receipts are read from. + Storage.getInstance().writeObject("CN1SubscriptionsData.dat", receipts); + CommerceManager.getInstance().refresh(); + } + + @FormTest + void signedValidateAnswerApplies() { + List bodies = recordValidate(); + mock("/validate", envelope(VALIDATE_ANSWER, null)); + validateOneReceipt(); + assertTrue(CommerceManager.getInstance().isEntitled("vip")); + assertFalse(bodies.isEmpty()); + assertTrue(bodies.get(bodies.size() - 1).contains("\"nonce\"")); + assertTrue(bodies.get(bodies.size() - 1).contains("\"productId\":\"vip_monthly\"")); + } + + @FormTest + void forgedValidateAnswerAppliesNothing() { + recordValidate(); + byte[] zeros = new byte[Ed25519.SIGNATURE_LENGTH]; + mock("/validate", envelope(VALIDATE_ANSWER, Base64.encodeNoNewline(zeros))); + validateOneReceipt(); + assertFalse(CommerceManager.getInstance().isEntitled("vip")); + assertTrue(CommerceManager.getInstance().isDegraded()); + } + + @FormTest + void unsignedValidateAnswerAppliesNothing() { + recordValidate(); + mock("/validate", VALIDATE_ANSWER); + validateOneReceipt(); + assertFalse(CommerceManager.getInstance().isEntitled("vip")); + assertTrue(CommerceManager.getInstance().isDegraded()); + } + + @FormTest + void withoutAKeyValidateIsSentAndItsAnswerIgnored() { + Display.getInstance().setProperty("commerce.cloud.publicKey", ""); + CommerceManager.resetForTests(); + CommerceManager.defaultPublicKey = ""; + List bodies = recordValidate(); + mock("/validate", VALIDATE_ANSWER); + validateOneReceipt(); + assertFalse(bodies.isEmpty()); + assertFalse(bodies.get(0).contains("\"nonce\"")); + assertFalse(CommerceManager.getInstance().isEntitled("vip")); + } + + @FormTest + void coldStartAnswersFromTheStoredCustomerBeforeAnyNetwork() { + mock("/customer", envelope(PRO_CUSTOMER.replace("USER", "u1"), null)); + CommerceManager.getInstance().setAppUserId("u1"); + CommerceManager.getInstance().refresh(); + + CommerceManager.resetForTests(); + implementation.clearNetworkMocks(); + CommerceManager cold = CommerceManager.getInstance(); + + assertEquals("u1", cold.getAppUserId()); + // expiresDate is 1000 ms after the epoch: long past by this clock, + // and still reported as the server last said. + assertTrue(cold.isEntitled("pro")); + assertEquals(1000L, cold.getCustomerInfo().getEntitlement("pro").getExpiresDate()); + assertEquals("gold", cold.getCustomerInfo().getAttributes().get("plan")); + assertEquals("pro", cold.getActiveEntitlements().get(0)); + } + + @FormTest + void storedCustomerBelongsToItsUser() { + mock("/customer", envelope(PRO_CUSTOMER.replace("USER", "u1"), null)); + CommerceManager cm = CommerceManager.getInstance(); + cm.setAppUserId("u1"); + cm.refresh(); + assertTrue(cm.isEntitled("pro")); + + cm.setAppUserId("someone/else"); + assertFalse(cm.isEntitled("pro")); + assertNull(cm.getCustomerInfo().getAppUserId()); + + cm.setAppUserId("u1"); + assertTrue(cm.isEntitled("pro")); + } + + @FormTest + void badOrMissingSignatureIsIgnoredWhenAKeyIsKnown() throws Exception { + byte[] priv = privateKey(); + Display.getInstance().setProperty("commerce.cloud.publicKey", + Base64.encodeNoNewline(Ed25519.publicKey(priv))); + CommerceManager cm = CommerceManager.getInstance(); + cm.setAppUserId("u1"); + + // Signed, but over a nonce this request never carried. + String payload = PRO_CUSTOMER.replace("USER", "u1"); + byte[] sig = Ed25519.sign(priv, ("not-the-nonce\n" + payload).getBytes(StandardCharsets.UTF_8)); + mock("/customer", envelope(payload, Base64.encodeNoNewline(sig))); + cm.refresh(); + assertTrue(cm.isDegraded()); + assertFalse(cm.isEntitled("pro")); + assertNull(cm.getCustomerInfo().getAppUserId()); + + // Not signed at all. + mock("/customer", envelope(payload, null)); + cm.refresh(); + assertTrue(cm.isDegraded()); + assertFalse(cm.isEntitled("pro")); + + // Nothing was stored for a cold start either. + CommerceManager.resetForTests(); + assertFalse(CommerceManager.getInstance().isEntitled("pro")); + } + + @FormTest + void goodSignatureOverTheRequestNonceIsAccepted() throws Exception { + final byte[] priv = privateKey(); + Display.getInstance().setProperty("commerce.cloud.publicKey", + Base64.encodeNoNewline(Ed25519.publicKey(priv))); + final String payload = PRO_CUSTOMER.replace("USER", "u1"); + final List nonces = new ArrayList(); + implementation.setNetworkMockHandler(new TestCodenameOneImplementation.NetworkMockHandler() { + @Override + public void handle(TestCodenameOneImplementation.TestConnection connection) { + try { + String body = new String(connection.getOutputData(), StandardCharsets.UTF_8); + Map request = JSONParser.parseJSON(body); + String nonce = String.valueOf(request.get("nonce")); + nonces.add(nonce); + assertEquals("test-build-key", request.get("buildKey")); + assertEquals("com.example.app", request.get("packageName")); + assertEquals("u1", request.get("appUserId")); + byte[] sig = Ed25519.sign(priv, (nonce + "\n" + payload).getBytes(StandardCharsets.UTF_8)); + connection.setInputData(envelope(payload, Base64.encodeNoNewline(sig)) + .getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + }); + CommerceManager cm = CommerceManager.getInstance(); + cm.setAppUserId("u1"); + + cm.refresh(); + assertFalse(cm.isDegraded()); + assertTrue(cm.isEntitled("pro")); + + cm.refresh(); + assertTrue(nonces.size() >= 2); + assertEquals(32, nonces.get(0).length()); + assertNotEquals(nonces.get(0), nonces.get(nonces.size() - 1)); + } + + @FormTest + void noAnswerIsTrustedWhenNoKeyIsKnown() { + Display.getInstance().setProperty("commerce.cloud.publicKey", ""); + CommerceManager.defaultPublicKey = ""; + implementation.setNetworkMockHandler(null); + mock("/customer", envelope(PRO_CUSTOMER.replace("USER", "u1"), null)); + CommerceManager cm = CommerceManager.getInstance(); + cm.setAppUserId("u1"); + cm.refresh(); + assertTrue(cm.isDegraded()); + assertFalse(cm.isEntitled("pro")); + assertNull(cm.getCustomerInfo().getAppUserId()); + + CommerceManager.resetForTests(); + assertFalse(CommerceManager.getInstance().isEntitled("pro")); + } + + private static String customerStorageKey(String id) { + return "CN1CommerceCustomer_" + id + "_" + Integer.toHexString(id.hashCode()); + } + + private void storeSignedCustomer() { + mock("/customer", envelope(PRO_CUSTOMER.replace("USER", "u1"), null)); + CommerceManager.getInstance().setAppUserId("u1"); + CommerceManager.getInstance().refresh(); + assertTrue(CommerceManager.getInstance().isEntitled("pro")); + CommerceManager.resetForTests(); + implementation.clearNetworkMocks(); + } + + @FormTest + void signedCacheIsHonouredAtColdStart() { + storeSignedCustomer(); + assertTrue(CommerceManager.getInstance().isEntitled("pro")); + assertEquals("u1", CommerceManager.getInstance().getCustomerInfo().getAppUserId()); + } + + @FormTest + void tamperedCacheIsDiscarded() throws Exception { + storeSignedCustomer(); + String name = customerStorageKey("u1"); + String stored = (String) Storage.getInstance().readObject(name); + assertTrue(stored.contains("u1")); + Storage.getInstance().writeObject(name, stored.replace("u1", "u2")); + assertFalse(CommerceManager.getInstance().isEntitled("pro")); + assertFalse(Storage.getInstance().exists(name)); + } + + @FormTest + void oldFormatCacheIsDiscarded() { + String name = customerStorageKey("u1"); + Storage.getInstance().writeObject(name, PRO_CUSTOMER.replace("USER", "u1")); + CommerceManager.getInstance().setAppUserId("u1"); + CommerceManager.resetForTests(); + implementation.clearNetworkMocks(); + assertFalse(CommerceManager.getInstance().isEntitled("pro")); + assertFalse(Storage.getInstance().exists(name)); + } + + @FormTest + void cacheIsDiscardedOnceTheKeyChanges() { + storeSignedCustomer(); + Display.getInstance().setProperty("commerce.cloud.publicKey", + Base64.encodeNoNewline(Ed25519.publicKey(privateKey()))); + assertFalse(CommerceManager.getInstance().isEntitled("pro")); + } + + @FormTest + void unreachableCloudIsDegradedAndKeepsTheLastAnswer() { + mock("/customer", envelope(PRO_CUSTOMER.replace("USER", "u1"), null)); + CommerceManager cm = CommerceManager.getInstance(); + cm.setAppUserId("u1"); + cm.refresh(); + implementation.addNetworkMockResponse(ENDPOINT + "/customer", 500, "Error", new byte[0]); + cm.refresh(); + assertTrue(cm.isDegraded()); + assertTrue(cm.isEntitled("pro")); + } + + @FormTest + void logInSwitchesTheIdAndAdoptsTheAnswer() { + CommerceManager cm = CommerceManager.getInstance(); + String anonymous = cm.getAppUserId(); + assertTrue(anonymous.startsWith("anon-")); + mock("/identify", envelope(PRO_CUSTOMER.replace("USER", "account-7"), null)); + + assertTrue(cm.logIn("account-7")); + + assertEquals("account-7", cm.getAppUserId()); + assertEquals("account-7", cm.getCustomerInfo().getAppUserId()); + assertTrue(cm.isEntitled("pro")); + assertFalse(cm.isDegraded()); + + // The new id and its customer survive a restart. + CommerceManager.resetForTests(); + implementation.clearNetworkMocks(); + assertEquals("account-7", CommerceManager.getInstance().getAppUserId()); + assertTrue(CommerceManager.getInstance().isEntitled("pro")); + } + + @FormTest + void failedLogInKeepsTheId() { + CommerceManager cm = CommerceManager.getInstance(); + String anonymous = cm.getAppUserId(); + implementation.addNetworkMockResponse(ENDPOINT + "/identify", 500, "Error", new byte[0]); + assertFalse(cm.logIn("account-7")); + assertEquals(anonymous, cm.getAppUserId()); + assertTrue(cm.isDegraded()); + } + + @FormTest + void logOutClearsTheCustomer() { + mock("/identify", envelope(PRO_CUSTOMER.replace("USER", "account-7"), null)); + CommerceManager cm = CommerceManager.getInstance(); + String first = cm.getAppUserId(); + assertTrue(cm.logIn("account-7")); + assertTrue(cm.isEntitled("pro")); + + cm.logOut(); + + String anonymous = cm.getAppUserId(); + assertTrue(anonymous.startsWith("anon-")); + assertNotEquals("account-7", anonymous); + assertNotEquals(first, anonymous); + assertFalse(cm.isEntitled("pro")); + assertNull(cm.getCustomerInfo().getAppUserId()); + assertTrue(cm.getActiveEntitlements().isEmpty()); + + // Logging back in to the same account on a cold start reads nothing + // stale: the stored customer went with the log out. + CommerceManager.resetForTests(); + CommerceManager.getInstance().setAppUserId("account-7"); + assertFalse(CommerceManager.getInstance().isEntitled("pro")); + } + + @FormTest + void listenerFiresWhenTheCustomerChanges() { + mock("/customer", envelope(PRO_CUSTOMER.replace("USER", "u1"), null)); + CommerceManager cm = CommerceManager.getInstance(); + cm.setAppUserId("u1"); + final List sources = new ArrayList(); + final boolean[] onEdt = {true}; + ActionListener l = new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + sources.add(evt.getSource()); + onEdt[0] &= Display.getInstance().isEdt(); + } + }; + cm.addCustomerInfoListener(l); + + cm.refresh(); + com.codename1.ui.DisplayTest.flushEdt(); + assertEquals(1, sources.size()); + assertSame(cm.getCustomerInfo(), sources.get(0)); + assertTrue(onEdt[0]); + + // The same state with a later server clock is not a change. + mock("/customer", envelope(PRO_CUSTOMER.replace("USER", "u1") + .replace("1700000000000", "1700000009999"), null)); + cm.refresh(); + com.codename1.ui.DisplayTest.flushEdt(); + assertEquals(1, sources.size()); + assertEquals(1700000009999L, cm.getCustomerInfo().getServerTime()); + + mock("/customer", envelope(PRO_CUSTOMER.replace("USER", "u1") + .replace("\"active\":true", "\"active\":false"), null)); + cm.refresh(); + com.codename1.ui.DisplayTest.flushEdt(); + assertEquals(2, sources.size()); + assertFalse(cm.isEntitled("pro")); + + cm.removeCustomerInfoListener(l); + cm.logOut(); + com.codename1.ui.DisplayTest.flushEdt(); + assertEquals(2, sources.size()); + } + + @FormTest + void customerRoundTripsThroughItsMap() throws Exception { + CustomerInfo a = new CustomerInfo(JSONParser.parseJSON(PRO_CUSTOMER)); + CustomerInfo b = new CustomerInfo(JSONParser.parseJSON(JSONParser.mapToJson(a.toMap()))); + assertTrue(a.sameState(b)); + assertEquals(a.getServerTime(), b.getServerTime()); + assertEquals(1000L, b.getEntitlement("pro").getExpiresDate()); + assertTrue(new CustomerInfo(null).getEntitlements().isEmpty()); + assertFalse(new CustomerInfo(null).isEntitled("pro")); + } + + @FormTest + void cloudDisabledIsANoOp() { + Display.getInstance().setProperty("build_key", ""); + // Any request would fail the test by degrading the manager. + implementation.addNetworkMockResponse(ENDPOINT + "/customer", 500, "Error", new byte[0]); + CommerceManager cm = CommerceManager.getInstance(); + assertFalse(cm.isCloudEnabled()); + + cm.refresh(); + CommerceManager.receiptsSynchronized(); + cm.restore(); + assertFalse(cm.setAttributes(new HashMap())); + + assertFalse(cm.isDegraded()); + assertFalse(cm.isEntitled("pro")); + assertNotNull(cm.getCustomerInfo()); + assertTrue(cm.getActiveEntitlements().isEmpty()); + assertTrue(cm.logIn("account-7")); + assertEquals("account-7", cm.getAppUserId()); + } + + @FormTest + void attributesArePosted() { + implementation.addNetworkMockResponse(ENDPOINT + "/attributes", 204, "No Content", new byte[0]); + Map attrs = new HashMap(); + attrs.put("plan", "gold"); + attrs.put("gone", null); + assertTrue(CommerceManager.getInstance().setAttributes(attrs)); + implementation.addNetworkMockResponse(ENDPOINT + "/attributes", 403, "Forbidden", new byte[0]); + assertFalse(CommerceManager.getInstance().setAttributes(attrs)); + } + + /// The service picks a verifier by store name; a receipt whose store it + /// does not recognise is one it cannot validate. + @FormTest + void receiptsAreSentUnderTheStoreNameTheServiceRoutesOn() { + Receipt amazon = new Receipt(); + amazon.setStoreCode(Receipt.STORE_CODE_AMAZON); + assertEquals("amazon", CommerceManager.storeOf(amazon)); + + Receipt play = new Receipt(); + play.setStoreCode(Receipt.STORE_CODE_PLAY); + assertEquals("google", CommerceManager.storeOf(play)); + + Receipt apple = new Receipt(); + apple.setStoreCode(Receipt.STORE_CODE_ITUNES); + assertEquals("apple", CommerceManager.storeOf(apple)); + + assertEquals("", CommerceManager.storeOf(new Receipt())); + } + + private void mock(String path, String body) { + implementation.addNetworkMockResponse(ENDPOINT + path, 200, "OK", body.getBytes(StandardCharsets.UTF_8)); + } + + @FormTest + void interleavedCallsEachCacheTheirOwnEnvelope() { + mock("/customer", envelope(PRO_CUSTOMER.replace("USER", "u1"), null)); + mock("/offerings", envelope("{\"accepted\":true,\"current\":\"main\",\"offerings\":[]}", null)); + final CommerceManager cm = CommerceManager.getInstance(); + cm.setAppUserId("u1"); + // The customer refresh runs on the EDT while the offerings request + // is out inside invokeAndBlock. + Display.getInstance().callSerially(new Runnable() { + public void run() { + cm.refresh(); + } + }); + cm.getOfferings(); + flushSerialCalls(); + String offeringsKey = "CN1CommerceOfferings_u1__" + Integer.toHexString("u1/".hashCode()); + String customer = (String) Storage.getInstance().readObject(customerStorageKey("u1")); + String offerings = (String) Storage.getInstance().readObject(offeringsKey); + assertTrue(customer.contains("originalAppUserId")); + assertFalse(customer.contains("offerings")); + assertTrue(offerings.contains("offerings")); + assertFalse(offerings.contains("originalAppUserId")); + } + + @FormTest + void answerForAnotherUserIsRejectedOnReceipt() { + mock("/customer", envelope(PRO_CUSTOMER.replace("USER", "someone-else"), null)); + CommerceManager cm = CommerceManager.getInstance(); + cm.setAppUserId("u1"); + cm.refresh(); + assertTrue(cm.isDegraded()); + assertFalse(cm.isEntitled("pro")); + assertFalse(Storage.getInstance().exists(customerStorageKey("u1"))); + } + + @FormTest + void answerForAnotherPackageIsRejected() { + mock("/customer", envelope(PRO_CUSTOMER.replace("USER", "u1") + .replace("{\"accepted\"", "{\"packageName\":\"com.other\",\"accepted\""), null)); + CommerceManager cm = CommerceManager.getInstance(); + cm.setAppUserId("u1"); + cm.refresh(); + assertFalse(cm.isEntitled("pro")); + } + + @FormTest + void matchingBindingFieldsAreAccepted() { + mock("/customer", envelope(PRO_CUSTOMER.replace("USER", "u1").replace("{\"accepted\"", + "{\"packageName\":\"com.example.app\",\"endpoint\":\"/customer\",\"accepted\""), null)); + CommerceManager cm = CommerceManager.getInstance(); + cm.setAppUserId("u1"); + cm.refresh(); + assertTrue(cm.isEntitled("pro")); + } + + @FormTest + void storedAnswerForAnotherUserIsRejectedOnLoad() { + storeSignedCustomer(); + Object stored = Storage.getInstance().readObject(customerStorageKey("u1")); + Storage.getInstance().writeObject(customerStorageKey("u2"), stored); + CommerceManager.resetForTests(); + implementation.clearNetworkMocks(); + CommerceManager.getInstance().setAppUserId("u2"); + assertFalse(CommerceManager.getInstance().isEntitled("pro")); + assertFalse(Storage.getInstance().exists(customerStorageKey("u2"))); + } + + @FormTest + void offeringsEnvelopeInTheCustomerSlotIsRejected() throws Exception { + String payload = PRO_CUSTOMER.replace("USER", "u1") + .replace("{\"accepted\"", "{\"endpoint\":\"/offerings\",\"accepted\""); + byte[] sig = Ed25519.sign(CommerceTestBase.testPrivateKey(), ("n1\n" + payload).getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder("{\"nonce\":\"n1\",\"signature\":\"") + .append(Base64.encodeNoNewline(sig)).append("\",\"payload\":\""); + for (int i = 0; i < payload.length(); i++) { + char c = payload.charAt(i); + if (c == '"' || c == '\\') { + sb.append('\\'); + } + sb.append(c); + } + sb.append("\"}"); + Storage.getInstance().writeObject(customerStorageKey("u1"), sb.toString()); + implementation.clearNetworkMocks(); + CommerceManager.getInstance().setAppUserId("u1"); + assertFalse(CommerceManager.getInstance().isEntitled("pro")); + assertFalse(Storage.getInstance().exists(customerStorageKey("u1"))); + } + + /// The signed envelope of the contract: the payload travels as JSON text + /// inside a string, since that text is what the signature covers. + private static String envelope(String payload, String signature) { + StringBuilder sb = new StringBuilder("{\"payload\":\""); + for (int i = 0; i < payload.length(); i++) { + char c = payload.charAt(i); + if (c == '"' || c == '\\') { + sb.append('\\'); + } + sb.append(c); + } + sb.append("\",\"signature\":"); + if (signature == null) { + sb.append("null"); + } else { + sb.append('"').append(signature).append('"'); + } + sb.append(",\"keyId\":null}"); + return sb.toString(); + } + + private static byte[] privateKey() { + byte[] priv = new byte[Ed25519.PRIVATE_KEY_LENGTH]; + for (int i = 0; i < priv.length; i++) { + priv[i] = (byte) (i * 7 + 3); + } + return priv; + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/payment/CommerceOfferingsTest.java b/maven/core-unittests/src/test/java/com/codename1/payment/CommerceOfferingsTest.java new file mode 100644 index 00000000000..62dd8e0e023 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/payment/CommerceOfferingsTest.java @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.payment; + +import com.codename1.junit.FormTest; +import com.codename1.ui.Display; + +import static org.junit.jupiter.api.Assertions.*; + +class CommerceOfferingsTest extends CommerceTestBase { + static final String OFFERINGS = + "{\"accepted\":true,\"currentOfferingId\":\"main\"," + + "\"experiment\":{\"id\":\"exp1\",\"variant\":\"B\"}," + + "\"offerings\":[{\"id\":\"main\",\"description\":\"Main offer\"," + + "\"metadata\":{\"theme\":\"gold\",\"tags\":[\"a\",\"b\"]}," + + "\"paywall\":{\"version\":1,\"id\":\"pw1\",\"root\":{\"type\":\"stack\"}}," + + "\"packages\":[" + + "{\"id\":\"monthly\",\"type\":\"MONTHLY\",\"productId\":\"pro_monthly\",\"period\":\"P1M\"," + + "\"trialPeriod\":\"P7D\",\"introPrice\":\"0.99\",\"introPeriod\":\"P1M\",\"eligibleForIntro\":true}," + + "{\"id\":\"annual\",\"type\":\"annual\",\"productId\":\"pro_annual\",\"period\":\"P1Y\"," + + "\"eligibleForIntro\":false}," + + "{\"id\":\"weekly\",\"type\":\"WEEKLY\",\"productId\":\"pro_weekly\",\"period\":\"P1W\"}," + + "{\"id\":\"forever\",\"type\":\"LIFETIME\",\"productId\":\"pro_forever\"}," + + "{\"id\":\"coins\",\"type\":\"CUSTOM\",\"productId\":\"coins_100\"}," + + "{\"id\":\"odd\",\"type\":\"CUSTOM\",\"productId\":\"pro_odd\",\"period\":\"P10D\"}]}," + + "{\"id\":\"sale\",\"description\":null,\"metadata\":{},\"paywall\":null,\"packages\":[]}]}"; + + @FormTest + void parsesOfferingsAndLooksPackagesUp() throws Exception { + Offerings all = offerings(OFFERINGS); + assertFalse(all.isEmpty()); + assertEquals(2, all.getAll().size()); + assertEquals("exp1", all.getExperimentId()); + assertEquals("B", all.getExperimentVariant()); + Offering main = all.getCurrent(); + assertSame(main, all.getOffering("main")); + assertNull(all.getOffering("missing")); + assertNull(all.getOffering(null)); + assertEquals("Main offer", main.getDescription()); + assertEquals("gold", main.getMetadata().get("theme")); + assertEquals(6, main.getPackages().size()); + assertEquals("pw1", main.getPaywall().get("id")); + // What a caller does to the answer does not reach the offering. + main.getPaywall().clear(); + main.getPackages().clear(); + main.getMetadata().clear(); + assertEquals("pw1", main.getPaywall().get("id")); + assertEquals(6, main.getPackages().size()); + assertEquals("gold", main.getMetadata().get("theme")); + + CommercePackage monthly = main.getMonthly(); + assertEquals("monthly", monthly.getId()); + assertEquals(CommercePackage.TYPE_MONTHLY, monthly.getType()); + assertEquals("pro_monthly", monthly.getProductId()); + assertEquals("P1M", monthly.getPeriod()); + assertEquals("P7D", monthly.getTrialPeriod()); + assertEquals("0.99", monthly.getIntroPrice()); + assertEquals("P1M", monthly.getIntroPeriod()); + assertTrue(monthly.isEligibleForIntro()); + assertNull(monthly.getProduct()); + assertTrue(monthly.isSubscription()); + + // A type is a protocol token: it matches whatever its case. + CommercePackage annual = main.getAnnual(); + assertEquals("annual", annual.getId()); + assertFalse(annual.isEligibleForIntro()); + assertNull(annual.getTrialPeriod()); + assertEquals("weekly", main.getWeekly().getId()); + assertEquals("forever", main.getLifetime().getId()); + assertFalse(main.getLifetime().isSubscription()); + assertFalse(main.getPackage("coins").isSubscription()); + assertTrue(main.getPackage("odd").isSubscription()); + assertNull(main.getPackage("missing")); + + Offering sale = all.getOffering("sale"); + assertNull(sale.getPaywall()); + assertNull(sale.getMonthly()); + assertTrue(sale.getPackages().isEmpty()); + + Offerings empty = new Offerings(null); + assertTrue(empty.isEmpty()); + assertNull(empty.getCurrent()); + assertNull(empty.getExperimentId()); + } + + @FormTest + void fetchesOfferingsAndAttachesStoreProducts() { + implementation.setInAppPurchase(new Purchase() { + @Override + public boolean isItemListingSupported() { + return true; + } + + @Override + public Product[] getProducts(String[] skus) { + Product p = new Product(); + p.setSku("pro_annual"); + p.setLocalizedPrice("$49.99"); + return new Product[] {p, null}; + } + }); + mock("/offerings", OFFERINGS); + Offerings all = CommerceManager.getInstance().getOfferings("onboarding"); + Offering main = all.getCurrent(); + assertNotNull(main); + assertEquals("$49.99", main.getAnnual().getProduct().getLocalizedPrice()); + assertNull(main.getMonthly().getProduct()); + } + + @FormTest + void answersFromTheStoredOfferingsWhenTheNetworkFails() { + mock("/offerings", OFFERINGS); + CommerceManager cm = CommerceManager.getInstance(); + cm.setAppUserId("u1"); + assertEquals("main", cm.getOfferings().getCurrent().getId()); + + CommerceManager.resetForTests(); + implementation.clearNetworkMocks(); + mockFailure("/offerings"); + Offerings stored = CommerceManager.getInstance().getOfferings(); + Offering main = stored.getCurrent(); + assertNotNull(main); + assertEquals("exp1", stored.getExperimentId()); + assertEquals(6, main.getPackages().size()); + assertTrue(main.getMonthly().isEligibleForIntro()); + assertEquals("P7D", main.getMonthly().getTrialPeriod()); + assertEquals("pw1", main.getPaywall().get("id")); + + // Another placement, and another customer, have no stored answer. + assertTrue(CommerceManager.getInstance().getOfferings("settings").isEmpty()); + } + + @FormTest + void aRefusedAnswerIsNotOfferings() { + mock("/offerings", "{\"accepted\":false,\"currentOfferingId\":\"main\",\"offerings\":[{\"id\":\"main\"}]}"); + assertTrue(CommerceManager.getInstance().getOfferings().isEmpty()); + } + + @FormTest + void purchasingAPackageSubscribesOrBuysOnce() throws Exception { + Offering main = offerings(OFFERINGS).getCurrent(); + CommerceManager cm = CommerceManager.getInstance(); + cm.purchase(main.getMonthly()); + cm.purchase(main.getLifetime()); + cm.purchase(main.getPackage("coins")); + cm.purchase(main.getPackage("odd")); + cm.purchase((CommercePackage) null); + assertEquals("[subscribe:pro_monthly, purchase:pro_forever, purchase:coins_100, subscribe:pro_odd]", + storeCalls.toString()); + } + + @FormTest + void managementUrlAndCurrenciesComeFromTheCustomer() { + mock("/customer", "{\"accepted\":true,\"appUserId\":\"u1\",\"managementUrl\":\"https://store.test/m\"," + + "\"currencies\":{\"gems\":120,\"coins\":0}}"); + CommerceManager cm = CommerceManager.getInstance(); + cm.setAppUserId("u1"); + assertNull(cm.getManagementUrl()); + cm.refresh(); + assertEquals("https://store.test/m", cm.getManagementUrl()); + CustomerInfo info = cm.getCustomerInfo(); + assertEquals(120L, info.getCurrencyBalance("gems")); + assertEquals(0L, info.getCurrencyBalance("missing")); + assertEquals(0L, info.getCurrencyBalance(null)); + assertEquals(2, info.getCurrencies().size()); + // and they survive a cold start + CommerceManager.resetForTests(); + assertEquals(120L, CommerceManager.getInstance().getCustomerInfo().getCurrencyBalance("gems")); + } + + @FormTest + void eventsAreQueuedAndSentTogether() { + final CommerceManager cm = CommerceManager.getInstance(); + final String[] body = new String[1]; + Runnable r = new Runnable() { + @Override + public void run() { + cm.logEvent("IMPRESSION", "main", "pw1", null); + cm.logEvent("CANCEL_REASON", null, null, null, "too \"pricey\""); + body[0] = cm.drainEvents(); + } + }; + if (Display.getInstance().isEdt()) { + r.run(); + } else { + Display.getInstance().callSerially(r); + flushSerialCalls(); + } + assertNotNull(body[0]); + assertTrue(body[0].contains("\"events\":[{\"type\":\"IMPRESSION\",\"offeringId\":\"main\",\"paywallId\":\"pw1\",\"packageId\":null,\"time\":")); + assertTrue(body[0].contains("\"reason\":\"too \\\"pricey\\\"\"")); + assertTrue(body[0].contains("\"buildKey\":\"test-build-key\"")); + assertNull(cm.drainEvents()); + + // With the cloud off an event goes nowhere. + Display.getInstance().setProperty("commerce.cloud.enabled", "false"); + cm.logEvent("CLOSE", null, null, null); + assertNull(cm.drainEvents()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/payment/CommerceTestBase.java b/maven/core-unittests/src/test/java/com/codename1/payment/CommerceTestBase.java new file mode 100644 index 00000000000..44a4c5a3304 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/payment/CommerceTestBase.java @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.payment; + +import com.codename1.analytics.AbstractAnalyticsProvider; +import com.codename1.analytics.Analytics; +import com.codename1.analytics.AnalyticsCapability; +import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.AnalyticsEvent; +import com.codename1.analytics.ConsentMode; +import com.codename1.io.JSONParser; +import com.codename1.security.Ed25519; +import com.codename1.testing.TestCodenameOneImplementation; +import com.codename1.util.Base64; +import com.codename1.io.Preferences; +import com.codename1.io.Storage; +import com.codename1.junit.UITestBase; +import com.codename1.components.SpanLabel; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/// What the commerce tests share: a cold manager pointed at a faked cloud, +/// and helpers to answer its requests and to look into a component tree. +public abstract class CommerceTestBase extends UITestBase { + protected static final String ENDPOINT = "http://commerce.test/api"; + + /// SKUs the faked store was asked to subscribe to or purchase. + protected final List storeCalls = new ArrayList(); + + @BeforeEach + void setUpCommerce() throws Exception { + Storage.setStorageInstance(null); + Storage.getInstance().clearStorage(); + Storage.getInstance().clearCache(); + Preferences.clearAll(); + storeCalls.clear(); + implementation.clearNetworkMocks(); + implementation.setInAppPurchase(createStore()); + Display d = Display.getInstance(); + d.setProperty("build_key", "test-build-key"); + d.setProperty("package_name", "com.example.app"); + d.setProperty("commerce.cloud.endpoint", ENDPOINT); + d.setProperty("commerce.cloud.enabled", "true"); + d.setProperty("commerce.cloud.publicKey", testPublicKey()); + signAnswers(implementation); + CommerceManager.resetForTests(); + } + + @AfterEach + void tearDownCommerce() { + Display d = Display.getInstance(); + d.setProperty("build_key", ""); + d.setProperty("commerce.cloud.enabled", "true"); + implementation.clearNetworkMocks(); + implementation.setInAppPurchase(null); + CommerceManager.resetForTests(); + Analytics.clearProviders(); + Analytics.setConsent(null); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.attributionAvailable(false); + } + + /// Registers a provider that records every event, with the user's + /// consent on record. Without this call a test has no analytics at all, + /// which is the state of an application that never registered a provider. + protected static AnalyticsRecorder analyticsOn() { + Analytics.clearProviders(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.granted()); + AnalyticsRecorder recorder = new AnalyticsRecorder(); + Analytics.addProvider(recorder); + return recorder; + } + + /// An analytics provider that keeps the events it is handed. + public static final class AnalyticsRecorder extends AbstractAnalyticsProvider { + private final List events = new ArrayList(); + + @Override + public String getName() { + return "commerce-recorder"; + } + + @Override + public void trackEvent(AnalyticsEvent event) { + events.add(event); + } + + @Override + public boolean supports(AnalyticsCapability capability) { + return true; + } + + public int count(String name) { + int n = 0; + for (AnalyticsEvent e : events) { + if (name.equals(e.getName())) { + n++; + } + } + return n; + } + + /// The last event of a name, or null. + public AnalyticsEvent last(String name) { + AnalyticsEvent found = null; + for (AnalyticsEvent e : events) { + if (name.equals(e.getName())) { + found = e; + } + } + return found; + } + + /// A parameter of the last event of a name; null when there is no + /// such event or it has no such parameter. + public Object param(String name, String key) { + AnalyticsEvent e = last(name); + return e == null ? null : e.getParameters().get(key); + } + } + + /// The store of the test: it records what it is asked to sell. + protected Purchase createStore() { + return new Purchase() { + @Override + public void subscribe(String sku) { + storeCalls.add("subscribe:" + sku); + } + + @Override + public void purchase(String sku) { + storeCalls.add("purchase:" + sku); + } + }; + } + + protected void mock(String path, String payload) { + implementation.addNetworkMockResponse(ENDPOINT + path, 200, "OK", + envelope(payload).getBytes(StandardCharsets.UTF_8)); + } + + protected void mockFailure(String path) { + implementation.addNetworkMockResponse(ENDPOINT + path, 500, "Error", new byte[0]); + } + + /// The private key the faked cloud signs with. + public static byte[] testPrivateKey() { + byte[] priv = new byte[Ed25519.PRIVATE_KEY_LENGTH]; + for (int i = 0; i < priv.length; i++) { + priv[i] = (byte) (i * 11 + 5); + } + return priv; + } + + /// The public key matching [#testPrivateKey()], as the hint carries it. + public static String testPublicKey() { + return Base64.encodeNoNewline(Ed25519.publicKey(testPrivateKey())); + } + + /// Makes the faked cloud sign every unsigned envelope it answers with, + /// over the nonce of the request it answers, as the real service does. + /// The client trusts no unsigned answer, so a mock written as a bare + /// envelope is signed here rather than in every test. + public static void signAnswers(TestCodenameOneImplementation impl) { + impl.setNetworkMockHandler(new TestCodenameOneImplementation.NetworkMockHandler() { + @Override + public void handle(TestCodenameOneImplementation.TestConnection connection) { + signConnection(connection); + } + }); + } + + public static void signConnection(TestCodenameOneImplementation.TestConnection connection) { + try { + byte[] in = connection.getInputData(); + byte[] out = connection.getOutputData(); + if (in == null || out == null) { + return; + } + String text = new String(in, StandardCharsets.UTF_8); + if (!text.contains("\"signature\":null")) { + return; + } + Object payload = JSONParser.parseJSON(text).get("payload"); + Object nonce = JSONParser.parseJSON(new String(out, StandardCharsets.UTF_8)).get("nonce"); + if (!(payload instanceof String) || !(nonce instanceof String)) { + return; + } + byte[] sig = Ed25519.sign(testPrivateKey(), + (nonce + "\n" + payload).getBytes(StandardCharsets.UTF_8)); + byte[] signed = text.replace("\"signature\":null", + "\"signature\":\"" + Base64.encodeNoNewline(sig) + "\"").getBytes(StandardCharsets.UTF_8); + connection.setInputData(signed); + connection.setContentLength(signed.length); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /// The unsigned envelope of the contract around a payload. + protected static String envelope(String payload) { + StringBuilder sb = new StringBuilder("{\"payload\":\""); + for (int i = 0; i < payload.length(); i++) { + char c = payload.charAt(i); + if (c == '"' || c == '\\') { + sb.append('\\'); + } + sb.append(c); + } + sb.append("\",\"signature\":null,\"keyId\":null}"); + return sb.toString(); + } + + protected static Offerings offerings(String json) throws Exception { + return new Offerings(JSONParser.parseJSON(json)); + } + + protected static void attach(CommercePackage pkg, String name, String price) { + Product p = new Product(); + p.setSku(pkg.getProductId()); + p.setDisplayName(name); + p.setLocalizedPrice(price); + pkg.setProduct(p); + } + + /// Every component under a root whose UIID, or text UIID for a span + /// label, is the given one. + protected static List byUiid(Component root, String uiid) { + List out = new ArrayList(); + collect(root, uiid, out); + return out; + } + + private static void collect(Component c, String uiid, List out) { + if (c instanceof SpanLabel) { + if (uiid.equals(((SpanLabel) c).getTextUIID())) { + out.add(c); + } + return; + } + if (uiid.equals(c.getUIID())) { + out.add(c); + } + if (c instanceof Container) { + Container cnt = (Container) c; + for (int i = 0; i < cnt.getComponentCount(); i++) { + collect(cnt.getComponentAt(i), uiid, out); + } + } + } + + protected static Component byName(Component c, String name) { + if (name.equals(c.getName())) { + return c; + } + if (c instanceof Container) { + Container cnt = (Container) c; + for (int i = 0; i < cnt.getComponentCount(); i++) { + Component found = byName(cnt.getComponentAt(i), name); + if (found != null) { + return found; + } + } + } + return null; + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/payment/CommerceWebPurchaseTest.java b/maven/core-unittests/src/test/java/com/codename1/payment/CommerceWebPurchaseTest.java new file mode 100644 index 00000000000..3db4ef93e37 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/payment/CommerceWebPurchaseTest.java @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.payment; + +import com.codename1.junit.FormTest; +import com.codename1.testing.TestCodenameOneImplementation; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/// The parts of the commerce client that leave the platform store: the Mac +/// store name, web checkout, the billing portal and signed promotional offers. +class CommerceWebPurchaseTest extends CommerceTestBase { + /// One offering whose package has a web price and no store product. + private static final String WEB_OFFERING = + "{\"currentOfferingId\":\"main\",\"offerings\":[{\"id\":\"main\",\"packages\":[" + + "{\"id\":\"annual\",\"type\":\"ANNUAL\",\"period\":\"P1Y\"}," + + "{\"id\":\"monthly\",\"type\":\"MONTHLY\",\"productId\":\"pro_monthly\",\"period\":\"P1M\"}]}]}"; + + private final List bodies = new ArrayList(); + private final List offerCalls = new ArrayList(); + + @Override + protected Purchase createStore() { + return new Purchase() { + @Override + public void subscribe(String sku) { + storeCalls.add("subscribe:" + sku); + } + + @Override + public void purchase(String sku) { + storeCalls.add("purchase:" + sku); + } + + @Override + public void purchase(String sku, PromotionalOffer offer) { + if (offer instanceof ApplePromotionalOffer) { + ApplePromotionalOffer a = (ApplePromotionalOffer) offer; + offerCalls.add(sku + "|" + a.getOfferIdentifier() + "|" + a.getKeyIdentifier() + "|" + + a.getNonce() + "|" + a.getSignature() + "|" + a.getTimestamp()); + } else { + offerCalls.add(sku + "|" + offer); + } + } + }; + } + + /// Keeps the body of every request, beside the canned answers. + private void recordBodies() { + bodies.clear(); + implementation.setNetworkMockHandler(new TestCodenameOneImplementation.NetworkMockHandler() { + @Override + public void handle(TestCodenameOneImplementation.TestConnection connection) { + byte[] out = connection.getOutputData(); + if (out != null) { + bodies.add(new String(out, StandardCharsets.UTF_8)); + } + signConnection(connection); + } + }); + } + + private boolean anyBodyHas(String text) { + for (String b : bodies) { + if (b.contains(text)) { + return true; + } + } + return false; + } + + private void customerOn(String store, String managementUrl) { + mock("/customer", "{\"accepted\":true,\"appUserId\":\"u1\"," + + "\"managementUrl\":\"" + managementUrl + "\"," + + "\"entitlements\":{" + + "\"old\":{\"entitlementId\":\"old\",\"active\":true,\"expiresDate\":10,\"store\":\"apple\"}," + + "\"pro\":{\"entitlementId\":\"pro\",\"active\":true,\"expiresDate\":5000,\"store\":\"" + store + "\"}," + + "\"gone\":{\"entitlementId\":\"gone\",\"active\":false,\"expiresDate\":9000,\"store\":\"paddle\"}}}"); + CommerceManager cm = CommerceManager.getInstance(); + cm.setAppUserId("u1"); + cm.refresh(); + } + + @FormTest + void aMacReceiptIsSentUnderTheMacStore() { + Receipt r = new Receipt(); + r.setStoreCode(Receipt.STORE_CODE_ITUNES); + Receipt play = new Receipt(); + play.setStoreCode(Receipt.STORE_CODE_PLAY); + try { + implementation.setPlatformName("mac"); + assertEquals("mac", CommerceManager.storeOf(r)); + assertEquals("google", CommerceManager.storeOf(play)); + implementation.setPlatformName("ios"); + assertEquals("apple", CommerceManager.storeOf(r)); + } finally { + implementation.setPlatformName(null); + } + } + + @FormTest + void aPackageKnowsItsOfferingAndTheCheckoutIsAskedForByBothIds() throws Exception { + CommercePackage annual = offerings(WEB_OFFERING).getCurrent().getPackage("annual"); + assertEquals("main", annual.getOfferingId()); + mock("/web-purchase", "{\"accepted\":true,\"url\":\"https://pay.test/c/1\"}"); + recordBodies(); + CommerceManager cm = CommerceManager.getInstance(); + assertEquals("https://pay.test/c/1", cm.getWebPurchaseUrl(annual, "app://done", "app://back")); + assertTrue(anyBodyHas("\"offeringId\":\"main\""), bodies.toString()); + assertTrue(anyBodyHas("\"packageId\":\"annual\""), bodies.toString()); + assertTrue(anyBodyHas("\"successUrl\":\"app://done\""), bodies.toString()); + assertTrue(anyBodyHas("\"cancelUrl\":\"app://back\""), bodies.toString()); + } + + @FormTest + void aRefusedOrUnreachableCheckoutIsNull() throws Exception { + CommercePackage annual = offerings(WEB_OFFERING).getCurrent().getPackage("annual"); + CommerceManager cm = CommerceManager.getInstance(); + mock("/web-purchase", "{\"accepted\":false}"); + assertNull(cm.getWebPurchaseUrl(annual, null, null)); + assertFalse(cm.purchaseOnWeb(annual)); + mock("/web-purchase", "{\"accepted\":true,\"url\":\"\"}"); + assertNull(cm.getWebPurchaseUrl(annual, null, null)); + mockFailure("/web-purchase"); + assertNull(cm.getWebPurchaseUrl(annual, null, null)); + assertNull(cm.getWebPurchaseUrl(null, null, null)); + } + + @FormTest + void withNoNativeStoreAPackageWithNoProductIsSoldOnTheWeb() throws Exception { + Offering main = offerings(WEB_OFFERING).getCurrent(); + CommercePackage annual = main.getPackage("annual"); + CommercePackage monthly = main.getPackage("monthly"); + mock("/web-purchase", "{\"accepted\":true,\"url\":\"https://pay.test/c/2\"}"); + CommerceManager cm = CommerceManager.getInstance(); + assertTrue(cm.isWebPurchase(annual)); + assertFalse(cm.isWebPurchase(monthly)); + cm.purchase(annual); + assertEquals("https://pay.test/c/2", implementation.getExecuteURL()); + assertTrue(storeCalls.isEmpty()); + cm.purchase(monthly); + assertEquals("[subscribe:pro_monthly]", storeCalls.toString()); + } + + @FormTest + void aNativeStoreNeverFallsBackToTheWeb() throws Exception { + implementation.setInAppPurchase(new Purchase() { + @Override + public boolean isSubscriptionSupported() { + return true; + } + }); + CommercePackage annual = offerings(WEB_OFFERING).getCurrent().getPackage("annual"); + assertFalse(CommerceManager.getInstance().isWebPurchase(annual)); + } + + @FormTest + void aWebSubscriptionIsManagedInThePortal() { + customerOn("stripe", "https://store.test/manage"); + CommerceManager cm = CommerceManager.getInstance(); + // nothing was asked yet, and asking is never implicit + assertEquals("https://store.test/manage", cm.getManagementUrl()); + mock("/web-portal", "{\"accepted\":true,\"url\":\"https://billing.test/p/1\"}"); + assertEquals("https://billing.test/p/1", cm.fetchManagementUrl()); + assertEquals("https://billing.test/p/1", cm.getManagementUrl()); + mock("/web-portal", "{\"accepted\":false}"); + assertEquals("https://billing.test/p/1", cm.fetchManagementUrl()); + } + + @FormTest + void aStoreSubscriptionIsManagedInTheStore() { + customerOn("apple", "https://store.test/manage"); + recordBodies(); + CommerceManager cm = CommerceManager.getInstance(); + mock("/web-portal", "{\"accepted\":true,\"url\":\"https://billing.test/p/1\"}"); + assertEquals("https://store.test/manage", cm.fetchManagementUrl()); + assertEquals("https://store.test/manage", cm.getManagementUrl()); + assertTrue(bodies.isEmpty(), bodies.toString()); + } + + @FormTest + void aSignedPromotionalOfferReachesTheStore() { + mock("/promo-offer-signature", "{\"accepted\":true,\"keyId\":\"KEY1\",\"nonce\":\"n-1\"," + + "\"timestamp\":1700000000123,\"signature\":\"c2ln\"}"); + recordBodies(); + CommerceManager cm = CommerceManager.getInstance(); + try { + // no promotional offers on this store: nothing is asked or bought + assertFalse(cm.purchaseWithPromotionalOffer("pro_half", "half")); + assertTrue(bodies.isEmpty(), bodies.toString()); + implementation.setPlatformName("ios"); + assertTrue(cm.purchaseWithPromotionalOffer("pro_half", "half")); + assertEquals("[pro_half|half|KEY1|n-1|c2ln|1700000000123]", offerCalls.toString()); + assertTrue(anyBodyHas("\"productId\":\"pro_half\""), bodies.toString()); + assertTrue(anyBodyHas("\"offerId\":\"half\""), bodies.toString()); + offerCalls.clear(); + mock("/promo-offer-signature", "{\"accepted\":false}"); + assertFalse(cm.purchaseWithPromotionalOffer("pro_half", "half")); + mock("/promo-offer-signature", "{\"accepted\":true,\"keyId\":\"KEY1\"}"); + assertFalse(cm.purchaseWithPromotionalOffer("pro_half", "half")); + assertFalse(cm.purchaseWithPromotionalOffer("pro_half", null)); + assertTrue(offerCalls.isEmpty()); + assertTrue(storeCalls.isEmpty()); + } finally { + implementation.setPlatformName(null); + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/payment/PaywallViewTest.java b/maven/core-unittests/src/test/java/com/codename1/payment/PaywallViewTest.java new file mode 100644 index 00000000000..dd209586f17 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/payment/PaywallViewTest.java @@ -0,0 +1,323 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.payment; + +import com.codename1.components.SpanLabel; +import com.codename1.junit.FormTest; +import com.codename1.payment.paywall.Paywall; +import com.codename1.payment.paywall.PaywallListener; +import com.codename1.payment.paywall.PaywallView; +import com.codename1.ui.Button; +import com.codename1.ui.Component; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Tabs; +import org.junit.jupiter.api.BeforeEach; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class PaywallViewTest extends CommerceTestBase { + private static final String PAYWALL = + "{\"version\":1,\"id\":\"pw1\",\"defaultLocale\":\"en\"," + + "\"strings\":{\"en\":{\"title.key\":\"Go Pro\"}}," + + "\"colors\":{\"light\":{\"background\":\"#101010\",\"text\":\"#222222\",\"muted\":\"#888888\"," + + "\"accent\":\"#ff0000\",\"accentText\":\"#FFFFFF\"},\"dark\":{\"accent\":\"#00ff00\"}}," + + "\"root\":{\"type\":\"stack\",\"axis\":\"y\",\"gap\":2,\"padding\":3,\"children\":[" + + "{\"type\":\"closeButton\"}," + + "{\"type\":\"image\",\"url\":\"https://img.test/hero.png\",\"height\":30}," + + "{\"type\":\"icon\",\"name\":\"star\"}," + + "{\"type\":\"text\",\"text\":\"title.key\",\"style\":\"title\",\"align\":\"center\"}," + + "{\"type\":\"text\",\"text\":\"Only {{price}} per {{ period }}\",\"style\":\"headline\"}," + + "{\"type\":\"text\",\"text\":\"Everything, everywhere\"}," + + "{\"type\":\"text\",\"text\":\"{{packageId}} {{unknown}} {{trialPeriod}}\",\"style\":\"caption\"}," + + "{\"type\":\"features\",\"items\":[{\"icon\":\"lock\",\"text\":\"Secure\"},{\"icon\":\"nope\",\"text\":\"Fast\"}]}," + + "{\"type\":\"packages\",\"style\":\"tiles\",\"selected\":\"annual\"}," + + "{\"type\":\"carousel\",\"children\":[{\"type\":\"text\",\"text\":\"Slide\"},{\"type\":\"hologram\"}]}," + + "{\"type\":\"purchaseButton\",\"text\":\"Buy {{productName}}\"}," + + "{\"type\":\"restoreButton\",\"text\":\"Restore\"}," + + "{\"type\":\"footer\",\"termsUrl\":\"https://t.test\",\"privacyUrl\":\"https://p.test\"}," + + "{\"type\":\"spacer\",\"size\":4}," + + "{\"type\":\"hologram\",\"text\":\"from the future\"}," + + "{\"type\":\"stack\",\"axis\":\"x\",\"align\":\"center\",\"children\":[{\"type\":\"icon\",\"name\":\"bolt\"}]}" + + "]}}"; + + private static final String OFFERING = + "{\"currentOfferingId\":\"main\",\"offerings\":[{\"id\":\"main\",\"paywall\":" + PAYWALL + "," + + "\"packages\":[" + + "{\"id\":\"monthly\",\"type\":\"MONTHLY\",\"productId\":\"pro_monthly\",\"period\":\"P1M\",\"trialPeriod\":\"P7D\"}," + + "{\"id\":\"annual\",\"type\":\"ANNUAL\",\"productId\":\"pro_annual\",\"period\":\"P1Y\"}]}," + + "{\"id\":\"plain\",\"packages\":[" + + "{\"id\":\"forever\",\"type\":\"LIFETIME\",\"productId\":\"pro_forever\"}]}]}"; + + @BeforeEach + void cloudOff() { + // The views report events; none of these tests is about the cloud. + Display.getInstance().setProperty("commerce.cloud.enabled", "false"); + } + + private static String text(Component c) { + if (c instanceof SpanLabel) { + return ((SpanLabel) c).getText(); + } + return ((Button) c).getText(); + } + + @FormTest + void buildsEveryNodeTypeAndSkipsUnknownOnes() throws Exception { + Offering main = offerings(OFFERING).getCurrent(); + attach(main.getAnnual(), "Pro Annual", "$49.99"); + PaywallView view = new PaywallView(main); + + assertEquals("pw1", view.getPaywallId()); + assertSame(main, view.getOffering()); + assertEquals("Paywall", view.getUIID()); + assertEquals(0x101010, view.getUnselectedStyle().getBgColor()); + + List titles = byUiid(view, "PaywallTitle"); + assertEquals(1, titles.size()); + assertEquals("Go Pro", text(titles.get(0))); + assertEquals("Only $49.99 per year", text(byUiid(view, "PaywallHeadline").get(0))); + // body: the plain text and the carousel's slide + assertEquals(2, byUiid(view, "PaywallBody").size()); + // an unknown variable stays, a variable with no value is empty + assertEquals("annual {{unknown}} ", text(byUiid(view, "PaywallCaption").get(0))); + assertEquals(1, byUiid(view, "PaywallImage").size()); + assertEquals(2, byUiid(view, "PaywallIcon").size()); + assertEquals(2, byUiid(view, "PaywallFeature").size()); + assertEquals(2, byUiid(view, "PaywallFeatureIcon").size()); + assertEquals("Fast", text(byUiid(view, "PaywallFeatureText").get(1))); + assertEquals(1, byUiid(view, "PaywallPackages").size()); + List carousel = byUiid(view, "PaywallCarousel"); + assertEquals(1, carousel.size()); + assertTrue(carousel.get(0) instanceof Tabs); + assertEquals(1, ((Tabs) carousel.get(0)).getTabCount()); + List buy = byUiid(view, "PaywallPurchaseButton"); + assertEquals(1, buy.size()); + assertEquals("Buy Pro Annual", text(buy.get(0))); + assertEquals(0xff0000, buy.get(0).getUnselectedStyle().getBgColor()); + assertEquals(0xffffff, buy.get(0).getUnselectedStyle().getFgColor()); + assertEquals("Restore", text(byUiid(view, "PaywallRestoreButton").get(0))); + assertEquals(1, byUiid(view, "PaywallCloseButton").size()); + assertEquals(2, byUiid(view, "PaywallFooterLink").size()); + assertFalse(byUiid(view, "PaywallSpacer").isEmpty()); + assertNull(findText(view, "from the future")); + } + + private static Component findText(Component root, String wanted) { + String[] uiids = {"PaywallTitle", "PaywallHeadline", "PaywallBody", "PaywallCaption"}; + for (String uiid : uiids) { + for (Component c : byUiid(root, uiid)) { + if (wanted.equals(text(c))) { + return c; + } + } + } + return null; + } + + @FormTest + void selectingAPackageMovesTheSelectedUiidAndTheTexts() throws Exception { + Offering main = offerings(OFFERING).getCurrent(); + attach(main.getAnnual(), "Pro Annual", "$49.99"); + PaywallView view = new PaywallView(main); + Component annual = byName(view, "PaywallPackage_annual"); + Component monthly = byName(view, "PaywallPackage_monthly"); + assertEquals("annual", view.getSelectedPackage().getId()); + assertEquals("PaywallPackageSelected", annual.getUIID()); + assertEquals("PaywallPackage", monthly.getUIID()); + assertEquals(1, byUiid(view, "PaywallPackageTitleSelected").size()); + assertEquals("Pro Annual", text(byUiid(view, "PaywallPackageTitleSelected").get(0))); + assertEquals(1, byUiid(view, "PaywallPackagePriceSelected").size()); + assertEquals(1, byUiid(view, "PaywallPackagePrice").size()); + + view.selectPackage("monthly"); + assertEquals("monthly", view.getSelectedPackage().getId()); + assertEquals("PaywallPackage", annual.getUIID()); + assertEquals("PaywallPackageSelected", monthly.getUIID()); + assertEquals("Buy Monthly", text(byUiid(view, "PaywallPurchaseButton").get(0))); + assertEquals("monthly {{unknown}} 7 days", text(byUiid(view, "PaywallCaption").get(0))); + + view.selectPackage("missing"); + assertEquals("monthly", view.getSelectedPackage().getId()); + } + + @FormTest + void anOfferingWithNoDocumentGetsTheDefaultPaywall() throws Exception { + Offering plain = offerings(OFFERING).getOffering("plain"); + assertNull(plain.getPaywall()); + Map doc = Paywall.createDefault(plain); + assertEquals("default", doc.get("id")); + PaywallView view = new PaywallView(plain); + assertEquals("default", view.getPaywallId()); + assertEquals(1, byUiid(view, "PaywallTitle").size()); + assertEquals("forever", view.getSelectedPackage().getId()); + assertEquals("PaywallPackageSelected", byName(view, "PaywallPackage_forever").getUIID()); + assertEquals("Continue", text(byUiid(view, "PaywallPurchaseButton").get(0))); + assertEquals(1, byUiid(view, "PaywallRestoreButton").size()); + assertEquals(1, byUiid(view, "PaywallFooter").size()); + + // No offering at all still builds, with nothing to sell. + PaywallView empty = new PaywallView(null); + assertNull(empty.getSelectedPackage()); + empty.purchase(); + assertTrue(storeCalls.isEmpty()); + } + + @FormTest + void purchaseAsksTheStoreAndClosingReportsItCancelled() throws Exception { + Offering main = offerings(OFFERING).getCurrent(); + PaywallView view = new PaywallView(main); + final List heard = new ArrayList(); + view.setPaywallListener(new PaywallListener() { + @Override + public void purchaseStarted(CommercePackage pkg) { + heard.add("started:" + pkg.getId()); + } + + @Override + public void purchaseCompleted(CustomerInfo info) { + heard.add("completed"); + } + + @Override + public void purchaseCancelled() { + heard.add("cancelled"); + } + + @Override + public void restoreCompleted(CustomerInfo info) { + heard.add("restored"); + } + + @Override + public void closed() { + heard.add("closed"); + } + }); + view.setCloseHandler(new Runnable() { + @Override + public void run() { + heard.add("handler"); + } + }); + view.purchase(); + assertEquals("[subscribe:pro_annual]", storeCalls.toString()); + view.close(); + view.close(); + assertEquals("[started:annual, cancelled, closed, handler]", heard.toString()); + } + + private static final String OWNS_ANNUAL = + "{\"accepted\":true,\"degraded\":false,\"serverTime\":1700000000000," + + "\"appUserId\":\"u1\",\"activeSubscriptions\":[\"pro_annual\"]," + + "\"entitlements\":{}}"; + + private static PaywallListener recorder(final List heard) { + return new PaywallListener() { + @Override + public void purchaseStarted(CommercePackage pkg) { + heard.add("started:" + pkg.getId()); + } + + @Override + public void purchaseCompleted(CustomerInfo info) { + heard.add("completed over " + Display.getInstance().getCurrent().getName()); + } + + @Override + public void purchaseCancelled() { + heard.add("cancelled"); + } + + @Override + public void restoreCompleted(CustomerInfo info) { + heard.add("restored"); + } + + @Override + public void closed() { + heard.add("closed"); + } + }; + } + + @FormTest + void shownPaywallReturnsToThePreviousFormWhenThePurchaseCompletes() throws Exception { + Display.getInstance().setProperty("commerce.cloud.enabled", "true"); + CommerceManager cm = CommerceManager.getInstance(); + cm.setAppUserId("u1"); + Form home = new Form("Home"); + home.setName("home"); + home.show(); + com.codename1.ui.DisplayTest.flushEdt(); + + final List heard = new ArrayList(); + PaywallView view = Paywall.show(offerings(OFFERING).getCurrent(), recorder(heard)); + com.codename1.ui.DisplayTest.flushEdt(); + assertSame(view.getComponentForm(), Display.getInstance().getCurrent()); + + view.purchase(); + mock("/customer", OWNS_ANNUAL); + cm.refresh(); + com.codename1.ui.DisplayTest.flushEdt(); + + assertEquals("[started:annual, completed over home, closed]", heard.toString()); + assertSame(home, Display.getInstance().getCurrent()); + // Already closed: closing again reports nothing. + view.close(); + assertEquals(3, heard.size()); + } + + @FormTest + void embeddedViewStaysWhenThePurchaseCompletes() throws Exception { + Display.getInstance().setProperty("commerce.cloud.enabled", "true"); + CommerceManager cm = CommerceManager.getInstance(); + cm.setAppUserId("u1"); + PaywallView view = new PaywallView(offerings(OFFERING).getCurrent()); + Form own = new Form("Own", new com.codename1.ui.layouts.BorderLayout()); + own.setName("own"); + own.add(com.codename1.ui.layouts.BorderLayout.CENTER, view); + own.show(); + com.codename1.ui.DisplayTest.flushEdt(); + + final List heard = new ArrayList(); + view.setPaywallListener(recorder(heard)); + view.setCloseHandler(new Runnable() { + @Override + public void run() { + heard.add("handler"); + } + }); + view.purchase(); + mock("/customer", OWNS_ANNUAL); + cm.refresh(); + com.codename1.ui.DisplayTest.flushEdt(); + + assertEquals("[started:annual, completed over own]", heard.toString()); + assertSame(own, Display.getInstance().getCurrent()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/payment/center/CustomerCenterTest.java b/maven/core-unittests/src/test/java/com/codename1/payment/center/CustomerCenterTest.java new file mode 100644 index 00000000000..3845f0ec973 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/payment/center/CustomerCenterTest.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.payment.center; + +import com.codename1.io.JSONParser; +import com.codename1.junit.FormTest; +import com.codename1.payment.CommerceManager; +import com.codename1.payment.CommerceTestBase; +import com.codename1.payment.CustomerInfo; +import com.codename1.payment.Purchase; +import com.codename1.ui.Form; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class CustomerCenterTest extends CommerceTestBase { + private static final String CONFIG = + "{\"accepted\":true,\"paths\":[\"MANAGE\",\"restore\",\"REFUND\",\"TELEPORT\",\"CANCEL\",\"SUPPORT\"]," + + "\"supportEmail\":\"help@example.test\"," + + "\"cancelReasons\":[{\"id\":\"price\",\"label\":\"Too expensive\"," + + "\"offer\":{\"productId\":\"pro_half\",\"offerId\":\"half\",\"text\":\"Half price\"}}," + + "{\"id\":\"unused\",\"label\":\"I do not use it\",\"offer\":null},{\"label\":\"no id\"}]," + + "\"strings\":{\"en\":{\"path.RESTORE\":\"Bring my purchases back\"}}}"; + + private static String paths(List rows) { + StringBuilder sb = new StringBuilder(); + for (CustomerCenter.Row r : rows) { + sb.append(r.path).append(':').append(r.sku).append(' '); + } + return sb.toString().trim(); + } + + private CustomerInfo subscriber() { + mock("/customer", "{\"accepted\":true,\"appUserId\":\"u1\",\"activeSubscriptions\":[\"pro_monthly\"]}"); + CommerceManager cm = CommerceManager.getInstance(); + cm.setAppUserId("u1"); + cm.refresh(); + return cm.getCustomerInfo(); + } + + private static Purchase store(final boolean refundable) { + return new Purchase() { + @Override + public boolean isRefundable(String sku) { + return refundable; + } + }; + } + + @FormTest + void aConfigurationMapsToRowsInItsOrder() throws Exception { + Map config = JSONParser.parseJSON(CONFIG); + CustomerInfo info = subscriber(); + List rows = CustomerCenter.buildRows(config, info, store(true)); + assertEquals("MANAGE:pro_monthly RESTORE:null REFUND:pro_monthly CANCEL:pro_monthly SUPPORT:null", + paths(rows)); + assertEquals("Manage subscription", rows.get(0).label); + assertEquals("Bring my purchases back", rows.get(1).label); + assertEquals("mailto:help@example.test", CustomerCenter.supportAddress(config)); + + // A store that refunds nothing offers no refund. + assertEquals("MANAGE:pro_monthly RESTORE:null CANCEL:pro_monthly SUPPORT:null", + paths(CustomerCenter.buildRows(config, info, store(false)))); + + List reasons = CustomerCenter.cancelReasons(config); + assertEquals(2, reasons.size()); + assertEquals("price", reasons.get(0).get("id")); + + Form form = CustomerCenter.createForm(config, info, store(true), null); + assertNotNull(byName(form, "CustomerCenterRow_CANCEL")); + assertEquals(5, byUiid(form, "CustomerCenterRow").size()); + assertEquals(1, byUiid(form, "CustomerCenterSubscription").size()); + } + + @FormTest + void withNoSubscriptionThereIsNothingToCancelOrRefund() throws Exception { + Map config = JSONParser.parseJSON(CONFIG); + config.put("supportUrl", "https://help.example.test"); + assertEquals("MANAGE:null RESTORE:null SUPPORT:null", + paths(CustomerCenter.buildRows(config, CommerceManager.getInstance().getCustomerInfo(), store(true)))); + assertEquals("https://help.example.test", CustomerCenter.supportAddress(config)); + Form form = CustomerCenter.createForm(config, null, null, null); + assertEquals(1, byUiid(form, "CustomerCenterEmpty").size()); + } + + @FormTest + void theDefaultConfigurationManagesAndRestores() { + Map config = CustomerCenter.defaultConfiguration(); + assertEquals("MANAGE:pro_monthly RESTORE:null", + paths(CustomerCenter.buildRows(config, subscriber(), null))); + assertNull(CustomerCenter.supportAddress(config)); + assertTrue(CustomerCenter.cancelReasons(config).isEmpty()); + assertTrue(CustomerCenter.buildRows(null, null, null).isEmpty()); + } + + @FormTest + void theCenterReportsWhatTheCustomerDoesThere() throws Exception { + AnalyticsRecorder recorder = analyticsOn(); + Map config = JSONParser.parseJSON("{\"paths\":[\"SUPPORT\"]}"); + CustomerCenter.perform(new CustomerCenter.Row(CustomerCenter.PATH_SUPPORT, "Support", "pro_monthly"), + config, null, null); + assertEquals(1, recorder.count("customer_center_action")); + assertEquals("commerce", recorder.last("customer_center_action").getCategory()); + assertEquals("SUPPORT", recorder.param("customer_center_action", "action")); + assertEquals("pro_monthly", recorder.param("customer_center_action", "product")); + + mockFailure("/web-portal"); + Map reason = JSONParser.parseJSON("{\"id\":\"price\"}"); + CustomerCenter.reasonChosen(config, reason, null, "pro_monthly"); + assertEquals(1, recorder.count("subscription_cancel_reason")); + assertEquals("price", recorder.param("subscription_cancel_reason", "reason")); + assertEquals("pro_monthly", recorder.param("subscription_cancel_reason", "product")); + + mock("/customer-center", CONFIG); + CustomerCenter.show(); + flushSerialCalls(); + assertEquals(1, recorder.count("customer_center_view")); + } + + @FormTest + void theConfigurationComesFromTheCloudWhenItAccepts() { + mock("/customer-center", CONFIG); + assertNotNull(CommerceManager.getInstance().getCustomerCenterConfiguration()); + mock("/customer-center", "{\"paths\":[\"MANAGE\"]}"); + assertNull(CommerceManager.getInstance().getCustomerCenterConfiguration()); + } +} diff --git a/scripts/cast-semantics-baseline.txt b/scripts/cast-semantics-baseline.txt index 9e716fd5c22..4dcfcf9d3f5 100644 --- a/scripts/cast-semantics-baseline.txt +++ b/scripts/cast-semantics-baseline.txt @@ -32,7 +32,6 @@ com/codename1/location/GeofenceManager#getExpiryTimes(Z)Ljava/util/Map;|cast to com/codename1/location/GeofenceManager#getListener()Lcom/codename1/location/GeofenceListener;|cast to com.codename1.location.GeofenceListener inside catch(java.lang.Throwable) com/codename1/maps/vector/MapStyle#fromJson(Ljava/lang/String;)Lcom/codename1/maps/vector/MapStyle;|cast to java.util.List inside catch(java.lang.Throwable) com/codename1/maps/vector/MapStyle#fromJson(Ljava/lang/String;)Lcom/codename1/maps/vector/MapStyle;|cast to java.util.Map inside catch(java.lang.Throwable) -com/codename1/payment/CommerceManager#validateReceipt(Lcom/codename1/payment/Receipt;)[Ljava/lang/Boolean;|cast to java.util.Map inside catch(java.lang.Throwable) com/codename1/properties/InstantUI#createEntryForProperty(Lcom/codename1/properties/PropertyBase;Lcom/codename1/ui/Container;Ljava/util/ArrayList;Lcom/codename1/properties/UiBinding;)V|cast to com.codename1.ui.Component inside catch(java.lang.RuntimeException) com/codename1/properties/SQLMap#selectImpl(Lcom/codename1/properties/PropertyBusinessObject;Ljava/lang/String;Ljava/lang/Class;[Ljava/lang/Object;Lcom/codename1/properties/Property;ZII)Ljava/util/List;|cast to com.codename1.properties.PropertyBase inside catch(java.lang.Throwable) com/codename1/properties/SQLMap#selectImpl(Lcom/codename1/properties/PropertyBusinessObject;Ljava/lang/String;Ljava/lang/Class;[Ljava/lang/Object;Lcom/codename1/properties/Property;ZII)Ljava/util/List;|cast to com.codename1.properties.PropertyBusinessObject inside catch(java.lang.Throwable) diff --git a/scripts/ios/purchase-tests/native-tests/PurchaseStoreKitTests.m b/scripts/ios/purchase-tests/native-tests/PurchaseStoreKitTests.m index 6edb4999863..58b624c6ad7 100644 --- a/scripts/ios/purchase-tests/native-tests/PurchaseStoreKitTests.m +++ b/scripts/ios/purchase-tests/native-tests/PurchaseStoreKitTests.m @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ #import #import @@ -29,6 +51,7 @@ - (BOOL)buyProductWithIdentifier:(NSString *)productIdentifier error:(NSError ** static NSString * const kProductId = @"com.codenameone.hello.pro"; static NSString * const kSinkKey = @"CN1IAPTestSubmittedReceipts"; +static NSString * const kCommerceKey = @"CN1IAPTestCommerceResult"; @interface PurchaseStoreKitTests : XCTestCase @end @@ -43,6 +66,7 @@ - (void)setUp { [super setUp]; self.continueAfterFailure = NO; [[NSUserDefaults standardUserDefaults] removeObjectForKey:kSinkKey]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:kCommerceKey]; [[NSUserDefaults standardUserDefaults] synchronize]; } @@ -107,6 +131,17 @@ - (void)testStoreKitPurchaseReachesReceiptStore { @"Recorded submissions: '%@'. This means the StoreKit purchase did not " @"flow through the CN1 observer into the receipt-sync engine and the " @"installed ReceiptStore.", [self recordedSubmissions]); + + // Once the purchase synchronized, the app refreshes CommerceManager, + // asks it for the entitlement (store-direct: a local build has no + // cloud commerce) and builds the bundled paywall and customer center. + // See CommerceCheck in scripts/purchase-test-app. + BOOL checked = [self waitUntil:^BOOL{ + return [[NSUserDefaults standardUserDefaults] stringForKey:kCommerceKey] != nil; + } timeout:60.0]; + NSString *commerce = [[NSUserDefaults standardUserDefaults] stringForKey:kCommerceKey]; + XCTAssertTrue(checked, @"The app never reported its commerce check after the purchase."); + XCTAssertEqualObjects(commerce, @"OK", @"Commerce check after the purchase failed: %@", commerce); } else { XCTSkip("StoreKitTest requires iOS 14+"); } diff --git a/scripts/purchase-test-app/app/android/src/main/java/com/codenameone/examples/purchasetest/PurchaseTestSinkImpl.java b/scripts/purchase-test-app/app/android/src/main/java/com/codenameone/examples/purchasetest/PurchaseTestSinkImpl.java index 96762687d3a..7cb55e6e11e 100644 --- a/scripts/purchase-test-app/app/android/src/main/java/com/codenameone/examples/purchasetest/PurchaseTestSinkImpl.java +++ b/scripts/purchase-test-app/app/android/src/main/java/com/codenameone/examples/purchasetest/PurchaseTestSinkImpl.java @@ -1,3 +1,25 @@ +/* + * 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.codenameone.examples.purchasetest; import java.util.ArrayList; @@ -24,6 +46,10 @@ public String recordedSubmissions() { } } + public void recordCommerceResult(String result) { + // Android asserts on the CN1SS:IAP:COMMERCE logcat line the app prints. + } + public void reset() { synchronized (SUBMITTED) { SUBMITTED.clear(); } } diff --git a/scripts/purchase-test-app/app/common/src/main/java/com/codenameone/examples/purchasetest/CommerceCheck.java b/scripts/purchase-test-app/app/common/src/main/java/com/codenameone/examples/purchasetest/CommerceCheck.java new file mode 100644 index 00000000000..2987c5b4b21 --- /dev/null +++ b/scripts/purchase-test-app/app/common/src/main/java/com/codenameone/examples/purchasetest/CommerceCheck.java @@ -0,0 +1,107 @@ +/* + * 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.codenameone.examples.purchasetest; + +import com.codename1.payment.CommerceManager; +import com.codename1.payment.center.CustomerCenter; +import com.codename1.payment.paywall.PaywallView; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; + +/** + * Runs once a purchase has synchronized: refreshes commerce, checks that a + * build without cloud commerce answers the entitlement from the store-direct + * signal, and builds the bundled paywall and the customer center so their code + * is translated and linked on every target. Nothing here needs a network: a + * local build carries no build key, so cloud commerce is off. + * + * The outcome goes to the {@link PurchaseTestSink} (read by the iOS XCTest) and + * to a CN1SS:IAP:COMMERCE log line. + */ +final class CommerceCheck { + private static boolean started; + + private CommerceCheck() { + } + + /** Called on the EDT, from the receipt synchronization callback. */ + static void run(final PurchaseTestSink sink, final String sku) { + if (started) { + return; + } + started = true; + // refresh() is a blocking call by contract, so it runs off the EDT. + Display.getInstance().scheduleBackgroundTask(new Runnable() { + public void run() { + final String state = checkManager(sku); + Display.getInstance().callSerially(new Runnable() { + public void run() { + report(sink, state.length() > 0 ? state : checkUi()); + } + }); + } + }); + } + + /** Empty when every check passed, else what failed. */ + private static String checkManager(String sku) { + try { + CommerceManager cm = CommerceManager.getInstance(); + cm.refresh(); + if (cm.isCloudEnabled()) { + return "cloud commerce is enabled in a build with no build key"; + } + if (cm.getCustomerInfo() == null) { + return "getCustomerInfo() answered null"; + } + if (!cm.isEntitled(sku)) { + return "isEntitled(" + sku + ") is false after the purchase"; + } + return ""; + } catch (Throwable t) { + return "CommerceManager threw " + t.getClass().getName() + ": " + t.getMessage(); + } + } + + private static String checkUi() { + try { + CommerceManager cm = CommerceManager.getInstance(); + Form f = new Form("Paywall", new BorderLayout()); + f.add(BorderLayout.CENTER, new PaywallView(cm.getOfferings().getCurrent())); + f.show(); + CustomerCenter.show(); + return ""; + } catch (Throwable t) { + return "paywall or customer center threw " + t.getClass().getName() + ": " + t.getMessage(); + } + } + + private static void report(PurchaseTestSink sink, String failure) { + String result = failure.length() == 0 ? "OK" : "FAIL " + failure; + System.out.println("CN1SS:IAP:COMMERCE " + result); + if (sink != null) { + sink.recordCommerceResult(result); + } + } +} diff --git a/scripts/purchase-test-app/app/common/src/main/java/com/codenameone/examples/purchasetest/PurchaseTestSink.java b/scripts/purchase-test-app/app/common/src/main/java/com/codenameone/examples/purchasetest/PurchaseTestSink.java index 8951777372d..b037a291f6a 100644 --- a/scripts/purchase-test-app/app/common/src/main/java/com/codenameone/examples/purchasetest/PurchaseTestSink.java +++ b/scripts/purchase-test-app/app/common/src/main/java/com/codenameone/examples/purchasetest/PurchaseTestSink.java @@ -1,3 +1,25 @@ +/* + * 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.codenameone.examples.purchasetest; import com.codename1.system.NativeInterface; @@ -13,5 +35,12 @@ public interface PurchaseTestSink extends NativeInterface { String recordedSubmissions(); + /** + * Records the outcome of the commerce check the app runs once a purchase + * has synchronized: "OK ..." or "FAIL ...". The iOS implementation stores + * it where the hosted XCTest reads it back. + */ + void recordCommerceResult(String result); + void reset(); } diff --git a/scripts/purchase-test-app/app/common/src/main/java/com/codenameone/examples/purchasetest/RecordingReceiptStore.java b/scripts/purchase-test-app/app/common/src/main/java/com/codenameone/examples/purchasetest/RecordingReceiptStore.java index 48bbbad7904..4b45fdf810a 100644 --- a/scripts/purchase-test-app/app/common/src/main/java/com/codenameone/examples/purchasetest/RecordingReceiptStore.java +++ b/scripts/purchase-test-app/app/common/src/main/java/com/codenameone/examples/purchasetest/RecordingReceiptStore.java @@ -1,10 +1,37 @@ +/* + * 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.codenameone.examples.purchasetest; +import com.codename1.payment.Purchase; import com.codename1.payment.Receipt; import com.codename1.payment.ReceiptStore; import com.codename1.system.NativeLookup; import com.codename1.util.SuccessCallback; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + /** * Test ReceiptStore installed at startup. Does no networking: it reports * success immediately and forwards the submitted transactionId to the native @@ -17,6 +44,7 @@ */ public class RecordingReceiptStore implements ReceiptStore { private final PurchaseTestSink sink; + private final List submitted = new ArrayList(); public RecordingReceiptStore() { PurchaseTestSink s = NativeLookup.create(PurchaseTestSink.class); @@ -29,10 +57,36 @@ public void submitReceipt(Receipt receipt, SuccessCallback callback) { } System.out.println("CN1SS:IAP:SUBMITTED " + (receipt == null ? "null" : receipt.getTransactionId())); + if (receipt != null) { + submitted.add(receipt); + final String sku = receipt.getSku(); + // A synchronization is in progress here, so this only registers + // the callback: it fires once the receipt was taken off the + // pending queue and the list below was fetched, which is the + // state a commerce refresh reads. + Purchase.getInAppPurchase().synchronizeReceipts(0, new SuccessCallback() { + public void onSucess(Boolean ok) { + CommerceCheck.run(sink, sku); + } + }); + } callback.onSucess(Boolean.TRUE); } + /** + * Answers what was submitted, as a receipt server would: with an expiry + * date. The store's own receipt carries none, and the expiry is what the + * store-direct entitlement signal (Purchase.isSubscribed) is computed + * from, so without it no purchase could ever read as entitled here. + */ public void fetchReceipts(SuccessCallback callback) { - callback.onSucess(new Receipt[0]); + Receipt[] out = new Receipt[submitted.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = submitted.get(i); + if (out[i].getExpiryDate() == null) { + out[i].setExpiryDate(new Date(System.currentTimeMillis() + 24L * 60 * 60 * 1000)); + } + } + callback.onSucess(out); } } diff --git a/scripts/purchase-test-app/app/ios/src/main/objectivec/com_codenameone_examples_purchasetest_PurchaseTestSinkImpl.m b/scripts/purchase-test-app/app/ios/src/main/objectivec/com_codenameone_examples_purchasetest_PurchaseTestSinkImpl.m index 1fd02f79451..cff4e013f11 100644 --- a/scripts/purchase-test-app/app/ios/src/main/objectivec/com_codenameone_examples_purchasetest_PurchaseTestSinkImpl.m +++ b/scripts/purchase-test-app/app/ios/src/main/objectivec/com_codenameone_examples_purchasetest_PurchaseTestSinkImpl.m @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ #import "com_codenameone_examples_purchasetest_PurchaseTestSinkImpl.h" // Persist submitted receipt transactionIds in NSUserDefaults so the hosted @@ -5,6 +27,9 @@ // purchase through SKTestSession. Key is shared with the test. static NSString * const CN1IAPTestSubmittedKey = @"CN1IAPTestSubmittedReceipts"; +// Outcome of the commerce check that runs after the purchase synchronized. +static NSString * const CN1IAPTestCommerceKey = @"CN1IAPTestCommerceResult"; + @implementation com_codenameone_examples_purchasetest_PurchaseTestSinkImpl -(void)recordSubmittedReceipt:(NSString*)transactionId { @@ -21,7 +46,14 @@ -(NSString*)recordedSubmissions { return existing ? [existing componentsJoinedByString:@","] : @""; } +-(void)recordCommerceResult:(NSString*)result { + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + [defaults setObject:(result != nil ? result : @"") forKey:CN1IAPTestCommerceKey]; + [defaults synchronize]; +} + -(void)reset { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:CN1IAPTestCommerceKey]; [[NSUserDefaults standardUserDefaults] removeObjectForKey:CN1IAPTestSubmittedKey]; [[NSUserDefaults standardUserDefaults] synchronize]; }