diff --git a/Documentation/IN_APP_CONTENT_BLOCKS.md b/Documentation/IN_APP_CONTENT_BLOCKS.md
index 20d2918c..15a0fd94 100644
--- a/Documentation/IN_APP_CONTENT_BLOCKS.md
+++ b/Documentation/IN_APP_CONTENT_BLOCKS.md
@@ -130,4 +130,80 @@ placeholderView.behaviourCallback = object : InAppContentBlockCallback {
handleUrlByYourApp(action.url)
}
}
-```
\ No newline at end of file
+```
+
+### Custom presentation of In-app content block
+
+In case that UI presentation of InAppContentBlockPlaceholderView does not fit UX design of your application (for example customized animations) you may create own View element that wraps existing InAppContentBlockPlaceholderView instance.
+Setup could differ from your use case but you should keep these 3 principles:
+
+1. Prepare InAppContentBlockPlaceholderView instance with deferred load and (important) add it into layout to keep View lifecycle:
+```kotlin
+class CustomView : FrameLayout {
+
+ private lateinit var placeholderView: InAppContentBlockPlaceholderView
+
+ constructor(context: Context) : super(context) {
+ placeholderView = Exponea.getInAppContentBlocksPlaceholder(
+ "placeholder_1",
+ context,
+ InAppContentBlockPlaceholderConfiguration(true)
+ ) ?: return
+ overrideBehaviour(placeholderView)
+ addView(placeholderView, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
+ placeholderView.refreshContent()
+ }
+}
+```
+2. Hook your CustomView to listen on In-app Content Block message arrival:
+```kotlin
+private fun overrideBehaviour(placeholderView: InAppContentBlockPlaceholderView) {
+ val originalBehavior = placeholderView.behaviourCallback
+ placeholderView.behaviourCallback = object : InAppContentBlockCallback {
+ override fun onMessageShown(placeholderId: String, contentBlock: InAppContentBlock) {
+ // Calling originalBehavior tracks 'show' event and opens URL
+ originalBehavior.onMessageShown(placeholderId, contentBlock)
+ showMessage(contentBlock)
+ }
+
+ override fun onNoMessageFound(placeholderId: String) {
+ showNoMessage()
+ }
+
+ override fun onError(placeholderId: String, contentBlock: InAppContentBlock?, errorMessage: String) {
+ // Calling originalBehavior tracks 'error' event
+ originalBehavior.onError(placeholderId, contentBlock, errorMessage)
+ showError()
+ }
+
+ override fun onCloseClicked(placeholderId: String, contentBlock: InAppContentBlock) {
+ // Calling originalBehavior tracks 'close' event
+ originalBehavior.onCloseClicked(placeholderId, contentBlock)
+ hideMe()
+ }
+
+ override fun onActionClicked(placeholderId: String, contentBlock: InAppContentBlock, action: InAppContentBlockAction) {
+ // Calling originalBehavior tracks 'click' event
+ originalBehavior.onActionClicked(placeholderId, contentBlock, action)
+ }
+ }
+}
+
+/**
+ * Update your customized content.
+ * This method could be called multiple times for every content block update, especially in case that multiple messages are assigned to given "placeholder_1" ID
+ */
+fun showMessage(data: InAppContentBlock) {
+ //...
+}
+```
+3. Invoke clicked action manually. For example if your CustomView contains Button that is registered with View.OnClickListener for action URL and is calling `onMyActionClick` method:
+```kotlin
+fun onMyActionClick(url: String) {
+ placeholderView.invokeActionClick(url)
+}
+```
+
+That is all, now your CustomView will receive all In-app Content Block data.
+
+> **Keep in mind:** Ensure that InAppContentBlockPlaceholderView instance is added to Layout. It could be hidden but it relays on [attachToWindow](https://developer.android.com/reference/android/view/View#onAttachedToWindow()) lifecycle to be able to refresh content on data update. You have to invoke refreshContent() manually after invokeActionClick() otherwise.
diff --git a/Documentation/RELEASE_NOTES.md b/Documentation/RELEASE_NOTES.md
index d5b4e6f3..9b50b04c 100644
--- a/Documentation/RELEASE_NOTES.md
+++ b/Documentation/RELEASE_NOTES.md
@@ -1,6 +1,12 @@
## :arrow_double_up: [SDK version update guide](./../Guides/VERSION_UPDATE.md)
## Release Notes
+## Release Notes for 3.11.2
+#### January 11, 2024
+* Bug Fixes
+ * Fixed: Invoking of In-app content blocks behaviour callback from outside is not reflected to local flags about showing and interaction
+
+
## Release Notes for 3.11.1
#### December 23, 2023
* Bug Fixes
diff --git a/Guides/INSTALL.md b/Guides/INSTALL.md
index bf68aac6..200ed338 100644
--- a/Guides/INSTALL.md
+++ b/Guides/INSTALL.md
@@ -5,7 +5,7 @@
2. Add ExponeaSDK dependency and sync your project
```groovy
dependencies {
- implementation 'com.exponea.sdk:sdk:3.11.1'
+ implementation 'com.exponea.sdk:sdk:3.11.2'
}
```
3. After synchronization is complete, you can start using the SDK.
diff --git a/README.md b/README.md
index 63f080c5..2753cc72 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ Download via Gradle:
```groovy
dependencies {
- implementation 'com.exponea.sdk:sdk:3.11.1'
+ implementation 'com.exponea.sdk:sdk:3.11.2'
}
```
@@ -32,7 +32,7 @@ Download via Maven:
com.exponea.sdk
sdk
- 3.11.1
+ 3.11.2
```
diff --git a/app/build.gradle b/app/build.gradle
index 2c3466c6..281bf25b 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -15,8 +15,8 @@ android {
applicationId "com.exponea.example"
minSdkVersion 21
targetSdkVersion 33
- versionCode 77
- versionName "3.11.1"
+ versionCode 78
+ versionName "3.11.2"
vectorDrawables.useSupportLibrary = true
}
compileOptions {
diff --git a/sdk/build.gradle b/sdk/build.gradle
index 51b2ebef..c6dfcbd4 100644
--- a/sdk/build.gradle
+++ b/sdk/build.gradle
@@ -11,8 +11,8 @@ android {
defaultConfig {
minSdkVersion 17
targetSdkVersion 33
- buildConfigField "String", "EXPONEA_VERSION_NAME", '"3.11.1"'
- buildConfigField "int", "EXPONEA_VERSION_CODE", "72"
+ buildConfigField "String", "EXPONEA_VERSION_NAME", '"3.11.2"'
+ buildConfigField "int", "EXPONEA_VERSION_CODE", "73"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles 'proguard-rules.pro'
}
diff --git a/sdk/src/main/java/com/exponea/sdk/view/InAppContentBlockPlaceholderView.kt b/sdk/src/main/java/com/exponea/sdk/view/InAppContentBlockPlaceholderView.kt
index 0fafbc78..7cd4c884 100644
--- a/sdk/src/main/java/com/exponea/sdk/view/InAppContentBlockPlaceholderView.kt
+++ b/sdk/src/main/java/com/exponea/sdk/view/InAppContentBlockPlaceholderView.kt
@@ -123,4 +123,12 @@ class InAppContentBlockPlaceholderView internal constructor(
fun setOnContentReadyListener(listener: (Boolean) -> Unit) {
onContentReady = listener
}
+
+ fun invokeActionClick(actionUrl: String) {
+ Logger.i(
+ this,
+ "InAppCB: Manual action $actionUrl invoked on placeholder ${controller.placeholderId}"
+ )
+ controller.onUrlClick(actionUrl)
+ }
}
diff --git a/sdk/src/test/java/com/exponea/sdk/manager/InAppContentBlocksManagerImplTest.kt b/sdk/src/test/java/com/exponea/sdk/manager/InAppContentBlocksManagerImplTest.kt
index b4ce834b..bad35b32 100644
--- a/sdk/src/test/java/com/exponea/sdk/manager/InAppContentBlocksManagerImplTest.kt
+++ b/sdk/src/test/java/com/exponea/sdk/manager/InAppContentBlocksManagerImplTest.kt
@@ -50,13 +50,14 @@ internal class InAppContentBlocksManagerImplTest {
data: Map? = null,
placeholders: List = listOf("placeholder_1"),
trackingConsentCategory: String? = null,
- priority: Int? = null
+ priority: Int? = null,
+ rawFrequency: String = InAppContentBlockFrequency.ALWAYS.name.lowercase()
): InAppContentBlock {
return InAppContentBlock(
id = id,
name = "Random name",
dateFilter = null,
- rawFrequency = InAppContentBlockFrequency.ALWAYS.name.lowercase(),
+ rawFrequency = rawFrequency,
priority = priority,
consentCategoryTracking = trackingConsentCategory,
rawContentType = type,
diff --git a/sdk/src/test/java/com/exponea/sdk/view/InAppContentBlockPlaceholderViewTest.kt b/sdk/src/test/java/com/exponea/sdk/view/InAppContentBlockPlaceholderViewTest.kt
index 7533d244..919c8074 100644
--- a/sdk/src/test/java/com/exponea/sdk/view/InAppContentBlockPlaceholderViewTest.kt
+++ b/sdk/src/test/java/com/exponea/sdk/view/InAppContentBlockPlaceholderViewTest.kt
@@ -19,6 +19,7 @@ import com.exponea.sdk.models.InAppContentBlock
import com.exponea.sdk.models.InAppContentBlockAction
import com.exponea.sdk.models.InAppContentBlockCallback
import com.exponea.sdk.models.InAppContentBlockDisplayState
+import com.exponea.sdk.models.InAppContentBlockFrequency
import com.exponea.sdk.models.InAppContentBlockPlaceholderConfiguration
import com.exponea.sdk.models.Result
import com.exponea.sdk.network.ExponeaService
@@ -35,8 +36,11 @@ import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
+import java.util.Date
import kotlin.test.assertEquals
import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.test.fail
import kotlinx.coroutines.CoroutineScope
@@ -67,13 +71,7 @@ internal class InAppContentBlockPlaceholderViewTest {
val context = ApplicationProvider.getApplicationContext()
fetchManager = mockk()
customerIdsRepository = mockk()
- displayStateRepository = mockk()
- every { displayStateRepository.get(any()) } returns InAppContentBlockDisplayState(
- null, 0, null, 0
- )
- every { displayStateRepository.setDisplayed(any(), any()) } just Runs
- every { displayStateRepository.setInteracted(any(), any()) } just Runs
- every { displayStateRepository.clear() } just Runs
+ displayStateRepository = InAppContentBlockDisplayStateMock()
bitmapCache = mockk()
every { bitmapCache.has(any()) } returns false
every { bitmapCache.preload(any(), any()) } just Runs
@@ -206,6 +204,213 @@ internal class InAppContentBlockPlaceholderViewTest {
assertEquals(View.GONE, placeholder.placeholder.visibility)
}
+ @Test
+ @LooperMode(LooperMode.Mode.PAUSED)
+ fun `should store interaction flags by invoking manual action`() {
+ val placeholderId = "ph1"
+ every { fetchManager.fetchStaticInAppContentBlocks(any(), any(), any()) } answers {
+ arg<(Result?>) -> Unit>(1).invoke(Result(true, arrayListOf(
+ buildMessage(
+ "id1",
+ type = "html",
+ placeholders = listOf(placeholderId),
+ data = mapOf("html" to buildHtmlMessageContent()),
+ rawFrequency = InAppContentBlockFrequency.UNTIL_VISITOR_INTERACTS.name.lowercase()
+ )
+ )))
+ }
+ inAppContentBlockManager.loadInAppContentBlockPlaceholders()
+ val placeholder = inAppContentBlockManager.getPlaceholderView(
+ placeholderId,
+ ApplicationProvider.getApplicationContext(),
+ InAppContentBlockPlaceholderConfiguration(true)
+ )
+ val manualActionUrl = "https://exponea.com"
+ var stepIndex = 0
+ var messageShown = 0
+ var actionClicked = 0
+ var noMessageFound = 0
+ var shownMessage: InAppContentBlock? = null
+ placeholder.behaviourCallback = object : InAppContentBlockCallback {
+ override fun onMessageShown(placeholderId: String, contentBlock: InAppContentBlock) {
+ assertEquals("id1", contentBlock.id)
+ messageShown = ++stepIndex
+ shownMessage = contentBlock
+ }
+ override fun onNoMessageFound(placeholderId: String) {
+ noMessageFound = ++stepIndex
+ }
+ override fun onError(placeholderId: String, contentBlock: InAppContentBlock?, errorMessage: String) {
+ fail("Should not throw error")
+ }
+ override fun onCloseClicked(placeholderId: String, contentBlock: InAppContentBlock) {
+ fail("Should not invoke close click")
+ }
+ override fun onActionClicked(
+ placeholderId: String,
+ contentBlock: InAppContentBlock,
+ action: InAppContentBlockAction
+ ) {
+ assertEquals("id1", contentBlock.id)
+ assertEquals(manualActionUrl, action.url)
+ actionClicked = ++stepIndex
+ }
+ }
+ placeholder.refreshContent()
+ shadowOf(getMainLooper()).idle()
+ assertEquals(1, messageShown)
+ assertNotNull(shownMessage)
+ placeholder.invokeActionClick(manualActionUrl)
+ placeholder.refreshContent()
+ shadowOf(getMainLooper()).idle()
+ assertEquals(2, actionClicked)
+ assertEquals(3, noMessageFound)
+ // message is visible 'until interaction' so next message should not be shown/found
+ assertEquals(1, messageShown)
+ // local flags validation
+ val displayState = displayStateRepository.get(shownMessage!!)
+ assertEquals(1, displayState.displayedCount)
+ assertNotNull(displayState.displayedLast)
+ assertEquals(1, displayState.interactedCount)
+ assertNotNull(displayState.interactedLast)
+ }
+
+ @Test
+ @LooperMode(LooperMode.Mode.PAUSED)
+ fun `should store interaction flags by invoking close action`() {
+ val placeholderId = "ph1"
+ every { fetchManager.fetchStaticInAppContentBlocks(any(), any(), any()) } answers {
+ arg<(Result?>) -> Unit>(1).invoke(Result(true, arrayListOf(
+ buildMessage(
+ "id1",
+ type = "html",
+ placeholders = listOf(placeholderId),
+ data = mapOf("html" to buildHtmlMessageContent()),
+ rawFrequency = InAppContentBlockFrequency.UNTIL_VISITOR_INTERACTS.name.lowercase()
+ )
+ )))
+ }
+ inAppContentBlockManager.loadInAppContentBlockPlaceholders()
+ val placeholder = inAppContentBlockManager.getPlaceholderView(
+ placeholderId,
+ ApplicationProvider.getApplicationContext(),
+ InAppContentBlockPlaceholderConfiguration(true)
+ )
+ val manualCloseUrl = "https://exponea.com/close_action"
+ var stepIndex = 0
+ var messageShown = 0
+ var actionClosed = 0
+ var noMessageFound = 0
+ var shownMessage: InAppContentBlock? = null
+ placeholder.behaviourCallback = object : InAppContentBlockCallback {
+ override fun onMessageShown(placeholderId: String, contentBlock: InAppContentBlock) {
+ assertEquals("id1", contentBlock.id)
+ messageShown = ++stepIndex
+ shownMessage = contentBlock
+ }
+ override fun onNoMessageFound(placeholderId: String) {
+ noMessageFound = ++stepIndex
+ }
+ override fun onError(placeholderId: String, contentBlock: InAppContentBlock?, errorMessage: String) {
+ fail("Should not throw error")
+ }
+ override fun onCloseClicked(placeholderId: String, contentBlock: InAppContentBlock) {
+ assertEquals("id1", contentBlock.id)
+ actionClosed = ++stepIndex
+ }
+ override fun onActionClicked(
+ placeholderId: String,
+ contentBlock: InAppContentBlock,
+ action: InAppContentBlockAction
+ ) {
+ fail("Should not invoke action click")
+ }
+ }
+ placeholder.refreshContent()
+ shadowOf(getMainLooper()).idle()
+ assertEquals(1, messageShown)
+ assertNotNull(shownMessage)
+ placeholder.invokeActionClick(manualCloseUrl)
+ placeholder.refreshContent()
+ shadowOf(getMainLooper()).idle()
+ assertEquals(2, actionClosed)
+ assertEquals(3, noMessageFound)
+ // message is visible 'until interaction' so next message should not be shown/found
+ assertEquals(1, messageShown)
+ // local flags validation
+ val displayState = displayStateRepository.get(shownMessage!!)
+ assertEquals(1, displayState.displayedCount)
+ assertNotNull(displayState.displayedLast)
+ assertEquals(1, displayState.interactedCount)
+ assertNotNull(displayState.interactedLast)
+ }
+
+ @Test
+ @LooperMode(LooperMode.Mode.PAUSED)
+ fun `should store interaction flags by invoking invalid action`() {
+ val placeholderId = "ph1"
+ every { fetchManager.fetchStaticInAppContentBlocks(any(), any(), any()) } answers {
+ arg<(Result?>) -> Unit>(1).invoke(Result(true, arrayListOf(
+ buildMessage(
+ "id1",
+ type = "html",
+ placeholders = listOf(placeholderId),
+ data = mapOf("html" to buildHtmlMessageContent()),
+ rawFrequency = InAppContentBlockFrequency.UNTIL_VISITOR_INTERACTS.name.lowercase()
+ )
+ )))
+ }
+ inAppContentBlockManager.loadInAppContentBlockPlaceholders()
+ val placeholder = inAppContentBlockManager.getPlaceholderView(
+ placeholderId,
+ ApplicationProvider.getApplicationContext(),
+ InAppContentBlockPlaceholderConfiguration(true)
+ )
+ val manualInvalidUrl = "https://exponea.com/is-not-listed-action"
+ var stepIndex = 0
+ var messageShown = 0
+ var onErrorFound = 0
+ var shownMessage: InAppContentBlock? = null
+ placeholder.behaviourCallback = object : InAppContentBlockCallback {
+ override fun onMessageShown(placeholderId: String, contentBlock: InAppContentBlock) {
+ assertEquals("id1", contentBlock.id)
+ messageShown = ++stepIndex
+ shownMessage = contentBlock
+ }
+ override fun onNoMessageFound(placeholderId: String) {
+ fail("Should not invoke no message step")
+ }
+ override fun onError(placeholderId: String, contentBlock: InAppContentBlock?, errorMessage: String) {
+ onErrorFound = ++stepIndex
+ }
+ override fun onCloseClicked(placeholderId: String, contentBlock: InAppContentBlock) {
+ fail("Should not invoke close click")
+ }
+ override fun onActionClicked(
+ placeholderId: String,
+ contentBlock: InAppContentBlock,
+ action: InAppContentBlockAction
+ ) {
+ fail("Should not invoke action click")
+ }
+ }
+ placeholder.refreshContent()
+ shadowOf(getMainLooper()).idle()
+ assertEquals(1, messageShown)
+ assertNotNull(shownMessage)
+ placeholder.invokeActionClick(manualInvalidUrl)
+ shadowOf(getMainLooper()).idle()
+ assertEquals(2, onErrorFound)
+ // message is visible 'until interaction' so next message should not be shown/found
+ assertEquals(1, messageShown)
+ // local flags validation
+ val displayState = displayStateRepository.get(shownMessage!!)
+ assertEquals(1, displayState.displayedCount)
+ assertNotNull(displayState.displayedLast)
+ assertEquals(0, displayState.interactedCount)
+ assertNull(displayState.interactedLast)
+ }
+
private fun preloadInAppContentBlocks(messages: ArrayList) {
every { fetchManager.fetchStaticInAppContentBlocks(any(), any(), any()) } answers {
arg<(Result?>) -> Unit>(1).invoke(Result(true, messages))
@@ -221,3 +426,36 @@ internal class InAppContentBlockPlaceholderViewTest {
inAppContentBlockManager.onEventCreated(Event(), EventType.TRACK_CUSTOMER)
}
}
+
+class InAppContentBlockDisplayStateMock : InAppContentBlockDisplayStateRepository {
+ private val displayStates = mutableMapOf()
+ override fun get(message: InAppContentBlock): InAppContentBlockDisplayState {
+ return displayStates[message.id] ?: InAppContentBlockDisplayState(
+ null, 0, null, 0
+ )
+ }
+
+ override fun setDisplayed(message: InAppContentBlock, date: Date) {
+ val displayState = get(message)
+ displayStates[message.id] = InAppContentBlockDisplayState(
+ date,
+ displayState.displayedCount + 1,
+ displayState.interactedLast,
+ displayState.interactedCount
+ )
+ }
+
+ override fun setInteracted(message: InAppContentBlock, date: Date) {
+ val displayState = get(message)
+ displayStates[message.id] = InAppContentBlockDisplayState(
+ displayState.displayedLast,
+ displayState.displayedCount,
+ date,
+ displayState.interactedCount + 1
+ )
+ }
+
+ override fun clear() {
+ displayStates.clear()
+ }
+}