Skip to content

Commit e78904e

Browse files
authored
chore: move in-app json parsing off ui thread (#581)
1 parent 812baa8 commit e78904e

File tree

9 files changed

+447
-48
lines changed

9 files changed

+447
-48
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package io.customer.commontest.extensions
2+
3+
import android.os.Handler
4+
import android.os.Looper
5+
import java.util.Timer
6+
import java.util.TimerTask
7+
import java.util.concurrent.CountDownLatch
8+
import java.util.concurrent.TimeUnit
9+
import kotlin.concurrent.schedule
10+
import org.junit.Assert.assertFalse
11+
import org.junit.Assert.assertTrue
12+
13+
/**
14+
* Executes an action after a delay on the main thread and waits for completion.
15+
* Useful for testing async behavior with precise timing control. Default 0ms delay
16+
* still ensures the action runs after the current execution cycle completes.
17+
*/
18+
inline fun postOnUiThread(
19+
delay: Long = 0L,
20+
timeout: Long = delay + 1_000L,
21+
crossinline action: TimerTask.() -> Unit
22+
) {
23+
val latch = CountDownLatch(1)
24+
25+
Timer().schedule(delay) {
26+
// Ensure the action runs on the main thread
27+
Handler(Looper.getMainLooper()).post {
28+
action()
29+
}
30+
latch.countDown()
31+
}
32+
33+
if (!latch.await(timeout, TimeUnit.MILLISECONDS)) {
34+
error("Scheduled block did not complete within timeout of $timeout after delay of $delay")
35+
}
36+
}
37+
38+
/**
39+
* Checks if the current thread is the main thread.
40+
*/
41+
private val isMainThread: Boolean
42+
get() = Looper.myLooper() == Looper.getMainLooper()
43+
44+
/**
45+
* Asserts that the current code is executing on the main thread.
46+
*/
47+
fun assertOnMainThread() {
48+
assertTrue(
49+
"Expected to be on main thread but was on ${Thread.currentThread().name}",
50+
isMainThread
51+
)
52+
}
53+
54+
/**
55+
* Asserts that the current code is executing on a background thread.
56+
*/
57+
fun assertNotOnMainThread() {
58+
assertFalse("Expected to be on background thread but was on main thread", isMainThread)
59+
}

common-test/src/main/java/io/customer/commontest/util/DispatchersProviderStub.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ class DispatchersProviderStub : DispatchersProvider {
2222
}
2323
}
2424

25+
// Resets to using fast test dispatchers instead of real dispatchers.
26+
// Should be called after setRealDispatchers() to avoid interfering with other tests.
27+
fun resetToTestDispatchers() {
28+
overrideBackground = null
29+
overrideMain = null
30+
overrideDefault = null
31+
}
32+
2533
override val background: CoroutineDispatcher
2634
get() = overrideBackground ?: UnconfinedTestDispatcher()
2735

messaginginapp/api/messaginginapp.api

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,6 @@ public final class io/customer/messaginginapp/gist/presentation/GistModalActivit
159159
public final fun newIntent (Landroid/content/Context;)Landroid/content/Intent;
160160
}
161161

162-
public final class io/customer/messaginginapp/gist/presentation/GistModalActivityKt {
163-
public static final field GIST_MESSAGE_INTENT Ljava/lang/String;
164-
public static final field GIST_MODAL_POSITION_INTENT Ljava/lang/String;
165-
}
166-
167162
public final class io/customer/messaginginapp/gist/presentation/GistSdk : io/customer/messaginginapp/gist/presentation/GistProvider {
168163
public fun <init> (Ljava/lang/String;Ljava/lang/String;Lio/customer/messaginginapp/gist/GistEnvironment;)V
169164
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Lio/customer/messaginginapp/gist/GistEnvironment;ILkotlin/jvm/internal/DefaultConstructorMarker;)V

messaginginapp/src/main/java/io/customer/messaginginapp/di/DIGraphMessagingInApp.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package io.customer.messaginginapp.di
22

