From f5046ef05c71f2e353797b800227f3f872d52a93 Mon Sep 17 00:00:00 2001 From: Alex Lementuev Date: Mon, 28 Sep 2020 13:10:16 -0400 Subject: [PATCH] Apptentive Android SDK 5.6.0 --- CHANGELOG.md | 6 + README.md | 2 +- apptentive/build.gradle | 6 + ...ppRatingDialogInteractionLauncherTest.java | 205 ++++++++++++++++++ .../engagement/InteractionLauncherTest.java | 15 ++ .../apptentive/android/sdk/ApptentiveLog.java | 5 +- .../android/sdk/ApptentiveLogTag.java | 3 +- .../sdk/conversation/Conversation.java | 18 +- .../sdk/external/DefaultEngagement.java | 68 ++++++ .../DefaultInAppReviewManagerFactory.java | 63 ++++++ .../android/sdk/external/Engagement.java | 15 ++ .../sdk/external/GooglePlayReviewManager.java | 105 +++++++++ .../sdk/external/InAppReviewListener.java | 6 + .../sdk/external/InAppReviewManager.java | 11 + .../external/InAppReviewManagerFactory.java | 9 + .../external/UnsupportedReviewManager.java | 17 ++ .../ActivityInteractionLauncher.java | 2 +- .../DefaultInteractionLauncherFactory.java | 32 +-- .../module/engagement/EngagementModule.java | 18 +- .../InAppRatingDialogInteractionLauncher.java | 106 +++++++++ .../engagement/InteractionLauncher.java | 4 +- .../NotificationInteractionLauncher.java | 2 +- .../model/InAppRatingDialogInteraction.java | 27 +++ .../interaction/model/Interaction.java | 3 + .../apptentive/android/sdk/util/Callback.java | 5 + .../android/sdk/util/Constants.java | 4 +- 26 files changed, 721 insertions(+), 36 deletions(-) create mode 100644 apptentive/src/androidTest/java/com/apptentive/android/sdk/module/engagement/InAppRatingDialogInteractionLauncherTest.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/external/DefaultEngagement.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/external/DefaultInAppReviewManagerFactory.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/external/Engagement.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/external/GooglePlayReviewManager.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/external/InAppReviewListener.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/external/InAppReviewManager.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/external/InAppReviewManagerFactory.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/external/UnsupportedReviewManager.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/InAppRatingDialogInteractionLauncher.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/InAppRatingDialogInteraction.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/util/Callback.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 93e562195..a30d8ce35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 2020-09-28 - v5.6.0 + +#### Improvements + +* Google Play In-App Review support. + # 2020-09-04 - v5.5.4 #### Fixes diff --git a/README.md b/README.md index 44d21bcbb..ef697cf4f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ use your app, to talk to them at the right time, and in the right way. ##### [Release Notes](https://learn.apptentive.com/knowledge-base/android-sdk-release-notes/) -##### Binary releases are hosted for Maven [here](http://search.maven.org/#artifactdetails|com.apptentive|apptentive-android|5.5.4|aar) +##### Binary releases are hosted for Maven [here](http://search.maven.org/#artifactdetails|com.apptentive|apptentive-android|5.6.0|aar) #### Reporting Bugs diff --git a/apptentive/build.gradle b/apptentive/build.gradle index 12e932a1e..57a88d61a 100644 --- a/apptentive/build.gradle +++ b/apptentive/build.gradle @@ -8,6 +8,12 @@ dependencies { implementation 'androidx.cardview:cardview:1.0.0' implementation 'com.google.android.material:material:1.2.0' + // Play Core library required for in-app review flow + implementation 'com.google.android.play:core:1.8.0' + + // Play Services library required to check for GPS availability before showing in-app review + implementation 'com.google.android.gms:play-services-base:17.4.0' + testImplementation 'junit:junit:4.13' testImplementation 'org.powermock:powermock-module-junit4:1.6.6' testImplementation 'org.powermock:powermock-module-junit4-rule:1.6.6' diff --git a/apptentive/src/androidTest/java/com/apptentive/android/sdk/module/engagement/InAppRatingDialogInteractionLauncherTest.java b/apptentive/src/androidTest/java/com/apptentive/android/sdk/module/engagement/InAppRatingDialogInteractionLauncherTest.java new file mode 100644 index 000000000..6eb816cc9 --- /dev/null +++ b/apptentive/src/androidTest/java/com/apptentive/android/sdk/module/engagement/InAppRatingDialogInteractionLauncherTest.java @@ -0,0 +1,205 @@ +package com.apptentive.android.sdk.module.engagement; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.apptentive.android.sdk.InstrumentationTestCaseBase; +import com.apptentive.android.sdk.external.Engagement; +import com.apptentive.android.sdk.external.InAppReviewListener; +import com.apptentive.android.sdk.external.InAppReviewManager; +import com.apptentive.android.sdk.external.InAppReviewManagerFactory; +import com.apptentive.android.sdk.module.engagement.interaction.model.InAppRatingDialogInteraction; +import com.apptentive.android.sdk.module.engagement.interaction.model.Interaction; +import com.apptentive.android.sdk.util.Callback; +import com.apptentive.android.sdk.util.StringUtils; + +import junit.framework.AssertionFailedError; + +import org.json.JSONException; +import org.junit.Test; + +import java.util.Map; + +public class InAppRatingDialogInteractionLauncherTest extends InstrumentationTestCaseBase { + private static final String FALLBACK_INTERACTION_ID = "1234567"; + + private static final String JSON = "{'type':'InAppRatingDialog','version':1,'configuration':{'not_shown_interaction': '" + FALLBACK_INTERACTION_ID + "'},'display_type':null,'id':'58eebbf2127096704e0000d0'}"; + private static final String JSON_MISSING_FALLBACK = "{'type':'InAppRatingDialog','version':1,'configuration':{'not_shown_interaction': '0000000'},'display_type':null,'id':'58eebbf2127096704e0000d0'}"; + private static final String JSON_NO_FALLBACK = "{'type':'InAppRatingDialog','version':1,'configuration':{},'display_type':null,'id':'58eebbf2127096704e0000d0'}"; + + //region In-App Review supported + + @Test + public void testInAppReviewSupportedLaunchSucceed() { + InAppRatingDialogInteractionLauncher launcher = createLauncher(MockInAppReviewManager.successful()); + launcher.launch(getContext(), createInteraction(JSON)); + + assertResult( + "engage=InAppRatingDialog#request", + "engage=InAppRatingDialog#shown" + ); + } + + @Test + public void testInAppReviewSupportedLaunchFailed() { + InAppRatingDialogInteractionLauncher launcher = createLauncher(MockInAppReviewManager.failed("Something went wrong")); + launcher.launch(getContext(), createInteraction(JSON)); + + assertResult( + "engage=InAppRatingDialog#request", + "engage=InAppRatingDialog#not_shown data='cause':'Something went wrong'" + ); + } + + //endregion + + //region In-App Review unsupported + + @Test + public void testInAppReviewUnsupportedWithFallbackAction() { + InAppRatingDialogInteractionLauncher launcher = createLauncher(MockInAppReviewManager.unsupported()); + launcher.launch(getContext(), createInteraction(JSON)); + + assertResult( + "engage=InAppRatingDialog#request", + "engage=InAppRatingDialog#not_supported", + "launch=1234567", // fallback interaction + "engage=InAppRatingDialog#launch" + ); + } + + @Test + public void testInAppReviewUnsupportedWithNoFallbackAction() { + InAppRatingDialogInteractionLauncher launcher = createLauncher(MockInAppReviewManager.unsupported()); + launcher.launch(getContext(), createInteraction(JSON_NO_FALLBACK)); + + assertResult( + "engage=InAppRatingDialog#request", + "engage=InAppRatingDialog#not_supported" + ); + } + + @Test + public void testInAppReviewUnsupportedWithMissingFallbackAction() { + InAppRatingDialogInteractionLauncher launcher = createLauncher(MockInAppReviewManager.unsupported()); + launcher.launch(getContext(), createInteraction(JSON_MISSING_FALLBACK)); + + assertResult( + "engage=InAppRatingDialog#request", + "engage=InAppRatingDialog#not_supported" + ); + } + + //endregion + + //region Helpers + + private InAppRatingDialogInteraction createInteraction(String json) { + try { + return new InAppRatingDialogInteraction(json.replace("'", "\"")); + } catch (JSONException e) { + throw new AssertionFailedError(e.getMessage()); + } + } + + private InAppRatingDialogInteractionLauncher createLauncher(InAppReviewManager manager) { + Engagement engagement = new Engagement() { + @Override + public void engageInternal(Context context, Interaction interaction, String eventName, @Nullable Map data) { + String event = interaction.getType() + "#" + eventName; + if (data != null) { + addResult("engage=%s data=%s", event, StringUtils.toString(data)); + } else { + addResult("engage=%s", event); + } + } + + @Override + public void launchInteraction(Context context, String interactionId, Callback callback) { + if (FALLBACK_INTERACTION_ID.equals(interactionId)) { + addResult("launch=%s", interactionId); + callback.onFinish(Boolean.TRUE); + } else { + callback.onFinish(Boolean.FALSE); + } + } + }; + + return new InAppRatingDialogInteractionLauncher(new MockInAppReviewManagerFactory(manager), engagement); + } + + //endregion + + //region Mocks + + private static class MockInAppReviewManager implements InAppReviewManager { + private final String errorMessage; + private final boolean supported; + + private MockInAppReviewManager(boolean supported, String errorMessage) { + this.errorMessage = errorMessage; + this.supported = supported; + } + + public static InAppReviewManager successful() { + return new MockInAppReviewManager(true, null); + } + + public static InAppReviewManager failed(String errorMessage) { + return new MockInAppReviewManager(true, errorMessage); + } + + public static InAppReviewManager unsupported() { + return new MockInAppReviewManager(false, null); + } + + @Override + public void launchReview(@NonNull Context context, InAppReviewListener callback) { + if (supported) { + if (errorMessage != null) { + callback.onReviewFlowFailed(errorMessage); + } else { + callback.onReviewFlowComplete(); + } + } else { + throw new AssertionFailedError("Should not get there"); + } + } + + @Override + public boolean isInAppReviewSupported() { + return supported; + } + } + + private static class MockInAppReviewManagerFactory implements InAppReviewManagerFactory { + private final InAppReviewManager manager; + + private MockInAppReviewManagerFactory(InAppReviewManager manager) { + this.manager = manager; + } + + @Override + public InAppReviewManager createReviewManager(@NonNull Context context) { + return manager; + } + } + + private static class MockInteraction extends Interaction { + public static Interaction create(String json) { + try { + return new MockInteraction(json); + } catch (JSONException e) { + throw new AssertionFailedError(e.getMessage()); + } + } + + private MockInteraction(String json) throws JSONException { + super(json); + } + } + + //endregion +} \ No newline at end of file diff --git a/apptentive/src/androidTest/java/com/apptentive/android/sdk/module/engagement/InteractionLauncherTest.java b/apptentive/src/androidTest/java/com/apptentive/android/sdk/module/engagement/InteractionLauncherTest.java index be41410b5..00e1b7050 100644 --- a/apptentive/src/androidTest/java/com/apptentive/android/sdk/module/engagement/InteractionLauncherTest.java +++ b/apptentive/src/androidTest/java/com/apptentive/android/sdk/module/engagement/InteractionLauncherTest.java @@ -11,6 +11,7 @@ import androidx.test.runner.AndroidJUnit4; import com.apptentive.android.sdk.InstrumentationTestCaseBase; +import com.apptentive.android.sdk.module.engagement.interaction.model.InAppRatingDialogInteraction; import com.apptentive.android.sdk.module.engagement.interaction.model.Interaction; import com.apptentive.android.sdk.module.engagement.interaction.model.Interaction.DisplayType; import com.apptentive.android.sdk.module.engagement.interaction.model.TextModalInteraction; @@ -43,6 +44,12 @@ protected InteractionLauncher createActivityInteractionLauncher() { protected InteractionLauncher createNotificationInteractionLauncher() { return new MockInteractionLauncher("Notification"); } + + @NonNull + @Override + InteractionLauncher createInAppRatingDialogInteractionLauncher() { + return new MockInteractionLauncher("InAppReview"); + } }); // Everything should run immediately @@ -65,6 +72,14 @@ public void testInteractionNotificationDisplayType() throws JSONException { assertResult("Notification"); } + @Test + public void testInteractionInAppReview() throws JSONException { + Interaction interaction = new InAppRatingDialogInteraction("{\"type\":\"InAppRatingDialog\"}"); + assertEquals(interaction.getType(), Interaction.Type.InAppRatingDialog); + EngagementModule.launchInteraction(getContext(), interaction); + assertResult("InAppReview"); + } + @Test public void testInteractionUnknownDisplayType() throws JSONException { Interaction interaction = new TextModalInteraction("{\"type\":\"TextModal\",\"display_Type\":\"unknown\"}"); diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveLog.java b/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveLog.java index d379b125c..32f54a3c0 100755 --- a/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveLog.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveLog.java @@ -102,10 +102,11 @@ private static void logGuarded(Level level, ApptentiveLogTag tag, Throwable thro message = extra.append(' ').append(message).toString(); } - log0(level, message); if (throwable != null) { - log0(level, StringUtils.getStackTrace(throwable)); + message += '\n' + StringUtils.getStackTrace(throwable); } + + log0(level, message); } private static void log0(Level level, String message) { diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveLogTag.java b/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveLogTag.java index f160150ed..c0db3a3d6 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveLogTag.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveLogTag.java @@ -16,5 +16,6 @@ public enum ApptentiveLogTag { TROUBLESHOOT, ADVERTISER_ID, PARTNERS, - SECURITY + SECURITY, + IN_APP_REVIEW } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/conversation/Conversation.java b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/Conversation.java index b734f6f06..1d8e7dddf 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/conversation/Conversation.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/Conversation.java @@ -215,11 +215,7 @@ public Interaction getApplicableInteraction(String eventLabel, boolean verbose) Targets targets = new Targets(getTargets()); String interactionId = targets.getApplicableInteraction(eventLabel, verbose); if (interactionId != null) { - String interactionsString = getInteractions(); - if (interactionsString != null) { - Interactions interactions = new Interactions(interactionsString); - return interactions.getInteraction(interactionId); - } + return getInteraction(interactionId); } } catch (JSONException e) { ApptentiveLog.e(INTERACTIONS, e, "Exception while getting applicable interaction: %s", eventLabel); @@ -229,6 +225,18 @@ public Interaction getApplicableInteraction(String eventLabel, boolean verbose) return null; } + /** + * Returns an Interaction for interactionId if there is one. + */ + public @Nullable Interaction getInteraction(@NonNull String interactionId) throws JSONException { + String interactionsString = getInteractions(); + if (interactionsString != null) { + Interactions interactions = new Interactions(interactionsString); + return interactions.getInteraction(interactionId); + } + return null; + } + public void fetchInteractions(Context context) { if (!isPollForInteractions()) { ApptentiveLog.d(CONVERSATION, "Interaction polling is turned off. Skipping fetch."); diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/external/DefaultEngagement.java b/apptentive/src/main/java/com/apptentive/android/sdk/external/DefaultEngagement.java new file mode 100644 index 000000000..3fe644a19 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/external/DefaultEngagement.java @@ -0,0 +1,68 @@ +package com.apptentive.android.sdk.external; + +import android.content.Context; + +import androidx.annotation.Nullable; + +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.ApptentiveLogTag; +import com.apptentive.android.sdk.conversation.Conversation; +import com.apptentive.android.sdk.conversation.ConversationDispatchTask; +import com.apptentive.android.sdk.module.engagement.EngagementModule; +import com.apptentive.android.sdk.module.engagement.interaction.model.Interaction; +import com.apptentive.android.sdk.util.Callback; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Map; + +import static com.apptentive.android.sdk.ApptentiveHelper.dispatchConversationTask; + +public class DefaultEngagement implements Engagement { + @Override + public void engageInternal(final Context context, final Interaction interaction, final String eventName, @Nullable final Map data) { + dispatchConversationTask(new ConversationDispatchTask() { + @Override + protected boolean execute(Conversation conversation) { + String jsonData = createJsonData(data); + return EngagementModule.engageInternal(context, conversation, interaction, eventName, jsonData); + } + }, "engage event '" + eventName + "'"); + } + + @Override + public void launchInteraction(final Context context, final String interactionId, final Callback callback) { + dispatchConversationTask(new ConversationDispatchTask() { + @Override + protected boolean execute(Conversation conversation) { + Interaction interaction = getInteraction(conversation); + if (interaction != null) { + EngagementModule.launchInteraction(context, conversation, interaction); + callback.onFinish(Boolean.TRUE); + } else { + callback.onFinish(Boolean.FALSE); + } + + return true; + } + + private @Nullable Interaction getInteraction(Conversation conversation) { + try { + return conversation.getInteraction(interactionId); + } catch (JSONException e) { + ApptentiveLog.e(ApptentiveLogTag.CONVERSATION, e, "Unable to get interaction '%s'", interactionId); + } + return null; + } + }, "launch interaction '" + interactionId + "'"); + } + + private static String createJsonData(Map data) { + if (data != null && data.size() > 0) { + return new JSONObject(data).toString(); + } + + return null; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/external/DefaultInAppReviewManagerFactory.java b/apptentive/src/main/java/com/apptentive/android/sdk/external/DefaultInAppReviewManagerFactory.java new file mode 100644 index 000000000..17b8862d9 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/external/DefaultInAppReviewManagerFactory.java @@ -0,0 +1,63 @@ +package com.apptentive.android.sdk.external; + +import android.app.Activity; +import android.content.Context; +import android.os.Build; + +import androidx.annotation.NonNull; + +import com.apptentive.android.sdk.ApptentiveLog; +import com.google.android.gms.common.GoogleApiAvailability; + +import static com.apptentive.android.sdk.ApptentiveLogTag.IN_APP_REVIEW; +import static com.google.android.gms.common.ConnectionResult.SERVICE_DISABLED; +import static com.google.android.gms.common.ConnectionResult.SERVICE_INVALID; +import static com.google.android.gms.common.ConnectionResult.SERVICE_MISSING; +import static com.google.android.gms.common.ConnectionResult.SERVICE_UPDATING; +import static com.google.android.gms.common.ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED; +import static com.google.android.gms.common.ConnectionResult.SUCCESS; + +public class DefaultInAppReviewManagerFactory implements InAppReviewManagerFactory { + private static final int MIN_ANDROID_API_VERSION = 21; + + public InAppReviewManager createReviewManager(@NonNull Context context) { + try { + if (Build.VERSION.SDK_INT < MIN_ANDROID_API_VERSION) { + ApptentiveLog.e(IN_APP_REVIEW, "Unable to create InAppReviewManager: Android version is too low %d", Build.VERSION.SDK_INT); + return new UnsupportedReviewManager(); + } + + int result = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context); + if (result != SUCCESS) { + ApptentiveLog.e(IN_APP_REVIEW, "Unable to create InAppReviewManager: Google Play Services not available (%s)", getStatusMessage(result)); + return new UnsupportedReviewManager(); + } + + Activity activity = (Activity) context; + GooglePlayReviewManager reviewManager = new GooglePlayReviewManager(activity); + ApptentiveLog.d(IN_APP_REVIEW, "Initialized Google Play in-App review manager"); + return reviewManager; + } catch (Exception e) { + ApptentiveLog.e(IN_APP_REVIEW, e, "Unable to create in-app review manager"); + } + + return new UnsupportedReviewManager(); + } + + private static String getStatusMessage(int result) { + switch (result) { + case SERVICE_MISSING: + return "SERVICE_MISSING"; + case SERVICE_UPDATING: + return "SERVICE_UPDATING"; + case SERVICE_VERSION_UPDATE_REQUIRED: + return "SERVICE_VERSION_UPDATE_REQUIRED"; + case SERVICE_DISABLED: + return "SERVICE_DISABLED"; + case SERVICE_INVALID: + return "SERVICE_INVALID"; + default: + return "unknown result: " + result; + } + } +} \ No newline at end of file diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/external/Engagement.java b/apptentive/src/main/java/com/apptentive/android/sdk/external/Engagement.java new file mode 100644 index 000000000..04b66019f --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/external/Engagement.java @@ -0,0 +1,15 @@ +package com.apptentive.android.sdk.external; + +import android.content.Context; + +import androidx.annotation.Nullable; + +import com.apptentive.android.sdk.module.engagement.interaction.model.Interaction; +import com.apptentive.android.sdk.util.Callback; + +import java.util.Map; + +public interface Engagement { + void engageInternal(Context context, Interaction interaction, String eventName, @Nullable Map data); + void launchInteraction(Context context, String interactionId, Callback callback); +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/external/GooglePlayReviewManager.java b/apptentive/src/main/java/com/apptentive/android/sdk/external/GooglePlayReviewManager.java new file mode 100644 index 000000000..b06f65d12 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/external/GooglePlayReviewManager.java @@ -0,0 +1,105 @@ +package com.apptentive.android.sdk.external; + +import android.app.Activity; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.util.ObjectUtils; +import com.apptentive.android.sdk.util.StringUtils; +import com.google.android.play.core.review.ReviewInfo; +import com.google.android.play.core.review.ReviewManager; +import com.google.android.play.core.review.ReviewManagerFactory; +import com.google.android.play.core.tasks.OnFailureListener; +import com.google.android.play.core.tasks.OnSuccessListener; +import com.google.android.play.core.tasks.Task; + +import static com.apptentive.android.sdk.ApptentiveLogTag.IN_APP_REVIEW; + +class GooglePlayReviewManager implements InAppReviewManager { + private final ReviewManager reviewManager; + + public GooglePlayReviewManager(Activity activity) { + reviewManager = ReviewManagerFactory.create(activity); + } + + @Override + public void launchReview(@NonNull final Context context, final InAppReviewListener callback) { + final Activity activity = ObjectUtils.as(context, Activity.class); + if (activity == null) { + notifyFailure(callback, null, "Failed to launch in-app review flow: make sure you pass Activity object into your Apptentive.engage() calls."); + return; + } + + try { + launchReviewGuarded(callback, activity); + } catch (Exception e) { + notifyFailure(callback, e, "Exception while launching in-app review flow"); + } + } + + private void launchReviewGuarded(final InAppReviewListener callback, final Activity activity) { + final long startTime = System.currentTimeMillis(); + + ApptentiveLog.d(IN_APP_REVIEW, "Requesting in-app review..."); + + Task task = reviewManager.requestReviewFlow(); + task.addOnSuccessListener(new OnSuccessListener() { + @Override + public void onSuccess(ReviewInfo result) { + final long elapsedTime = System.currentTimeMillis() - startTime; + try { + ApptentiveLog.d(IN_APP_REVIEW, "ReviewInfo request complete (took %d ms). Launching review flow...", elapsedTime); + launchReviewFlow(activity, result, callback); + } catch (Exception e) { + notifyFailure(callback, e, "Failed to launch review flow (took %d ms): make sure your device has Google Play Services installed.", elapsedTime); + } + } + }); + task.addOnFailureListener(new OnFailureListener() { + @Override + public void onFailure(Exception e) { + long elapsedTime = System.currentTimeMillis() - startTime; + notifyFailure(callback, e, "Failed to request ReviewInfo (took %d ms)", elapsedTime); + } + }); + } + + private void launchReviewFlow(@NonNull final Activity activity, ReviewInfo reviewInfo, final InAppReviewListener callback) { + final long startTime = System.currentTimeMillis(); + + Task task = reviewManager.launchReviewFlow(activity, reviewInfo); + task.addOnSuccessListener(new OnSuccessListener() { + @Override + public void onSuccess(Void result) { + long elapsedTime = System.currentTimeMillis() - startTime; + if (elapsedTime < 1000L) { + notifyFailure(callback, null, "In-app review flow completed too fast (%d ms) and we have good reasons to believe it just failed silently.", elapsedTime); // FIXME: better error message + } else { + ApptentiveLog.d(IN_APP_REVIEW, "In-app review complete (took %d ms)", elapsedTime); + callback.onReviewFlowComplete(); + } + } + }); + task.addOnFailureListener(new OnFailureListener() { + @Override + public void onFailure(Exception e) { + long elapsedTime = System.currentTimeMillis() - startTime; + notifyFailure(callback, e, "Unable to launch in-app review (took %d ms)", elapsedTime); + } + }); + } + + private void notifyFailure(InAppReviewListener listener, @Nullable Throwable e, String format, Object... args) { + final String message = StringUtils.format(format, args); + ApptentiveLog.e(IN_APP_REVIEW, e, message); + listener.onReviewFlowFailed(message); + } + + @Override + public boolean isInAppReviewSupported() { + return true; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/external/InAppReviewListener.java b/apptentive/src/main/java/com/apptentive/android/sdk/external/InAppReviewListener.java new file mode 100644 index 000000000..8cf1b63ff --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/external/InAppReviewListener.java @@ -0,0 +1,6 @@ +package com.apptentive.android.sdk.external; + +public interface InAppReviewListener { + void onReviewFlowComplete(); + void onReviewFlowFailed(String message); +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/external/InAppReviewManager.java b/apptentive/src/main/java/com/apptentive/android/sdk/external/InAppReviewManager.java new file mode 100644 index 000000000..30f06bda9 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/external/InAppReviewManager.java @@ -0,0 +1,11 @@ +package com.apptentive.android.sdk.external; + +import android.content.Context; + +import androidx.annotation.NonNull; + +public interface InAppReviewManager { + void launchReview(@NonNull Context context, InAppReviewListener callback); + + boolean isInAppReviewSupported(); +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/external/InAppReviewManagerFactory.java b/apptentive/src/main/java/com/apptentive/android/sdk/external/InAppReviewManagerFactory.java new file mode 100644 index 000000000..c47cf551c --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/external/InAppReviewManagerFactory.java @@ -0,0 +1,9 @@ +package com.apptentive.android.sdk.external; + +import android.content.Context; + +import androidx.annotation.NonNull; + +public interface InAppReviewManagerFactory { + InAppReviewManager createReviewManager(@NonNull Context context); +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/external/UnsupportedReviewManager.java b/apptentive/src/main/java/com/apptentive/android/sdk/external/UnsupportedReviewManager.java new file mode 100644 index 000000000..b19b9a6e1 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/external/UnsupportedReviewManager.java @@ -0,0 +1,17 @@ +package com.apptentive.android.sdk.external; + +import android.content.Context; + +import androidx.annotation.NonNull; + +class UnsupportedReviewManager implements InAppReviewManager { + @Override + public void launchReview(@NonNull Context context, InAppReviewListener callback) { + callback.onReviewFlowFailed("In-app review is not supported"); + } + + @Override + public boolean isInAppReviewSupported() { + return false; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/ActivityInteractionLauncher.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/ActivityInteractionLauncher.java index da33af45a..90ad1c4cf 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/ActivityInteractionLauncher.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/ActivityInteractionLauncher.java @@ -15,7 +15,7 @@ import com.apptentive.android.sdk.module.engagement.interaction.model.Interaction; import com.apptentive.android.sdk.util.Constants; -class ActivityInteractionLauncher implements InteractionLauncher { +class ActivityInteractionLauncher implements InteractionLauncher { @Override public boolean launch(Context context, Interaction interaction) { Intent intent = new Intent(); diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/DefaultInteractionLauncherFactory.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/DefaultInteractionLauncherFactory.java index 078ee2c6a..62903a6f7 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/DefaultInteractionLauncherFactory.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/DefaultInteractionLauncherFactory.java @@ -11,27 +11,24 @@ import com.apptentive.android.sdk.module.engagement.interaction.model.Interaction; import com.apptentive.android.sdk.module.engagement.interaction.model.Interaction.DisplayType; -import java.util.HashMap; -import java.util.Map; - public class DefaultInteractionLauncherFactory implements InteractionLauncherFactory { - private final Map launcherLookup; - DefaultInteractionLauncherFactory() { - launcherLookup = createLauncherLookup(); - } - - private Map createLauncherLookup() { - Map lookup = new HashMap<>(); - lookup.put(DisplayType.notification, createNotificationInteractionLauncher()); - // This is for maintaining existing behavior - lookup.put(DisplayType.unknown, createActivityInteractionLauncher()); - return lookup; } @Override public InteractionLauncher launcherForInteraction(Interaction interaction) { - return launcherLookup.get(interaction.getDisplayType()); + final Interaction.Type type = interaction.getType(); + if (Interaction.Type.TextModal.equals(type)) { + return DisplayType.notification.equals(interaction.getDisplayType()) + ? createNotificationInteractionLauncher() + : createActivityInteractionLauncher(); + } + + if (Interaction.Type.InAppRatingDialog.equals(type)) { + return createInAppRatingDialogInteractionLauncher(); + } + + return createActivityInteractionLauncher(); } // for Unit-tests @@ -45,4 +42,9 @@ InteractionLauncher createActivityInteractionLauncher() { InteractionLauncher createNotificationInteractionLauncher() { return new NotificationInteractionLauncher(); } + + @NonNull + InteractionLauncher createInAppRatingDialogInteractionLauncher() { + return new InAppRatingDialogInteractionLauncher(); + } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/EngagementModule.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/EngagementModule.java index bd1e75610..e02f76a9a 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/EngagementModule.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/EngagementModule.java @@ -19,7 +19,6 @@ import com.apptentive.android.sdk.model.ExtendedData; import com.apptentive.android.sdk.module.engagement.interaction.model.Interaction; import com.apptentive.android.sdk.module.engagement.interaction.model.MessageCenterInteraction; -import com.apptentive.android.sdk.module.metric.MetricModule; import com.apptentive.android.sdk.util.Util; import com.apptentive.android.sdk.util.threading.DispatchTask; @@ -91,16 +90,20 @@ private static boolean doEngage(Conversation conversation, Context context, Stri Interaction interaction = conversation.getApplicableInteraction(eventLabel, true); if (interaction != null) { - String versionName = ApptentiveInternal.getInstance().getApplicationVersionName(); - int versionCode = ApptentiveInternal.getInstance().getApplicationVersionCode(); - conversation.getEventData().storeInteractionForCurrentAppVersion(Util.currentTimeSeconds(), versionCode, versionName, interaction.getId()); - launchInteraction(context, interaction); - return true; + return launchInteraction(context, conversation, interaction); } ApptentiveLog.d(INTERACTIONS, "No interaction to show for event: '%s'", eventLabel); return false; } + public static boolean launchInteraction(Context context, Conversation conversation, Interaction interaction) { + String versionName = ApptentiveInternal.getInstance().getApplicationVersionName(); + int versionCode = ApptentiveInternal.getInstance().getApplicationVersionCode(); + conversation.getEventData().storeInteractionForCurrentAppVersion(Util.currentTimeSeconds(), versionCode, versionName, interaction.getId()); + launchInteraction(context, interaction); + return true; + } + public static void launchInteraction(final Context context, final Interaction interaction) { if (context == null) { ApptentiveLog.e("Unable to launch interaction: context is null"); // TODO: throw an exception instead? @@ -124,8 +127,11 @@ protected void execute() { try { ApptentiveLog.i(INTERACTIONS, "Launching interaction: '%s'", interaction.getType()); + + @SuppressWarnings("rawtypes") InteractionLauncher launcher = LAUNCHER_FACTORY.launcherForInteraction(interaction); if (launcher != null) { + @SuppressWarnings("unchecked") boolean launched = launcher.launch(context, interaction); ApptentiveLog.d("Interaction %slaunched", launched ? "" : "NOT "); } else { diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/InAppRatingDialogInteractionLauncher.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/InAppRatingDialogInteractionLauncher.java new file mode 100644 index 000000000..553c3a921 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/InAppRatingDialogInteractionLauncher.java @@ -0,0 +1,106 @@ +package com.apptentive.android.sdk.module.engagement; + +import android.content.Context; + +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.external.DefaultEngagement; +import com.apptentive.android.sdk.external.DefaultInAppReviewManagerFactory; +import com.apptentive.android.sdk.external.Engagement; +import com.apptentive.android.sdk.external.InAppReviewListener; +import com.apptentive.android.sdk.external.InAppReviewManager; +import com.apptentive.android.sdk.external.InAppReviewManagerFactory; +import com.apptentive.android.sdk.module.engagement.interaction.model.InAppRatingDialogInteraction; +import com.apptentive.android.sdk.module.engagement.interaction.model.Interaction; +import com.apptentive.android.sdk.util.Callback; + +import java.util.HashMap; +import java.util.Map; + +import static com.apptentive.android.sdk.ApptentiveLogTag.IN_APP_REVIEW; + +public class InAppRatingDialogInteractionLauncher implements InteractionLauncher { + private static final String EVENT_NAME_REQUEST = "request"; + private static final String EVENT_NAME_SHOWN = "shown"; + private static final String EVENT_NAME_NOT_SHOWN = "not_shown"; + private static final String EVENT_NAME_NOT_SUPPORTED = "not_supported"; + private static final String EVENT_NAME_LAUNCH = "launch"; + + private static final String DATA_KEY_CAUSE = "cause"; + + private final InAppReviewManagerFactory managerFactory; + private final Engagement engagement; + + public InAppRatingDialogInteractionLauncher() { + this(new DefaultInAppReviewManagerFactory(), new DefaultEngagement()); + } + + public InAppRatingDialogInteractionLauncher(InAppReviewManagerFactory managerFactory, Engagement engagement) { + this.managerFactory = managerFactory; + this.engagement = engagement; + } + + @Override + public boolean launch(final Context context, final InAppRatingDialogInteraction interaction) { + engageInternal(context, interaction, EVENT_NAME_REQUEST); + + InAppReviewManager reviewManager = managerFactory.createReviewManager(context); + if (reviewManager.isInAppReviewSupported()) { + reviewManager.launchReview(context, new InAppReviewListener() { + @Override + public void onReviewFlowComplete() { + onReviewShown(context, interaction); + } + + @Override + public void onReviewFlowFailed(String message) { + onReviewNotShown(context, interaction, message); + } + }); + } else { + onReviewNotSupported(context, interaction); + } + + return true; + } + + private void onReviewShown(Context context, InAppRatingDialogInteraction interaction) { + engageInternal(context, interaction, EVENT_NAME_SHOWN); + } + + private void onReviewNotShown(final Context context, final InAppRatingDialogInteraction interaction, String message) { + Map data = new HashMap<>(); + data.put(DATA_KEY_CAUSE, message); + engageInternal(context, interaction, EVENT_NAME_NOT_SHOWN, data); + } + + private void onReviewNotSupported(final Context context, final InAppRatingDialogInteraction interaction) { + // engage 'not_supported' event + engageInternal(context, interaction, EVENT_NAME_NOT_SUPPORTED); + + // engage 'fallback' interaction (if any) + final String fallbackInteractionId = interaction.getFallbackInteractionId(); + if (fallbackInteractionId != null) { + engagement.launchInteraction(context, fallbackInteractionId, new Callback() { + @Override + public void onFinish(Boolean successful) { + if (successful) { + // engage 'launch' event + engagement.engageInternal(context, interaction, EVENT_NAME_LAUNCH, null); + } else { + ApptentiveLog.e(IN_APP_REVIEW, "Fallback interaction was not launched: %s", fallbackInteractionId); + } + } + }); + } else { + ApptentiveLog.d(IN_APP_REVIEW, "No fallback interaction"); + } + } + + private void engageInternal(Context context, Interaction interaction, String eventName) { + engageInternal(context, interaction, eventName, null); + } + + private void engageInternal(final Context context, final Interaction interaction, final String eventName, final Map data) { + engagement.engageInternal(context, interaction, eventName, data); + } +} \ No newline at end of file diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/InteractionLauncher.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/InteractionLauncher.java index 9633f08b8..0401dc0bd 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/InteractionLauncher.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/InteractionLauncher.java @@ -10,9 +10,9 @@ import com.apptentive.android.sdk.module.engagement.interaction.model.Interaction; -public interface InteractionLauncher { +public interface InteractionLauncher { /** * Returns true if interaction was successfully launched */ - boolean launch(Context context, Interaction interaction); + boolean launch(Context context, T interaction); } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/NotificationInteractionLauncher.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/NotificationInteractionLauncher.java index 45377bf02..000099b8c 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/NotificationInteractionLauncher.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/NotificationInteractionLauncher.java @@ -20,7 +20,7 @@ import static com.apptentive.android.sdk.util.Constants.NOTIFICATION_EXTRA_INTERACTION_DEFINITION; import static com.apptentive.android.sdk.util.Constants.NOTIFICATION_EXTRA_INTERACTION_TYPE; -class NotificationInteractionLauncher implements InteractionLauncher { +class NotificationInteractionLauncher implements InteractionLauncher { @Override public boolean launch(Context context, Interaction interaction) { if (!NotificationUtils.isNotificationChannelEnabled(context, NOTIFICATION_CHANNEL_DEFAULT)) { diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/InAppRatingDialogInteraction.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/InAppRatingDialogInteraction.java new file mode 100644 index 000000000..db4fdb9f8 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/InAppRatingDialogInteraction.java @@ -0,0 +1,27 @@ +package com.apptentive.android.sdk.module.engagement.interaction.model; + +import androidx.annotation.Nullable; + +import org.json.JSONException; + +import static com.apptentive.android.sdk.debug.ErrorMetrics.logException; + +public class InAppRatingDialogInteraction extends Interaction { + private static final String KEY_FALLBACK_INTERACTION_ID = "not_shown_interaction"; + + public InAppRatingDialogInteraction(String json) throws JSONException { + super(json); + } + + public @Nullable String getFallbackInteractionId() { + try { + InteractionConfiguration configuration = getConfiguration(); + if (configuration != null && configuration.has(KEY_FALLBACK_INTERACTION_ID)) { + return configuration.getString(KEY_FALLBACK_INTERACTION_ID); + } + } catch (JSONException e) { + logException(e); + } + return null; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/Interaction.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/Interaction.java index 4473568cf..19bf2a771 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/Interaction.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/Interaction.java @@ -119,6 +119,7 @@ public enum Type { Survey, TextModal, NavigateToLink, + InAppRatingDialog, unknown; public static Type parse(String type) { @@ -175,6 +176,8 @@ public static Interaction parseInteraction(String interactionString) { return new TextModalInteraction(interactionString); case NavigateToLink: return new NavigateToLinkInteraction(interactionString); + case InAppRatingDialog: + return new InAppRatingDialogInteraction(interactionString); case unknown: break; } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/util/Callback.java b/apptentive/src/main/java/com/apptentive/android/sdk/util/Callback.java new file mode 100644 index 000000000..447c9a2b0 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/util/Callback.java @@ -0,0 +1,5 @@ +package com.apptentive.android.sdk.util; + +public interface Callback { + void onFinish(T result); +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/util/Constants.java b/apptentive/src/main/java/com/apptentive/android/sdk/util/Constants.java index c49f5dfd1..71add3c5f 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/util/Constants.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/util/Constants.java @@ -8,8 +8,8 @@ public class Constants { - public static final int API_VERSION = 9; - private static final String APPTENTIVE_SDK_VERSION = "5.5.4"; + public static final int API_VERSION = 10; + private static final String APPTENTIVE_SDK_VERSION = "5.6.0"; public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 45000; public static final int DEFAULT_READ_TIMEOUT_MILLIS = 45000;