Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner

internal const val PRELOAD_CACHE_HIT_LOG_MESSAGE = "Returning cached preloaded WebView."

internal data class PreloadKey(val url: String) {
companion object {
fun forUrl(url: String): PreloadKey {
Expand Down Expand Up @@ -118,7 +120,7 @@ internal class PreloadCache(
ShopifyCheckoutKit.log.d(LOG_TAG, "Preloaded WebView is already presented; creating a new WebView.")
null
} else {
ShopifyCheckoutKit.log.d(LOG_TAG, "Returning cached preloaded WebView.")
ShopifyCheckoutKit.log.d(LOG_TAG, PRELOAD_CACHE_HIT_LOG_MESSAGE)
observer = null
cached.view.markPreloadConsumed()
cached.view
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import java.util.concurrent.TimeUnit

@RunWith(RobolectricTestRunner::class)
class PreloadCacheTest {
@Test
fun `cache-hit diagnostic stays aligned with sample observers`() {
assertThat(PRELOAD_CACHE_HIT_LOG_MESSAGE).isEqualTo("Returning cached preloaded WebView.")
}

@Test
fun `retaining after presentation schedules expiry for the remaining ttl`() {
var now = 1_000L
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.shopify.checkoutkit.androiddemo.accessibility
object AccessibilityIdentifiers {
const val APP_READY = "checkout-kit-sample-ready"
const val PRELOAD_STATE_PREFIX = "preload-state-"
const val PRELOAD_CACHE_HIT_PREFIX = "preload-cache-hit-"

object Cart {
const val CHECKOUT_READY = "cart-checkout-ready"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentSize
Expand Down Expand Up @@ -63,6 +64,7 @@ fun CartView(
val loading = cartViewModel.loadingState.collectAsState().value
val checkoutPresentationMode = cartViewModel.checkoutPresentationMode.collectAsState().value
val preloadStateTestId = cartViewModel.preloadStateTestId.collectAsState().value
val preloadCacheHitTestId = cartViewModel.preloadCacheHitTestId.collectAsState().value

val activity = LocalActivity.current as ComponentActivity
var mutableQuantity by remember { mutableStateOf<Map<String, Int>>(mutableMapOf()) }
Expand Down Expand Up @@ -91,12 +93,19 @@ fun CartView(
it.title to it.quantity
}

// Exposes the current preload state as a preload identifier.
Column(
modifier = Modifier
.padding(top = 4.dp)
.testTag(preloadStateTestId)
) {
// Keep a separate semantics node without adding a second child to the outer
// SpaceBetween column, which would shift short cart content to the bottom.
Box(
modifier = Modifier
.size(1.dp)
.testTag(preloadCacheHitTestId)
)

CartLines(
lines = state.cartLines,
loading = loading,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.shopify.checkoutkit.CheckoutPresentation
import com.shopify.checkoutkit.CheckoutProtocol
import com.shopify.checkoutkit.PreloadState
import com.shopify.checkoutkit.ShopifyCheckoutKit
import com.shopify.checkoutkit.androiddemo.BuildConfig
import com.shopify.checkoutkit.androiddemo.MainActivity
import com.shopify.checkoutkit.androiddemo.R
import com.shopify.checkoutkit.androiddemo.cart.data.CartRepository
Expand All @@ -23,6 +24,8 @@ import com.shopify.checkoutkit.androiddemo.common.SnackbarEvent
import com.shopify.checkoutkit.androiddemo.common.logs.LogLevel
import com.shopify.checkoutkit.androiddemo.common.logs.Logger
import com.shopify.checkoutkit.androiddemo.common.navigation.Screen
import com.shopify.checkoutkit.androiddemo.e2e.PreloadCacheHitLog
import com.shopify.checkoutkit.androiddemo.e2e.PreloadCacheHitMarker
import com.shopify.checkoutkit.androiddemo.e2e.PreloadStateMarker
import com.shopify.checkoutkit.androiddemo.settings.PreferencesManager
import com.shopify.checkoutkit.androiddemo.settings.authentication.data.AuthenticationState
Expand All @@ -36,9 +39,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
Expand All @@ -62,9 +68,26 @@ class CartViewModel(
private val _checkoutPresentationMode = MutableStateFlow(CheckoutPresentationMode.CheckoutKitSheet)
val checkoutPresentationMode: StateFlow<CheckoutPresentationMode> = _checkoutPresentationMode.asStateFlow()

private val _preloadState = MutableStateFlow<PreloadState>(PreloadState.Idle)
private val _preloadStateTestId = MutableStateFlow(PreloadStateMarker.testId(PreloadState.Idle))
val preloadStateTestId: StateFlow<String> = _preloadStateTestId.asStateFlow()

// The SDK's log sink is internal, so the cache hit is observed from this process's own
// Logcat and republished as an identifier. Gate the hit when the log arrives so an entry
// consumed while still loading cannot later count as a ready cache hit.
private val preloadCacheHitLog = PreloadCacheHitLog(
isPreloadReady = { _preloadState.value is PreloadState.Ready }
).also {
if (BuildConfig.DEBUG) it.start(viewModelScope)
}
val preloadCacheHitTestId: StateFlow<String> = preloadCacheHitLog.observed
.map { PreloadCacheHitMarker.testId(it) }
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
PreloadCacheHitMarker.testId(false)
)

private var demoBuyerIdentityEnabled = false
private var checkoutPreloadingEnabled = true
private var windowOpenHandler = WindowOpenHandler.Default
Expand Down Expand Up @@ -176,10 +199,15 @@ class CartViewModel(
Timber.i("Preloading checkout")
ShopifyCheckoutKit.preload(url, activity) { state ->
Timber.i("Preload state changed to $state")
_preloadState.value = state
_preloadStateTestId.value = PreloadStateMarker.testId(state)
}
}

override fun onCleared() {
preloadCacheHitLog.close()
}

fun continueShopping(navController: NavController) {
Timber.i("Continue shopping clicked, navigating to products")
navController.navigate(Screen.Products.route)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package com.shopify.checkoutkit.androiddemo.e2e

import android.os.Process
import com.shopify.checkoutkit.androiddemo.accessibility.AccessibilityIdentifiers
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber

/** Maps whether the SDK reported a ready preload cache hit to an identifier. */
object PreloadCacheHitMarker {
fun testId(observed: Boolean): String =
"${AccessibilityIdentifiers.PRELOAD_CACHE_HIT_PREFIX}${text(observed)}"

fun text(observed: Boolean): String = if (observed) "observed" else "none"
}

/** A Logcat stream plus the resources that must be closed to stop reading it. */
class LogStream(
val lines: Sequence<String>,
private val closeAction: () -> Unit = {},
) {
private val closed = AtomicBoolean(false)

fun close() {
if (closed.compareAndSet(false, true)) closeAction()
}
}

/**
* Watches this app's PID-scoped Logcat for a ready SDK cache hit.
*
* The SDK log sink is internal, so the sample reads its own logs instead of installing a logger.
* A unique boundary keeps buffered lines from an earlier app process outside this observation.
*/
class PreloadCacheHitLog(
private val observationBoundary: String = "$OBSERVATION_BOUNDARY_PREFIX${UUID.randomUUID()}",
private val openLines: () -> LogStream = ::followOwnLogcat,
private val writeBoundary: (String) -> Unit = { Timber.tag(OBSERVATION_TAG).d(it) },
private val isPreloadReady: () -> Boolean = { false },
private val reportError: (Throwable) -> Unit = {
Timber.e(it, "Failed to observe the preload cache-hit diagnostic")
},
) {
companion object {
/** Must stay in step with PreloadCache.kt, which logs this on a cache hit. */
const val DIAGNOSTIC = "Returning cached preloaded WebView."

private const val OBSERVATION_TAG = "PreloadObservability"
private const val OBSERVATION_BOUNDARY_PREFIX = "Observation started: "

/** Follows this process's SDK diagnostics plus the boundary that arms this observation. */
private fun followOwnLogcat(): LogStream {
val process = ProcessBuilder(
"logcat",
"-T",
"1",
"--pid=${Process.myPid()}",
"$OBSERVATION_TAG:D",
"PreloadCache:D",
"*:S",
).redirectErrorStream(true).start()
val reader = BufferedReader(InputStreamReader(process.inputStream))

return LogStream(reader.lineSequence()) {
process.destroy()
runCatching { reader.close() }
}
}
}
Comment on lines +46 to +80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you been able to do much testing against this to ensure it doesn't false positive?
I'm wondering if at app launch we can add a marker UUID that designates the start of logs to read from to avoid possible conflicts reading a stale cache hit marker


private val _observed = MutableStateFlow(false)
val observed: StateFlow<Boolean> = _observed.asStateFlow()

private val resourceLock = Any()
private var stream: LogStream? = null
private var job: Job? = null
private var closed = false
private var observationStarted = false

fun start(scope: CoroutineScope, dispatcher: CoroutineDispatcher = Dispatchers.IO): Job {
check(job == null) { "Preload cache-hit observation already started" }

return scope.launch(dispatcher) {
try {
val opened = openLines()
val shouldRead = synchronized(resourceLock) {
if (closed) {
false
} else {
stream = opened
true
}
}

if (!shouldRead) {
opened.close()
return@launch
}

writeBoundary(observationBoundary)
opened.lines.forEach(::record)
} catch (error: Exception) {
val shouldReport = synchronized(resourceLock) { !closed } && error !is CancellationException
if (shouldReport) reportError(error)
} finally {
closeStream()
}
}.also { job = it }
}

fun close() {
val opened = synchronized(resourceLock) {
if (closed) return
closed = true
stream.also { stream = null }
}

opened?.close()
job?.cancel()
}

fun record(line: String) {
if (!observationStarted) {
if (line.contains(observationBoundary)) observationStarted = true
return
}

if (line.contains(DIAGNOSTIC) && isPreloadReady()) {
_observed.value = true
}
}

private fun closeStream() {
val opened = synchronized(resourceLock) {
stream.also { stream = null }
}
opened?.close()
}
}
Loading
Loading