3+
import com.google.gson.Gson
34
import io.customer.messaginginapp.MessagingInAppModuleConfig
45
import io.customer.messaginginapp.ModuleMessagingInApp
56
import io.customer.messaginginapp.gist.data.listeners.GistQueue
67
import io.customer.messaginginapp.gist.data.listeners.Queue
78
import io.customer.messaginginapp.gist.presentation.GistProvider
89
import io.customer.messaginginapp.gist.presentation.GistSdk
10+
import io.customer.messaginginapp.gist.utilities.ModalMessageGsonParser
11+
import io.customer.messaginginapp.gist.utilities.ModalMessageParser
12+
import io.customer.messaginginapp.gist.utilities.ModalMessageParserDefault
913
import io.customer.messaginginapp.state.InAppMessagingManager
1014
import io.customer.messaginginapp.store.InAppPreferenceStore
1115
import io.customer.messaginginapp.store.InAppPreferenceStoreImpl
@@ -37,6 +41,15 @@ internal val SDKComponent.gistSdk: GistSdk
3741
GistSdk(siteId = inAppModuleConfig.siteId, dataCenter = inAppModuleConfig.region.code)
3842
}
3943

44+
internal val SDKComponent.modalMessageParser: ModalMessageParser
45+
get() = singleton<ModalMessageParser> {
46+
ModalMessageParserDefault(
47+
logger = logger,
48+
dispatchersProvider = dispatchersProvider,
49+
parser = ModalMessageGsonParser(gson = Gson())
50+
)
51+
}
52+
4053
/**
4154
* Get the [ModuleMessagingInApp] instance from the [CustomerIOInstance]
4255
* needed for the in-app messaging dismiss() method

messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/GistModalActivity.kt

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,17 @@ import android.view.WindowManager
99
import androidx.activity.OnBackPressedCallback
1010
import androidx.appcompat.app.AppCompatActivity
1111
import androidx.core.animation.doOnEnd
12-
import com.google.gson.Gson
12+
import androidx.lifecycle.lifecycleScope
1313
import io.customer.base.internal.InternalCustomerIOApi
1414
import io.customer.messaginginapp.databinding.ActivityGistBinding
1515
import io.customer.messaginginapp.di.inAppMessagingManager
16+
import io.customer.messaginginapp.di.modalMessageParser
1617
import io.customer.messaginginapp.gist.data.model.Message
1718
import io.customer.messaginginapp.gist.data.model.MessagePosition
1819
import io.customer.messaginginapp.gist.utilities.ElapsedTimer
1920
import io.customer.messaginginapp.gist.utilities.MessageOverlayColorParser
2021
import io.customer.messaginginapp.gist.utilities.ModalAnimationUtil
22+
import io.customer.messaginginapp.gist.utilities.ModalMessageExtras
2123
import io.customer.messaginginapp.state.InAppMessagingAction
2224
import io.customer.messaginginapp.state.InAppMessagingManager
2325
import io.customer.messaginginapp.state.InAppMessagingState
@@ -26,9 +28,7 @@ import io.customer.messaginginapp.ui.bridge.ModalInAppMessageViewCallback
2628
import io.customer.sdk.core.di.SDKComponent
2729
import io.customer.sdk.tracking.TrackableScreen
2830
import kotlinx.coroutines.Job
29-
30-
const val GIST_MESSAGE_INTENT: String = "GIST_MESSAGE"
31-
const val GIST_MODAL_POSITION_INTENT: String = "GIST_MODAL_POSITION"
31+
import kotlinx.coroutines.launch
3232

3333
@InternalCustomerIOApi
3434
class GistModalActivity : AppCompatActivity(), ModalInAppMessageViewCallback, TrackableScreen {
@@ -38,6 +38,8 @@ class GistModalActivity : AppCompatActivity(), ModalInAppMessageViewCallback, Tr
3838
private val attributesListenerJob: MutableList<Job> = mutableListOf()
3939
private val elapsedTimer: ElapsedTimer = ElapsedTimer()
4040
private val logger = SDKComponent.logger
41+
private val dispatchersProvider = SDKComponent.dispatchersProvider
42+
private val modalMessageParser = SDKComponent.modalMessageParser
4143

4244
// Dependencies requiring ModuleMessagingInApp to be initialized must be accessed lazily,
4345
// only after confirming the SDK has been initialized.
@@ -63,35 +65,35 @@ class GistModalActivity : AppCompatActivity(), ModalInAppMessageViewCallback, Tr
6365

6466
override fun onCreate(savedInstanceState: Bundle?) {
6567
super.onCreate(savedInstanceState)
66-
val message = validateAndParseIntentMessage()
67-
if (message == null) {
68+
prepareActivity()
69+
}
70+
71+
private fun prepareActivity() = lifecycleScope.launch(dispatchersProvider.main) {
72+
val result = validateAndParseIntentExtras()
73+
74+
if (result == null) {
6875
// Finish the activity immediately to avoid running animations or further processing
6976
finishImmediately()
70-
return
77+
return@launch
7178
}
7279

80+
val (message, modalPosition) = result
81+
messagePosition = modalPosition
82+
7383
initializeActivity()
7484
setupMessage(message)
7585
subscribeToAttributes()
7686
setupBackPressedCallback(message)
7787
}
7888

79-
private fun validateAndParseIntentMessage(): Message? {
89+
private suspend fun validateAndParseIntentExtras(): ModalMessageExtras? {
8090
// Check if the SDK is initialized before parsing the intent
8191
if (inAppMessagingManager == null) {
8292
logger.error("GistModalActivity onCreate: ModuleMessagingInApp not initialized")
8393
return null
8494
}
8595

86-
val messageStr = intent.getStringExtra(GIST_MESSAGE_INTENT)
87-
val message = runCatching {
88-
Gson().fromJson(messageStr, Message::class.java)
89-
}.getOrNull()
90-
if (message == null) {
91-
logger.error("GistModelActivity onCreate: Message is null or invalid")
92-
return null
93-
}
94-
return message
96+
return modalMessageParser.parseExtras(intent = intent)
9597
}
9698

9799
private fun initializeActivity() {
@@ -108,14 +110,7 @@ class GistModalActivity : AppCompatActivity(), ModalInAppMessageViewCallback, Tr
108110
binding.gistView.setViewCallback(this)
109111
binding.gistView.setup(message)
110112

111-
// Configure message position
112-
val modalPositionStr = intent.getStringExtra(GIST_MODAL_POSITION_INTENT)
113-
val messagePosition = if (modalPositionStr == null) {
114-
message.gistProperties.position
115-
} else {
116-
MessagePosition.valueOf(modalPositionStr.uppercase())
117-
}
118-
113+
// Apply pre-parsed message position
119114
when (messagePosition) {
120115
MessagePosition.CENTER -> binding.modalGistViewLayout.setVerticalGravity(Gravity.CENTER_VERTICAL)
121116
MessagePosition.BOTTOM -> binding.modalGistViewLayout.setVerticalGravity(Gravity.BOTTOM)
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package io.customer.messaginginapp.gist.utilities
2+
3+
import android.content.Intent
4+
import com.google.gson.Gson
5+
import io.customer.messaginginapp.gist.data.model.Message
6+
import io.customer.messaginginapp.gist.data.model.MessagePosition
7+
import io.customer.sdk.core.util.DispatchersProvider
8+
import io.customer.sdk.core.util.Logger
9+
import kotlinx.coroutines.withContext
10+
11+
/**
12+
* Parsed modal message data ready for display, including message content and position.
13+
*/
14+
internal data class ModalMessageExtras(
15+
val message: Message,
16+
val messagePosition: MessagePosition
17+
)
18+
19+
/**
20+
* Interface for parsing modal messages from extras like Intent or Bundle.
21+
* It provides methods to extract message data and position from extras.
22+
*/
23+
internal interface ModalMessageParser {
24+
suspend fun parseExtras(intent: Intent): ModalMessageExtras?
25+
26+
/**
27+
* Abstraction for parsing JSON strings into Message objects.
28+
*/
29+
interface JsonParser {
30+
@Throws(Exception::class)
31+
fun parseMessageFromJson(json: String): Message?
32+
}
33+
34+
companion object {
35+
const val EXTRA_IN_APP_MESSAGE: String = "GIST_MESSAGE"
36+
const val EXTRA_IN_APP_MODAL_POSITION: String = "GIST_MODAL_POSITION"
37+
}
38+
}
39+
40+
/**
41+
* Default JSON parser implementation using Gson for modal message deserialization.
42+
*/
43+
internal class ModalMessageGsonParser(
44+
private val gson: Gson
45+
) : ModalMessageParser.JsonParser {
46+
@Throws(Exception::class)
47+
override fun parseMessageFromJson(json: String): Message? {
48+
return gson.fromJson(json, Message::class.java)
49+
}
50+
}
51+
52+
/**
53+
* Default implementation of ModalMessageParser to be used in production environments.
54+
*/
55+
internal class ModalMessageParserDefault(
56+
private val logger: Logger,
57+
private val dispatchersProvider: DispatchersProvider,
58+
private val parser: ModalMessageParser.JsonParser
59+
) : ModalMessageParser {
60+
override suspend fun parseExtras(intent: Intent): ModalMessageExtras? {
61+
val rawMessage = intent.getStringExtra(ModalMessageParser.EXTRA_IN_APP_MESSAGE)
62+
if (rawMessage.isNullOrEmpty()) {
63+
logger.error("ModalMessageParser: Message is null or empty")
64+
return null
65+
}
66+
67+
return withContext(dispatchersProvider.background) {
68+
try {
69+
val message = requireNotNull(parser.parseMessageFromJson(rawMessage))
70+
val rawPosition = intent.getStringExtra(ModalMessageParser.EXTRA_IN_APP_MODAL_POSITION)
71+
val position = if (rawPosition == null) {
72+
message.gistProperties.position
73+
} else {
74+
MessagePosition.valueOf(rawPosition.uppercase())
75+
}
76+
77+
return@withContext ModalMessageExtras(
78+
message = message,
79+
messagePosition = position
80+
)
81+
} catch (ex: Exception) {
82+
logger.error("ModalMessageParser: Failed to parse modal message with error: ${ex.message}")
83+
return@withContext null
84+
}
85+
}
86+
}
87+
}

messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingMiddlewares.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@ import io.customer.messaginginapp.di.gistQueue
66
import io.customer.messaginginapp.di.gistSdk
77
import io.customer.messaginginapp.gist.data.model.Message
88
import io.customer.messaginginapp.gist.data.model.matchesRoute
9-
import io.customer.messaginginapp.gist.presentation.GIST_MESSAGE_INTENT
10-
import io.customer.messaginginapp.gist.presentation.GIST_MODAL_POSITION_INTENT
119
import io.customer.messaginginapp.gist.presentation.GistListener
1210
import io.customer.messaginginapp.gist.presentation.GistModalActivity
11+
import io.customer.messaginginapp.gist.utilities.ModalMessageParser
1312
import io.customer.sdk.core.di.SDKComponent
1413
import org.reduxkotlin.Store
1514
import org.reduxkotlin.middleware
@@ -86,8 +85,8 @@ private fun handleModalMessageDisplay(store: Store<InAppMessagingState>, action:
8685
SDKComponent.logger.debug("Showing message: ${action.message} with position: ${action.position} and context: $context")
8786
val intent = GistModalActivity.newIntent(context).apply {
8887
flags = Intent.FLAG_ACTIVITY_NEW_TASK
89-
putExtra(GIST_MESSAGE_INTENT, Gson().toJson(action.message))
90-
putExtra(GIST_MODAL_POSITION_INTENT, action.position?.toString())
88+
putExtra(ModalMessageParser.EXTRA_IN_APP_MESSAGE, Gson().toJson(action.message))
89+
putExtra(ModalMessageParser.EXTRA_IN_APP_MODAL_POSITION, action.position?.toString())
9190
}
9291
context.startActivity(intent)
9392
next(action)

0 commit comments

Comments
 (0)