From 5a962a20419d3864ce730cc31509c9d462b10ab6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:49:22 +0300 Subject: [PATCH 01/17] Developer guide: settle the seven Monetization sentences that promise code The chapter had the largest cluster of holes in the guide: seven sentences ending in a colon with nothing after them, left over from the snippet extraction. Two kinds, handled differently, because they are different problems. Two are ordinary Codename One code and are restored as compiled snippets: the button that calls synchronizeReceipts, and the main-form label that reads getExpiryDate after a sync. Both recovered from bbdc6058f0~1 and now compile with the rest, so they cannot rot again. Five describe server-side JAX-RS and JPA, or client code built on the RESTfulWebServiceClient cn1lib. None of it compiles in a Codename One module, which is why it was dropped and why restoring it would mean either a second build or an inline block the snippet validator rejects. Those sentences now point at the two demo projects the chapter already links, which is where the code actually lives and stays current. The NetBeans web service wizard goes with them. It generated the controller years ago, it is not how anyone would produce one now, and the sentence was carrying it purely as provenance. Ratchet drops from 34 to 27. Co-Authored-By: Claude Opus 5 (1M context) --- .../generated/MonetizationJava101Snippet.java | 74 +++++++++++++++++ .../generated/MonetizationJava102Snippet.java | 80 +++++++++++++++++++ docs/developer-guide/Monetization.asciidoc | 20 +++-- .../missing-code-blocks-baseline.txt | 7 -- 4 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java new file mode 100644 index 00000000000..285b1713a9f --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java @@ -0,0 +1,74 @@ +/* + * 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.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class MonetizationJava101Snippet { + + Form hi; + Purchase iap = Purchase.getInAppPurchase(); + String[] PRODUCTS = {"com.codename1.world.month", "com.codename1.world.year"}; + + // tag::monetization-java-101[] + void syncButton() { + Button syncReceipts = new Button("Synchronize Receipts"); + + syncReceipts.addActionListener(e -> { + iap.synchronizeReceipts(0, res -> { + // Update the UI + }); + }); + } + // end::monetization-java-101[] +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java new file mode 100644 index 00000000000..17009fcba90 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java @@ -0,0 +1,80 @@ +/* + * 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.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class MonetizationJava102Snippet { + + Form hi; + Purchase iap = Purchase.getInAppPurchase(); + String[] PRODUCTS = {"com.codename1.world.month", "com.codename1.world.year"}; + + // tag::monetization-java-102[] + void expiryLabel() { + SpanLabel rentalStatus = new SpanLabel("Loading rental details..."); + Button syncReceipts = new Button("Synchronize Receipts"); + + syncReceipts.addActionListener(e -> { + iap.synchronizeReceipts(0, res -> { + if (iap.isSubscribed(PRODUCTS)) { + rentalStatus.setText("World rental expires " + iap.getExpiryDate(PRODUCTS)); + } else { + rentalStatus.setText("You do not currently have a subscription to the world"); + } + hi.revalidate(); + }); + }); + } + // end::monetization-java-102[] +} diff --git a/docs/developer-guide/Monetization.asciidoc b/docs/developer-guide/Monetization.asciidoc index 12e6fb59be3..a9ab15a5d91 100644 --- a/docs/developer-guide/Monetization.asciidoc +++ b/docs/developer-guide/Monetization.asciidoc @@ -246,6 +246,11 @@ include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/g And you also provide a button to allow the user to manually synchronize the receipts: +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java[tag=monetization-java-101,indent=0] +---- + ===== Expiry dates and subscription status @@ -259,6 +264,11 @@ If you need to know more information about subscriptions, you can always just ca In the hello world app you'll use this information in a few different places. On your main form you'll include a label to show the current expiry date, and you allow the user to press a button to synchronize receipts manually if they think the value is out of date: +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java[tag=monetization-java-102,indent=0] +---- + ===== Allowing the user to purchase the subscription @@ -423,17 +433,17 @@ Now that you've set up and built the app, take a look at the source code so you The example uses the https://github.com/shannah/cn1-generic-webservice-client[Generic Webservice Client Library] from inside your `ReceiptStore` implementation to load receipts from the web service, and insert new receipts to the database. -The source for your ReceiptStore is as follows: +The full `ReceiptStore` implementation is in the client project linked above. Notice that you aren't doing any calculation of expiry dates in your client app, as you did in the previous post (on non-renewable receipts). Since you are using a server now, it makes sense to move all that logic over to the server. -The `createRESTClient()` method shown there creates a `RESTfulWebServiceClient` and configuring it to use basic authentication with a username and password. The idea is that your user would have logged into your app at some point, and you would have a username and password on hand to pass back to the web service with the receipt data so that you can connect the subscription to a user account. The source of that method is listed here: +Its `createRESTClient()` method builds a `RESTfulWebServiceClient` using basic authentication. The idea is that your user has logged into your app at some point, so you have a username and password to send along with the receipt data and can tie the subscription to an account. ===== Server-Side -On the server-side, your REST controller is a standard JAX-RS REST interface. The Netbeans web service wizard generated it and then it was modified to suit the purposes here. The methods of the `ReceiptsFacadeREST` class for the REST API are shown here: +On the server side the REST controller is a standard JAX-RS interface. Its `ReceiptsFacadeREST` class is in the server project linked above. The magic happens inside that `validateAndSaveReceipt()` method, which You'll cover in detail soon. @@ -451,7 +461,7 @@ NOTE: This example only checks receipts from the iTunes and Play stores because For this tutorial, the example uses a purpose-built library to handle receipt validation in a way that hides as much of the complexity as possible. It supports both Google Play receipts and iTunes receipts. -The general usage is as follows: +The server project linked above shows it in use. As you can see from this snippet, the complexity of receipt validation has been reduced to entering three configuration strings: @@ -464,7 +474,7 @@ The next section walks through the steps to get these values. ==== The `validateAndSaveReceipt()` method -You are now ready to see the full magic of the `validateAndSaveReceipt()` method in all its glory: +`validateAndSaveReceipt()` is where the work happens, and it's worth reading in full in the server project linked above. NOTE: In many of the code snippets for the Server-side code, you'll see references to both a `Receipts` class and a `Receipt` class. This is slightly confusing. The `Receipts` class is a JPA entity the encapsulates a row from the "receipts" table of your SQL database. The `Receipt` class is `com.codename1.payment.Receipt`. It's used to interface with the IAP validation library. diff --git a/scripts/developer-guide/missing-code-blocks-baseline.txt b/scripts/developer-guide/missing-code-blocks-baseline.txt index 2d64765057a..4e4719139ac 100644 --- a/scripts/developer-guide/missing-code-blocks-baseline.txt +++ b/scripts/developer-guide/missing-code-blocks-baseline.txt @@ -10,13 +10,6 @@ Deep-Links-Routing.asciidoc without redirects. The plugin's `AasaBuilder` produc Maven-Creating-CN1Libs.adoc Now try it out. Try adding the following code to your application project's main class (or anywhere in the application project, for that matter): Maven-Creating-CN1Libs.adoc The simulator dispatches every action on the Codename One EDT through `Display.callSerially`, so your method can call `Display.getInstance()`, `Form.show()`, `Dialog.show()`, `ToastBar.showInfoMessage()` and any other CN1 API. Reflection uses the same classloader that loaded `Display`, so cn1lib internals (including package-private classes) resolve normally: Miscellaneous-Features.asciidoc To solve this sort of used case you have two APIs in `Display`: -Monetization.asciidoc And you also provide a button to allow the user to manually synchronize the receipts: -Monetization.asciidoc In the hello world app you'll use this information in a few different places. On your main form you'll include a label to show the current expiry date, and you allow the user to press a button to synchronize receipts manually if they think the value is out of date: -Monetization.asciidoc On the server-side, your REST controller is a standard JAX-RS REST interface. The Netbeans web service wizard generated it and then it was modified to suit the purposes here. The methods of the `ReceiptsFacadeREST` class for the REST API are shown here: -Monetization.asciidoc The `createRESTClient()` method shown there creates a `RESTfulWebServiceClient` and configuring it to use basic authentication with a username and password. The idea is that your user would have logged into your app at some point, and you would have a username and password on hand to pass back to the web service with the receipt data so that you can connect the subscription to a user account. The source of that method is listed here: -Monetization.asciidoc The general usage is as follows: -Monetization.asciidoc The source for your ReceiptStore is as follows: -Monetization.asciidoc You are now ready to see the full magic of the `validateAndSaveReceipt()` method in all its glory: SVG-Transcoder.asciidoc the generated class directly: The-Components-Of-Codename-One.asciidoc Call the builder from a Maven plugin, an Ant task or a one-shot `main`: The-Components-Of-Codename-One.asciidoc This code should output "The result was 7" to the console. It's fully asynchronous, so you can include this code anywhere without worrying about it "bogging down" your code. The full signature of this form of the https://www.codenameone.com/javadoc/com/codename1/ui/BrowserComponent.html#execute(java.lang.String,com.codename1.util.SuccessCallback)[execute()] method is: From 1824002e603e8303e2da4d514b28ca1ccba77d03 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:57:21 +0300 Subject: [PATCH 02/17] Developer guide: three review fixes on the Monetization listings synchronizeReceipts reports true only when every pending purchase reached the receipt store AND the receipts came back, so the expiry-label listing was presenting a stale cached status as a fresh answer whenever the network leg failed. It now branches on the flag and says which it is showing; the reason is in a comment beside the branch, because the trap is in the callback's contract rather than in this code. Both listings also built a component and dropped it. A reader copying the manual-sync listing got a Button that is never added to anything and a listener that can never run. Both now add what they build to the form. And the CN1-IAP-Validator section still opened its follow-up with "As you can see from this snippet" after the snippet became a pointer to the server project. It now names the project. Co-Authored-By: Claude Opus 5 (1M context) --- .../generated/MonetizationJava101Snippet.java | 12 +++++++++--- .../generated/MonetizationJava102Snippet.java | 16 +++++++++++++--- docs/developer-guide/Monetization.asciidoc | 2 +- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java index 285b1713a9f..dff703b31a4 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java @@ -61,14 +61,20 @@ class MonetizationJava101Snippet { String[] PRODUCTS = {"com.codename1.world.month", "com.codename1.world.year"}; // tag::monetization-java-101[] - void syncButton() { + void addSyncButton() { Button syncReceipts = new Button("Synchronize Receipts"); syncReceipts.addActionListener(e -> { - iap.synchronizeReceipts(0, res -> { - // Update the UI + iap.synchronizeReceipts(0, success -> { + if (success) { + // Update the UI from the freshly loaded receipts + } else { + ToastBar.showErrorMessage("Could not reach the receipt store"); + } }); }); + + hi.add(syncReceipts); } // end::monetization-java-101[] } diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java index 17009fcba90..78fce253fcb 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java @@ -61,13 +61,20 @@ class MonetizationJava102Snippet { String[] PRODUCTS = {"com.codename1.world.month", "com.codename1.world.year"}; // tag::monetization-java-102[] - void expiryLabel() { + void addExpiryLabel() { SpanLabel rentalStatus = new SpanLabel("Loading rental details..."); Button syncReceipts = new Button("Synchronize Receipts"); syncReceipts.addActionListener(e -> { - iap.synchronizeReceipts(0, res -> { - if (iap.isSubscribed(PRODUCTS)) { + iap.synchronizeReceipts(0, success -> { + // synchronizeReceipts reports true only when every pending purchase + // reached the receipt store AND the receipts came back. On false the + // receipts are still whatever the last successful sync left behind, + // so say so rather than presenting them as a fresh answer. + if (!success) { + rentalStatus.setText("Could not reach the receipt store, " + + "showing the last known status"); + } else if (iap.isSubscribed(PRODUCTS)) { rentalStatus.setText("World rental expires " + iap.getExpiryDate(PRODUCTS)); } else { rentalStatus.setText("You do not currently have a subscription to the world"); @@ -75,6 +82,9 @@ void expiryLabel() { hi.revalidate(); }); }); + + hi.add(rentalStatus); + hi.add(syncReceipts); } // end::monetization-java-102[] } diff --git a/docs/developer-guide/Monetization.asciidoc b/docs/developer-guide/Monetization.asciidoc index a9ab15a5d91..690f1824977 100644 --- a/docs/developer-guide/Monetization.asciidoc +++ b/docs/developer-guide/Monetization.asciidoc @@ -464,7 +464,7 @@ For this tutorial, the example uses a purpose-built library to handle receipt va The server project linked above shows it in use. -As you can see from this snippet, the complexity of receipt validation has been reduced to entering three configuration strings: +In that project the complexity of receipt validation is reduced to entering three configuration strings: 1. `APPLE_SECRET` - This is a "secret" string that you will get from iTunes connect when you set up your in-app products. 2. `GOOGLE_DEVELOPER_API_CLIENT_ID` - A client ID that you'll get from the Google developer API console when you set up your API service credentials. From 499df394aa95a53596f169271b6011a437ed1181 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:00:53 +0300 Subject: [PATCH 03/17] Developer guide: show the cached expiry before the user asks for a sync The prose says the main form shows the current expiry date and the button is there for a user who thinks that value is out of date, but the listing set the label only from inside the listener, so a form nobody pressed sat on "Loading rental details..." forever. The receipts already on the device answer the question with no round trip, so the label is now filled in as the form is built and the sync path reuses the same method. Co-Authored-By: Claude Opus 5 (1M context) --- .../generated/MonetizationJava102Snippet.java | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java index 78fce253fcb..567cf127990 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java @@ -62,22 +62,25 @@ class MonetizationJava102Snippet { // tag::monetization-java-102[] void addExpiryLabel() { - SpanLabel rentalStatus = new SpanLabel("Loading rental details..."); + SpanLabel rentalStatus = new SpanLabel(); Button syncReceipts = new Button("Synchronize Receipts"); + // The receipts already on the device answer this without a round trip, + // so the label is correct the moment the form appears. The button is + // there for the user who thinks that answer has gone stale. + showRentalStatus(rentalStatus); + syncReceipts.addActionListener(e -> { iap.synchronizeReceipts(0, success -> { // synchronizeReceipts reports true only when every pending purchase // reached the receipt store AND the receipts came back. On false the // receipts are still whatever the last successful sync left behind, // so say so rather than presenting them as a fresh answer. - if (!success) { + if (success) { + showRentalStatus(rentalStatus); + } else { rentalStatus.setText("Could not reach the receipt store, " + "showing the last known status"); - } else if (iap.isSubscribed(PRODUCTS)) { - rentalStatus.setText("World rental expires " + iap.getExpiryDate(PRODUCTS)); - } else { - rentalStatus.setText("You do not currently have a subscription to the world"); } hi.revalidate(); }); @@ -86,5 +89,13 @@ void addExpiryLabel() { hi.add(rentalStatus); hi.add(syncReceipts); } + + void showRentalStatus(SpanLabel rentalStatus) { + if (iap.isSubscribed(PRODUCTS)) { + rentalStatus.setText("World rental expires " + iap.getExpiryDate(PRODUCTS)); + } else { + rentalStatus.setText("You do not currently have a subscription to the world"); + } + } // end::monetization-java-102[] } From f8c8ce092f5f0a9c22012b56e08a3f8ec1cfc1b4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:05:25 +0300 Subject: [PATCH 04/17] Developer guide: a failed sync must not cost the user the status they had The failure branch replaced the expiry label with an error string that said it was showing the last known status, which is the one thing it was not doing. Nothing was reloaded on that path, so there is nothing new to put in the label: the error goes to a ToastBar and the label keeps the answer the receipts already gave. Co-Authored-By: Claude Opus 5 (1M context) --- .../generated/MonetizationJava102Snippet.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java index 567cf127990..90108ade964 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java @@ -73,16 +73,17 @@ void addExpiryLabel() { syncReceipts.addActionListener(e -> { iap.synchronizeReceipts(0, success -> { // synchronizeReceipts reports true only when every pending purchase - // reached the receipt store AND the receipts came back. On false the - // receipts are still whatever the last successful sync left behind, - // so say so rather than presenting them as a fresh answer. + // reached the receipt store AND the receipts came back. On false + // nothing was reloaded, so there is nothing new to show. if (success) { showRentalStatus(rentalStatus); + hi.revalidate(); } else { - rentalStatus.setText("Could not reach the receipt store, " - + "showing the last known status"); + // Leave the label alone. It is still the last answer the + // receipts gave, and overwriting it with the error would + // cost the user the only status they had. + ToastBar.showErrorMessage("Could not reach the receipt store"); } - hi.revalidate(); }); }); From 5cce9e57428d23ec4fc6d2f7d2141ffefcad4b02 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:11:21 +0300 Subject: [PATCH 05/17] Developer guide: the startup sync has to reach the same label The chapter synchronizes receipts in two places -- once at the end of start(), once behind a manual button -- and only the button's path was touching the expiry label. Stale receipts at launch therefore stayed on screen until the user pressed a button they had no reason to press, which is exactly the case the automatic sync exists to cover. The label is now a field rather than a local, and both callbacks call the same showRentalStatus(). The start() listing's callback stops being a comment and does the thing the comment described. Co-Authored-By: Claude Opus 5 (1M context) --- .../generated/MonetizationJava037Snippet.java | 14 +++++++++++--- .../generated/MonetizationJava102Snippet.java | 15 +++++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java index 6ef0b2142c2..93c121b3edf 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java @@ -78,6 +78,10 @@ class MonetizationJava037Snippet { Container myForm; Component component; Button button; + SpanLabel rentalStatus = new SpanLabel(); + + void showRentalStatus() { + } MultiButton myMultiButton; Label label; BrowserComponent browserComponent; @@ -89,9 +93,13 @@ public void start() { // ... // Now synchronize the receipts - iap.synchronizeReceipts(0, res->{ - // Update the UI as necessary to reflect - + iap.synchronizeReceipts(0, success -> { + // Whatever this brought back, the expiry label is now out of date. + // Repaint it from the same method the manual button uses. + if (success) { + showRentalStatus(); + hi.revalidate(); + } }); } // end::monetization-java-037[] diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java index 90108ade964..c2937ad6fc7 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java @@ -61,14 +61,17 @@ class MonetizationJava102Snippet { String[] PRODUCTS = {"com.codename1.world.month", "com.codename1.world.year"}; // tag::monetization-java-102[] + // A field, not a local: the synchronization at the end of start() has to + // reach the same label, and so does the manual button below. + SpanLabel rentalStatus = new SpanLabel(); + void addExpiryLabel() { - SpanLabel rentalStatus = new SpanLabel(); Button syncReceipts = new Button("Synchronize Receipts"); // The receipts already on the device answer this without a round trip, - // so the label is correct the moment the form appears. The button is - // there for the user who thinks that answer has gone stale. - showRentalStatus(rentalStatus); + // so the label is right the moment the form appears. The button is for + // the user who thinks that answer has gone stale. + showRentalStatus(); syncReceipts.addActionListener(e -> { iap.synchronizeReceipts(0, success -> { @@ -76,7 +79,7 @@ void addExpiryLabel() { // reached the receipt store AND the receipts came back. On false // nothing was reloaded, so there is nothing new to show. if (success) { - showRentalStatus(rentalStatus); + showRentalStatus(); hi.revalidate(); } else { // Leave the label alone. It is still the last answer the @@ -91,7 +94,7 @@ void addExpiryLabel() { hi.add(syncReceipts); } - void showRentalStatus(SpanLabel rentalStatus) { + void showRentalStatus() { if (iap.isSubscribed(PRODUCTS)) { rentalStatus.setText("World rental expires " + iap.getExpiryDate(PRODUCTS)); } else { From bb209355c2ea3c28359e3fef04275fa771a7826f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:17:48 +0300 Subject: [PATCH 06/17] Developer guide: one Synchronize Receipts button, not two The chapter shows the manual button, then shows it again as part of the expiry-label listing, so a reader following it in order builds two of them and only the second one refreshes anything. There is only one button in the app being described, so there is now only one in the listings. The button listing owns it and calls the same showRentalStatus() that start() calls; the expiry listing is reduced to what it is actually introducing -- the label, the field it lives in, and the method that paints it from the receipts already on the device. The sentence above it said the reader would add a button there, which is why the second one existed at all. It now says what the listing does. Co-Authored-By: Claude Opus 5 (1M context) --- .../generated/MonetizationJava101Snippet.java | 11 ++++++- .../generated/MonetizationJava102Snippet.java | 31 +++---------------- docs/developer-guide/Monetization.asciidoc | 2 +- 3 files changed, 16 insertions(+), 28 deletions(-) diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java index dff703b31a4..f8830343673 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java @@ -59,6 +59,10 @@ class MonetizationJava101Snippet { Form hi; Purchase iap = Purchase.getInAppPurchase(); String[] PRODUCTS = {"com.codename1.world.month", "com.codename1.world.year"}; + SpanLabel rentalStatus = new SpanLabel(); + + void showRentalStatus() { + } // tag::monetization-java-101[] void addSyncButton() { @@ -66,8 +70,13 @@ void addSyncButton() { syncReceipts.addActionListener(e -> { iap.synchronizeReceipts(0, success -> { + // synchronizeReceipts reports true only when every pending + // purchase reached the receipt store AND the receipts came + // back. On false nothing was reloaded, so there is nothing + // new to show and the status on screen stays as it was. if (success) { - // Update the UI from the freshly loaded receipts + showRentalStatus(); + hi.revalidate(); } else { ToastBar.showErrorMessage("Could not reach the receipt store"); } diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java index c2937ad6fc7..b81d1709df9 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java @@ -61,37 +61,16 @@ class MonetizationJava102Snippet { String[] PRODUCTS = {"com.codename1.world.month", "com.codename1.world.year"}; // tag::monetization-java-102[] - // A field, not a local: the synchronization at the end of start() has to - // reach the same label, and so does the manual button below. + // A field, not a local: the synchronization at the end of start() and the + // button above both have to reach this label. SpanLabel rentalStatus = new SpanLabel(); void addExpiryLabel() { - Button syncReceipts = new Button("Synchronize Receipts"); - - // The receipts already on the device answer this without a round trip, - // so the label is right the moment the form appears. The button is for - // the user who thinks that answer has gone stale. + // The receipts already on the device answer this with no round trip, + // so the label is right the moment the form appears rather than after + // the first synchronization comes back. showRentalStatus(); - - syncReceipts.addActionListener(e -> { - iap.synchronizeReceipts(0, success -> { - // synchronizeReceipts reports true only when every pending purchase - // reached the receipt store AND the receipts came back. On false - // nothing was reloaded, so there is nothing new to show. - if (success) { - showRentalStatus(); - hi.revalidate(); - } else { - // Leave the label alone. It is still the last answer the - // receipts gave, and overwriting it with the error would - // cost the user the only status they had. - ToastBar.showErrorMessage("Could not reach the receipt store"); - } - }); - }); - hi.add(rentalStatus); - hi.add(syncReceipts); } void showRentalStatus() { diff --git a/docs/developer-guide/Monetization.asciidoc b/docs/developer-guide/Monetization.asciidoc index 690f1824977..83d4e5400cf 100644 --- a/docs/developer-guide/Monetization.asciidoc +++ b/docs/developer-guide/Monetization.asciidoc @@ -262,7 +262,7 @@ Now that you have a receipt store registered, and you have synchronized your rec If you need to know more information about subscriptions, you can always just call `getReceipts()` to get a list of all the current receipts and determine for yourself what the user should have access to. -In the hello world app you'll use this information in a few different places. On your main form you'll include a label to show the current expiry date, and you allow the user to press a button to synchronize receipts manually if they think the value is out of date: +In the hello world app you'll use this information in a few different places. On your main form you'll include a label showing the current expiry date, painted from the receipts already on the device and repainted by whichever of the two synchronizations above comes back first: [source,java] ---- From 99bc5b2c454e4953d48a5796156a2545da7366ae Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:23:17 +0300 Subject: [PATCH 07/17] Developer guide: the two listings take the form rather than assuming one hi is a local of start() in the hello-world app the chapter builds, and the Form field these snippet wrappers carry sits outside the AsciiDoc tag, so the published listings referred to something a reader copying them cannot see and could not compile. Both helpers now take the Form. Co-Authored-By: Claude Opus 5 (1M context) --- .../snippets/generated/MonetizationJava101Snippet.java | 2 +- .../snippets/generated/MonetizationJava102Snippet.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java index f8830343673..96cb50f0561 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java @@ -65,7 +65,7 @@ void showRentalStatus() { } // tag::monetization-java-101[] - void addSyncButton() { + void addSyncButton(Form hi) { Button syncReceipts = new Button("Synchronize Receipts"); syncReceipts.addActionListener(e -> { diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java index b81d1709df9..5821b6e9d52 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java @@ -65,7 +65,7 @@ class MonetizationJava102Snippet { // button above both have to reach this label. SpanLabel rentalStatus = new SpanLabel(); - void addExpiryLabel() { + void addExpiryLabel(Form hi) { // The receipts already on the device answer this with no round trip, // so the label is right the moment the form appears rather than after // the first synchronization comes back. From c586ed88ad61b952ea983a5e1051174ea27c16d5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:28:51 +0300 Subject: [PATCH 08/17] Developer guide: something has to call the two helpers Following the listings in order produced a form with neither the expiry label nor the sync button on it, because the only listing that runs at startup did the synchronization and nothing else. start() now builds the form and calls both helpers, so the three listings assemble into the app the chapter describes. Co-Authored-By: Claude Opus 5 (1M context) --- .../generated/MonetizationJava037Snippet.java | 16 +++++++++++++++- docs/developer-guide/Monetization.asciidoc | 2 +- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java index 93c121b3edf..5f381c11205 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java @@ -82,6 +82,12 @@ class MonetizationJava037Snippet { void showRentalStatus() { } + + void addExpiryLabel(Form hi) { + } + + void addSyncButton(Form hi) { + } MultiButton myMultiButton; Label label; BrowserComponent browserComponent; @@ -89,8 +95,16 @@ void showRentalStatus() { // tag::monetization-java-037[] public void start() { + Form hi = new Form("Hello World", BoxLayout.y()); + + // ... the rest of the form + + // The expiry label and the button that refreshes it, both of which + // the next two listings build. + addExpiryLabel(hi); + addSyncButton(hi); - // ... + hi.show(); // Now synchronize the receipts iap.synchronizeReceipts(0, success -> { diff --git a/docs/developer-guide/Monetization.asciidoc b/docs/developer-guide/Monetization.asciidoc index 83d4e5400cf..1133f023d75 100644 --- a/docs/developer-guide/Monetization.asciidoc +++ b/docs/developer-guide/Monetization.asciidoc @@ -237,7 +237,7 @@ The following methods can be used for synchronization: In your hello world app you synchronize the subscriptions in a few places. -At the end of the `start()` method: +In the `start()` method, after the form is built: [source,java] ---- From ca3a585aaf3dc4cdd9edf33e5b9d52c1e88e65f9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:38:30 +0300 Subject: [PATCH 09/17] Developer guide: declare the SKUs the whole chapter reads Every listing from the non-renewable-subscription section onward calls iap.isSubscribed(PRODUCTS) or iap.getExpiryDate(PRODUCTS), and no published block ever declared either name -- the snippet wrappers carry them outside the tag, so the code in the book referred to something a reader could not see. The prose introduces PRODUCTS in the same breath ("the example uses an array"), and the paragraph that follows it opens with "Notice that you create two separate SKUs", which is a sentence about a listing that was no longer there. The three declarations now appear where the chapter introduces them, once, ahead of every listing that uses them. Co-Authored-By: Claude Opus 5 (1M context) --- .../generated/MonetizationJava103Snippet.java | 70 +++++++++++++++++++ docs/developer-guide/Monetization.asciidoc | 6 ++ 2 files changed, 76 insertions(+) create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava103Snippet.java diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava103Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava103Snippet.java new file mode 100644 index 00000000000..20a6109ed4a --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava103Snippet.java @@ -0,0 +1,70 @@ +/* + * 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.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class MonetizationJava103Snippet { + + // tag::monetization-java-103[] + static final String SKU_WORLD_1_MONTH = "com.codename1.world.month"; + static final String SKU_WORLD_1_YEAR = "com.codename1.world.year"; + + // Both periods of the same subscription group. Every Purchase method that + // asks about status or expiry takes the whole group, so keeping them in + // one array is what makes the later listings read the way they do. + static final String[] PRODUCTS = { SKU_WORLD_1_MONTH, SKU_WORLD_1_YEAR }; + + Purchase iap = Purchase.getInAppPurchase(); + // end::monetization-java-103[] +} diff --git a/docs/developer-guide/Monetization.asciidoc b/docs/developer-guide/Monetization.asciidoc index 1133f023d75..ffde44041e4 100644 --- a/docs/developer-guide/Monetization.asciidoc +++ b/docs/developer-guide/Monetization.asciidoc @@ -171,6 +171,12 @@ You'll expand on the theme of "Buying" the world for this app, except, this time . A 1-month subscription . A 1-year subscription +Every listing in the rest of this chapter uses these three declarations: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava103Snippet.java[tag=monetization-java-103,indent=0] +---- Notice that you create two separate SKUs for the 1 month and 1-year subscription. **Each subscription period must have its own SKU**. The example uses an array (`PRODUCTS`) that contains both of the SKUs. This is handy, as you'll see in the examples ahead, because the APIs for checking status and expiry date of a subscription take the SKUs in a "subscription group" as input. From 7060a2daa91c0b8fd48e08fcf992d4b56e29a3eb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:43:29 +0300 Subject: [PATCH 10/17] Developer guide: the subscription start() needs the same resume guard Codename One calls start() again when the app is resumed, and the listing built a fresh Form every time and handed it components the first form still owns -- insertComponentAtImpl rejects a component that already has a parent, so the app the chapter describes throws on resume. The earlier start() listing in this same chapter already carries the current-form guard; this one now does too. Co-Authored-By: Claude Opus 5 (1M context) --- .../snippets/generated/MonetizationJava037Snippet.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java index 5f381c11205..b20935d432a 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java @@ -80,6 +80,8 @@ class MonetizationJava037Snippet { Button button; SpanLabel rentalStatus = new SpanLabel(); + Form current; + void showRentalStatus() { } @@ -95,6 +97,13 @@ void addSyncButton(Form hi) { // tag::monetization-java-037[] public void start() { + // The same guard the earlier start() listing uses. Without it a resume + // builds a second form and hands it components the first one still owns, + // which Container rejects. + if (current != null) { + current.show(); + return; + } Form hi = new Form("Hello World", BoxLayout.y()); // ... the rest of the form @@ -104,6 +113,7 @@ public void start() { addExpiryLabel(hi); addSyncButton(hi); + current = hi; hi.show(); // Now synchronize the receipts From a4d7c322892e3563839f4fa50dcab2956a361cea Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:47:42 +0300 Subject: [PATCH 11/17] Developer guide: the resume needs the synchronization more than the launch does The guard I added last round returned before the synchronization, so a resume showed the old form and never refreshed it -- and a resume is exactly when a subscription bought, renewed or cancelled on another device becomes visible. The sync now sits outside the branch: the launch builds the form, the resume shows the one that survived, and both fall through to it. Co-Authored-By: Claude Opus 5 (1M context) --- .../generated/MonetizationJava037Snippet.java | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java index b20935d432a..95c1bf77a12 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java @@ -97,13 +97,12 @@ void addSyncButton(Form hi) { // tag::monetization-java-037[] public void start() { - // The same guard the earlier start() listing uses. Without it a resume - // builds a second form and hands it components the first one still owns, - // which Container rejects. if (current != null) { + // A resume. The form and everything on it survived, so rebuilding it + // would hand a second form components the first one still owns, which + // Container rejects. current.show(); - return; - } + } else { Form hi = new Form("Hello World", BoxLayout.y()); // ... the rest of the form @@ -115,14 +114,17 @@ public void start() { current = hi; hi.show(); + } - // Now synchronize the receipts + // Outside the branch on purpose: a subscription can be bought, renewed + // or cancelled on another device while this one is suspended, so the + // resume needs this as much as the launch does. iap.synchronizeReceipts(0, success -> { // Whatever this brought back, the expiry label is now out of date. // Repaint it from the same method the manual button uses. if (success) { showRentalStatus(); - hi.revalidate(); + current.revalidate(); } }); } From 0310641f7e0a5c22e5e21c8327139857740ee9d0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:54:14 +0300 Subject: [PATCH 12/17] Developer guide: repaint the expiry label when the purchase lands itemPurchased already reloads the receipts and then only showed a toast, so the form behind it kept saying the user has no subscription right after they bought one -- until a manual sync or a resume. It now calls the same showRentalStatus() the other two paths call, and revalidates. Co-Authored-By: Claude Opus 5 (1M context) --- .../snippets/generated/MonetizationJava041Snippet.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava041Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava041Snippet.java index d3e73eb01a6..6edc87bccf3 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava041Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava041Snippet.java @@ -98,6 +98,11 @@ public void itemPurchased(String sku) { return; } ToastBar.showMessage("Your subscription has been extended to "+iap.getExpiryDate(PRODUCTS), FontImage.MATERIAL_THUMB_UP); + + // The form the user is looking at still shows the status from before the + // purchase. The toast is not a substitute for repainting it. + showRentalStatus(); + current.revalidate(); } @Override @@ -105,6 +110,11 @@ public void itemPurchaseError(String sku, String errorMessage) { ToastBar.showErrorMessage("Failure occurred: "+errorMessage); } // end::monetization-java-041[] + + Form current = new Form(); + + void showRentalStatus() { + } } String SKU_WORLD_1_MONTH = "com.example.world.month"; From 0940e4429ec6ed1652648d5fb927699a721834a9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:00:00 +0300 Subject: [PATCH 13/17] Developer guide: synchronize the Purchase instance the label reads Receipts are cached on the Purchase instance -- the field is per-object and loaded lazily -- and most ports hand back a fresh instance from every getInAppPurchase() call. The purchase callback made its own, synchronized that, and then repainted from the shared one, which had not been touched: the label kept the answer it had before the purchase, which is exactly the bug the repaint was added to fix. It now uses the shared instance the rest of the chapter declares. Co-Authored-By: Claude Opus 5 (1M context) --- .../snippets/generated/MonetizationJava041Snippet.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava041Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava041Snippet.java index 6edc87bccf3..68fed11f5f4 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava041Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava041Snippet.java @@ -87,7 +87,10 @@ abstract class Sample implements PurchaseCallback { // tag::monetization-java-041[] @Override public void itemPurchased(String sku) { - Purchase iap = Purchase.getInAppPurchase(); + // The shared iap field, not a fresh getInAppPurchase(). Receipts are + // cached per Purchase instance and most ports hand back a new instance + // every call, so synchronizing a local one leaves the instance the + // label reads exactly as stale as it was. // Reload the receipts from the store. This answers false when the receipt // could not be submitted or fetched, and the receipt then stays pending -- @@ -111,6 +114,8 @@ public void itemPurchaseError(String sku, String errorMessage) { } // end::monetization-java-041[] + Purchase iap = Purchase.getInAppPurchase(); + Form current = new Form(); void showRentalStatus() { From fb043b0f4a057a34b3480753c3810c4d6ab3e440 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:05:37 +0300 Subject: [PATCH 14/17] Developer guide: the rental buttons read the shared Purchase too Both listeners held their own getInAppPurchase(), so after a purchase synchronized the shared instance the next click still described the state from before it -- "you're already renting the world until ", or no subscription at all. They use the shared field now, and their purchase() calls go through it as well rather than making yet another instance. The one-time-purchase listings earlier in the chapter are left alone on purpose: wasPurchased() is implemented per port and does not read the instance receipt cache, so nothing there goes stale. Co-Authored-By: Claude Opus 5 (1M context) --- .../snippets/generated/MonetizationJava040Snippet.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava040Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava040Snippet.java index 02dd8079a38..f88232f4339 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava040Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava040Snippet.java @@ -85,7 +85,9 @@ class MonetizationJava040Snippet { void snippet() throws Exception { // tag::monetization-java-040[] - Purchase iap = Purchase.getInAppPurchase(); + // The shared iap field, not a fresh getInAppPurchase(): receipts are + // cached on the instance, so a listener holding its own copy keeps + // answering from before the last synchronization. //... Button rentWorld1M = new Button("Rent World 1 Month"); rentWorld1M.addActionListener(e->{ @@ -98,7 +100,7 @@ void snippet() throws Exception { msg = "Rent the world for 1 month?"; } if (Dialog.show("Confirm", msg, "Yes", "No")) { - Purchase.getInAppPurchase().purchase(SKU_WORLD_1_MONTH); // <3> + iap.purchase(SKU_WORLD_1_MONTH); // <3> // Note: since this is a non-renewable subscription it's a regular // product in the play store - therefore you use the purchase() method. // If it were a "subscription" product in the play store, then you @@ -117,7 +119,7 @@ void snippet() throws Exception { msg = "Rent the world for 1 year?"; } if (Dialog.show("Confirm", msg, "Yes", "No")) { - Purchase.getInAppPurchase().purchase(SKU_WORLD_1_YEAR); + iap.purchase(SKU_WORLD_1_YEAR); // Note: since this is a non-renewable subscription it's a regular // product in the play store - therefore you use the purchase() method. // If it were a "subscription" product in the play store, then you @@ -125,8 +127,10 @@ void snippet() throws Exception { } }); // end::monetization-java-040[] + } + Purchase iap = Purchase.getInAppPurchase(); static final String SKU_WORLD_1_YEAR = "com.example.world.year"; String[] PRODUCTS = {SKU_WORLD_1_MONTH, SKU_WORLD_1_YEAR}; static final String SKU_WORLD_1_MONTH = "com.example.world.month"; From d51458ee10ee347fa8ce45ee92ca6317a8a8e932 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:11:46 +0300 Subject: [PATCH 15/17] Developer guide: hold no Purchase instance at all Two rounds of this thread moved toward sharing one Purchase across the chapter. Reading the class settles it the other way. synchronizeReceipts coalesces on a static syncInProgress, so a second call while the port's own synchronization is running registers its callback and returns without loading anything. The receipts it waits for land in the instance the port created, via setReceipts, which writes the instance field AND persists to storage. A held instance never sees them; its cache is loaded once, lazily, and nothing invalidates it. The shared state is therefore the persisted receipts, not a shared object. Every use site now takes a fresh getInAppPurchase(), whose cache loads from storage on first read -- after the synchronization has written there. That is a storage read rather than a network call, and it is what the ports themselves do. The declarations listing drops the field and says why, and the purchase callback notes that nothing reads receipts off its instance until after the synchronous sync, which is what makes the order work. Co-Authored-By: Claude Opus 5 (1M context) --- .../snippets/generated/MonetizationJava037Snippet.java | 2 +- .../snippets/generated/MonetizationJava040Snippet.java | 8 +++++--- .../snippets/generated/MonetizationJava041Snippet.java | 8 ++++---- .../snippets/generated/MonetizationJava101Snippet.java | 2 +- .../snippets/generated/MonetizationJava102Snippet.java | 1 + .../snippets/generated/MonetizationJava103Snippet.java | 8 +++++++- docs/developer-guide/Monetization.asciidoc | 2 +- 7 files changed, 20 insertions(+), 11 deletions(-) diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java index 95c1bf77a12..c14398da2ea 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava037Snippet.java @@ -119,7 +119,7 @@ public void start() { // Outside the branch on purpose: a subscription can be bought, renewed // or cancelled on another device while this one is suspended, so the // resume needs this as much as the launch does. - iap.synchronizeReceipts(0, success -> { + Purchase.getInAppPurchase().synchronizeReceipts(0, success -> { // Whatever this brought back, the expiry label is now out of date. // Repaint it from the same method the manual button uses. if (success) { diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava040Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava040Snippet.java index f88232f4339..a79949b6237 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava040Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava040Snippet.java @@ -85,13 +85,14 @@ class MonetizationJava040Snippet { void snippet() throws Exception { // tag::monetization-java-040[] - // The shared iap field, not a fresh getInAppPurchase(): receipts are - // cached on the instance, so a listener holding its own copy keeps - // answering from before the last synchronization. + // A fresh Purchase on every click, never one captured by the + // listener: receipts are cached per instance, so a captured one keeps + // answering from before the purchase the user just made. //... Button rentWorld1M = new Button("Rent World 1 Month"); rentWorld1M.addActionListener(e->{ String msg = null; + Purchase iap = Purchase.getInAppPurchase(); if (iap.isSubscribed(PRODUCTS)) { // <1> msg = "you're already renting the world until " +iap.getExpiryDate(PRODUCTS) // <2> @@ -111,6 +112,7 @@ void snippet() throws Exception { Button rentWorld1Y = new Button("Rent World 1 Year"); rentWorld1Y.addActionListener(e->{ String msg = null; + Purchase iap = Purchase.getInAppPurchase(); if (iap.isSubscribed(PRODUCTS)) { msg = "you're already renting the world until "+ iap.getExpiryDate(PRODUCTS)+ diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava041Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava041Snippet.java index 68fed11f5f4..8ad2e2a56ec 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava041Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava041Snippet.java @@ -87,10 +87,10 @@ abstract class Sample implements PurchaseCallback { // tag::monetization-java-041[] @Override public void itemPurchased(String sku) { - // The shared iap field, not a fresh getInAppPurchase(). Receipts are - // cached per Purchase instance and most ports hand back a new instance - // every call, so synchronizing a local one leaves the instance the - // label reads exactly as stale as it was. + // Nothing reads receipts off this instance until after the call below, + // so its cache loads from storage once the synchronization has written + // there -- which is the point of not holding a Purchase around. + Purchase iap = Purchase.getInAppPurchase(); // Reload the receipts from the store. This answers false when the receipt // could not be submitted or fetched, and the receipt then stays pending -- diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java index 96cb50f0561..e04e1dcfc8d 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java @@ -69,7 +69,7 @@ void addSyncButton(Form hi) { Button syncReceipts = new Button("Synchronize Receipts"); syncReceipts.addActionListener(e -> { - iap.synchronizeReceipts(0, success -> { + Purchase.getInAppPurchase().synchronizeReceipts(0, success -> { // synchronizeReceipts reports true only when every pending // purchase reached the receipt store AND the receipts came // back. On false nothing was reloaded, so there is nothing diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java index 5821b6e9d52..2b9f0603ed2 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java @@ -74,6 +74,7 @@ void addExpiryLabel(Form hi) { } void showRentalStatus() { + Purchase iap = Purchase.getInAppPurchase(); if (iap.isSubscribed(PRODUCTS)) { rentalStatus.setText("World rental expires " + iap.getExpiryDate(PRODUCTS)); } else { diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava103Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava103Snippet.java index 20a6109ed4a..444e49fba37 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava103Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava103Snippet.java @@ -65,6 +65,12 @@ class MonetizationJava103Snippet { // one array is what makes the later listings read the way they do. static final String[] PRODUCTS = { SKU_WORLD_1_MONTH, SKU_WORLD_1_YEAR }; - Purchase iap = Purchase.getInAppPurchase(); + // There is deliberately no Purchase field here. Receipts are cached on the + // instance and loaded from storage the first time one is asked for, while + // the synchronization that refreshes them is static and may be running on + // an instance the port created. A held instance therefore keeps answering + // from the snapshot it loaded; a fresh getInAppPurchase() reads what the + // last completed synchronization persisted. It is a storage read, not a + // network call. // end::monetization-java-103[] } diff --git a/docs/developer-guide/Monetization.asciidoc b/docs/developer-guide/Monetization.asciidoc index ffde44041e4..62b91e78be0 100644 --- a/docs/developer-guide/Monetization.asciidoc +++ b/docs/developer-guide/Monetization.asciidoc @@ -171,7 +171,7 @@ You'll expand on the theme of "Buying" the world for this app, except, this time . A 1-month subscription . A 1-year subscription -Every listing in the rest of this chapter uses these three declarations: +Every listing in the rest of this chapter uses these declarations: [source,java] ---- From b56b53309f8c413e3998d7a6a89eb3f444444108 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:15:51 +0300 Subject: [PATCH 16/17] Developer guide: the purchase callback can run before the form exists iOS adds its StoreKit transaction observer while the view controller is being set up, so an unfinished transaction is re-delivered to itemPurchased() before start() has built anything. The listing then dereferenced a form that did not exist yet. The label is a field and is safe to set, so the status still gets written; only the revalidate is guarded, and start() paints from the same method when it does run. Co-Authored-By: Claude Opus 5 (1M context) --- .../snippets/generated/MonetizationJava041Snippet.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava041Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava041Snippet.java index 8ad2e2a56ec..a67a86ec906 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava041Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava041Snippet.java @@ -104,8 +104,15 @@ public void itemPurchased(String sku) { // The form the user is looking at still shows the status from before the // purchase. The toast is not a substitute for repainting it. + // + // iOS registers its StoreKit observer during initialization, so an + // unfinished transaction can be re-delivered here before start() has + // built the form. The label is a field and is safe to set; the form may + // not exist yet, and start() paints it from the same method anyway. showRentalStatus(); + if (current != null) { current.revalidate(); + } } @Override @@ -116,7 +123,7 @@ public void itemPurchaseError(String sku, String errorMessage) { Purchase iap = Purchase.getInAppPurchase(); - Form current = new Form(); + Form current; void showRentalStatus() { } From 935a3df49462c8e255404fa4e27d4bfd8686b4aa Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:31:54 +0300 Subject: [PATCH 17/17] Purchase: drain receipts queued while a synchronization was fetching synchronizeReceipts coalesces on a static syncInProgress, so a purchase completing mid-fetch queues its receipt through postReceipt and cannot start a synchronization of its own. The fetch already in flight was requested before that receipt existed, and onLoadReceiptsComplete reported its success to every waiting caller -- so the caller got a snapshot that predates the purchase and the receipt stayed pending until something synchronized again. On a subscription app that is a purchase the user made and the app does not show. onLoadReceiptsComplete now re-runs when the pending queue is non-empty, the same way onSubmitReceiptComplete already continues draining. The registered callbacks are untouched and fire from that later pass, so the existing "callback fires exactly once" guarantee holds. The receiptStore null check is load-bearing rather than defensive: with no store, synchronizeReceipts skips the submit branch and comes straight back here with the queue still non-empty. There is already a test in this suite for the sibling infinite-recursion case, which is what made the hazard obvious. Found while checking a developer-guide listing that repaints a subscription label from this callback. Verified by reverting the fix: the new test fails with "expected: <1> but was: <0>" -- the receipt never submitted -- and passes with it, 18/18 in the class either way otherwise. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/com/codename1/payment/Purchase.java | 19 +++++++ .../com/codename1/payment/PurchaseTest.java | 51 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/CodenameOne/src/com/codename1/payment/Purchase.java b/CodenameOne/src/com/codename1/payment/Purchase.java index 5cc88108e24..2b3f01f844c 100644 --- a/CodenameOne/src/com/codename1/payment/Purchase.java +++ b/CodenameOne/src/com/codename1/payment/Purchase.java @@ -800,6 +800,25 @@ public void run() { return; } syncInProgress = false; + // A purchase can complete while this fetch is in flight. Its + // postReceipt queued the receipt but could not start a + // synchronization of its own -- syncInProgress was true -- and this + // fetch was requested before the receipt existed, so the snapshot it + // brought back does not contain it. Reporting success now hands every + // waiting caller a result that predates the purchase, and the receipt + // stays pending until something synchronizes again. Run once more + // instead; the callbacks are still registered and fire from that pass, + // the same way onSubmitReceiptComplete continues draining. + // + // Guarded on receiptStore: with no store, synchronizeReceipts skips + // the submit branch and comes straight back here with the queue still + // non-empty, which would never terminate. + if (Boolean.TRUE.equals(fetchSucceeded) + && receiptStore != null + && !getPendingPurchases().isEmpty()) { + synchronizeReceipts(0, null); + return; + } fireSynchronizeReceiptsCallbacks(Boolean.TRUE.equals(fetchSucceeded)); } diff --git a/maven/core-unittests/src/test/java/com/codename1/payment/PurchaseTest.java b/maven/core-unittests/src/test/java/com/codename1/payment/PurchaseTest.java index 6b929521c46..7f23edc5b3c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/payment/PurchaseTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/payment/PurchaseTest.java @@ -382,6 +382,45 @@ void testPostReceiptSkipsDuplicateTransactionIdAlreadyPending() { "Duplicate transactionId enqueued before sync should be dropped at addPendingPurchase"); } + @EdtTest + void testReceiptQueuedDuringFetchIsSubmittedBeforeSyncReportsSuccess() { + // A purchase completing while a synchronization is already fetching + // could not start one of its own -- syncInProgress was already true -- + // and the fetch in flight was requested before the receipt existed. + // Reporting success at the end of that fetch handed the caller a + // snapshot without the purchase in it and left the receipt pending + // until something synchronized again. + final TestReceiptStore store = new TestReceiptStore(); + purchase.setReceiptStore(store); + + store.setOnFetch(new Runnable() { + public void run() { + // Stand in for the native purchase callback arriving while + // this fetch is outstanding. This is the entry point the + // ports use. + Purchase.postReceipt(Receipt.STORE_CODE_ITUNES, "late", "tx-late", + System.currentTimeMillis(), "order-late"); + } + }); + + final boolean[] result = new boolean[1]; + final int[] callCount = new int[1]; + purchase.synchronizeReceipts(0, new SuccessCallback() { + public void onSucess(Boolean value) { + callCount[0]++; + result[0] = Boolean.TRUE.equals(value); + } + }); + flushSerialCalls(); + + assertEquals(1, callCount[0], "the callback still fires exactly once"); + assertTrue(result[0]); + assertEquals(1, store.getSubmittedReceipts().size(), + "the receipt queued during the fetch must be submitted before success is reported"); + assertTrue(purchase.getPendingPurchases().isEmpty(), + "and it must not be left in the pending queue"); + } + @EdtTest void testSynchronizeReceiptsDoesNotInfinitelyResubmitReceiptWithNullTransactionId() { // A receipt with a null transactionId must still be removable from the @@ -438,6 +477,13 @@ private static class TestReceiptStore implements ReceiptStore { private List receipts = new ArrayList(); private final List submitted = new ArrayList(); private boolean submitResult = true; + /// Runs once, inside the first fetch, so a test can simulate a + /// purchase arriving while a synchronization is outstanding. + private Runnable onFetch; + + void setOnFetch(Runnable onFetch) { + this.onFetch = onFetch; + } void setReceipts(List receipts) { this.receipts = new ArrayList(receipts); @@ -452,6 +498,11 @@ List getSubmittedReceipts() { } public void fetchReceipts(SuccessCallback callback) { + if (onFetch != null) { + Runnable r = onFetch; + onFetch = null; + r.run(); + } Receipt[] data = receipts.toArray(new Receipt[receipts.size()]); callback.onSucess(data); }