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() + } +}