Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Android] AsyncHydrationTrackerPlugin #296

Merged
merged 11 commits into from
Mar 5, 2024
Original file line number Diff line number Diff line change
@@ -2,8 +2,11 @@ package com.intuit.playerui.android.reference.demo.lifecycle

import com.intuit.playerui.android.AndroidPlayer
import com.intuit.playerui.android.AndroidPlayer.Config
import com.intuit.playerui.android.asset.SuspendableAsset.AsyncHydrationTrackerPlugin
import com.intuit.playerui.android.asset.asyncHydrationTrackerPlugin
import com.intuit.playerui.android.lifecycle.PlayerViewModel
import com.intuit.playerui.android.reference.assets.ReferenceAssetsPlugin
import com.intuit.playerui.core.experimental.ExperimentalPlayerApi
import com.intuit.playerui.core.managed.AsyncFlowIterator
import com.intuit.playerui.core.player.state.PlayerFlowState
import com.intuit.playerui.plugins.transactions.PendingTransactionPlugin
@@ -18,6 +21,7 @@ class DemoPlayerViewModel(iterator: AsyncFlowIterator) : PlayerViewModel(iterato
CommonTypesPlugin(),
ReferenceAssetsPlugin(),
PendingTransactionPlugin(),
AsyncHydrationTrackerPlugin(),
)

override val config: Config = Config(
@@ -28,11 +32,16 @@ class DemoPlayerViewModel(iterator: AsyncFlowIterator) : PlayerViewModel(iterato

public val playerFlowState: StateFlow<PlayerFlowState?> = _playerFlowState.asStateFlow()

@OptIn(ExperimentalPlayerApi::class)
override fun apply(androidPlayer: AndroidPlayer) {
super.apply(androidPlayer)

androidPlayer.hooks.state.tap { state ->
_playerFlowState.tryEmit(state)
}

androidPlayer.asyncHydrationTrackerPlugin!!.hooks.onHydrationComplete.tap(this::class.java.name) {
androidPlayer.logger.info("Done hydrating!!!!")
}
}
}
Original file line number Diff line number Diff line change
@@ -8,10 +8,17 @@ import android.view.View
import android.view.View.OnAttachStateChangeListener
import android.view.ViewGroup
import androidx.core.view.children
import com.intuit.hooks.HookContext
import com.intuit.hooks.SyncHook
import com.intuit.playerui.android.AndroidPlayer
import com.intuit.playerui.android.AndroidPlayerPlugin
import com.intuit.playerui.android.AssetContext
import com.intuit.playerui.android.R
import com.intuit.playerui.android.asset.SuspendableAsset.AsyncHydrationTrackerPlugin
import com.intuit.playerui.core.experimental.ExperimentalPlayerApi
import com.intuit.playerui.core.player.PlayerException
import com.intuit.playerui.core.player.state.inProgressState
import com.intuit.playerui.core.plugins.findPlugin
import com.intuit.playerui.core.utils.InternalPlayerApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
@@ -30,11 +37,15 @@ public abstract class SuspendableAsset<Data>(assetContext: AssetContext, seriali
// To be launched in Dispatchers.Default
public abstract suspend fun initView(data: Data): View

final override fun initView(): View = AsyncViewStub(
hydrationScope,
hydrationScope.async { doInitView() },
requireContext(),
) { doHydrate(); player.cacheAssetView(assetContext, this) }
final override fun initView(): View {
// ensure we pre-track hydration to ensure all assets are accounted for during async hydration
player.asyncHydrationTrackerPlugin?.trackHydration(this@SuspendableAsset)
return AsyncViewStub(
hydrationScope,
hydrationScope.async { doInitView() },
requireContext(),
) { doHydrate(); player.cacheAssetView(assetContext, this) }
}

private suspend fun doInitView() = withContext(Dispatchers.Default) {
initView(getData()).apply { setTag(R.bool.view_hydrated, false) }
@@ -46,6 +57,7 @@ public abstract class SuspendableAsset<Data>(assetContext: AssetContext, seriali
final override fun View.hydrate() {
if (this is AsyncViewStub) return

player.asyncHydrationTrackerPlugin?.trackHydration(this@SuspendableAsset)
setTag(R.bool.view_hydrated, false)
hydrationScope.launch(Dispatchers.Main) { doHydrate() }
}
@@ -57,6 +69,58 @@ public abstract class SuspendableAsset<Data>(assetContext: AssetContext, seriali
} catch (exception: StaleViewException) {
// b/c we're launched in a scope that isn't cared about anymore, we can't appropriately handle this, so just fast fail
player.inProgressState?.fail(PlayerException("SuspendableAssets can't appropriately handle invalidateViews currently, this should be handled in a future major", exception))
} finally {
player.asyncHydrationTrackerPlugin?.hydrationDone(this@SuspendableAsset)
}
}

@ExperimentalPlayerApi
public class AsyncHydrationTrackerPlugin : AndroidPlayerPlugin {
private var trackedHydrations = mutableSetOf<String>()

public val hooks: Hooks = Hooks()

public fun trackHydration(asset: SuspendableAsset<*>) {
synchronized(trackedHydrations) {
if (trackedHydrations.isEmpty()) hooks.onHydrationStarted.call()
trackedHydrations.add(asset.assetContext.id)
}
}

public fun hydrationDone(asset: SuspendableAsset<*>) {
val doneHydrating = synchronized(trackedHydrations) {
trackedHydrations.remove(asset.assetContext.id)
trackedHydrations.isEmpty()
}

if (doneHydrating) {
hooks.onHydrationComplete.call()
}
}

override fun apply(androidPlayer: AndroidPlayer) {
androidPlayer.onUpdate { _, _ ->
synchronized(trackedHydrations) {
trackedHydrations.clear()
}
}
}

public class Hooks {
public class OnHydrationStartedHook : SyncHook<(HookContext) -> Unit>() {
public fun call(): Unit = super.call { f, context ->
f(context)
}
}

public class OnHydrationCompleteHook : SyncHook<(HookContext) -> Unit>() {
public fun call(): Unit = super.call { f, context ->
f(context)
}
}

public val onHydrationStarted: OnHydrationStartedHook = OnHydrationStartedHook()
public val onHydrationComplete: OnHydrationCompleteHook = OnHydrationCompleteHook()
}
}

@@ -141,3 +205,5 @@ public abstract class SuspendableAsset<Data>(assetContext: AssetContext, seriali
}
}
}

public val AndroidPlayer.asyncHydrationTrackerPlugin: AsyncHydrationTrackerPlugin? get() = findPlugin()
Original file line number Diff line number Diff line change
@@ -4,24 +4,29 @@ import android.widget.LinearLayout
import com.intuit.playerui.android.AndroidPlayer
import com.intuit.playerui.android.AssetContext
import com.intuit.playerui.android.asset.SuspendableAsset
import com.intuit.playerui.android.asset.SuspendableAsset.AsyncHydrationTrackerPlugin
import com.intuit.playerui.android.asset.SuspendableAsset.AsyncViewStub
import com.intuit.playerui.android.asset.asyncHydrationTrackerPlugin
import com.intuit.playerui.android.utils.NestedAsset
import com.intuit.playerui.android.utils.SimpleAsset
import com.intuit.playerui.android.utils.awaitFirstView
import com.intuit.playerui.core.experimental.ExperimentalPlayerApi
import com.intuit.playerui.core.flow.Flow
import com.intuit.playerui.core.player.state.InProgressState
import com.intuit.playerui.utils.test.runBlockingTest
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

internal class NestedAssetTest : BaseRenderableAssetTest() {

override val asset get() = NestedAsset.sampleAsset

override val player get() = AndroidPlayer(beaconPlugin).apply {
override val player get() = AndroidPlayer(beaconPlugin, AsyncHydrationTrackerPlugin()).apply {
registerAsset("simple", ::SimpleAsset)
registerAsset("nested", ::NestedAsset)
val inProgressState = mockk<InProgressState>()
@@ -52,4 +57,30 @@ internal class NestedAssetTest : BaseRenderableAssetTest() {
assertEquals(mockContext, it?.context)
} ?: Unit
}

@OptIn(ExperimentalPlayerApi::class)
@Test
fun `test hydration tracker`() = runBlockingTest {
val player = player
val plugin = player.asyncHydrationTrackerPlugin!!
var onHydrationStarted = false
plugin.hooks.onHydrationStarted.tap("test") {
onHydrationStarted = true
}
var onHydrationCompleted = false
plugin.hooks.onHydrationComplete.tap("test") {
onHydrationCompleted = true
}

val asset = player.awaitFirstView(NestedAsset.sampleFlow) as NestedAsset

assertFalse(onHydrationStarted)
assertFalse(onHydrationCompleted)
val view = asset.render(mockContext) as AsyncViewStub
assertTrue(onHydrationStarted)
assertFalse(onHydrationCompleted)
view.awaitView()
assertTrue(onHydrationStarted)
assertTrue(onHydrationCompleted)
}
}
Original file line number Diff line number Diff line change
@@ -2,7 +2,6 @@ package com.intuit.playerui.plugins.beacon

import com.intuit.playerui.core.asset.Asset
import com.intuit.playerui.core.bridge.getInvokable
import com.intuit.playerui.core.bridge.runtime
import com.intuit.playerui.core.bridge.runtime.Runtime
import com.intuit.playerui.core.bridge.runtime.ScriptContext
import com.intuit.playerui.core.bridge.runtime.add
@@ -13,7 +12,6 @@ import com.intuit.playerui.core.plugins.JSScriptPluginWrapper
import com.intuit.playerui.core.plugins.Pluggable
import com.intuit.playerui.core.plugins.findPlugin
import com.intuit.playerui.plugins.settimeout.SetTimeoutPlugin
import kotlinx.coroutines.launch
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -57,18 +55,17 @@ public class BeaconPlugin(override val plugins: List<JSPluginWrapper>) : JSScrip
handlers.add(handler)
}

// TODO: Convert to suspend method to ensure view scope is captured in a non-blocking way
/** Fire a beacon event */
public fun beacon(action: String, element: String, asset: Asset, data: Any? = null) {
runtime.scope.launch {
instance.getInvokable<Any?>("beacon")!!.invoke(
mapOf(
"action" to action,
"element" to element,
"asset" to asset,
"data" to data,
),
)
}
instance.getInvokable<Any?>("beacon")!!.invoke(
mapOf(
"action" to action,
"element" to element,
"asset" to asset,
"data" to data,
),
)
}

private companion object {