diff --git a/CHANGELOG.md b/CHANGELOG.md index 6da7d098..cee7fbd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 7.13.0 + +- Implementation for Overlay Messaging channel. Check optimove developer docs for more. + ## 7.12.5 - Wrap `pendingResult.finish()` in PushBroadcastReceiver in try/catch to prevent crash. @@ -10,11 +14,11 @@ ## 7.12.3 -- Wraps `isLaunchActivity()` in try/catch and safely returns false on failure to prevent crashes. +- Wraps `isLaunchActivity()` in try/catch and safely returns false on failure to prevent crashes. ## 7.12.2 -- Introduces a lastShownByInterceptorId field that tracks which message was last shown through the interceptor, preventing duplicate interception of the same head message. +- Introduces a lastShownByInterceptorId field that tracks which message was last shown through the interceptor, preventing duplicate interception of the same head message. ## 7.12.1 @@ -26,12 +30,10 @@ - Fixed dismissed in-app messages re-appearing on fast consecutive app foregrounds - ## 7.11.1 - Fix: expiryDate is always absent in EmbeddedMessage due to inconsistent format given by v2 endpoint - ## 7.11.0 - Updated delayed configuration to no longer require a redundant region parameter. Region is now inferred from credentials. Deprecated `OptimoveConfig.Builder(Region, FeatureSet)` — use `Builder(FeatureSet)` instead. @@ -40,7 +42,6 @@ - Minor bug fixes for Embedded Messaging: correct field mapping - ## 7.10.1 - Bumped GSON version number to fix a vulnerability issue @@ -60,7 +61,8 @@ ## 7.8.0 -Add In-App Message Interceptor API `OptimoveInApp.getInstance().setInAppMessageInterceptor`, basic usage: +Add In-App Message Interceptor API `OptimoveInApp.getInstance().setInAppMessageInterceptor`, basic usage: + ```java OptimoveInApp.getInstance().setInAppMessageInterceptor((message, decision) -> { // Example: decide based on your own logic diff --git a/OptimoveSDK/app/src/main/AndroidManifest.xml b/OptimoveSDK/app/src/main/AndroidManifest.xml index 1756682c..dce17306 100644 --- a/OptimoveSDK/app/src/main/AndroidManifest.xml +++ b/OptimoveSDK/app/src/main/AndroidManifest.xml @@ -28,6 +28,10 @@ android:name="com.optimove.android.optimovemobilesdk.GamifyWidgetActivity" android:exported="false" android:parentActivityName=".MainActivity"/> + + @@ -115,16 +128,18 @@ class MainActivity : AppCompatActivity() { } Optimove.getInstance().sendLocationUpdate(location) outputText = "Location sent: ($lat, $lng)" - }, + }, onResetToken = {}, onOpenDeeplinkTest = ::openDeeplinkTest, onRegisterPush = { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - ContextCompat.checkSelfPermission(this@MainActivity, Manifest.permission.POST_NOTIFICATIONS) - == PackageManager.PERMISSION_DENIED && - !shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission( + this@MainActivity, Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_DENIED && !shouldShowRequestPermissionRationale( + Manifest.permission.POST_NOTIFICATIONS + ) ) { - outputText = "Notification permission permanently denied. Opening settings..." + outputText = + "Notification permission permanently denied. Opening settings..." startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { putExtra(Settings.EXTRA_APP_PACKAGE, this@MainActivity.packageName) }) @@ -143,6 +158,7 @@ class MainActivity : AppCompatActivity() { onDelayedInitToggle = { enabled -> isDelayedInit = enabled qaPrefs.edit().putBoolean(MyApplication.KEY_DELAYED_INIT, enabled).apply() + outputText = if (enabled) "Delayed init enabled — restart app to apply" else @@ -174,6 +190,7 @@ class MainActivity : AppCompatActivity() { runFromWorker { Optimove.getInstance().reportEvent(SimpleCustomEvent()) } runFromWorker { Optimove.getInstance().reportEvent("Event_No ParaMs ") } } + private fun clearAppData() { AlertDialog.Builder(this) .setTitle("Clear App Data") @@ -224,30 +241,27 @@ class MainActivity : AppCompatActivity() { outputText = "Calling setUserId" Optimove.getInstance().setUserId(userId) } + userId.isEmpty() -> { outputText = "Calling setUserEmail" Optimove.getInstance().setUserEmail(userEmail) } + else -> { outputText = "Calling registerUser" Optimove.getInstance().registerUser(userId, userEmail) } } - identityPrefs.edit() - .putString(KEY_USER_ID, userId) - .putString(KEY_USER_EMAIL, userEmail) + identityPrefs.edit().putString(KEY_USER_ID, userId).putString(KEY_USER_EMAIL, userEmail) .apply() } private fun clearIdentity() { - identityPrefs.edit() - .remove(KEY_USER_ID) - .remove(KEY_USER_EMAIL) - .apply() + identityPrefs.edit().remove(KEY_USER_ID).remove(KEY_USER_EMAIL).apply() persistedUserId = "" persistedUserEmail = "" outputText = "Identity cleared (saved values removed)" - } + } private fun readInbox() { val items = OptimoveInApp.getInstance().inboxItems @@ -274,18 +288,21 @@ class MainActivity : AppCompatActivity() { items.forEach { OptimoveInApp.getInstance().deleteMessageFromInbox(it) } } + private fun getPreferences() { OptimovePreferenceCenter.getInstance().getPreferencesAsync { result, preferences -> when (result) { - OptimovePreferenceCenter.ResultType.ERROR_USER_NOT_SET, - OptimovePreferenceCenter.ResultType.ERROR, - OptimovePreferenceCenter.ResultType.ERROR_CREDENTIALS_NOT_SET -> Log.d(PC_TAG, result.toString()) + OptimovePreferenceCenter.ResultType.ERROR_USER_NOT_SET, OptimovePreferenceCenter.ResultType.ERROR, OptimovePreferenceCenter.ResultType.ERROR_CREDENTIALS_NOT_SET -> Log.d( + PC_TAG, result.toString() + ) + OptimovePreferenceCenter.ResultType.SUCCESS -> preferences?.let { prefs -> Log.d(PC_TAG, "configured: ${prefs.configuredChannels}") prefs.customerPreferences.forEach { topic -> Log.d(PC_TAG, "${topic.id} ${topic.name} ${topic.subscribedChannels}") } } + else -> Log.d(PC_TAG, "unknown res type") } } @@ -294,9 +311,10 @@ class MainActivity : AppCompatActivity() { private fun setPreferences() { OptimovePreferenceCenter.getInstance().getPreferencesAsync { result, preferences -> when (result) { - OptimovePreferenceCenter.ResultType.ERROR_USER_NOT_SET, - OptimovePreferenceCenter.ResultType.ERROR, - OptimovePreferenceCenter.ResultType.ERROR_CREDENTIALS_NOT_SET -> Log.d(PC_TAG, result.toString()) + OptimovePreferenceCenter.ResultType.ERROR_USER_NOT_SET, OptimovePreferenceCenter.ResultType.ERROR, OptimovePreferenceCenter.ResultType.ERROR_CREDENTIALS_NOT_SET -> Log.d( + PC_TAG, result.toString() + ) + OptimovePreferenceCenter.ResultType.SUCCESS -> preferences?.let { prefs -> Log.d(PC_TAG, "loaded prefs for set: good") val configuredChannels: List = prefs.configuredChannels @@ -305,10 +323,10 @@ class MainActivity : AppCompatActivity() { PreferenceUpdate(topic.id, configuredChannels.subList(0, 1)) } OptimovePreferenceCenter.getInstance().setCustomerPreferencesAsync( - { setResult -> Log.d(PC_TAG, setResult.toString()) }, - updates + { setResult -> Log.d(PC_TAG, setResult.toString()) }, updates ) } + else -> Log.d(PC_TAG, "unknown res type") } } @@ -318,12 +336,22 @@ class MainActivity : AppCompatActivity() { startActivity(Intent(this, EmbeddedMessagingActivity::class.java)) } + + private fun viewOverlayMessaging() { + startActivity(Intent(this, OverlayMessagingActivity::class.java)) + } + private fun openGamifyWidget() { startActivity(Intent(this, GamifyWidgetActivity::class.java)) + } private fun openDeeplinkTest() { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(DeeplinkTargetActivity.DEEPLINK_TEST_URI))) + startActivity( + Intent( + Intent.ACTION_VIEW, Uri.parse(DeeplinkTargetActivity.DEEPLINK_TEST_URI) + ) + ) } private fun setCredentials(optimove: String?, optimobile: String?, prefCenter: String?) { @@ -354,19 +382,22 @@ class MainActivity : AppCompatActivity() { val options = arrayOf("Default (5000 ms)", "12,000 ms") var selected = 0 - AlertDialog.Builder(this) - .setTitle("Enable In-App Interception") + AlertDialog.Builder(this).setTitle("Enable In-App Interception") .setSingleChoiceItems(options, 0) { _, which -> selected = which } .setPositiveButton("Enable") { d, _ -> val timeoutMs = if (selected == 0) 5000L else 12000L enableInAppInterception(timeoutMs) isInterceptingInApp = true + outputText = "In-App interception enabled ($timeoutMs ms timeout per message)" - Toast.makeText(this, "In-App interception enabled ($timeoutMs ms)", Toast.LENGTH_SHORT).show() + Toast.makeText( + this, + "In-App interception enabled ($timeoutMs ms)", + Toast.LENGTH_SHORT + ).show() + d.dismiss() - } - .setNegativeButton("Cancel", null) - .show() + }.setNegativeButton("Cancel", null).show() } private fun disableInAppInterception() { @@ -379,10 +410,13 @@ class MainActivity : AppCompatActivity() { private fun enableInAppInterception(timeoutMs: Long) { OptimoveInApp.getInstance().setInAppMessageInterceptor(object : InAppMessageInterceptor { - override fun processMessage(messageData: JSONObject?, decision: InAppMessageInterceptorCallback) { + override fun processMessage( + messageData: JSONObject?, decision: InAppMessageInterceptorCallback + ) { runOnUiThread { inAppDecisionDialog?.takeIf { it.isShowing }?.dismiss() val dataText = messageData?.toString() ?: "No data provided" + inAppDecisionDialog = AlertDialog.Builder(this@MainActivity) .setTitle("QA: In-App Message") .setMessage("$dataText\nChoose Show, Postpone, or Suppress.") @@ -400,6 +434,7 @@ class MainActivity : AppCompatActivity() { } .setOnCancelListener { decision.suppress() } .create() + inAppDecisionDialog?.show() } } @@ -411,8 +446,7 @@ class MainActivity : AppCompatActivity() { private class SimpleCustomEvent : OptimoveEvent() { override fun getName(): String = "Simple cUSTOM_Event " override fun getParameters(): Map = mapOf( - "strinG_param" to " some_string ", - "number_param" to 42 + "strinG_param" to " some_string ", "number_param" to 42 ) } diff --git a/OptimoveSDK/app/src/main/java/com/optimove/android/optimovemobilesdk/MyApplication.kt b/OptimoveSDK/app/src/main/java/com/optimove/android/optimovemobilesdk/MyApplication.kt index 08cb45f1..426814e0 100644 --- a/OptimoveSDK/app/src/main/java/com/optimove/android/optimovemobilesdk/MyApplication.kt +++ b/OptimoveSDK/app/src/main/java/com/optimove/android/optimovemobilesdk/MyApplication.kt @@ -26,6 +26,7 @@ class MyApplication : Application() { .enableEmbeddedMessaging("embedded_config_string") .setPushSmallIconId(R.drawable.small_icon) .setPushAccentColor(Color.parseColor("#FF0000")) + .enableOverlayMessaging(1) .build() } else { OptimoveConfig.Builder( @@ -35,6 +36,7 @@ class MyApplication : Application() { .enableInAppMessaging(OptimoveConfig.InAppConsentStrategy.AUTO_ENROLL) .setPushSmallIconId(R.drawable.small_icon) .setPushAccentColor(Color.parseColor("#FF0000")) + .enableOverlayMessaging(1) .build() } diff --git a/OptimoveSDK/app/src/main/java/com/optimove/android/optimovemobilesdk/OverlayMessagingActivity.kt b/OptimoveSDK/app/src/main/java/com/optimove/android/optimovemobilesdk/OverlayMessagingActivity.kt new file mode 100644 index 00000000..f9c6447d --- /dev/null +++ b/OptimoveSDK/app/src/main/java/com/optimove/android/optimovemobilesdk/OverlayMessagingActivity.kt @@ -0,0 +1,210 @@ +package com.optimove.android.optimovemobilesdk + +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.optimove.android.optimobile.OptimoveOverlayMessaging +import com.optimove.android.optimovemobilesdk.ui.theme.AppTheme + +class OverlayMessagingActivity : AppCompatActivity() { + + private var isInterceptorSet by mutableStateOf(false) + private var pendingCallback by mutableStateOf( + null + ) + private var timeoutSeconds by mutableStateOf("30") + private var lastOutcome by mutableStateOf(null) + private val handler = Handler(Looper.getMainLooper()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + AppTheme { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Top + ) { + Button( + onClick = { OptimoveOverlayMessaging.getInstance().resetSession() }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp) + ) { + Text("Reset Session") + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = { setInterceptor() }, + enabled = !isInterceptorSet, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp) + ) { + Text("Set Interceptor") + } + Button( + onClick = { unsetInterceptor() }, + enabled = isInterceptorSet, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary + ) + ) { + Text("Unset Interceptor") + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Interceptor timeout", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = timeoutSeconds, + onValueChange = { timeoutSeconds = it.filter { c -> c.isDigit() } }, + suffix = { Text("s") }, + singleLine = true, + enabled = !isInterceptorSet, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.width(88.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + focusedLabelColor = MaterialTheme.colorScheme.primary + ) + ) + } + + if (isInterceptorSet) { + Spacer(modifier = Modifier.height(16.dp)) + val hasMessage = pendingCallback != null + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + pendingCallback?.show(); pendingCallback = null; lastOutcome = + "show" + }, + enabled = hasMessage, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp) + ) { + Text("Show") + } + Button( + onClick = { + pendingCallback?.discard(); pendingCallback = + null; lastOutcome = "discard" + }, + enabled = hasMessage, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Discard") + } + Button( + onClick = { + pendingCallback?.defer(); pendingCallback = null; lastOutcome = + "defer" + }, + enabled = hasMessage, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary + ) + ) { + Text("Defer") + } + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Outcome: ${lastOutcome ?: "—"}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + + private fun setInterceptor() { + val timeoutMs = (timeoutSeconds.toLongOrNull() ?: 30L) * 1000L + OptimoveOverlayMessaging.getInstance() + .setInterceptor(object : OptimoveOverlayMessaging.OverlayMessagingInterceptor { + override fun onMessageLoaded( + message: com.optimove.android.optimobile.OverlayMessagingMessage, + callback: OptimoveOverlayMessaging.OverlayMessagingInterceptorCallback + ) { + runOnUiThread { + pendingCallback = callback + handler.postDelayed( + { pendingCallback = null; lastOutcome = "timeout" }, timeoutMs + ) + } + } + + override fun getTimeoutMs(): Long = timeoutMs + }) + isInterceptorSet = true + } + + override fun onDestroy() { + super.onDestroy() + unsetInterceptor() + } + + private fun unsetInterceptor() { + OptimoveOverlayMessaging.getInstance().setInterceptor(null) + isInterceptorSet = false + pendingCallback = null + } +} diff --git a/OptimoveSDK/app/src/main/java/com/optimove/android/optimovemobilesdk/ui/MainScreen.kt b/OptimoveSDK/app/src/main/java/com/optimove/android/optimovemobilesdk/ui/MainScreen.kt index 1f3671a0..ce76e925 100644 --- a/OptimoveSDK/app/src/main/java/com/optimove/android/optimovemobilesdk/ui/MainScreen.kt +++ b/OptimoveSDK/app/src/main/java/com/optimove/android/optimovemobilesdk/ui/MainScreen.kt @@ -43,6 +43,7 @@ fun MainScreen( outputText: String, showPreferenceCenter: Boolean, showEmbeddedMessaging: Boolean, + showOverlayMessaging: Boolean, showDelayedConfig: Boolean, credentialsSubmitted: Boolean, isInterceptingInApp: Boolean, @@ -61,6 +62,7 @@ fun MainScreen( onGetPreferences: () -> Unit, onSetPreferences: () -> Unit, onViewEmbeddedMessaging: () -> Unit, + onViewOverlayMessaging: () -> Unit, onSetCredentials: (optimove: String?, optimobile: String?, prefCenter: String?) -> Unit, onInAppInterceptionClicked: () -> Unit, onResetToken: () -> Unit, @@ -272,6 +274,17 @@ fun MainScreen( } } + if (showOverlayMessaging) { + Spacer(modifier = Modifier.height(4.dp)) + Button( + onClick = onViewOverlayMessaging, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp) + ) { + Text("Overlay Messaging") + } + } + if (showDelayedConfig) { Spacer(modifier = Modifier.height(16.dp)) Surface(modifier = Modifier.fillMaxWidth(), shape = CardShape, color = MaterialTheme.colorScheme.surfaceVariant) { diff --git a/OptimoveSDK/gradle.properties b/OptimoveSDK/gradle.properties index 9e26ea2c..cbfe81ec 100644 --- a/OptimoveSDK/gradle.properties +++ b/OptimoveSDK/gradle.properties @@ -7,8 +7,10 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx1536m -sdk_version=7.12.5 -sdk_version_code=71205 + +sdk_version=7.13.0 +sdk_version_code=71300 + sdk_platform=Android android.useAndroidX=true android.enableJetifier=true diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/OptimoveConfig.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/OptimoveConfig.java index daf9d5e8..af52f559 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/OptimoveConfig.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/OptimoveConfig.java @@ -73,6 +73,9 @@ public final class OptimoveConfig { private @Nullable EmbeddedMessagingConfig embeddedMessagingConfig; + private boolean overlayMessagingEnabled; + private @Nullable Integer overlayMessagingSessionLengthHours; + public enum InAppConsentStrategy { AUTO_ENROLL, EXPLICIT_BY_USER @@ -138,6 +141,7 @@ public FeatureSet withEmbeddedMessaging() { } + boolean has(Feature feature) { return features.contains(feature); } @@ -415,6 +419,14 @@ public boolean usesDelayedConfiguration() { return this.embeddedMessagingConfig; } + public boolean isOverlayMessagingEnabled() { + return this.overlayMessagingEnabled; + } + + public int getOverlayMessagingSessionLengthHours() { + return this.overlayMessagingSessionLengthHours; + } + private boolean hasFinishedInitialisation() { boolean hasOptimoveCreds = optimoveToken != null && configFileName != null; boolean hasOptimobileCreds = apiKey != null && secretKey != null; @@ -474,6 +486,8 @@ public static class Builder { private @Nullable LogLevel minLogLevel; + private @Nullable Integer overlayMessagingSessionLengthHours; + /** * @deprecated Use {@link Builder#Builder(FeatureSet)} instead */ @@ -575,6 +589,17 @@ public Builder enableEmbeddedMessaging(@NonNull String embeddedMessagingConfigur return this; } + public Builder enableOverlayMessaging(int sessionLengthHours) { + if (sessionLengthHours <= 0) { + throw new IllegalArgumentException("OverlayMessaging: sessionLengthHours must be greater than 0"); + } + if (!this.featureSet.has(FeatureSet.Feature.OPTIMOBILE)) { + throw new IllegalArgumentException("OverlayMessaging: optimobile feature required"); + } + this.overlayMessagingSessionLengthHours = sessionLengthHours; + return this; + } + /** * The minimum amount of time the user has to have left the app for a session end event to be * recorded. @@ -665,6 +690,9 @@ public OptimoveConfig build() { newConfig.setMinLogLevel(this.minLogLevel); + newConfig.overlayMessagingEnabled = this.overlayMessagingSessionLengthHours != null; + newConfig.overlayMessagingSessionLengthHours = this.overlayMessagingSessionLengthHours; + return newConfig; } } diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/AnalyticsContract.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/AnalyticsContract.java index 30ed26f1..7ddad2a2 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/AnalyticsContract.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/AnalyticsContract.java @@ -45,6 +45,8 @@ final class AnalyticsContract { static final String EVENT_TYPE_MESSAGE_DELIVERED = "k.message.delivered"; static final String EVENT_TYPE_MESSAGE_READ = "k.message.read"; static final String MESSAGE_DELETED_FROM_INBOX = "k.message.inbox.deleted"; + static final String EVENT_TYPE_OM_INTERCEPTED = "optimove.om.intercepted"; + static final String EVENT_TYPE_OM_DISMISSED = "optimove.om.dismissed"; static final String EVENT_TYPE_DEEP_LINK_MATCHED = "k.deepLink.matched"; static final String EVENT_TYPE_LOCATION_UPDATED = "k.engage.locationUpdated"; static final String EVENT_TYPE_ENTERED_BEACON_PROXIMITY = "k.engage.beaconEnteredProximity"; diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/BaseMessageView.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/BaseMessageView.java new file mode 100644 index 00000000..bc5688f0 --- /dev/null +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/BaseMessageView.java @@ -0,0 +1,505 @@ +package com.optimove.android.optimobile; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.Dialog; +import android.content.Context; +import android.content.Intent; +import android.graphics.Rect; +import android.net.Uri; +import android.net.http.SslError; +import android.os.Build; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.Pair; +import android.view.Display; +import android.view.DisplayCutout; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.Window; +import android.view.WindowInsets; +import android.view.WindowManager; +import android.webkit.JavascriptInterface; +import android.webkit.RenderProcessGoneDetail; +import android.webkit.SslErrorHandler; +import android.webkit.WebResourceError; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.ProgressBar; +import android.widget.RelativeLayout; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.UiThread; + +import com.optimove.android.BuildConfig; +import com.optimove.android.R; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +abstract class BaseMessageView extends WebViewClient { + + protected enum MessageCloseSource { + HARDWARE, + CLIENT + } + + private enum State { + INITIAL, + LOADING, + READY, + DISPOSED + } + private static final String TAG = BaseMessageView.class.getName(); + + private static final String HOST_MESSAGE_TYPE_PRESENT_MESSAGE = "PRESENT_MESSAGE"; + private static final String HOST_MESSAGE_TYPE_CLOSE_MESSAGE = "CLOSE_MESSAGE"; + private static final String HOST_MESSAGE_TYPE_SET_NOTCH_INSETS = "SET_NOTCH_INSETS"; + + private static final String JS_NAME = "Android"; + + private State state; + private boolean pageFinished; + + @NonNull + protected final Activity currentActivity; + + @Nullable + private WebView wv; + @Nullable + private Dialog dialog; + @Nullable + private ProgressBar spinner; + + private int prevStatusBarColor; + private boolean prevFlagTranslucentStatus; + private boolean prevFlagDrawsSystemBarBackgrounds; + private final boolean manageStatusBarColor; + + + @UiThread + BaseMessageView(@NonNull Activity currentActivity, boolean manageStatusBarColor) { + this.state = State.INITIAL; + pageFinished = false; + + this.currentActivity = currentActivity; + this.manageStatusBarColor = manageStatusBarColor; + } + + @UiThread + void dispose() { + if (state == State.DISPOSED) return; + closeDialog(currentActivity); + state = State.DISPOSED; + } + + @UiThread + void handleViewError(){ + dispose(); + + onViewError(); + } + + @UiThread + protected void sendCurrentMessageToClient() { + if (state == State.READY && pageFinished) { + JSONObject content = this.getCurrentMessageContent(); + + sendToClient(HOST_MESSAGE_TYPE_PRESENT_MESSAGE, content); + } + } + + @UiThread + private void setSpinnerVisibility(int visibility) { + if (spinner != null) { + spinner.setVisibility(visibility); + } + } + + @UiThread + protected void sendToClient(String type, JSONObject data) { + if (wv == null) { + return; + } + + JSONObject j = new JSONObject(); + try { + j.put("data", data); + j.put("type", type); + } catch (JSONException e) { + Log.d(TAG, "Could not create client message"); + return; + } + + String script = "window.postHostMessage(" + j.toString() + ")"; + + wv.evaluateJavascript(script, null); + } + + @UiThread + private void setStatusBarColorForDialog(Activity currentActivity) { + if (currentActivity == null) { + return; + } + + Window window = currentActivity.getWindow(); + + prevStatusBarColor = window.getStatusBarColor(); + + int flags = window.getAttributes().flags; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + prevFlagTranslucentStatus = (flags & WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) != 0; + window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); + } + + prevFlagDrawsSystemBarBackgrounds = (flags & WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) != 0; + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); + int statusBarColor; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + statusBarColor = currentActivity.getResources().getColor(R.color.statusBarColorForNotch, null); + } else { + statusBarColor = currentActivity.getResources().getColor(R.color.statusBarColorForNotch); + } + + window.setStatusBarColor(statusBarColor); + } + + @UiThread + private void unsetStatusBarColorForDialog(Activity dialogActivity) { + if (dialogActivity == null) { + return; + } + + Window window = dialogActivity.getWindow(); + window.setStatusBarColor(prevStatusBarColor); + + if (prevFlagTranslucentStatus) { + window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); + } + + if (!prevFlagDrawsSystemBarBackgrounds) { + window.clearFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); + } + } + + @UiThread + private void closeDialog(Activity dialogActivity) { + if (dialog != null) { + dialog.setOnKeyListener(null); + dialog.dismiss(); + + if (manageStatusBarColor) { + unsetStatusBarColorForDialog(dialogActivity); + } + } + + if (null != wv) { + wv.destroy(); + } + + dialog = null; + wv = null; + spinner = null; + } + + @SuppressLint({"SetJavaScriptEnabled", "AddJavascriptInterface", "InflateParams"}) + @UiThread + protected void showWebView(@NonNull final Activity currentActivity, @NonNull String iarUrl) { + try { + if (BuildConfig.DEBUG) { + WebView.setWebContentsDebuggingEnabled(true); + } + + RelativeLayout.LayoutParams paramsWebView = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT); + dialog = new Dialog(currentActivity, android.R.style.Theme_Translucent_NoTitleBar_Fullscreen); + + Window window = dialog.getWindow(); + if (window != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + WindowManager.LayoutParams windowAttributes = dialog.getWindow().getAttributes(); + windowAttributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + + View view = window.getDecorView(); + view.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); + } + + LayoutInflater inflater = (LayoutInflater) currentActivity.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + dialog.setContentView(inflater.inflate(R.layout.optimobile_dialog_view, null), paramsWebView); + dialog.setOnKeyListener((dialog, keyCode, event) -> { + if (keyCode == KeyEvent.KEYCODE_BACK && event.getAction() != KeyEvent.ACTION_DOWN) { + closeCurrentMessage(MessageCloseSource.HARDWARE); + } + return true; + }); + + wv = dialog.findViewById(R.id.optimobile_webview); + spinner = dialog.findViewById(R.id.optimobile_progressBar); + + if (null == wv || null == spinner) { + dispose(); + return; + } + + int cacheMode = WebSettings.LOAD_DEFAULT; + if (BuildConfig.DEBUG) { + cacheMode = WebSettings.LOAD_NO_CACHE; + } + wv.getSettings().setCacheMode(cacheMode); + + wv.setBackgroundColor(android.graphics.Color.TRANSPARENT); + + WebSettings settings = wv.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setAllowFileAccess(false); + settings.setAllowContentAccess(false); + + settings.setMediaPlaybackRequiresUserGesture(false); + + + wv.addJavascriptInterface(this, JS_NAME); + wv.setWebViewClient(this); + + dialog.show(); + setSpinnerVisibility(View.VISIBLE); + wv.loadUrl(iarUrl); + state = State.LOADING; + } catch (Exception e) { + Optimobile.log(TAG, e.getMessage()); + } + } + + protected void closeCurrentMessage(MessageCloseSource source) { + sendToClient(HOST_MESSAGE_TYPE_CLOSE_MESSAGE, null); + + onMessageCloseRequested(source); + } + + @Override + public void onPageFinished(WebView view, String url) { + view.setBackgroundColor(android.graphics.Color.TRANSPARENT); + if (manageStatusBarColor) { + setStatusBarColorForDialog(currentActivity); + } + pageFinished = true; + + sendCurrentMessageToClient(); + + super.onPageFinished(view, url); + } + + @Override + public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) { + super.onReceivedHttpError(view, request, errorResponse); + + String url = request.getUrl().toString(); + + try { + // Only consider handling for failures of our renderer assets + // 3rd-party fonts/images etc. shouldn't trigger this + String iarBaseUrl = Optimobile.urlForService(UrlBuilder.Service.IAR, ""); + if (!url.startsWith(iarBaseUrl)) { + return; + } + + // Cached index page may refer to stale JS/CSS file hashes + // Evict the cache to allow next presentation to re-fetch + if (404 == errorResponse.getStatusCode()) { + view.clearCache(true); + } + + handleViewError(); + } catch (Optimobile.PartialInitialisationException e) { + Optimobile.log(TAG, "Cannot handle HTTP error: credentials not yet available"); + } + } + + @Override + public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) { + handler.cancel(); + handleViewError(); + } + + @Override + public boolean onRenderProcessGone(WebView view, RenderProcessGoneDetail detail) { + handleViewError(); + + // Allow app to keep running, don't terminate + return true; + } + + @Override + public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { + Optimobile.log(TAG, "Error code: " + errorCode + ". " + description + " " + failingUrl); + + String extension = failingUrl.substring(failingUrl.length() - 4); + boolean isVideo = extension.matches(".mp4|.m4a|.m4p|.m4b|.m4r|.m4v"); + if (errorCode == -1 && "net::ERR_FAILED".equals(description) && isVideo) { + // This is a workaround for a bug in the WebView. + // See these chromium issues for more context: + // https://bugs.chromium.org/p/chromium/issues/detail?id=1023678 + // https://bugs.chromium.org/p/chromium/issues/detail?id=1050635 + + //We encountered the issue only with some (and not other) videos, but possibly not limited to other file types + return; + } + + handleViewError(); + } + + @Override + @RequiresApi(Build.VERSION_CODES.M) + public void onReceivedError(WebView view, WebResourceRequest req, WebResourceError rerr) { + onReceivedError(view, rerr.getErrorCode(), rerr.getDescription().toString(), req.getUrl().toString()); + } + + @JavascriptInterface + @AnyThread + public void postClientMessage(String msg) { + String messageType; + JSONObject data; + try { + JSONObject message = new JSONObject(msg); + messageType = message.getString("type"); + data = message.optJSONObject("data"); + } catch (JSONException e) { + Log.d(TAG, "Incorrect message format: " + msg); + return; + } + + switch (messageType) { + case "READY": + currentActivity.runOnUiThread(() -> { + if (state == State.DISPOSED) { + return; + } + + state = State.READY; + + maybeSetNotchInsets(currentActivity); + sendCurrentMessageToClient(); + }); + return; + case "MESSAGE_OPENED": + currentActivity.runOnUiThread(() -> { + if (state == State.DISPOSED) { + return; + } + + setSpinnerVisibility(View.GONE); + onMessageOpened(); + }); + return; + case "MESSAGE_CLOSED": + currentActivity.runOnUiThread(() -> onMessageClosedByClient()); + return; + case "EXECUTE_ACTIONS": + onExecuteActions(data); + return; + case "COMMAND": + onCommand(data); + return; + case "PRESENTATION_ERROR": + currentActivity.runOnUiThread(this::handleViewError); + return; + default: + Log.d(TAG, "Unknown message type: " + messageType); + } + } + + + @UiThread + private void maybeSetNotchInsets(Context context) { + if (dialog == null) { + return; + } + + Window window = dialog.getWindow(); + if (window == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return; + } + + WindowInsets insets = window.getDecorView().getRootWindowInsets(); + if (insets == null) { + return; + } + + DisplayCutout displayCutout = insets.getDisplayCutout(); + if (displayCutout == null) { + return; + } + + List cutoutBoundingRectangles = displayCutout.getBoundingRects(); + if (cutoutBoundingRectangles.isEmpty()) { + return; + } + + Pair notchPositions = determineNotchPositions(window, cutoutBoundingRectangles); + float density = context.getResources().getDisplayMetrics().density; + + JSONObject notchData = new JSONObject(); + try { + notchData.put("hasNotchOnTheLeft", notchPositions.first); + notchData.put("hasNotchOnTheRight", notchPositions.second); + notchData.put("insetTop", displayCutout.getSafeInsetTop() / density); + notchData.put("insetRight", displayCutout.getSafeInsetRight() / density); + notchData.put("insetBottom", displayCutout.getSafeInsetBottom() / density); + notchData.put("insetLeft", displayCutout.getSafeInsetLeft() / density); + + sendToClient(HOST_MESSAGE_TYPE_SET_NOTCH_INSETS, notchData); + } catch (JSONException e) { + Optimobile.log(TAG, e.getMessage()); + } + } + + @UiThread + private Pair determineNotchPositions(Window window, List cutoutBoundingRectangles) { + Display display = window.getWindowManager().getDefaultDisplay(); + DisplayMetrics outMetrics = new DisplayMetrics(); + display.getMetrics(outMetrics); + + boolean hasNotchOnTheRight = false; + boolean hasNotchOnTheLeft = false; + for (Rect rect : cutoutBoundingRectangles) { + if (rect.top == 0) { + if (rect.left > outMetrics.widthPixels - rect.right) { + hasNotchOnTheRight = true; + } else if (rect.left < outMetrics.widthPixels - rect.right) { + hasNotchOnTheLeft = true; + } + } else if (rect.right >= outMetrics.widthPixels) { + hasNotchOnTheRight = true; + } else if (rect.left == 0) { + hasNotchOnTheLeft = true; + } + } + + return new Pair<>(hasNotchOnTheLeft, hasNotchOnTheRight); + } + + // - Abstracts + abstract protected JSONObject getCurrentMessageContent(); + + abstract protected void onMessageClosedByClient(); + + abstract protected void onMessageCloseRequested(MessageCloseSource source); + + abstract protected void onMessageOpened(); + + abstract protected void onExecuteActions(JSONObject data); + + abstract protected void onCommand(JSONObject data); + + @UiThread + abstract protected void onViewError(); +} diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/InAppMessagePresenter.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/InAppMessagePresenter.java index 3c82683a..fafc99d4 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/InAppMessagePresenter.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/InAppMessagePresenter.java @@ -145,6 +145,11 @@ void messageClosed() { presentMessageToClient(); } + @UiThread + void onViewError() { + disposeView(); + } + @UiThread private void disposeView() { if (view == null) { diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/InAppMessageView.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/InAppMessageView.java index ef7e3b84..bf928b1a 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/InAppMessageView.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/InAppMessageView.java @@ -1,47 +1,14 @@ package com.optimove.android.optimobile; -import android.annotation.SuppressLint; -import android.annotation.TargetApi; import android.app.Activity; -import android.app.Dialog; -import android.content.Context; import android.content.Intent; -import android.graphics.Rect; import android.net.Uri; -import android.net.http.SslError; -import android.os.Build; -import android.util.DisplayMetrics; import android.util.Log; -import android.util.Pair; -import android.view.Display; -import android.view.DisplayCutout; -import android.view.KeyEvent; -import android.view.LayoutInflater; -import android.view.View; -import android.view.Window; -import android.view.WindowInsets; -import android.view.WindowManager; -import android.webkit.JavascriptInterface; -import android.webkit.RenderProcessGoneDetail; -import android.webkit.SslErrorHandler; -import android.webkit.WebResourceError; -import android.webkit.WebResourceRequest; -import android.webkit.WebResourceResponse; -import android.webkit.WebSettings; -import android.webkit.WebView; -import android.webkit.WebViewClient; -import android.widget.ProgressBar; -import android.widget.RelativeLayout; - -import androidx.annotation.AnyThread; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; import androidx.annotation.UiThread; -import com.optimove.android.BuildConfig; -import com.optimove.android.R; - import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -49,14 +16,7 @@ import java.util.ArrayList; import java.util.List; -class InAppMessageView extends WebViewClient { - - private enum State { - INITIAL, - LOADING, - READY, - DISPOSED - } +class InAppMessageView extends BaseMessageView { private static final String TAG = InAppMessageView.class.getName(); @@ -67,29 +27,6 @@ private enum State { private static final String BUTTON_ACTION_REQUEST_APP_STORE_RATING = "requestAppStoreRating"; private static final String BUTTON_ACTION_PUSH_REGISTER = "promptPushPermission"; - private static final String HOST_MESSAGE_TYPE_PRESENT_MESSAGE = "PRESENT_MESSAGE"; - private static final String HOST_MESSAGE_TYPE_CLOSE_MESSAGE = "CLOSE_MESSAGE"; - private static final String HOST_MESSAGE_TYPE_SET_NOTCH_INSETS = "SET_NOTCH_INSETS"; - - private static final String JS_NAME = "Android"; - - private State state; - private boolean pageFinished; - - @NonNull - private final Activity currentActivity; - - @Nullable - private WebView wv; - @Nullable - private Dialog dialog; - @Nullable - private ProgressBar spinner; - - private int prevStatusBarColor; - private boolean prevFlagTranslucentStatus; - private boolean prevFlagDrawsSystemBarBackgrounds; - @NonNull private final InAppMessagePresenter presenter; @NonNull @@ -98,11 +35,14 @@ private enum State { private final String region; @UiThread - InAppMessageView(@NonNull InAppMessagePresenter presenter, @NonNull InAppMessage message, @NonNull Activity currentActivity, @NonNull String iarUrl, @Nullable String region) { - this.state = State.INITIAL; - pageFinished = false; + InAppMessageView(@NonNull InAppMessagePresenter presenter, + @NonNull InAppMessage message, + @NonNull Activity currentActivity, + @NonNull String iarUrl, + @Nullable String region) { + super(currentActivity, true); + this.presenter = presenter; - this.currentActivity = currentActivity; this.currentMessage = message; this.region = region; @@ -118,403 +58,13 @@ void showMessage(@NonNull InAppMessage message) { sendCurrentMessageToClient(); } - @UiThread - void dispose() { - closeDialog(currentActivity); - state = State.DISPOSED; - } - - @UiThread - private void sendCurrentMessageToClient() { - if (state == State.READY && pageFinished) { - JSONObject content = currentMessage.getContent(); - if (region != null) { - try { - content.put("region", region); - } catch (JSONException e) { - Log.w(TAG, "Could not pass region to In-App renderer"); - } - } - - sendToClient(HOST_MESSAGE_TYPE_PRESENT_MESSAGE, content); - } - } - - @UiThread - private void setSpinnerVisibility(int visibility) { - if (spinner != null) { - spinner.setVisibility(visibility); - } - } - - @UiThread - private void sendToClient(String type, JSONObject data) { - if (wv == null) { - return; - } - - JSONObject j = new JSONObject(); - try { - j.put("data", data); - j.put("type", type); - } catch (JSONException e) { - Log.d(TAG, "Could not create client message"); - return; - } - - String script = "window.postHostMessage(" + j.toString() + ")"; - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - wv.evaluateJavascript(script, null); - } else { - wv.loadUrl("javascript:" + script); - } - } - - @UiThread - @SuppressWarnings("deprecation") - private void setStatusBarColorForDialog(Activity currentActivity) { - if (currentActivity == null) { - return; - } - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - return; - } - - Window window = currentActivity.getWindow(); - - prevStatusBarColor = window.getStatusBarColor(); - - int flags = window.getAttributes().flags; - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - prevFlagTranslucentStatus = (flags & WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) != 0; - window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); - } - - prevFlagDrawsSystemBarBackgrounds = (flags & WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) != 0; - window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); - int statusBarColor; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - statusBarColor = currentActivity.getResources().getColor(R.color.statusBarColorForNotch, null); - } else { - statusBarColor = currentActivity.getResources().getColor(R.color.statusBarColorForNotch); - } - - window.setStatusBarColor(statusBarColor); - } - - @UiThread - @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) - private void unsetStatusBarColorForDialog(Activity dialogActivity) { - if (dialogActivity == null) { - return; - } - - Window window = dialogActivity.getWindow(); - window.setStatusBarColor(prevStatusBarColor); - - if (prevFlagTranslucentStatus) { - window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); - } - - if (!prevFlagDrawsSystemBarBackgrounds) { - window.clearFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); - } - } - - @UiThread - private void closeDialog(Activity dialogActivity) { - if (dialog != null) { - dialog.setOnKeyListener(null); - dialog.dismiss(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - unsetStatusBarColorForDialog(dialogActivity); - } - } - - if (null != wv) { - wv.destroy(); - } - - dialog = null; - wv = null; - spinner = null; - } - - @SuppressLint({"SetJavaScriptEnabled", "AddJavascriptInterface", "InflateParams"}) - @UiThread - private void showWebView(@NonNull final Activity currentActivity, @NonNull String iarUrl) { - try { - if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - WebView.setWebContentsDebuggingEnabled(true); - } - - RelativeLayout.LayoutParams paramsWebView = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT); - dialog = new Dialog(currentActivity, android.R.style.Theme_Translucent_NoTitleBar_Fullscreen); - - Window window = dialog.getWindow(); - if (window != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - WindowManager.LayoutParams windowAttributes = dialog.getWindow().getAttributes(); - windowAttributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; - - View view = window.getDecorView(); - view.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); - } - - LayoutInflater inflater = (LayoutInflater) currentActivity.getSystemService(Context.LAYOUT_INFLATER_SERVICE); - dialog.setContentView(inflater.inflate(R.layout.optimobile_dialog_view, null), paramsWebView); - dialog.setOnKeyListener((dialog, keyCode, event) -> { - if (keyCode == KeyEvent.KEYCODE_BACK && event.getAction() != KeyEvent.ACTION_DOWN) { - closeCurrentMessage(); - } - return true; - }); - - wv = dialog.findViewById(R.id.optimobile_webview); - spinner = dialog.findViewById(R.id.optimobile_progressBar); - - if (null == wv || null == spinner) { - dispose(); - return; - } - - int cacheMode = WebSettings.LOAD_DEFAULT; - if (BuildConfig.DEBUG) { - cacheMode = WebSettings.LOAD_NO_CACHE; - } - wv.getSettings().setCacheMode(cacheMode); - - wv.setBackgroundColor(android.graphics.Color.TRANSPARENT); - - WebSettings settings = wv.getSettings(); - settings.setJavaScriptEnabled(true); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { - settings.setMediaPlaybackRequiresUserGesture(false); - } - - wv.addJavascriptInterface(this, JS_NAME); - wv.setWebViewClient(this); - - dialog.show(); - setSpinnerVisibility(View.VISIBLE); - wv.loadUrl(iarUrl); - state = State.LOADING; - } catch (Exception e) { - Optimobile.log(TAG, e.getMessage()); - } - } - - private void closeCurrentMessage() { - sendToClient(HOST_MESSAGE_TYPE_CLOSE_MESSAGE, null); - InAppMessageService.handleMessageClosed(currentActivity, currentMessage); - } - - @Override - public void onPageFinished(WebView view, String url) { - view.setBackgroundColor(android.graphics.Color.TRANSPARENT); - setStatusBarColorForDialog(currentActivity); - pageFinished = true; - - sendCurrentMessageToClient(); - - super.onPageFinished(view, url); - } - - @Override - @TargetApi(Build.VERSION_CODES.LOLLIPOP) - public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) { - super.onReceivedHttpError(view, request, errorResponse); - - String url = request.getUrl().toString(); - - try { - // Only consider handling for failures of our renderer assets - // 3rd-party fonts/images etc. shouldn't trigger this - String iarBaseUrl = Optimobile.urlForService(UrlBuilder.Service.IAR, ""); - if (!url.startsWith(iarBaseUrl)) { - return; - } - - // Cached index page may refer to stale JS/CSS file hashes - // Evict the cache to allow next presentation to re-fetch - if (404 == errorResponse.getStatusCode()) { - view.clearCache(true); - } - - closeDialog(currentActivity); - } catch (Optimobile.PartialInitialisationException e) { - Optimobile.log(TAG, "Cannot handle HTTP error: credentials not yet available"); - } - } - - @Override - public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) { - handler.cancel(); - closeDialog(currentActivity); - } - - @Override - public boolean onRenderProcessGone(WebView view, RenderProcessGoneDetail detail) { - closeDialog(currentActivity); - - // Allow app to keep running, don't terminate - return true; - } - - @Override - public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { - Optimobile.log(TAG, "Error code: " + errorCode + ". " + description + " " + failingUrl); - - String extension = failingUrl.substring(failingUrl.length() - 4); - boolean isVideo = extension.matches(".mp4|.m4a|.m4p|.m4b|.m4r|.m4v"); - if (errorCode == -1 && "net::ERR_FAILED".equals(description) && isVideo) { - // This is a workaround for a bug in the WebView. - // See these chromium issues for more context: - // https://bugs.chromium.org/p/chromium/issues/detail?id=1023678 - // https://bugs.chromium.org/p/chromium/issues/detail?id=1050635 - - //We encountered the issue only with some (and not other) videos, but possibly not limited to other file types - return; - } - - closeDialog(currentActivity); - } - - @Override - @TargetApi(Build.VERSION_CODES.M) - public void onReceivedError(WebView view, WebResourceRequest req, WebResourceError rerr) { - onReceivedError(view, rerr.getErrorCode(), rerr.getDescription().toString(), req.getUrl().toString()); - } - - @JavascriptInterface - @AnyThread - public void postClientMessage(String msg) { - String messageType; - JSONObject data; - try { - JSONObject message = new JSONObject(msg); - messageType = message.getString("type"); - data = message.optJSONObject("data"); - } catch (JSONException e) { - Log.d(TAG, "Incorrect message format: " + msg); - return; - } - - switch (messageType) { - case "READY": - currentActivity.runOnUiThread(() -> { - if (state == State.DISPOSED) { - return; - } - - state = State.READY; - - maybeSetNotchInsets(currentActivity); - sendCurrentMessageToClient(); - }); - return; - case "MESSAGE_OPENED": - currentActivity.runOnUiThread(() -> { - if (state == State.DISPOSED) { - return; - } - - setSpinnerVisibility(View.GONE); - InAppMessageService.handleMessageOpened(currentActivity, currentMessage); - }); - return; - case "MESSAGE_CLOSED": - currentActivity.runOnUiThread(presenter::messageClosed); - return; - case "EXECUTE_ACTIONS": - if (null == data) { - return; - } - List actions = this.parseButtonActionData(data); - currentActivity.runOnUiThread(() -> this.executeActions(currentActivity, actions)); - return; - default: - Log.d(TAG, "Unknown message type: " + messageType); - } - } - - @UiThread - private void maybeSetNotchInsets(Context context) { - if (dialog == null) { - return; - } - - Window window = dialog.getWindow(); - if (window == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - return; - } - - WindowInsets insets = window.getDecorView().getRootWindowInsets(); - if (insets == null) { - return; - } - - DisplayCutout displayCutout = insets.getDisplayCutout(); - if (displayCutout == null) { - return; - } - - List cutoutBoundingRectangles = displayCutout.getBoundingRects(); - if (cutoutBoundingRectangles.size() == 0) { - return; - } - - Pair notchPositions = determineNotchPositions(window, cutoutBoundingRectangles); - float density = context.getResources().getDisplayMetrics().density; - - JSONObject notchData = new JSONObject(); - try { - notchData.put("hasNotchOnTheLeft", notchPositions.first); - notchData.put("hasNotchOnTheRight", notchPositions.second); - notchData.put("insetTop", displayCutout.getSafeInsetTop() / density); - notchData.put("insetRight", displayCutout.getSafeInsetRight() / density); - notchData.put("insetBottom", displayCutout.getSafeInsetBottom() / density); - notchData.put("insetLeft", displayCutout.getSafeInsetLeft() / density); - - sendToClient(HOST_MESSAGE_TYPE_SET_NOTCH_INSETS, notchData); - } catch (JSONException e) { - Optimobile.log(TAG, e.getMessage()); - } - } - - @UiThread - private Pair determineNotchPositions(Window window, List cutoutBoundingRectangles) { - Display display = window.getWindowManager().getDefaultDisplay(); - DisplayMetrics outMetrics = new DisplayMetrics(); - display.getMetrics(outMetrics); - - boolean hasNotchOnTheRight = false; - boolean hasNotchOnTheLeft = false; - for (Rect rect : cutoutBoundingRectangles) { - if (rect.top == 0) { - if (rect.left > outMetrics.widthPixels - rect.right) { - hasNotchOnTheRight = true; - } else if (rect.left < outMetrics.widthPixels - rect.right) { - hasNotchOnTheLeft = true; - } - } else if (rect.right >= outMetrics.widthPixels) { - hasNotchOnTheRight = true; - } else if (rect.left == 0) { - hasNotchOnTheLeft = true; - } - } - - return new Pair<>(hasNotchOnTheLeft, hasNotchOnTheRight); - } - @UiThread private void executeActions(Activity currentActivity, List actions) { // Handle 'secondary' actions for (ExecutableAction action : actions) { switch (action.getType()) { case BUTTON_ACTION_CLOSE_MESSAGE: - closeCurrentMessage(); + closeCurrentMessage(MessageCloseSource.CLIENT); break; case BUTTON_ACTION_TRACK_CONVERSION_EVENT: Optimobile.trackEventImmediately(currentActivity, action.getEventType(), action.getConversionEventData()); @@ -684,4 +234,60 @@ JSONObject getDeepLink() { } } + + // - Implementations for abstracts + + @Override + protected JSONObject getCurrentMessageContent() { + JSONObject content = currentMessage.getContent(); + if (region != null) { + try { + content.put("region", region); + } catch (JSONException e) { + Log.w(TAG, "Could not pass region to In-App renderer"); + } + } + + return content; + } + + @Override + protected void onMessageClosedByClient() { + // this happens when IAR client closed message + presenter.messageClosed(); + } + + @Override + protected void onViewError() { + presenter.onViewError(); + } + + @Override + protected void onCommand(JSONObject data) { + // noop: in-app uses EXECUTE_ACTIONS, not COMMAND + } + + @Override + protected void onMessageCloseRequested(MessageCloseSource source) { + // this happens when we told IAR to close message + // TODO: the split keeps existing behaviour, but simpler would be to run this when message closed by client as well (or was there a reason?) + InAppMessageService.handleMessageClosed(currentActivity, currentMessage); + } + + @Override + protected void onMessageOpened() { + InAppMessageService.handleMessageOpened(currentActivity, currentMessage); + } + + @Override + protected void onExecuteActions(JSONObject data) { + if (null == data) { + return; + } + + List actions = this.parseButtonActionData(data); + currentActivity.runOnUiThread(() -> this.executeActions(currentActivity, actions)); + } + + } diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/Optimobile.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/Optimobile.java index 74a937a5..6de753bb 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/Optimobile.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/Optimobile.java @@ -117,6 +117,10 @@ public static synchronized void initialize(final Application application, Optimo OptimoveInApp.initialize(application, config); + if (config.isOverlayMessagingEnabled()) { + OptimoveOverlayMessaging.initialize(application, config); + } + if (config.getDeferredDeepLinkHandler() != null) { deepLinkHelper = new DeferredDeepLinkHelper(); } @@ -598,6 +602,9 @@ public static synchronized void completeDelayedConfiguration(Context context, Op urlBuilder = new UrlBuilder(config.getBaseUrlMap()); persistMediaBaseUrl(context, config.getBaseUrlMap()); OptimoveInApp.getInstance().onCredentialsAvailable(); + if (config.isOverlayMessagingEnabled()) { + OptimoveOverlayMessaging.getInstance().onCredentialsAvailable(); + } } flushEvents(context); diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OptimoveOverlayMessaging.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OptimoveOverlayMessaging.java new file mode 100644 index 00000000..d4b980e5 --- /dev/null +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OptimoveOverlayMessaging.java @@ -0,0 +1,94 @@ +package com.optimove.android.optimobile; + +import android.app.Application; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; + +import com.optimove.android.OptimoveConfig; + +public class OptimoveOverlayMessaging { + + private static OptimoveOverlayMessaging shared; + + @Nullable + private OverlayMessagingSessionManager sessionManager; + private final OverlayMessagingManager manager; + private final Application application; + private final long sessionLengthHours; + + public interface OverlayMessagingInterceptorCallback { + @UiThread + void show(); + + @UiThread + void discard(); + + @UiThread + void defer(); + } + + public interface OverlayMessagingInterceptor { + @UiThread + void onMessageLoaded(@NonNull OverlayMessagingMessage message, @NonNull OverlayMessagingInterceptorCallback callback); + + default long getTimeoutMs() { + return 5000L; + } + } + + private OptimoveOverlayMessaging(@NonNull Application application, long sessionLengthHours) { + this.application = application; + this.sessionLengthHours = sessionLengthHours; + this.manager = new OverlayMessagingManager(application); + } + + private void startSessionManager() { + OverlayMessagingSessionManager.Listener sessionListener = () -> + manager.onTriggerReceived(OverlayMessagingMessage.MessageType.SESSION); + this.sessionManager = new OverlayMessagingSessionManager(application, sessionLengthHours, sessionListener); + } + + //============================================================================================== + //-- Public API + + public static OptimoveOverlayMessaging getInstance() { + if (shared == null) { + throw new IllegalStateException("OptimoveOverlayMessaging is not initialized"); + } + return shared; + } + + public void setInterceptor(@Nullable OverlayMessagingInterceptor interceptor) { + manager.setInterceptor(interceptor); + } + + @UiThread + public void resetSession() { + if (sessionManager != null) { + sessionManager.resetSession(); + } + } + + //============================================================================================== + //-- Internal + + @UiThread + void onPushTriggerReceived() { + manager.onTriggerReceived(OverlayMessagingMessage.MessageType.IMMEDIATE); + } + + static void initialize(@NonNull Application application, @NonNull OptimoveConfig config) { + shared = new OptimoveOverlayMessaging(application, config.getOverlayMessagingSessionLengthHours()); + if (!config.usesDelayedOptimobileConfiguration()) { + shared.startSessionManager(); + } + } + + @AnyThread + void onCredentialsAvailable() { + Optimobile.handler.post(this::startSessionManager); + } +} diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingManager.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingManager.java new file mode 100644 index 00000000..b7164433 --- /dev/null +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingManager.java @@ -0,0 +1,310 @@ +package com.optimove.android.optimobile; + +import android.app.Activity; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.List; + +import java.util.ArrayDeque; +import java.util.HashSet; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +class OverlayMessagingManager implements AppStateWatcher.AppStateChangedListener { + + private static final int SESSION_SLOT_CAPACITY = 1; + private static final int IMMEDIATE_SLOT_CAPACITY = 1; + + enum InterceptorOutcome { + SHOW, DISCARD, DEFER, TIMEOUT; + + String toEventValue() { + switch (this) { + case SHOW: + return "show"; + case DISCARD: + return "discard"; + case DEFER: + return "defer"; + case TIMEOUT: + return "timeout"; + default: + throw new IllegalStateException("Unhandled outcome: " + this); + } + } + } + + private final Context context; + private final Queue displayQueue = new ArrayDeque<>(); + private final ScheduledExecutorService interceptorExecutor = Executors.newSingleThreadScheduledExecutor(); + + @Nullable + private OptimoveOverlayMessaging.OverlayMessagingInterceptor interceptor; + @Nullable + private OverlayMessagingView currentView; + @Nullable + private Activity currentActivity; + + private int sessionSlotCount = 0; + private int immediateSlotCount = 0; + // Prevents re-processing the same message when triggers arrive before backend events propagate + private final Set seenMessageIds = new HashSet<>(); + + OverlayMessagingManager(Context context) { + this.context = context.getApplicationContext(); + OptimobileInitProvider.getAppStateWatcher().registerListener(this); + } + + + @UiThread + void setInterceptor(@Nullable OptimoveOverlayMessaging.OverlayMessagingInterceptor interceptor) { + this.interceptor = interceptor; + } + + @UiThread + void onTriggerReceived(OverlayMessagingMessage.MessageType type) { + switch (type) { + case SESSION: + if (sessionSlotCount >= SESSION_SLOT_CAPACITY) { + return; + } + sessionSlotCount++; + loadMessage(type); + break; + case IMMEDIATE: + if (immediateSlotCount >= IMMEDIATE_SLOT_CAPACITY) { + return; + } + immediateSlotCount++; + loadMessage(type); + break; + } + } + + @UiThread + void onSlotCleared(OverlayMessagingMessage.MessageType type) { + switch (type) { + case SESSION: + sessionSlotCount = Math.max(0, sessionSlotCount - 1); + break; + case IMMEDIATE: + immediateSlotCount = Math.max(0, immediateSlotCount - 1); + break; + } + } + + private void loadMessage(OverlayMessagingMessage.MessageType type) { + Optimobile.executorService.submit(() -> { + OverlayMessagingMessage message = OverlayMessagingRequestService.readOverlayMessage(context, type); + Optimobile.handler.post(() -> onMessageLoaded(type, message)); + }); + } + + @UiThread + private void onMessageLoaded(OverlayMessagingMessage.MessageType type, @Nullable OverlayMessagingMessage message) { + if (message == null || seenMessageIds.contains(message.getId())) { + onSlotCleared(type); + return; + } + + seenMessageIds.add(message.getId()); + processMessage(message); + } + + @UiThread + private void processMessage(OverlayMessagingMessage message) { + if (interceptor == null) { + displayQueue.add(message); + maybeShowNext(); + return; + } + + AtomicBoolean processed = new AtomicBoolean(false); + + OptimoveOverlayMessaging.OverlayMessagingInterceptorCallback callback = new OptimoveOverlayMessaging.OverlayMessagingInterceptorCallback() { + @Override + public void show() { + if (!processed.compareAndSet(false, true)) return; + Optimobile.handler.post(() -> handleInterceptorOutcome(message, InterceptorOutcome.SHOW)); + } + + @Override + public void discard() { + if (!processed.compareAndSet(false, true)) return; + Optimobile.handler.post(() -> handleInterceptorOutcome(message, InterceptorOutcome.DISCARD)); + } + + @Override + public void defer() { + if (!processed.compareAndSet(false, true)) return; + Optimobile.handler.post(() -> handleInterceptorOutcome(message, InterceptorOutcome.DEFER)); + } + }; + + interceptorExecutor.schedule(() -> { + if (!processed.compareAndSet(false, true)) return; + Optimobile.handler.post(() -> handleInterceptorOutcome(message, InterceptorOutcome.TIMEOUT)); + }, interceptor.getTimeoutMs(), TimeUnit.MILLISECONDS); + + interceptor.onMessageLoaded(message, callback); + } + + @UiThread + private void handleInterceptorOutcome(@NonNull OverlayMessagingMessage message, @NonNull InterceptorOutcome outcome) { + switch (outcome) { + case SHOW: + displayQueue.add(message); + maybeShowNext(); + trackInterceptedEvent(message.getId(), outcome); + break; + case DEFER: + case TIMEOUT: + seenMessageIds.remove(message.getId()); + onSlotCleared(message.getType()); + trackInterceptedEvent(message.getId(), outcome); + break; + case DISCARD: + onSlotCleared(message.getType()); + trackInterceptedEvent(message.getId(), outcome); + break; + } + } + + @UiThread + private void maybeShowNext() { + OverlayMessagingMessage next = displayQueue.peek(); + + if (next == null) { + if (currentView != null) { + currentView.dispose(); + currentView = null; + } + return; + } + + if (currentView != null) { + currentView.showMessage(next); + return; + } + + if (currentActivity == null) { + return; + } + + String iarUrl; + try { + iarUrl = Optimobile.urlForService(UrlBuilder.Service.IAR, ""); + } catch (Optimobile.PartialInitialisationException e) { + return; + } + + currentView = new OverlayMessagingView(next, currentActivity, iarUrl, new OverlayMessagingView.Listener() { + @Override + public void onMessageClosed(OverlayMessagingMessage closedMessage) { + displayQueue.poll(); + onSlotCleared(closedMessage.getType()); + maybeShowNext(); + } + + @Override + public void onEvents(OverlayMessagingMessage message, List events) { + trackOverlayMessagingRendererEvents(message.getId(), events); + } + + @Override + public void onDismissed(OverlayMessagingMessage message) { + trackDismissedEvent(message.getId()); + } + + @Override + public void onViewError(OverlayMessagingMessage failedMessage) { + currentView.dispose(); + currentView = null; + // Immediate messages are short-lived. In case of an error we dont want them to stay on queue and surface later + displayQueue.poll(); + onSlotCleared(failedMessage.getType()); + maybeShowNext(); + } + }); + } + + private void trackOverlayMessagingRendererEvents(long messageId, List events) { + for (OverlayMessagingRendererEvent event : events) { + try { + JSONObject data = event.data != null ? event.data : new JSONObject(); + data.put("id", messageId); + Optimobile.trackEvent(context, event.type, data, System.currentTimeMillis(), event.immediateFlush); + } catch (JSONException e) { + e.printStackTrace(); + } + } + } + + private void trackDismissedEvent(long messageId) { + try { + JSONObject props = new JSONObject(); + props.put("id", messageId); + Optimobile.trackEventImmediately(context, AnalyticsContract.EVENT_TYPE_OM_DISMISSED, props); + } catch (JSONException e) { + e.printStackTrace(); + } + } + + private void trackInterceptedEvent(long messageId, @NonNull InterceptorOutcome outcome) { + try { + JSONObject props = new JSONObject(); + props.put("outcome", outcome.toEventValue()); + props.put("id", messageId); + Optimobile.trackEventImmediately(context, AnalyticsContract.EVENT_TYPE_OM_INTERCEPTED, props); + } catch (JSONException e) { + e.printStackTrace(); + } + } + + //============================================================================================== + //-- AppStateChangedListener + + @Override + @UiThread + public void activityAvailable(@NonNull Activity activity) { + if (currentActivity != activity) { + if (currentView != null) { + currentView.dispose(); + currentView = null; + } + currentActivity = activity; + } + maybeShowNext(); + } + + @Override + @UiThread + public void activityUnavailable(@NonNull Activity activity) { + if (activity != currentActivity) { + return; + } + currentActivity = null; + if (currentView != null) { + currentView.dispose(); + currentView = null; + } + } + + @Override + public void appEnteredForeground() { /* noop */ } + + @Override + public void appEnteredBackground() { /* noop */ } + +} diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingMessage.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingMessage.java new file mode 100644 index 00000000..39944ab6 --- /dev/null +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingMessage.java @@ -0,0 +1,42 @@ +package com.optimove.android.optimobile; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONObject; + +public class OverlayMessagingMessage { + + public enum MessageType { + SESSION, IMMEDIATE + } + + private final long id; + private final JSONObject content; + private final JSONObject data; + private final MessageType type; + + OverlayMessagingMessage(long id, @NonNull JSONObject content, @Nullable JSONObject data, @NonNull MessageType type) { + this.id = id; + this.content = content; + this.data = data; + this.type = type; + } + + public long getId() { + return id; + } + + public JSONObject getContent() { + return content; + } + + @Nullable + public JSONObject getData() { + return data; + } + + public MessageType getType() { + return type; + } +} diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingRendererEvent.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingRendererEvent.java new file mode 100644 index 00000000..aed53804 --- /dev/null +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingRendererEvent.java @@ -0,0 +1,38 @@ +package com.optimove.android.optimobile; + +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +class OverlayMessagingRendererEvent { + final String type; + final boolean immediateFlush; + @Nullable + final JSONObject data; + + private OverlayMessagingRendererEvent(String type, boolean immediateFlush, @Nullable JSONObject data) { + this.type = type; + this.immediateFlush = immediateFlush; + this.data = data; + } + + static List parseAll(@Nullable JSONArray raw) { + List result = new ArrayList<>(); + if (raw == null) return result; + + for (int i = 0; i < raw.length(); i++) { + JSONObject obj = raw.optJSONObject(i); + if (obj == null) continue; + result.add(new OverlayMessagingRendererEvent( + obj.optString("type"), + obj.optBoolean("immediateFlush", true), + obj.optJSONObject("data") + )); + } + return result; + } +} diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingRequestService.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingRequestService.java new file mode 100644 index 00000000..ae757df4 --- /dev/null +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingRequestService.java @@ -0,0 +1,84 @@ +package com.optimove.android.optimobile; + +import android.content.Context; +import android.net.Uri; +import android.util.Log; + +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; + +import okhttp3.Response; + +class OverlayMessagingRequestService { + + private static final String TAG = OverlayMessagingRequestService.class.getName(); + + static @Nullable OverlayMessagingMessage readOverlayMessage(Context c, OverlayMessagingMessage.MessageType type) { + OptimobileHttpClient httpClient = OptimobileHttpClient.getInstance(); + String userIdentifier = Optimobile.getCurrentUserIdentifier(c); + + String encodedIdentifier = Uri.encode(userIdentifier); + + try { + String messageType = type == OverlayMessagingMessage.MessageType.SESSION ? "session" : "immediate"; + + String url = Optimobile.urlForService(UrlBuilder.Service.OVERLAY_MESSAGING, "/api/v1/users/" + encodedIdentifier + "/messages/mobile?messageType=" + messageType); + + try (Response response = httpClient.getSync(url)) { + + if (!response.isSuccessful()) { + logFailedResponse(response); + return null; + } + + if (response.code() == 204) { + return null; + } + + return buildMessage(response, type); + } + } catch (IOException e) { + e.printStackTrace(); + } catch (Optimobile.PartialInitialisationException e) { + // noop -- will skip loading until credentials are set + } + + return null; + } + + private static void logFailedResponse(Response response) { + switch (response.code()) { + case 404: + Optimobile.log(TAG, "User not found"); + break; + case 422: + try { + Optimobile.log(TAG, response.body().string()); + } catch (NullPointerException | IOException e) { + Optimobile.log(TAG, e.getMessage()); + } + break; + default: + Optimobile.log(TAG, response.message()); + break; + } + } + + private static @Nullable OverlayMessagingMessage buildMessage(Response response, OverlayMessagingMessage.MessageType type) { + try { + JSONObject json = new JSONObject(response.body().string()); + long id = json.getLong("id"); + JSONObject content = json.getJSONObject("content"); + JSONObject data = json.optJSONObject("data"); + + return new OverlayMessagingMessage(id, content, data, type); + } catch (NullPointerException | JSONException | IOException e) { + Optimobile.log(TAG, e.getMessage()); + return null; + } + } +} diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingSessionManager.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingSessionManager.java new file mode 100644 index 00000000..b83773cd --- /dev/null +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingSessionManager.java @@ -0,0 +1,98 @@ +package com.optimove.android.optimobile; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.NonNull; +import androidx.annotation.UiThread; + +class OverlayMessagingSessionManager implements AppStateWatcher.AppStateChangedListener { + + interface Listener { + @UiThread + void onSessionStarted(); + } + + private static final long SCHEDULE_BUFFER_MS = 1_000L; + private static final String PREFS_FILE = "optimove_overlay_messaging"; + private static final String KEY_LAST_SESSION_START = "last_session_start"; + + private final Handler handler; + private final long sessionLengthMs; + private final Listener listener; + private final SharedPreferences prefs; + private boolean appInForeground; + + private final Runnable ticker = new Runnable() { + @Override + public void run() { + startNewSession(); + scheduleNextTick(); + } + }; + + OverlayMessagingSessionManager(@NonNull Context context, long sessionLengthHours, @NonNull Listener listener) { + this.handler = new Handler(Looper.getMainLooper()); + this.sessionLengthMs = sessionLengthHours * 3_600_000L; + this.listener = listener; + this.prefs = context.getApplicationContext().getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE); + + OptimobileInitProvider.getAppStateWatcher().registerListener(this); + } + + @UiThread + void resetSession() { + prefs.edit().remove(KEY_LAST_SESSION_START).apply(); + if (!appInForeground) { + return; + } + handler.removeCallbacks(ticker); + startNewSession(); + scheduleNextTick(); + } + + @Override + @UiThread + public void appEnteredForeground() { + appInForeground = true; + long lastSessionStart = prefs.getLong(KEY_LAST_SESSION_START, 0L); + boolean noPreviousSession = lastSessionStart == 0L; + boolean sessionExpired = (System.currentTimeMillis() - lastSessionStart) >= sessionLengthMs; + + if (noPreviousSession || sessionExpired) { + startNewSession(); + } + scheduleNextTick(); + } + + @Override + @UiThread + public void appEnteredBackground() { + appInForeground = false; + handler.removeCallbacks(ticker); + } + + private void scheduleNextTick() { + + long lastSessionStart = prefs.getLong(KEY_LAST_SESSION_START, 0L); + long nextSessionAt = lastSessionStart + sessionLengthMs + SCHEDULE_BUFFER_MS; + long delay = Math.max(0, nextSessionAt - System.currentTimeMillis()); + handler.removeCallbacks(ticker); + handler.postDelayed(ticker, delay); + } + + private void startNewSession() { + prefs.edit().putLong(KEY_LAST_SESSION_START, System.currentTimeMillis()).apply(); + listener.onSessionStarted(); + } + + + @Override + public void activityAvailable(@NonNull Activity activity) { /* noop */ } + + @Override + public void activityUnavailable(@NonNull Activity activity) { /* noop */ } +} diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingView.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingView.java new file mode 100644 index 00000000..c3f04303 --- /dev/null +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingView.java @@ -0,0 +1,160 @@ +package com.optimove.android.optimobile; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.List; + +class OverlayMessagingView extends BaseMessageView { + + private static final String SDK_ACTION_OPEN_DEEP_LINK = "OPEN_DEEP_LINK"; + + private static class RendererCommand { + final boolean close; + @Nullable + final List events; + @Nullable + final JSONArray sdkActions; + + private RendererCommand(boolean close, @Nullable List events, @Nullable JSONArray sdkActions) { + this.close = close; + this.events = events; + this.sdkActions = sdkActions; + } + + @Nullable + static RendererCommand parse(@Nullable JSONObject data) { + if (data == null) return null; + List events = OverlayMessagingRendererEvent.parseAll(data.optJSONArray("events")); + return new RendererCommand(data.optBoolean("close", false), events.isEmpty() ? null : events, data.optJSONArray("executeSdkActions")); + } + } + + interface Listener { + @UiThread + void onMessageClosed(OverlayMessagingMessage message); + + @UiThread + void onEvents(OverlayMessagingMessage message, List events); + + @UiThread + void onDismissed(OverlayMessagingMessage message); + + @UiThread + void onViewError(OverlayMessagingMessage message); + } + + @NonNull + private OverlayMessagingMessage currentMessage; + @NonNull + private final Listener listener; + + @UiThread + OverlayMessagingView(@NonNull OverlayMessagingMessage message, @NonNull Activity currentActivity, @NonNull String iarUrl, @NonNull Listener listener) { + super(currentActivity, false); + + this.currentMessage = message; + this.listener = listener; + + showWebView(currentActivity, iarUrl); + } + + @UiThread + void showMessage(@NonNull OverlayMessagingMessage message) { + if (currentMessage.getId() == message.getId()) { + return; + } + currentMessage = message; + sendCurrentMessageToClient(); + } + + private void openUrl(Activity currentActivity, String uri) { + Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri)); + if (browserIntent.resolveActivity(currentActivity.getPackageManager()) != null) { + currentActivity.startActivity(browserIntent); + } + } + + + // - Implementations for abstracts + + @Override + protected JSONObject getCurrentMessageContent() { + return currentMessage.getContent(); + } + + @Override + protected void onViewError() { + listener.onViewError(currentMessage); + } + + @Override + protected void onMessageClosedByClient() { + listener.onMessageClosed(currentMessage); + } + + @Override + protected void onMessageCloseRequested(MessageCloseSource source) { + switch (source) { + case CLIENT: + // events already tracked when COMMAND handled + break; + case HARDWARE: + listener.onDismissed(currentMessage); + break; + } + } + + @Override + protected void onMessageOpened() { + // noop: no event needed yet + } + + @Override + protected void onExecuteActions(JSONObject data) { + // noop: OM uses COMMAND, not EXECUTE_ACTIONS + } + + @Override + protected void onCommand(JSONObject data) { + RendererCommand command = RendererCommand.parse(data); + if (command == null) { + return; + } + + currentActivity.runOnUiThread(() -> { + if (command.events != null) { + listener.onEvents(currentMessage, command.events); + } + + if (command.close) { + closeCurrentMessage(MessageCloseSource.CLIENT); + } + + if (command.sdkActions == null) { + return; + } + for (int i = 0; i < command.sdkActions.length(); i++) { + JSONObject action = command.sdkActions.optJSONObject(i); + if (action == null) continue; + String type = action.optString("type"); + JSONObject actionData = action.optJSONObject("data"); + switch (type) { + case SDK_ACTION_OPEN_DEEP_LINK: + if (actionData != null) { + openUrl(currentActivity, actionData.optString("url")); + } + break; + } + } + }); + } +} diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/PushBroadcastReceiver.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/PushBroadcastReceiver.java index 1ab6fdb8..f575cdd4 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/PushBroadcastReceiver.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/PushBroadcastReceiver.java @@ -90,6 +90,8 @@ protected void onPushReceived(Context context, PushMessage pushMessage) { this.pushTrackDelivered(context, pushMessage); + this.maybeTriggerOverlayMessagingSync(context, pushMessage); + this.maybeTriggerInAppSync(context, pushMessage); if (pushMessage.runBackgroundHandler()) { @@ -101,6 +103,19 @@ protected void onPushReceived(Context context, PushMessage pushMessage) { } } + protected void maybeTriggerOverlayMessagingSync(Context context, PushMessage pushMessage) { + if (!Optimove.getConfig().isOverlayMessagingEnabled()) { + return; + } + + if (!pushMessage.isOverlayMessagingTrigger()) { + return; + } + + Optimobile.handler.post(() -> OptimoveOverlayMessaging.getInstance().onPushTriggerReceived()); + } + + private void processPushMessage(Context context, PushMessage pushMessage) { Notification.Builder builder = getNotificationBuilder(context, pushMessage); if (null == builder) { diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/PushMessage.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/PushMessage.java index 1b5eea47..0abe2313 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/PushMessage.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/PushMessage.java @@ -18,6 +18,7 @@ public final class PushMessage implements Parcelable { public static final String EXTRAS_KEY = "com.optimove.push.message"; private static final int DEEP_LINK_TYPE_IN_APP = 1; + private static final int DEEP_LINK_TYPE_OVERLAY_MESSAGE = 2; public static final String TAG = PushMessage.class.getName(); private final int id; @@ -31,6 +32,7 @@ public final class PushMessage implements Parcelable { Uri url; private final boolean runBackgroundHandler; private final int tickleId; + private final boolean isOverlayMessagingTrigger; private final @Nullable String pictureUrl; private @Nullable @@ -59,6 +61,7 @@ public final class PushMessage implements Parcelable { this.message = message; this.data = data; this.tickleId = this.getTickleId(data); + this.isOverlayMessagingTrigger = this.isOverlayMessagingTrigger(data); this.timeSent = timeSent; this.url = url; this.runBackgroundHandler = runBackgroundHandler; @@ -90,6 +93,7 @@ private PushMessage(Parcel in) { url = Uri.parse(urlString); } tickleId = in.readInt(); + isOverlayMessagingTrigger = (in.readInt() == 1); pictureUrl = in.readString(); String buttonsString = in.readString(); @@ -142,6 +146,22 @@ private Integer getTickleId(JSONObject data) { } } + boolean isOverlayMessagingTrigger(JSONObject data) { + JSONObject deepLink = data.optJSONObject("k.deepLink"); + + if (deepLink == null) { + return false; + } + + int linkType = deepLink.optInt("type", -1); + + if (linkType != DEEP_LINK_TYPE_OVERLAY_MESSAGE) { + return false; + } + + return true; + } + public static final Creator CREATOR = new Creator() { @Override public PushMessage createFromParcel(Parcel in) { @@ -173,6 +193,7 @@ public void writeToParcel(Parcel dest, int flags) { dest.writeString(dataString); dest.writeString(urlString); dest.writeInt(tickleId); + dest.writeInt(isOverlayMessagingTrigger ? 1 : 0); dest.writeString(pictureUrl); dest.writeString(buttonsString); dest.writeString(sound); @@ -215,6 +236,10 @@ int getTickleId() { return tickleId; } + boolean isOverlayMessagingTrigger() { + return isOverlayMessagingTrigger; + } + public boolean runBackgroundHandler() { return runBackgroundHandler; } diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/UrlBuilder.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/UrlBuilder.java index cea0feb6..708f5328 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/UrlBuilder.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/UrlBuilder.java @@ -14,6 +14,7 @@ public enum Service { IAR, MEDIA, PUSH, + OVERLAY_MESSAGING } private final Map baseUrlMap; @@ -39,13 +40,15 @@ public static Map defaultMapping(@NonNull String region) { baseUrlMap.put(Service.IAR, "https://iar.app.delivery"); + baseUrlMap.put(Service.PUSH, "https://push-" + region + ".kumulos.com"); baseUrlMap.put(Service.CRM, "https://crm-" + region + ".kumulos.com"); baseUrlMap.put(Service.EVENTS, "https://events-" + region + ".kumulos.com"); baseUrlMap.put(Service.DDL, "https://links-" + region + ".kumulos.com"); baseUrlMap.put(Service.MEDIA, "https://i-" + region + ".app.delivery"); + baseUrlMap.put(Service.OVERLAY_MESSAGING, "https://optimobile-overlay-srv-" + region + ".optimove.net"); + return baseUrlMap; } - }