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/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..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 @@ -78,6 +78,18 @@ class MonetizationJava037Snippet { Container myForm; Component component; Button button; + SpanLabel rentalStatus = new SpanLabel(); + + Form current; + + void showRentalStatus() { + } + + void addExpiryLabel(Form hi) { + } + + void addSyncButton(Form hi) { + } MultiButton myMultiButton; Label label; BrowserComponent browserComponent; @@ -85,13 +97,35 @@ class MonetizationJava037Snippet { // tag::monetization-java-037[] public void start() { + 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(); + } else { + 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); - // Now synchronize the receipts - iap.synchronizeReceipts(0, res->{ - // Update the UI as necessary to reflect + current = hi; + hi.show(); + } + // 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. + 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) { + showRentalStatus(); + current.revalidate(); + } }); } // end::monetization-java-037[] 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..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,11 +85,14 @@ class MonetizationJava040Snippet { void snippet() throws Exception { // tag::monetization-java-040[] - Purchase iap = Purchase.getInAppPurchase(); + // 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> @@ -98,7 +101,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 @@ -109,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)+ @@ -117,7 +121,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 +129,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"; 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..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 @@ -87,6 +87,9 @@ abstract class Sample implements PurchaseCallback { // tag::monetization-java-041[] @Override public void itemPurchased(String sku) { + // 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 @@ -98,6 +101,18 @@ 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. + // + // 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 @@ -105,6 +120,13 @@ public void itemPurchaseError(String sku, String errorMessage) { ToastBar.showErrorMessage("Failure occurred: "+errorMessage); } // end::monetization-java-041[] + + Purchase iap = Purchase.getInAppPurchase(); + + Form current; + + void showRentalStatus() { + } } String SKU_WORLD_1_MONTH = "com.example.world.month"; 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..e04e1dcfc8d --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava101Snippet.java @@ -0,0 +1,89 @@ +/* + * 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"}; + SpanLabel rentalStatus = new SpanLabel(); + + void showRentalStatus() { + } + + // tag::monetization-java-101[] + void addSyncButton(Form hi) { + Button syncReceipts = new Button("Synchronize Receipts"); + + syncReceipts.addActionListener(e -> { + 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 + // new to show and the status on screen stays as it was. + if (success) { + showRentalStatus(); + hi.revalidate(); + } 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 new file mode 100644 index 00000000000..2b9f0603ed2 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava102Snippet.java @@ -0,0 +1,85 @@ +/* + * 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[] + // 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(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. + showRentalStatus(); + hi.add(rentalStatus); + } + + void showRentalStatus() { + Purchase iap = Purchase.getInAppPurchase(); + 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[] +} 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..444e49fba37 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MonetizationJava103Snippet.java @@ -0,0 +1,76 @@ +/* + * 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 }; + + // 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 12e6fb59be3..62b91e78be0 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 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. @@ -237,7 +243,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] ---- @@ -246,6 +252,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 @@ -257,7 +268,12 @@ 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] +---- +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 +439,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,10 +467,10 @@ 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: +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. @@ -464,7 +480,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/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); } 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: