diff --git a/CodenameOne/src/com/codename1/appreview/AppReview.java b/CodenameOne/src/com/codename1/appreview/AppReview.java new file mode 100644 index 0000000000..97194c1503 --- /dev/null +++ b/CodenameOne/src/com/codename1/appreview/AppReview.java @@ -0,0 +1,285 @@ +/* + * 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 Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.appreview; + +import com.codename1.io.Preferences; +import com.codename1.ui.CN; +import com.codename1.util.SuccessCallback; + +/// Entry point for requesting an app store review and collecting feedback. +/// +/// `AppReview` prefers the platform's native review prompt +/// (`SKStoreReviewController` on iOS, the Play In-App Review API on Android) +/// and transparently falls back to a Codename One drawn rating widget on the +/// simulator, desktop, the web target and platforms/OS versions without a +/// native prompt. +/// +/// There are two ways to use it: +/// +/// 1. **Manual** -- call [#requestReview] at a moment that makes sense in your +/// app (e.g. right after the user completed a meaningful task). You decide +/// the timing entirely. +/// +/// 2. **Scheduled** -- configure the engagement heuristics once and call +/// [#registerSession] on every app start. `AppReview` keeps a small amount +/// of state in [com.codename1.io.Preferences] (launch count, install date, +/// last prompt time) and only prompts once the thresholds are met and the +/// user has not already rated or opted out. +/// +/// The fallback widget routes high ratings to the store and low ratings to a +/// private feedback channel so unhappy users are heard before they post a one +/// star public review (see [#setHighRatingThreshold] and +/// [#setFeedbackListener]). +/// +/// ```java +/// AppReview.getInstance() +/// .setStoreUrl("https://apps.apple.com/app/id0000000000") +/// .setSupportEmail("support@example.com") +/// .registerSession(); +/// ``` +public class AppReview { + private static final String PREF_LAUNCHES = "cn1$appReview$launches"; + private static final String PREF_FIRST_INSTALL = "cn1$appReview$firstInstall"; + private static final String PREF_LAST_PROMPT = "cn1$appReview$lastPrompt"; + private static final String PREF_COMPLETED = "cn1$appReview$completed"; + + private static final long DAY_MILLIS = 24L * 60L * 60L * 1000L; + + private static final AppReview instance = new AppReview(); + + private int minimumLaunches = 5; + private int minimumDaysInstalled = 3; + private int daysBetweenPrompts = 30; + private int highRatingThreshold = 4; + private String storeUrl; + private String supportEmail; + private FeedbackListener feedbackListener; + + AppReview() { + } + + /// The shared `AppReview` instance. + /// + /// #### Returns + /// + /// the singleton used by the whole application. + public static AppReview getInstance() { + return instance; + } + + /// The number of app launches that must accumulate before the scheduler in + /// [#registerSession] will prompt for a review. Defaults to 5. + /// + /// #### Returns + /// + /// this instance for chaining. + public AppReview setMinimumLaunches(int minimumLaunches) { + this.minimumLaunches = minimumLaunches; + return this; + } + + /// The number of days that must elapse after the first recorded launch + /// before the scheduler will prompt for a review. Defaults to 3. + /// + /// #### Returns + /// + /// this instance for chaining. + public AppReview setMinimumDaysInstalled(int minimumDaysInstalled) { + this.minimumDaysInstalled = minimumDaysInstalled; + return this; + } + + /// The minimum number of days between two consecutive review prompts shown + /// by the scheduler. Defaults to 30. + /// + /// #### Returns + /// + /// this instance for chaining. + public AppReview setDaysBetweenPrompts(int daysBetweenPrompts) { + this.daysBetweenPrompts = daysBetweenPrompts; + return this; + } + + /// The lowest star value (1-5) that is still considered a positive rating + /// in the fallback widget. Ratings at or above this value send the user to + /// the store, lower ratings open the private feedback flow. Defaults to 4. + /// + /// #### Returns + /// + /// this instance for chaining. + public AppReview setHighRatingThreshold(int highRatingThreshold) { + this.highRatingThreshold = highRatingThreshold; + return this; + } + + /// The store URL opened by the fallback widget for a positive rating (and + /// used when no native prompt is available). On iOS/Android with a native + /// prompt this is not needed. Typically your App Store or Google Play + /// listing URL. + /// + /// #### Returns + /// + /// this instance for chaining. + public AppReview setStoreUrl(String storeUrl) { + this.storeUrl = storeUrl; + return this; + } + + /// The support e-mail address used by the fallback widget to collect + /// feedback for low ratings when no [FeedbackListener] handled it. When + /// null and no listener is set, the feedback step is skipped. + /// + /// #### Returns + /// + /// this instance for chaining. + public AppReview setSupportEmail(String supportEmail) { + this.supportEmail = supportEmail; + return this; + } + + /// Registers a listener that intercepts the outcome of the fallback rating + /// widget so feedback can be delivered through your own channel. + /// + /// #### Returns + /// + /// this instance for chaining. + public AppReview setFeedbackListener(FeedbackListener feedbackListener) { + this.feedbackListener = feedbackListener; + return this; + } + + int getHighRatingThreshold() { + return highRatingThreshold; + } + + String getStoreUrl() { + return storeUrl; + } + + String getSupportEmail() { + return supportEmail; + } + + FeedbackListener getFeedbackListener() { + return feedbackListener; + } + + /// Records the current app session and, when the configured engagement + /// thresholds are satisfied and the user has not already rated or opted + /// out, prompts for a review. Call this once per app start (e.g. from the + /// `start` lifecycle method). It is cheap and safe to call every launch. + public void registerSession() { + int launches = Preferences.get(PREF_LAUNCHES, 0) + 1; + Preferences.set(PREF_LAUNCHES, launches); + if (Preferences.get(PREF_FIRST_INSTALL, 0L) == 0L) { + Preferences.set(PREF_FIRST_INSTALL, System.currentTimeMillis()); + } + if (shouldPrompt()) { + requestReview(); + } + } + + /// Whether [#registerSession] would prompt for a review given the current + /// persisted state and configuration. Exposed mainly for testing and for + /// apps that want to drive the prompt from their own trigger. + /// + /// #### Returns + /// + /// true if a prompt is currently due. + public boolean shouldPrompt() { + if (Preferences.get(PREF_COMPLETED, false)) { + return false; + } + if (Preferences.get(PREF_LAUNCHES, 0) < minimumLaunches) { + return false; + } + long now = System.currentTimeMillis(); + long firstInstall = Preferences.get(PREF_FIRST_INSTALL, now); + if (now - firstInstall < ((long) minimumDaysInstalled) * DAY_MILLIS) { + return false; + } + long lastPrompt = Preferences.get(PREF_LAST_PROMPT, 0L); + return lastPrompt <= 0L || now - lastPrompt >= ((long) daysBetweenPrompts) * DAY_MILLIS; + } + + /// Immediately asks the user for a review. Uses the native store review + /// prompt when available, otherwise shows the Codename One rating widget. + /// Unlike [#registerSession] this ignores the scheduling thresholds, but it + /// still respects the "already completed" opt out and records the prompt + /// time so the scheduler will not pile on. + public void requestReview() { + if (Preferences.get(PREF_COMPLETED, false)) { + // The user already rated or opted out -- honour that even for a + // manual request, as documented above. + return; + } + Preferences.set(PREF_LAST_PROMPT, System.currentTimeMillis()); + if (CN.isEdt()) { + requestReviewImpl(); + } else { + CN.callSerially(new Runnable() { + @Override + public void run() { + requestReviewImpl(); + } + }); + } + } + + private void requestReviewImpl() { + if (CN.isNativeInAppReviewSupported()) { + CN.requestNativeInAppReview(new SuccessCallback() { + @Override + public void onSucess(Boolean handled) { + if (handled != null && handled.booleanValue()) { + // The OS now owns the rate-limiting / cadence of review + // prompts, so we stop driving our own scheduler. + markCompleted(); + } else { + RatingDialog.show(AppReview.this); + } + } + }); + } else { + RatingDialog.show(this); + } + } + + /// Permanently stops the scheduler from prompting again (the user rated the + /// app or chose "don't ask again"). The fallback widget calls this for you; + /// it is exposed for apps that gather a rating through their own UI. + public void markCompleted() { + Preferences.set(PREF_COMPLETED, true); + } + + /// Clears all persisted engagement state (launch count, install date, last + /// prompt time and the completed flag) so the engagement cycle starts over. + /// Mostly useful for testing. + public void reset() { + Preferences.delete(PREF_LAUNCHES); + Preferences.delete(PREF_FIRST_INSTALL); + Preferences.delete(PREF_LAST_PROMPT); + Preferences.delete(PREF_COMPLETED); + } +} diff --git a/CodenameOne/src/com/codename1/appreview/FeedbackListener.java b/CodenameOne/src/com/codename1/appreview/FeedbackListener.java new file mode 100644 index 0000000000..22402c3cf6 --- /dev/null +++ b/CodenameOne/src/com/codename1/appreview/FeedbackListener.java @@ -0,0 +1,64 @@ +/* + * 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 Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.appreview; + +/// Receives the outcome of the Codename One drawn rating widget shown by +/// [com.codename1.appreview.AppReview] when the platform has no native review +/// prompt (or when the user gave a low rating). Register an implementation via +/// [AppReview#setFeedbackListener] to collect feedback through your own +/// channel (e.g. a support backend) instead of the built in e-mail composer. +public interface FeedbackListener { + /// Invoked when the user picked a rating below the configured high rating + /// threshold (see [AppReview#setHighRatingThreshold]) and therefore should + /// be routed to a private feedback flow rather than the public store. + /// + /// Returning `true` signals that the listener is presenting its own + /// feedback experience, so `AppReview` will not show the built in feedback + /// composer. Returning `false` lets `AppReview` fall back to its default + /// behaviour (e-mail to the configured support address, if any). + /// + /// #### Parameters + /// + /// - `rating`: the star value the user selected, from 1 to the rating + /// widget's maximum (5 by default). + /// + /// #### Returns + /// + /// true if the listener handled the low rating itself. + boolean lowRating(int rating); + + /// Invoked with the free text the user typed in the built in feedback + /// composer, when [AppReview] is left to handle the low rating flow and a + /// support e-mail address was configured. Implement this to intercept the + /// text and deliver it yourself; it is not called when [#lowRating] already + /// returned `true`. + /// + /// #### Parameters + /// + /// - `rating`: the star value the user selected. + /// + /// - `feedback`: the free text entered by the user, never null but possibly + /// empty. + void feedback(int rating, String feedback); +} diff --git a/CodenameOne/src/com/codename1/appreview/RatingDialog.java b/CodenameOne/src/com/codename1/appreview/RatingDialog.java new file mode 100644 index 0000000000..cb95557a07 --- /dev/null +++ b/CodenameOne/src/com/codename1/appreview/RatingDialog.java @@ -0,0 +1,175 @@ +/* + * 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 Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.appreview; + +import com.codename1.components.SpanLabel; +import com.codename1.messaging.Message; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Sheet; +import com.codename1.ui.TextArea; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BoxLayout; + +/// The Codename One drawn rating widget shown by [AppReview] when no native +/// review prompt is available. It is presented as a bottom [Sheet] so the user +/// can dismiss it with a swipe rather than a blocking modal: a row of stars, +/// then a high rating routes to the store while a low rating opens a private +/// feedback step. +/// +/// This class is intentionally package private -- apps interact with it +/// exclusively through [AppReview]. +final class RatingDialog { + private static final int MAX_STARS = 5; + + private RatingDialog() { + } + + static void show(final AppReview config) { + String appName = CN.getProperty("AppName", "this app"); + final Sheet sheet = new Sheet(null, "Enjoying " + appName + "?"); + Container content = sheet.getContentPane(); + content.setLayout(BoxLayout.y()); + + SpanLabel prompt = new SpanLabel("Tap a star to rate your experience."); + prompt.setUIID("DialogBody"); + content.add(prompt); + + // A horizontal box keeps the stars together at the leading edge + // (left, or right under RTL) on a single row -- a grid would space + // them across the full width and a flow layout would wrap them. + Container stars = new Container(BoxLayout.x()); + for (int i = 1; i <= MAX_STARS; i++) { + final int value = i; + Button star = new Button(); + star.setUIID("Label"); + FontImage.setMaterialIcon(star, FontImage.MATERIAL_STAR_BORDER, 5); + star.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + sheet.back(); + onRating(config, value); + } + }); + stars.add(star); + } + content.add(stars); + + Button never = new Button("Don't ask again"); + never.setUIID("DialogCommandText"); + never.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + config.markCompleted(); + sheet.back(); + } + }); + content.add(never); + + sheet.show(); + } + + private static void onRating(AppReview config, int rating) { + if (rating >= config.getHighRatingThreshold()) { + config.markCompleted(); + openStore(config); + } else { + collectFeedback(config, rating); + } + } + + private static void openStore(AppReview config) { + // Even within the fallback widget the native prompt may have become + // available (e.g. a positive rating on an older flow); prefer it. + if (CN.isNativeInAppReviewSupported()) { + CN.requestNativeInAppReview(null); + return; + } + String storeUrl = config.getStoreUrl(); + if (storeUrl != null && storeUrl.length() > 0) { + CN.execute(storeUrl); + } + } + + private static void collectFeedback(final AppReview config, final int rating) { + FeedbackListener listener = config.getFeedbackListener(); + if (listener != null && listener.lowRating(rating)) { + // The listener is presenting its own feedback experience. + config.markCompleted(); + return; + } + + String supportEmail = config.getSupportEmail(); + if ((listener == null && (supportEmail == null || supportEmail.length() == 0))) { + // Nowhere to route the feedback -- nothing more to do. + config.markCompleted(); + return; + } + + final Sheet sheet = new Sheet(null, "Help us improve"); + Container content = sheet.getContentPane(); + content.setLayout(BoxLayout.y()); + SpanLabel prompt = new SpanLabel("Sorry to hear it wasn't great. What can we do better?"); + prompt.setUIID("DialogBody"); + content.add(prompt); + + final TextArea feedback = new TextArea("", 4, 20); + feedback.setHint("Your feedback"); + content.add(feedback); + + Button send = new Button("Send"); + send.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + String text = feedback.getText(); + if (text == null) { + text = ""; + } + config.markCompleted(); + sheet.back(); + deliverFeedback(config, rating, text); + } + }); + content.add(send); + + sheet.show(); + } + + private static void deliverFeedback(AppReview config, int rating, String text) { + FeedbackListener listener = config.getFeedbackListener(); + if (listener != null) { + listener.feedback(rating, text); + return; + } + String supportEmail = config.getSupportEmail(); + if (supportEmail != null && supportEmail.length() > 0) { + String appName = CN.getProperty("AppName", "the app"); + Message msg = new Message(text); + Message.sendMessage(new String[]{supportEmail}, "Feedback for " + appName, msg); + } + } +} diff --git a/CodenameOne/src/com/codename1/appreview/package-info.java b/CodenameOne/src/com/codename1/appreview/package-info.java new file mode 100644 index 0000000000..61ad4ac6b1 --- /dev/null +++ b/CodenameOne/src/com/codename1/appreview/package-info.java @@ -0,0 +1,10 @@ +/// App review & feedback engagement API. +/// +/// [com.codename1.appreview.AppReview] requests the platform's native +/// "rate this app" prompt (Apple `SKStoreReviewController` / Google Play +/// In-App Review) when available and falls back to a Codename One drawn +/// rating widget elsewhere. It also offers an optional engagement scheduler +/// that decides when to ask for a review based on launch count and elapsed +/// days, and a smart feedback split that routes unhappy users to a private +/// feedback channel instead of a public store review. +package com.codename1.appreview; diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 326e735cdf..e83ad8f3f6 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -7472,6 +7472,37 @@ public void share(String text, String image, String mimeType, Rectangle sourceRe } } + /// Indicates whether the underlying platform exposes a native in-app + /// review/rating prompt (the OS-sanctioned "rate this app" sheet) that + /// can be triggered via [#requestNativeInAppReview]. When this returns + /// false the higher level API falls back to a Codename One drawn rating + /// widget. + /// + /// #### Returns + /// + /// true if the platform can present a native review prompt. + public boolean isNativeInAppReviewSupported() { + return false; + } + + /// Requests the native in-app review prompt. This should only be invoked + /// when [#isNativeInAppReviewSupported] returns true. The platforms + /// deliberately hide whether the user actually submitted a rating and may + /// silently ignore the request based on their own quota/throttling + /// policies; `done` therefore reports whether the request was handed off + /// to the native review controller, not whether a review was written. + /// + /// #### Parameters + /// + /// - `done`: invoked with `true` once the native prompt was requested or + /// `false` when the platform did not handle it (in which case the caller + /// may show its own fallback). May be null. + public void requestNativeInAppReview(SuccessCallback done) { + if (done != null) { + done.onSucess(Boolean.FALSE); + } + } + /// Indicates if the underlying platform can print documents through /// [#print(String,String,PrintResultListener)]. /// diff --git a/CodenameOne/src/com/codename1/ui/CN.java b/CodenameOne/src/com/codename1/ui/CN.java index 9ed3c6d9ab..2bb6589cdf 100644 --- a/CodenameOne/src/com/codename1/ui/CN.java +++ b/CodenameOne/src/com/codename1/ui/CN.java @@ -38,6 +38,7 @@ import com.codename1.ui.geom.Rectangle; import com.codename1.util.Simd; import com.codename1.util.RunnableWithResultSync; +import com.codename1.util.SuccessCallback; import java.io.IOException; import java.io.InputStream; @@ -1189,6 +1190,31 @@ public static boolean isNativeShareSupported() { return Display.impl.isNativeShareSupported(); } + /// Indicates whether the platform exposes a native in-app review/rating + /// prompt (the OS-sanctioned "rate this app" sheet). When false the + /// [com.codename1.appreview.AppReview] API falls back to a Codename One + /// drawn rating widget. + /// + /// #### Returns + /// + /// true if the platform can present a native review prompt. + public static boolean isNativeInAppReviewSupported() { + return Display.impl.isNativeInAppReviewSupported(); + } + + /// Requests the native in-app review prompt. Should only be invoked when + /// [#isNativeInAppReviewSupported] returns true. The platforms hide whether + /// the user actually rated and may throttle the prompt; `done` reports + /// whether the request reached the native review controller. + /// + /// #### Parameters + /// + /// - `done`: invoked with `true` once the native prompt was requested or + /// `false` when the platform did not handle it. May be null. + public static void requestNativeInAppReview(SuccessCallback done) { + Display.impl.requestNativeInAppReview(done); + } + /// Share the required information using the platform sharing services. /// a Sharing service can be: mail, sms, facebook, twitter,... /// This method is implemented if isNativeShareSupported() returned true for diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 1a217240d1..15733bc5a3 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -5244,6 +5244,31 @@ public boolean isNativeShareSupported() { return impl.isNativeShareSupported(); } + /// Indicates whether the platform exposes a native in-app review/rating + /// prompt (the OS-sanctioned "rate this app" sheet). When false the + /// [com.codename1.appreview.AppReview] API falls back to a Codename One + /// drawn rating widget. + /// + /// #### Returns + /// + /// true if the platform can present a native review prompt. + public boolean isNativeInAppReviewSupported() { + return impl.isNativeInAppReviewSupported(); + } + + /// Requests the native in-app review prompt. Should only be invoked when + /// [#isNativeInAppReviewSupported] returns true. The platforms hide whether + /// the user actually rated and may throttle the prompt; `done` reports + /// whether the request reached the native review controller. + /// + /// #### Parameters + /// + /// - `done`: invoked with `true` once the native prompt was requested or + /// `false` when the platform did not handle it. May be null. + public void requestNativeInAppReview(SuccessCallback done) { + impl.requestNativeInAppReview(done); + } + /// Share the required information using the platform sharing services. /// a Sharing service can be: mail, sms, facebook, twitter,... /// This method is implemented if isNativeShareSupported() returned true for diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index c87e295fd8..6ffce9f2f7 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -7840,6 +7840,29 @@ public boolean isNativeShareSupported() { return true; } + @Override + public boolean isNativeInAppReviewSupported() { + // True only when the Play In-App Review library was bundled, which the + // AndroidGradleBuilder does when the app references the app-review API. + return getActivity() != null && AppReviewSupport.isSupported(); + } + + @Override + public void requestNativeInAppReview(final SuccessCallback done) { + final CodenameOneActivity activity = getActivity(); + if (activity == null || !AppReviewSupport.isSupported()) { + if (done != null) { + done.onSucess(Boolean.FALSE); + } + return; + } + activity.runOnUiThread(new Runnable() { + public void run() { + AppReviewSupport.requestReview(activity, done); + } + }); + } + @Override public void share(String text, String image, String mimeType, Rectangle sourceRect){ share(text, image, mimeType, sourceRect, null); diff --git a/Ports/Android/src/com/codename1/impl/android/AppReviewSupport.java b/Ports/Android/src/com/codename1/impl/android/AppReviewSupport.java new file mode 100644 index 0000000000..a312bb38c0 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AppReviewSupport.java @@ -0,0 +1,164 @@ +/* + * 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 Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android; + +import android.app.Activity; +import android.content.Context; +import com.codename1.util.SuccessCallback; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Bridges {@link com.codename1.appreview.AppReview} to the Google Play In-App + * Review API ({@code com.google.android.play:review}). + * + *

The Play review library is only added to the app's Gradle build when the + * app references the app-review API (the {@code AndroidGradleBuilder} detects + * this during its class scan), so this class talks to the library purely + * through reflection. That keeps the Android port itself compilable without the + * extra dependency, and the {@code Task}/{@code OnCompleteListener} interfaces + * are resolved from the methods at runtime so the helper is agnostic to whether + * the library is on the legacy {@code com.google.android.play.core.tasks} or the + * newer {@code com.google.android.gms.tasks} package.

+ */ +class AppReviewSupport { + private static final String FACTORY = "com.google.android.play.core.review.ReviewManagerFactory"; + + private AppReviewSupport() { + } + + /** + * @return true if the Play In-App Review library is present in the running + * app (i.e. the app referenced the review API and the builder bundled it). + */ + static boolean isSupported() { + try { + Class.forName(FACTORY); + return true; + } catch (Throwable t) { + return false; + } + } + + /** + * Requests the native review flow. Must be called on the Android UI thread. + * Any failure (library missing, Play services unavailable, quota reached) + * completes {@code done} with {@code false} so the caller can fall back. + */ + static void requestReview(final Activity activity, final SuccessCallback done) { + try { + Class factory = Class.forName(FACTORY); + Method create = factory.getMethod("create", Context.class); + final Object manager = create.invoke(null, activity); + + Object requestFlow = manager.getClass().getMethod("requestReviewFlow").invoke(manager); + addOnCompleteListener(requestFlow, new InvocationHandler() { + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if (!"onComplete".equals(method.getName()) || args == null || args.length != 1) { + return defaultValue(method); + } + Object task = args[0]; + if (isSuccessful(task)) { + Object reviewInfo = task.getClass().getMethod("getResult").invoke(task); + Object launchFlow = invokeNamed(manager, "launchReviewFlow", activity, reviewInfo); + addOnCompleteListener(launchFlow, new InvocationHandler() { + public Object invoke(Object p2, Method m2, Object[] a2) throws Throwable { + if ("onComplete".equals(m2.getName())) { + complete(done, true); + } + return defaultValue(m2); + } + }); + } else { + complete(done, false); + } + return defaultValue(method); + } + }); + } catch (Throwable t) { + Logger.getLogger("Codename One").log(Level.WARNING, "Native in-app review failed", t); + complete(done, false); + } + } + + private static boolean isSuccessful(Object task) throws Exception { + Object result = task.getClass().getMethod("isSuccessful").invoke(task); + return result instanceof Boolean && ((Boolean) result).booleanValue(); + } + + /** + * Calls {@code task.addOnCompleteListener(listener)} for the single-argument + * overload, building the listener as a dynamic proxy of whatever + * {@code OnCompleteListener} interface that overload declares. + */ + private static void addOnCompleteListener(Object task, InvocationHandler handler) throws Exception { + Method add = null; + Method[] methods = task.getClass().getMethods(); + for (int i = 0; i < methods.length; i++) { + Method m = methods[i]; + if (m.getName().equals("addOnCompleteListener") && m.getParameterTypes().length == 1) { + add = m; + break; + } + } + if (add == null) { + throw new NoSuchMethodException("addOnCompleteListener"); + } + Class listenerType = add.getParameterTypes()[0]; + Object listener = Proxy.newProxyInstance(listenerType.getClassLoader(), + new Class[]{listenerType}, handler); + add.invoke(task, listener); + } + + private static Object invokeNamed(Object target, String name, Object arg1, Object arg2) throws Exception { + Method[] methods = target.getClass().getMethods(); + for (int i = 0; i < methods.length; i++) { + Method m = methods[i]; + if (m.getName().equals(name) && m.getParameterTypes().length == 2) { + return m.invoke(target, arg1, arg2); + } + } + throw new NoSuchMethodException(name); + } + + private static void complete(SuccessCallback done, boolean value) { + if (done != null) { + done.onSucess(value ? Boolean.TRUE : Boolean.FALSE); + } + } + + private static Object defaultValue(Method method) { + Class ret = method.getReturnType(); + if (ret == Boolean.TYPE) { + return Boolean.FALSE; + } + if (ret == Integer.TYPE) { + return Integer.valueOf(0); + } + return null; + } +} diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index 612b20ea23..557e748183 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -41,7 +41,8 @@ #endif #import //#define CN1_USE_STOREKIT -#ifdef CN1_USE_STOREKIT +//#define CN1_USE_APPREVIEW +#if defined(CN1_USE_STOREKIT) || defined(CN1_USE_APPREVIEW) #import "StoreKit/StoreKit.h" #endif #if !TARGET_OS_WATCH diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 10afbf9305..e35bc6167a 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -7244,6 +7244,24 @@ void com_codename1_impl_ios_IOSNative_dial___java_lang_String(CN1_THREAD_STATE_M #endif // !TARGET_OS_WATCH } +void com_codename1_impl_ios_IOSNative_requestAppStoreReview__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + // The symbol is always emitted so ParparVM can link; the StoreKit body is + // compiled only when the build detected the app-review API in use (the + // IPhoneBuilder flips CN1_USE_APPREVIEW and links StoreKit.framework). When + // the macro is off this is a harmless no-op with no StoreKit dependency. +#ifdef CN1_USE_APPREVIEW +#if !TARGET_OS_WATCH + POOL_BEGIN(); + dispatch_async(dispatch_get_main_queue(), ^{ + if (@available(iOS 10.3, *)) { + [SKStoreReviewController requestReview]; + } + }); + POOL_END(); +#endif // !TARGET_OS_WATCH +#endif // CN1_USE_APPREVIEW +} + void com_codename1_impl_ios_IOSNative_sendSMS___java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT number, JAVA_OBJECT text) { #if TARGET_OS_MACCATALYST || TARGET_OS_WATCH diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 3863ca23d8..924667a1ce 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -10056,8 +10056,41 @@ public boolean isNativeShareSupported(){ } return true; } - - + + @Override + public boolean isNativeInAppReviewSupported() { + // SKStoreReviewController.requestReview is available since iOS 10.3. + String ver = nativeInstance.getOSVersion(); + int dot = ver.indexOf('.'); + try { + int major = Integer.parseInt(dot < 0 ? ver : ver.substring(0, dot)); + if (major > 10) { + return true; + } + if (major < 10) { + return false; + } + String rest = dot < 0 ? "" : ver.substring(dot + 1); + int dot2 = rest.indexOf('.'); + int minor = Integer.parseInt(dot2 < 0 ? rest : rest.substring(0, dot2)); + return minor >= 3; + } catch (NumberFormatException err) { + // Unknown/odd version string -- assume a modern OS supports it. + return true; + } + } + + @Override + public void requestNativeInAppReview(SuccessCallback done) { + // StoreKit gives no callback and may silently throttle the prompt, so + // we simply report that the request was handed off to the controller. + nativeInstance.requestAppStoreReview(); + if (done != null) { + done.onSucess(Boolean.TRUE); + } + } + + @Override public void share(String text, String image, String mimeType, Rectangle sourceRect){ diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index cae89d825c..9d712c597a 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -546,6 +546,7 @@ native void gl3dDrawArrays(long contextPeer, long pipelinePeer, long vboPeer, in native boolean deleteContact(int id); native void dial(String phone); + native void requestAppStoreReview(); native void sendSMS(String phone, String text); native void registerPush(); diff --git a/docs/developer-guide/App-Review.asciidoc b/docs/developer-guide/App-Review.asciidoc new file mode 100644 index 0000000000..ee8b43472b --- /dev/null +++ b/docs/developer-guide/App-Review.asciidoc @@ -0,0 +1,68 @@ +== App Review & Feedback + +Both Apple and Google provide an in-app review prompt that lets the user rate your app without leaving it. Asking for a review at the right moment is one of the most effective ways to improve your store rating. Codename One exposes this through the `com.codename1.appreview.AppReview` API, which uses the native review sheet (`SKStoreReviewController` on iOS, the Play In-App Review API on Android) when it's available and falls back to a Codename One drawn rating widget everywhere else (the simulator, desktop, the web target and older OS versions). + +The native prompt is the store-sanctioned way to ask for a review, so prefer it over a hand-rolled dialog: the operating system controls how often it appears and throttles excessive prompts, which keeps your app within store policy. + +=== Asking for a review + +The simplest usage is a single call at a moment that makes sense in your app -- typically right after the user completed something rewarding (finished a level, saved a document, completed an order): + +[source,java] +---- +AppReview.getInstance().requestReview(); +---- + +`requestReview()` shows the native review sheet when the platform supports it. On platforms without a native prompt it shows the built-in rating widget instead. You decide the timing. + +=== The engagement scheduler + +Rather than picking the moment yourself, you can let `AppReview` decide based on simple engagement heuristics: how many times the app was launched, how long ago it was installed, and how long since it last asked. Configure it once (for example in your app's `init` or `start` method) and call `registerSession()` on every launch: + +[source,java] +---- +AppReview.getInstance() + .setMinimumLaunches(5) // at least 5 launches + .setMinimumDaysInstalled(3) // and installed at least 3 days + .setDaysBetweenPrompts(30) // never nag more than monthly + .setStoreUrl("https://apps.apple.com/app/id0000000000") + .setSupportEmail("support@example.com") + .registerSession(); +---- + +`registerSession()` records the launch and, once the thresholds are met and the user hasn't already rated or opted out, prompts. The bookkeeping (launch count, install date, last-prompt time and a completion flag) is stored in https://www.codenameone.com/javadoc/com/codename1/io/Preferences.html[Preferences] so it survives restarts. Once the user rates the app or chooses to stop, `AppReview` stops prompting. Call `reset()` to clear this state. + +TIP: You can combine both styles -- use `registerSession()` for the automatic cadence and still call `requestReview()` from a "Rate this app" menu item. + +=== Smart feedback split + +The fallback widget is presented as a bottom sheet, so the user can swipe it away instead of dismissing a blocking dialog. It asks for a star rating and then routes the user based on the result. A high rating (at or above the threshold set by `setHighRatingThreshold(int)`, four stars by default) sends the user to your store listing. A low rating opens a private feedback step instead, so unhappy users reach you directly rather than posting a one-star public review. + +.The Codename One fallback rating sheet, shown when no native review prompt is available +image::img/app-review-sheet.png[Fallback rating sheet,scaledwidth=30%] + +By default the low-rating feedback is collected through an e-mail to the address passed to `setSupportEmail(String)`. To deliver feedback through your own backend, register a `FeedbackListener`: + +[source,java] +---- +AppReview.getInstance().setFeedbackListener(new FeedbackListener() { + public boolean lowRating(int rating) { + // return true to present your own feedback UI and suppress the + // built-in e-mail composer + return false; + } + + public void feedback(int rating, String text) { + // called with the free text the user typed in the built-in composer + myBackend.submitFeedback(rating, text); + } +}); +---- + +=== How the platform decides + +`AppReview` checks `CN.isNativeInAppReviewSupported()` to choose between the native prompt and the fallback sheet. On Android the native path is active only when the app references the app-review API -- the build adds the Play In-App Review library in that case, so apps that never ask for a review carry no extra dependency. On iOS the StoreKit review controller is linked the same way, only when the API is used. + +The native prompts are drawn by the operating system, so this guide can't show them as Codename One screenshots. On iOS the prompt is the StoreKit rating panel: a star selector the system overlays on your app, submitted without leaving it. On Android it's the Play In-App Review card that slides up from the bottom. Both are styled and rate-limited by the OS -- you can't theme them, force them to appear, or read the outcome, which is why the fallback sheet shown above renders on every other platform. + +NOTE: The native review controllers hide whether the user submitted a rating, and may decline to show the prompt at all based on their own quotas. Treat a review request as best-effort; never block your UI waiting for a result. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index b6d7acf2d5..4fba89d4da 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -93,6 +93,8 @@ include::Crash-Protection.asciidoc[] include::Monetization.asciidoc[] +include::App-Review.asciidoc[] + include::Apple-Wallet-Extension.asciidoc[] include::Advanced-Topics-Under-The-Hood.asciidoc[] diff --git a/docs/developer-guide/img/app-review-sheet.png b/docs/developer-guide/img/app-review-sheet.png new file mode 100644 index 0000000000..5b0fb00311 Binary files /dev/null and b/docs/developer-guide/img/app-review-sheet.png differ diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 9d59735734..3ba4425cb3 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -289,6 +289,7 @@ public File getGradleProjectDirectory() { private boolean usesOidc; private boolean usesAppleSignIn; private boolean usesWebauthn; + private boolean usesAppReview; private boolean vibratePermission; private boolean smsPermission; private boolean gpsPermission; @@ -1299,6 +1300,12 @@ public void usesClass(String cls) { if (cls.indexOf("com/codename1/payment") > -1) { purchasePermissions = true; } + // App review API -> pull in the Play In-App Review library + // (see dependency injection further below). Detected from + // actual usage so apps that never review stay lean. + if (!usesAppReview && cls.indexOf("com/codename1/appreview") == 0) { + usesAppReview = true; + } if (cls.indexOf("com/codename1/location/Geofence") > -1) { if (!"true".equals(playServicesValue)) { // If play services are not currently "blanket" enabled @@ -1387,6 +1394,14 @@ public void usesClassMethod(String cls, String method) { vibratePermission = true; } + // Apps that call the low-level CN/Display review entry point + // directly (without the com.codename1.appreview facade). + if (!usesAppReview + && (cls.indexOf("com/codename1/ui/CN") == 0 || cls.indexOf("com/codename1/ui/Display") == 0) + && method.indexOf("equestNativeInAppReview") > -1) { + usesAppReview = true; + } + if ((cls.indexOf("com/codename1/media/MediaManager") == 0 && method.indexOf("createBackgroundMedia") > -1)) { if (targetSDKVersionInt >= 28) { foregroundServicePermission = true; @@ -4056,6 +4071,13 @@ public void usesClassMethod(String cls, String method) { additionalDependencies += " implementation 'com.android.billingclient:billing:"+billingClientVersion+"'\n"; } + // Play In-App Review library, added only when the app references the + // app-review API (detected during the class scan above). + if (usesAppReview) { + String reviewVersion = request.getArg("android.appReview.version", "2.0.1"); + additionalDependencies += " implementation 'com.google.android.play:review:"+reviewVersion+"'\n"; + } + // OidcClient routes sign-in through androidx.browser Custom Tabs. // Pull the browser dep in automatically when the app references // anything in com.codename1.io.oidc -- otherwise apps that don't diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index ee76f884d4..9c13be6165 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -99,6 +99,7 @@ public class IPhoneBuilder extends Executor { private String buildVersion; private boolean usesLocalNotifications; private boolean usesPurchaseAPI; + private boolean usesAppReview; private boolean usesWalletApi; private boolean usesCryptoAPI; private boolean usesCryptoGcm; @@ -729,6 +730,12 @@ public void usesClass(String cls) { if (!usesPurchaseAPI && cls.indexOf("com/codename1/payment") == 0) { usesPurchaseAPI = true; } + // App review API (SKStoreReviewController). Gated on actual + // usage so StoreKit.framework + the CN1_USE_APPREVIEW native + // bridge are only linked when the app references the API. + if (!usesAppReview && cls.indexOf("com/codename1/appreview") == 0) { + usesAppReview = true; + } // Wallet issuer-provisioning natives are only compiled in when // the app actually references the API (or enables the extension // via the ios.wallet.extension hint) - see CN1_INCLUDE_WALLET. @@ -812,6 +819,14 @@ public void usesClassMethod(String cls, String method) { || method.indexOf("disconnect") > -1)) { usesWifiHotspotConfig = true; } + // Apps that call the low-level CN/Display review entry point + // directly (without the com.codename1.appreview facade) still + // need StoreKit + the native bridge. + if (!usesAppReview + && (cls.equals("com/codename1/ui/CN") || cls.equals("com/codename1/ui/Display")) + && method.indexOf("equestNativeInAppReview") > -1) { + usesAppReview = true; + } } }); } catch (Exception ex) { @@ -1830,6 +1845,11 @@ public void usesClassMethod(String cls, String method) { replaceInFile(CodenameOne_GLViewController_h, "//#define CN1_USE_STOREKIT", "#define CN1_USE_STOREKIT"); } + if (usesAppReview) { + File CodenameOne_GLViewController_h = new File(buildinRes, "CodenameOne_GLViewController.h"); + replaceInFile(CodenameOne_GLViewController_h, "//#define CN1_USE_APPREVIEW", "#define CN1_USE_APPREVIEW"); + + } } catch (Exception ex) { throw new BuildException("Failure while injecting code from build hints", ex); } @@ -2261,6 +2281,15 @@ public void usesClassMethod(String cls, String method) { if (usesPurchaseAPI) { addLibs += ";StoreKit.framework"; } + // App review (SKStoreReviewController) also lives in StoreKit; + // link it when detected unless the purchase API already did. + if (usesAppReview && (addLibs == null || addLibs.toLowerCase().indexOf("storekit.framework") < 0)) { + if (addLibs == null || addLibs.length() == 0) { + addLibs = "StoreKit.framework"; + } else { + addLibs += ";StoreKit.framework"; + } + } } catch (Exception ex) { throw new BuildException("Failed to process build hints", ex); } diff --git a/maven/core-unittests/src/test/java/com/codename1/appreview/AppReviewTest.java b/maven/core-unittests/src/test/java/com/codename1/appreview/AppReviewTest.java new file mode 100644 index 0000000000..4e4f802b35 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/appreview/AppReviewTest.java @@ -0,0 +1,159 @@ +package com.codename1.appreview; + +import com.codename1.io.Preferences; +import com.codename1.io.Storage; +import com.codename1.junit.EdtTest; +import com.codename1.junit.UITestBase; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for the {@link AppReview} engagement scheduler decision logic + * ({@link AppReview#shouldPrompt()} and the {@link AppReview#registerSession()} + * bookkeeping). The Preferences keys below intentionally mirror the private + * constants in {@code AppReview} so the test can seed launch count / install + * date / last-prompt state directly, without launching real sessions (which on + * a platform with no native review prompt would pop the modal rating dialog). + */ +class AppReviewTest extends UITestBase { + private static final String PREF_LAUNCHES = "cn1$appReview$launches"; + private static final String PREF_FIRST_INSTALL = "cn1$appReview$firstInstall"; + private static final String PREF_LAST_PROMPT = "cn1$appReview$lastPrompt"; + + private static final long DAY = 24L * 60L * 60L * 1000L; + + private String originalLocation; + + @BeforeEach + void setUpAppReview() throws Exception { + Storage.setStorageInstance(null); + Storage storage = Storage.getInstance(); + storage.clearCache(); + storage.clearStorage(); + implementation.clearStorage(); + originalLocation = Preferences.getPreferencesLocation(); + Preferences.setPreferencesLocation("AppReviewTest-" + System.nanoTime()); + Preferences.clearAll(); + // Reset the singleton config to known defaults for each test. + AppReview.getInstance() + .setMinimumLaunches(5) + .setMinimumDaysInstalled(3) + .setDaysBetweenPrompts(30) + .setHighRatingThreshold(4) + .setStoreUrl(null) + .setSupportEmail(null) + .setFeedbackListener(null) + .reset(); + } + + @AfterEach + void tearDownAppReview() throws Exception { + AppReview.getInstance().reset(); + Preferences.clearAll(); + if (originalLocation != null) { + Preferences.setPreferencesLocation(originalLocation); + } + Storage storage = Storage.getInstance(); + storage.clearCache(); + storage.clearStorage(); + implementation.clearStorage(); + } + + @EdtTest + void doesNotPromptBelowLaunchThreshold() { + AppReview r = AppReview.getInstance(); + Preferences.set(PREF_LAUNCHES, 4); + Preferences.set(PREF_FIRST_INSTALL, now() - 10 * DAY); + assertFalse(r.shouldPrompt(), "Should not prompt below the launch threshold"); + } + + @EdtTest + void doesNotPromptBeforeMinimumDaysInstalled() { + AppReview r = AppReview.getInstance(); + Preferences.set(PREF_LAUNCHES, 9); + Preferences.set(PREF_FIRST_INSTALL, now() - 1 * DAY); + assertFalse(r.shouldPrompt(), "Should not prompt before the minimum days installed"); + } + + @EdtTest + void promptsOnceThresholdsAreMet() { + AppReview r = AppReview.getInstance(); + Preferences.set(PREF_LAUNCHES, 9); + Preferences.set(PREF_FIRST_INSTALL, now() - 10 * DAY); + assertTrue(r.shouldPrompt(), "Should prompt once launch + day thresholds are met"); + } + + @EdtTest + void recentPromptSuppressesWithinCooldown() { + AppReview r = AppReview.getInstance(); + Preferences.set(PREF_LAUNCHES, 9); + Preferences.set(PREF_FIRST_INSTALL, now() - 10 * DAY); + Preferences.set(PREF_LAST_PROMPT, now() - 5 * DAY); + assertFalse(r.shouldPrompt(), "Should not re-prompt inside daysBetweenPrompts"); + } + + @EdtTest + void promptsAgainAfterCooldown() { + AppReview r = AppReview.getInstance(); + Preferences.set(PREF_LAUNCHES, 9); + Preferences.set(PREF_FIRST_INSTALL, now() - 10 * DAY); + Preferences.set(PREF_LAST_PROMPT, now() - 31 * DAY); + assertTrue(r.shouldPrompt(), "Should prompt again after the cool-down window"); + } + + @EdtTest + void markCompletedSuppressesPermanently() { + AppReview r = AppReview.getInstance(); + Preferences.set(PREF_LAUNCHES, 9); + Preferences.set(PREF_FIRST_INSTALL, now() - 10 * DAY); + assertTrue(r.shouldPrompt()); + r.markCompleted(); + assertFalse(r.shouldPrompt(), "Should never prompt after completion"); + } + + @EdtTest + void resetClearsSuppression() { + AppReview r = AppReview.getInstance(); + r.markCompleted(); + r.reset(); + Preferences.set(PREF_LAUNCHES, 9); + Preferences.set(PREF_FIRST_INSTALL, now() - 10 * DAY); + assertTrue(r.shouldPrompt(), "reset() should clear the completed flag and state"); + } + + @EdtTest + void registerSessionAccumulatesLaunchesWithoutPrompting() { + AppReview r = AppReview.getInstance(); + // A launch threshold high enough that shouldPrompt() never fires here, + // so registerSession() only performs bookkeeping (no modal dialog). + r.setMinimumLaunches(1000); + + assertEquals(0, Preferences.get(PREF_LAUNCHES, 0)); + r.registerSession(); + r.registerSession(); + r.registerSession(); + + assertEquals(3, Preferences.get(PREF_LAUNCHES, 0), "registerSession should count launches"); + assertTrue(Preferences.get(PREF_FIRST_INSTALL, 0L) > 0L, "registerSession should stamp the install date"); + assertFalse(r.shouldPrompt(), "shouldPrompt stays false below the (high) launch threshold"); + } + + @EdtTest + void requestReviewRespectsCompleted() { + AppReview r = AppReview.getInstance(); + r.markCompleted(); + Preferences.set(PREF_LAST_PROMPT, 12345L); + // Already completed -> requestReview() must no-op (no prompt, no + // re-stamp). It returns before any UI, so this is safe to call here. + r.requestReview(); + assertEquals(12345L, Preferences.get(PREF_LAST_PROMPT, 0L), + "requestReview must not prompt or re-stamp once completed"); + } + + private static long now() { + return System.currentTimeMillis(); + } +} diff --git a/scripts/android/screenshots/AppReviewDialog.png b/scripts/android/screenshots/AppReviewDialog.png new file mode 100644 index 0000000000..90ff17c74b Binary files /dev/null and b/scripts/android/screenshots/AppReviewDialog.png differ diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/AppReviewDialogScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/AppReviewDialogScreenshotTest.java new file mode 100644 index 0000000000..4a65758d48 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/AppReviewDialogScreenshotTest.java @@ -0,0 +1,72 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.Sheet; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.util.UITimer; + +/** + * Screenshot coverage for the {@code com.codename1.appreview} fallback rating + * widget -- the bottom {@link Sheet} that {@code AppReview} shows when the + * platform has no native review prompt. + * + *

The native store review widgets ({@code SKStoreReviewController} on iOS, + * the Play In-App Review API on Android) are OS-drawn, throttled overlays that + * live outside Codename One's {@code Display.screenshot()} pipeline, so they + * can't be captured here. This test therefore exercises the Codename One drawn + * fallback sheet, which is what renders on the simulator, desktop and the web + * target. It builds the same sheet content as the real {@code RatingDialog} + * (single-row {@link GridLayout} star strip so the stars never wrap) and shows + * a genuine {@code Sheet.show()} -- following the {@link SheetScreenshotTest} + * pattern -- so the capture includes the actual sheet chrome.

+ */ +public class AppReviewDialogScreenshotTest extends BaseTest { + private static final int MAX_STARS = 5; + + private Sheet sheet; + + @Override + public boolean runTest() { + Form form = createForm("App Review", new BorderLayout(), "AppReviewDialog"); + form.add(BorderLayout.CENTER, new Label("Rating sheet")); + sheet = buildRatingSheet(); + form.show(); + return true; + } + + @Override + protected void registerReadyCallback(Form parent, Runnable run) { + sheet.show(); + UITimer.timer(1500, false, parent, run); + } + + private Sheet buildRatingSheet() { + Sheet ratingSheet = new Sheet(null, "Enjoying HelloCodenameOne?"); + Container content = ratingSheet.getContentPane(); + content.setLayout(BoxLayout.y()); + + SpanLabel prompt = new SpanLabel("Tap a star to rate your experience."); + prompt.setUIID("DialogBody"); + content.add(prompt); + + Container stars = new Container(BoxLayout.x()); + for (int i = 0; i < MAX_STARS; i++) { + Button star = new Button(); + star.setUIID("Label"); + FontImage.setMaterialIcon(star, FontImage.MATERIAL_STAR_BORDER, 5); + stars.add(star); + } + content.add(stars); + + Button never = new Button("Don't ask again"); + never.setUIID("DialogCommandText"); + content.add(never); + return ratingSheet; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 862bc643e3..2a69a92ed6 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -231,6 +231,7 @@ private static int testTimeoutMs(BaseTest testClass) { new MultiButtonThemeScreenshotTest(), new ListThemeScreenshotTest(), new DialogThemeScreenshotTest(), + new AppReviewDialogScreenshotTest(), new FloatingActionButtonThemeScreenshotTest(), new SpanLabelThemeScreenshotTest(), new DarkLightShowcaseThemeScreenshotTest(), diff --git a/scripts/ios/screenshots-metal/AppReviewDialog.png b/scripts/ios/screenshots-metal/AppReviewDialog.png new file mode 100644 index 0000000000..6d8f416452 Binary files /dev/null and b/scripts/ios/screenshots-metal/AppReviewDialog.png differ diff --git a/scripts/ios/screenshots-watch/AppReviewDialog.png b/scripts/ios/screenshots-watch/AppReviewDialog.png new file mode 100644 index 0000000000..8cdeb16da7 Binary files /dev/null and b/scripts/ios/screenshots-watch/AppReviewDialog.png differ diff --git a/scripts/ios/screenshots/AppReviewDialog.png b/scripts/ios/screenshots/AppReviewDialog.png new file mode 100644 index 0000000000..9a944113c4 Binary files /dev/null and b/scripts/ios/screenshots/AppReviewDialog.png differ diff --git a/scripts/javascript/screenshots/AppReviewDialog.png b/scripts/javascript/screenshots/AppReviewDialog.png new file mode 100644 index 0000000000..917bcb3214 Binary files /dev/null and b/scripts/javascript/screenshots/AppReviewDialog.png differ diff --git a/scripts/linux/screenshots-arm/AppReviewDialog.png b/scripts/linux/screenshots-arm/AppReviewDialog.png new file mode 100644 index 0000000000..7c3ca9f55b Binary files /dev/null and b/scripts/linux/screenshots-arm/AppReviewDialog.png differ diff --git a/scripts/linux/screenshots/AppReviewDialog.png b/scripts/linux/screenshots/AppReviewDialog.png new file mode 100644 index 0000000000..7c3ca9f55b Binary files /dev/null and b/scripts/linux/screenshots/AppReviewDialog.png differ diff --git a/scripts/mac-native/screenshots/AppReviewDialog.png b/scripts/mac-native/screenshots/AppReviewDialog.png new file mode 100644 index 0000000000..b69e02b8be Binary files /dev/null and b/scripts/mac-native/screenshots/AppReviewDialog.png differ diff --git a/scripts/windows/screenshots/AppReviewDialog.png b/scripts/windows/screenshots/AppReviewDialog.png new file mode 100644 index 0000000000..1c2131ba51 Binary files /dev/null and b/scripts/windows/screenshots/AppReviewDialog.png differ