diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt
index 525566b365..4da11616cf 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt
@@ -15,9 +15,6 @@
*/
package app.cash.redwood.treehouse
-import kotlin.coroutines.CoroutineContext
-import kotlinx.coroutines.CoroutineExceptionHandler
-
/** Manages loading and hot-reloading a series of code sessions. */
internal interface CodeHost {
val stateStore: StateStore
@@ -25,39 +22,11 @@ internal interface CodeHost {
/** Only accessed on [TreehouseDispatchers.ui]. */
val session: CodeSession?
- fun newServiceScope(): ServiceScope
-
- /** Cancels the current code and propagates [exception] to all listeners. */
- fun handleUncaughtException(exception: Throwable)
-
fun addListener(listener: Listener)
fun removeListener(listener: Listener)
interface Listener {
fun codeSessionChanged(next: CodeSession)
- fun uncaughtException(exception: Throwable)
- }
-
- /**
- * Tracks all of the services created to produce a UI, and offers a single mechanism to close
- * them all. Note that closing this does not close the app services it was applied to.
- */
- interface ServiceScope {
- /**
- * Returns a new instance that forwards calls to [appService] and keeps track of returned
- * instances so they may be closed.
- */
- fun apply(appService: A): A
- fun close()
- }
-}
-
-internal fun CodeHost<*>.asExceptionHandler() = object : CoroutineExceptionHandler {
- override val key: CoroutineContext.Key<*>
- get() = CoroutineExceptionHandler.Key
-
- override fun handleException(context: CoroutineContext, exception: Throwable) {
- handleUncaughtException(exception)
}
}
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeSession.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeSession.kt
index 5193d5a139..9d7f374b32 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeSession.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeSession.kt
@@ -15,6 +15,9 @@
*/
package app.cash.redwood.treehouse
+import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.json.Json
/** The host state for a single code load. We get a new session each time we get new code. */
@@ -23,7 +26,44 @@ internal interface CodeSession {
val json: Json
- fun start()
+ fun start(sessionScope: CoroutineScope)
+
+ fun addListener(listener: Listener)
+
+ fun removeListener(listener: Listener)
+
+ fun newServiceScope(): ServiceScope
+
+ /** Propagates [exception] to all listeners and cancels this session. */
+ fun handleUncaughtException(exception: Throwable)
fun cancel()
+
+ /**
+ * Tracks all of the services created to produce a UI, and offers a single mechanism to close
+ * them all. Note that closing this does not close the app services it was applied to.
+ */
+ interface ServiceScope {
+ /**
+ * Returns a new instance that forwards calls to [appService] and keeps track of returned
+ * instances so they may be closed.
+ */
+ fun apply(appService: A): A
+ fun close()
+ }
+
+ interface Listener {
+ fun onUncaughtException(codeSession: CodeSession, exception: Throwable)
+ fun onCancel(codeSession: CodeSession)
+ }
}
+
+internal val CodeSession<*>.coroutineExceptionHandler: CoroutineExceptionHandler
+ get() = object : CoroutineExceptionHandler {
+ override val key: CoroutineContext.Key<*>
+ get() = CoroutineExceptionHandler.Key
+
+ override fun handleException(context: CoroutineContext, exception: Throwable) {
+ handleUncaughtException(exception)
+ }
+ }
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt
index 96e6151109..742387286d 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt
@@ -16,16 +16,12 @@
package app.cash.redwood.treehouse
import app.cash.zipline.Zipline
-import app.cash.zipline.ZiplineScope
import app.cash.zipline.loader.LoadResult
import app.cash.zipline.loader.ManifestVerifier
import app.cash.zipline.loader.ZiplineCache
import app.cash.zipline.loader.ZiplineHttpClient
import app.cash.zipline.loader.ZiplineLoader
-import app.cash.zipline.withScope
import kotlin.native.ObjCName
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
@@ -50,8 +46,7 @@ public class TreehouseApp private constructor(
) {
private val codeHost = ZiplineCodeHost()
- public val dispatchers: TreehouseDispatchers =
- TreehouseDispatchersWithExceptionHandler(factory.dispatchers, codeHost.asExceptionHandler())
+ public val dispatchers: TreehouseDispatchers = factory.dispatchers
private val eventPublisher = RealEventPublisher(factory.eventListener, this)
@@ -123,14 +118,10 @@ public class TreehouseApp private constructor(
* Continuously polls for updated code, and emits a new [LoadResult] instance when new code is
* found.
*/
- @OptIn(ExperimentalStdlibApi::class)
private fun ziplineFlow(): Flow {
- val dispatcher = dispatchers.zipline[CoroutineDispatcher.Key]
- ?: error("expected TreehouseDispatchers.zipline to include a CoroutineDispatcher")
-
// Loads applications from the network only. The cache is neither read nor written.
var loader = ZiplineLoader(
- dispatcher = dispatcher,
+ dispatcher = dispatchers.zipline,
manifestVerifier = factory.manifestVerifier,
httpClient = factory.httpClient,
eventListener = eventPublisher.ziplineEventListener,
@@ -179,20 +170,14 @@ public class TreehouseApp private constructor(
closed = true
appScope.launch(dispatchers.ui) {
val session = codeHost.session ?: return@launch
+ session.removeListener(codeHost)
session.cancel()
codeHost.session = null
}
eventPublisher.appCanceled()
}
- private class TreehouseDispatchersWithExceptionHandler(
- val delegate: TreehouseDispatchers,
- exceptionHandler: CoroutineExceptionHandler,
- ) : TreehouseDispatchers by delegate {
- override val zipline = delegate.zipline + exceptionHandler
- }
-
- private inner class ZiplineCodeHost : CodeHost {
+ private inner class ZiplineCodeHost : CodeHost, CodeSession.Listener {
/**
* Contents that this app is currently responsible for.
*
@@ -204,20 +189,6 @@ public class TreehouseApp private constructor(
override var session: ZiplineCodeSession? = null
- override fun newServiceScope(): CodeHost.ServiceScope {
- val ziplineScope = ZiplineScope()
-
- return object : CodeHost.ServiceScope {
- override fun apply(appService: A): A {
- return appService.withScope(ziplineScope)
- }
-
- override fun close() {
- ziplineScope.close()
- }
- }
- }
-
override fun addListener(listener: CodeHost.Listener) {
dispatchers.checkUi()
listeners += listener
@@ -228,47 +199,40 @@ public class TreehouseApp private constructor(
listeners -= listener
}
- override fun handleUncaughtException(exception: Throwable) {
- appScope.launch(dispatchers.ui) {
- for (listener in listeners) {
- listener.uncaughtException(exception)
- }
- codeHost.session?.cancel()
- codeHost.session = null
- }
+ override fun onUncaughtException(codeSession: CodeSession, exception: Throwable) {
+ }
- eventPublisher.onUncaughtException(exception)
+ override fun onCancel(codeSession: CodeSession) {
+ check(codeSession == this.session)
+ this.session = null
}
fun onCodeChanged(zipline: Zipline, appService: A) {
- val sessionScope = CoroutineScope(SupervisorJob(appScope.coroutineContext.job))
+ val next = ZiplineCodeSession(
+ dispatchers = dispatchers,
+ eventPublisher = eventPublisher,
+ appScope = appScope,
+ frameClock = factory.frameClock,
+ appService = appService,
+ zipline = zipline,
+ )
+
+ val sessionScope = CoroutineScope(
+ SupervisorJob(appScope.coroutineContext.job) + next.coroutineExceptionHandler,
+ )
+
sessionScope.launch(dispatchers.ui) {
val previous = session
+ previous?.removeListener(this@ZiplineCodeHost)
+ previous?.cancel()
- val next = ZiplineCodeSession(
- codeHost = this@ZiplineCodeHost,
- dispatchers = dispatchers,
- eventPublisher = eventPublisher,
- appScope = appScope,
- frameClock = factory.frameClock,
- sessionScope = sessionScope,
- appService = appService,
- zipline = zipline,
- )
-
- next.start()
+ session = next
+ next.addListener(this@ZiplineCodeHost)
+ next.start(sessionScope)
for (listener in listeners) {
listener.codeSessionChanged(next)
}
-
- if (previous != null) {
- sessionScope.launch(dispatchers.zipline) {
- previous.cancel()
- }
- }
-
- session = next
}
}
}
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt
index d1a8ef1096..eaa242be63 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt
@@ -35,7 +35,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
-import kotlinx.serialization.json.Json
private class State(
val viewState: ViewState,
@@ -75,7 +74,7 @@ internal class TreehouseAppContent(
private val eventPublisher: EventPublisher,
private val codeListener: CodeListener,
private val source: TreehouseContentSource,
-) : Content, CodeHost.Listener {
+) : Content, CodeHost.Listener, CodeSession.Listener {
private val stateFlow = MutableStateFlow>(State(ViewState.None, CodeState.Idle()))
override fun preload(
@@ -176,7 +175,9 @@ internal class TreehouseAppContent(
// Cancel the code if necessary.
codeHost.removeListener(this)
if (previousState.codeState is CodeState.Running) {
- previousState.codeState.viewContentCodeBinding.cancel()
+ val binding = previousState.codeState.viewContentCodeBinding
+ binding.cancel()
+ binding.codeSession.removeListener(this)
}
stateFlow.value = State(nextViewState, nextCodeState)
@@ -217,7 +218,9 @@ internal class TreehouseAppContent(
// If we replaced an old binding, cancel that old binding.
if (previousCodeState is CodeState.Running) {
- previousCodeState.viewContentCodeBinding.cancel()
+ val binding = previousCodeState.viewContentCodeBinding
+ binding.cancel()
+ binding.codeSession.removeListener(this)
}
stateFlow.value = State(viewState, nextCodeState)
@@ -227,18 +230,20 @@ internal class TreehouseAppContent(
* If the code crashes, show an error on the UI and cancel the UI binding. This sets the code
* state back to idle.
*/
- override fun uncaughtException(exception: Throwable) {
+ override fun onUncaughtException(codeSession: CodeSession, exception: Throwable) {
dispatchers.checkUi()
val previousState = stateFlow.value
val viewState = previousState.viewState
val previousCodeState = previousState.codeState
- // If there wasn't code running, there's nothing to do.
- if (previousCodeState !is CodeState.Running) return
+ // This listener should only fire if we're actively running code.
+ require(previousCodeState is CodeState.Running)
// Cancel the UI binding to the crashed code.
- previousCodeState.viewContentCodeBinding.cancel()
+ val binding = previousCodeState.viewContentCodeBinding
+ binding.cancel()
+ binding.codeSession.removeListener(this)
// If there's a UI, give it the error to display.
val view = (viewState as? ViewState.Bound)?.view
@@ -250,6 +255,9 @@ internal class TreehouseAppContent(
stateFlow.value = State(viewState, nextCodeState)
}
+ override fun onCancel(codeSession: CodeSession) {
+ }
+
/** This function may only be invoked on [TreehouseDispatchers.ui]. */
private fun startViewCodeContentBinding(
codeSession: CodeSession,
@@ -258,21 +266,22 @@ internal class TreehouseAppContent(
firstUiConfiguration: StateFlow,
): ViewContentCodeBinding {
dispatchers.checkUi()
+ codeSession.addListener(this)
return ViewContentCodeBinding(
- codeHost = codeHost,
+ stateStore = codeHost.stateStore,
dispatchers = dispatchers,
appScope = appScope,
eventPublisher = eventPublisher,
contentSource = source,
codeListener = codeListener,
stateFlow = stateFlow,
+ codeSession = codeSession,
isInitialLaunch = isInitialLaunch,
- json = codeSession.json,
onBackPressedDispatcher = onBackPressedDispatcher,
firstUiConfiguration = firstUiConfiguration,
).apply {
- start(codeSession)
+ start()
}
}
}
@@ -290,21 +299,23 @@ internal class TreehouseAppContent(
* binding.
*/
private class ViewContentCodeBinding(
- val codeHost: CodeHost,
+ val stateStore: StateStore,
val dispatchers: TreehouseDispatchers,
val appScope: CoroutineScope,
val eventPublisher: EventPublisher,
val contentSource: TreehouseContentSource,
val codeListener: CodeListener,
val stateFlow: MutableStateFlow>,
+ val codeSession: CodeSession,
private val isInitialLaunch: Boolean,
- private val json: Json,
private val onBackPressedDispatcher: OnBackPressedDispatcher,
firstUiConfiguration: StateFlow,
) : EventSink, ChangesSinkService, TreehouseView.SaveCallback, ZiplineTreehouseUi.Host {
private val uiConfigurationFlow = SequentialStateFlow(firstUiConfiguration)
- private val bindingScope = CoroutineScope(SupervisorJob(appScope.coroutineContext.job))
+ private val bindingScope = CoroutineScope(
+ SupervisorJob(appScope.coroutineContext.job) + codeSession.coroutineExceptionHandler,
+ )
/** Only accessed on [TreehouseDispatchers.ui]. Null before [initView] and after [cancel]. */
private var viewOrNull: TreehouseView<*>? = null
@@ -313,7 +324,7 @@ private class ViewContentCodeBinding(
private var bridgeOrNull: ProtocolBridge<*>? = null
/** Only accessed on [TreehouseDispatchers.zipline]. */
- private val serviceScope = codeHost.newServiceScope()
+ private val serviceScope = codeSession.newServiceScope()
/** Only accessed on [TreehouseDispatchers.zipline]. Null after [cancel]. */
private var treehouseUiOrNull: ZiplineTreehouseUi? = null
@@ -351,7 +362,7 @@ private class ViewContentCodeBinding(
bridgeOrNull = ProtocolBridge(
container = view.children as Widget.Children,
factory = view.widgetSystem.widgetFactory(
- json = json,
+ json = codeSession.json,
protocolMismatchHandler = eventPublisher.widgetProtocolMismatchHandler,
) as ProtocolNodeFactory,
eventSink = this,
@@ -416,13 +427,13 @@ private class ViewContentCodeBinding(
bridge.sendChanges(changes)
}
- fun start(session: CodeSession) {
+ fun start() {
bindingScope.launch(dispatchers.zipline) {
- val scopedAppService = serviceScope.apply(session.appService)
+ val scopedAppService = serviceScope.apply(codeSession.appService)
val treehouseUi = contentSource.get(scopedAppService)
treehouseUiOrNull = treehouseUi
stateSnapshot = viewOrNull?.stateSnapshotId?.let {
- codeHost.stateStore.get(it.value.orEmpty())
+ stateStore.get(it.value.orEmpty())
}
try {
treehouseUi.start(this@ViewContentCodeBinding)
@@ -464,9 +475,9 @@ private class ViewContentCodeBinding(
}
override fun performSave(id: String) {
- appScope.launch(dispatchers.zipline) {
+ bindingScope.launch(dispatchers.zipline) {
val state = treehouseUiOrNull?.snapshotState() ?: return@launch
- codeHost.stateStore.put(id, state)
+ stateStore.put(id, state)
}
}
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseDispatchers.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseDispatchers.kt
index 3fdf2753e3..caa7d33f4b 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseDispatchers.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseDispatchers.kt
@@ -15,8 +15,8 @@
*/
package app.cash.redwood.treehouse
-import kotlin.coroutines.CoroutineContext
import kotlin.native.ObjCName
+import kotlinx.coroutines.CoroutineDispatcher
/**
* One of the trickiest things Treehouse needs to do is balance its two dispatchers:
@@ -28,11 +28,9 @@ import kotlin.native.ObjCName
*/
@ObjCName("TreehouseDispatchers", exact = true)
public interface TreehouseDispatchers {
- /** Must contain a non-null [kotlinx.coroutines.CoroutineDispatcher]. */
- public val ui: CoroutineContext
+ public val ui: CoroutineDispatcher
- /** Must contain a non-null [kotlinx.coroutines.CoroutineDispatcher]. */
- public val zipline: CoroutineContext
+ public val zipline: CoroutineDispatcher
/**
* Confirm that this is being called on the UI thread.
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineCodeSession.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineCodeSession.kt
index a10df125d2..950f672270 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineCodeSession.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineCodeSession.kt
@@ -18,6 +18,7 @@ package app.cash.redwood.treehouse
import app.cash.redwood.protocol.EventTag
import app.cash.redwood.protocol.Id
import app.cash.redwood.protocol.WidgetTag
+import app.cash.redwood.treehouse.CodeSession.Listener
import app.cash.zipline.Zipline
import app.cash.zipline.ZiplineScope
import app.cash.zipline.withScope
@@ -27,45 +28,68 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
internal class ZiplineCodeSession(
- private val codeHost: CodeHost<*>,
private val dispatchers: TreehouseDispatchers,
private val eventPublisher: EventPublisher,
private val appScope: CoroutineScope,
private val frameClock: FrameClock,
- private val sessionScope: CoroutineScope,
override val appService: A,
val zipline: Zipline,
-) : CodeSession {
+) : CodeSession, AppLifecycle.Host {
+ private val listeners = mutableListOf>()
private val ziplineScope = ZiplineScope()
override val json: Json
get() = zipline.json
- override fun start() {
- frameClock.start(sessionScope, dispatchers)
+ /** Only accessed on [TreehouseDispatchers.zipline]. */
+ private lateinit var sessionScope: CoroutineScope
+
+ /** Only accessed on [TreehouseDispatchers.zipline]. */
+ private lateinit var appLifecycle: AppLifecycle
+
+ private var canceled = false
+
+ override fun start(sessionScope: CoroutineScope) {
+ dispatchers.checkUi()
sessionScope.launch(dispatchers.zipline) {
- val appLifecycle = appService.withScope(ziplineScope).appLifecycle
- val host = RealAppLifecycleHost(codeHost, appLifecycle, eventPublisher, frameClock)
- appLifecycle.start(host)
+ this@ZiplineCodeSession.sessionScope = sessionScope
+
+ frameClock.start(sessionScope, dispatchers)
+
+ val service = appService.withScope(ziplineScope).appLifecycle
+ appLifecycle = service
+ service.start(this@ZiplineCodeSession)
}
}
+ override fun addListener(listener: Listener) {
+ dispatchers.checkUi()
+ listeners += listener
+ }
+
+ override fun removeListener(listener: Listener) {
+ dispatchers.checkUi()
+ listeners -= listener
+ }
+
override fun cancel() {
+ if (canceled) return
+ canceled = true
+
+ dispatchers.checkUi()
+
+ val listenersArray = listeners.toTypedArray() // onCancel mutates.
+ for (listener in listenersArray) {
+ listener.onCancel(this)
+ }
+
appScope.launch(dispatchers.zipline) {
sessionScope.cancel()
ziplineScope.close()
zipline.close()
}
}
-}
-/** Platform features to the guest application. */
-private class RealAppLifecycleHost(
- val codeHost: CodeHost<*>,
- val appLifecycle: AppLifecycle,
- val eventPublisher: EventPublisher,
- val frameClock: FrameClock,
-) : AppLifecycle.Host {
override fun requestFrame() {
frameClock.requestFrame(appLifecycle)
}
@@ -85,6 +109,28 @@ private class RealAppLifecycleHost(
}
override fun handleUncaughtException(exception: Throwable) {
- codeHost.handleUncaughtException(exception)
+ appScope.launch(dispatchers.ui) {
+ val listenersArray = listeners.toTypedArray() // onUncaughtException mutates.
+ for (listener in listenersArray) {
+ listener.onUncaughtException(this@ZiplineCodeSession, exception)
+ }
+ this@ZiplineCodeSession.cancel()
+ }
+
+ eventPublisher.onUncaughtException(exception)
+ }
+
+ override fun newServiceScope(): CodeSession.ServiceScope {
+ val ziplineScope = ZiplineScope()
+
+ return object : CodeSession.ServiceScope {
+ override fun apply(appService: A): A {
+ return appService.withScope(ziplineScope)
+ }
+
+ override fun close() {
+ ziplineScope.close()
+ }
+ }
}
}
diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt
index 23b07c5f5b..7cf875a1b9 100644
--- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt
+++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt
@@ -15,58 +15,60 @@
*/
package app.cash.redwood.treehouse
-internal class FakeCodeHost : CodeHost {
+import app.cash.redwood.treehouse.CodeHost.Listener
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlinx.coroutines.CoroutineScope
+
+internal class FakeCodeHost(
+ private val eventLog: EventLog,
+) : CodeHost {
override val stateStore = MemoryStateStore()
+ private val codeSessionListener = object : CodeSession.Listener {
+ override fun onUncaughtException(
+ codeSession: CodeSession,
+ exception: Throwable,
+ ) {
+ }
+
+ override fun onCancel(
+ codeSession: CodeSession,
+ ) {
+ check(codeSession == this@FakeCodeHost.session)
+ this@FakeCodeHost.session = null
+ }
+ }
+
override var session: CodeSession? = null
set(value) {
val previous = field
+ previous?.removeListener(codeSessionListener)
previous?.cancel()
if (value != null) {
- value.start()
+ value.start(CoroutineScope(EmptyCoroutineContext))
for (listener in listeners) {
listener.codeSessionChanged(value)
}
}
+ value?.addListener(codeSessionListener)
field = value
}
- private val listeners = mutableListOf>()
+ private val listeners = mutableListOf>()
- override fun newServiceScope(): CodeHost.ServiceScope {
- return object : CodeHost.ServiceScope {
- val uisToClose = mutableListOf()
-
- override fun apply(appService: FakeAppService): FakeAppService {
- return appService.withListener(object : FakeAppService.Listener {
- override fun onNewUi(ui: ZiplineTreehouseUi) {
- uisToClose += ui
- }
- })
- }
-
- override fun close() {
- for (ui in uisToClose) {
- ui.close()
- }
- }
- }
- }
-
- override fun handleUncaughtException(exception: Throwable) {
- for (listener in listeners) {
- listener.uncaughtException(exception)
- }
- session = null
+ fun startCodeSession(name: String): CodeSession {
+ val result = FakeCodeSession(eventLog, name)
+ session = result
+ return result
}
- override fun addListener(listener: CodeHost.Listener) {
+ override fun addListener(listener: Listener) {
listeners += listener
}
- override fun removeListener(listener: CodeHost.Listener) {
+ override fun removeListener(listener: Listener) {
listeners -= listener
}
}
diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeSession.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeSession.kt
index 2e158a1a31..fced2e625c 100644
--- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeSession.kt
+++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeSession.kt
@@ -15,21 +15,72 @@
*/
package app.cash.redwood.treehouse
+import app.cash.redwood.treehouse.CodeSession.Listener
+import app.cash.redwood.treehouse.CodeSession.ServiceScope
+import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.json.Json
internal class FakeCodeSession(
- private val name: String,
private val eventLog: EventLog,
+ private val name: String,
) : CodeSession {
+ private val listeners = mutableListOf>()
+
override val json = Json
override val appService = FakeAppService("$name.app", eventLog)
- override fun start() {
+ private var canceled = false
+
+ override fun start(sessionScope: CoroutineScope) {
eventLog += "$name.start()"
}
+ override fun addListener(listener: Listener) {
+ listeners += listener
+ }
+
+ override fun removeListener(listener: Listener) {
+ listeners -= listener
+ }
+
+ override fun handleUncaughtException(exception: Throwable) {
+ val listenersArray = listeners.toTypedArray() // onUncaughtException mutates.
+ for (listener in listenersArray) {
+ listener.onUncaughtException(this, exception)
+ }
+ cancel()
+ }
+
+ override fun newServiceScope(): ServiceScope {
+ return object : ServiceScope {
+ val uisToClose = mutableListOf()
+
+ override fun apply(appService: FakeAppService): FakeAppService {
+ return appService.withListener(object : FakeAppService.Listener {
+ override fun onNewUi(ui: ZiplineTreehouseUi) {
+ uisToClose += ui
+ }
+ })
+ }
+
+ override fun close() {
+ for (ui in uisToClose) {
+ ui.close()
+ }
+ }
+ }
+ }
+
override fun cancel() {
+ if (canceled) return
+ canceled = true
+
+ val listenersArray = listeners.toTypedArray() // onCancel mutates.
+ for (listener in listenersArray) {
+ listener.onCancel(this)
+ }
+
eventLog += "$name.cancel()"
}
}
diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt
index 20579a584e..376805eead 100644
--- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt
+++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt
@@ -33,7 +33,7 @@ class TreehouseAppContentTest {
private val eventLog = EventLog()
private val dispatcher = UnconfinedTestDispatcher()
- private val codeHost = FakeCodeHost()
+ private val codeHost = FakeCodeHost(eventLog)
private val dispatchers = FakeDispatchers(dispatcher, dispatcher)
private val eventPublisher = FakeEventPublisher()
private val codeListener = FakeCodeListener(eventLog)
@@ -52,11 +52,11 @@ class TreehouseAppContentTest {
content.bind(view1)
eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)")
- codeHost.session = FakeCodeSession("codeSessionA", eventLog)
+ val codeSessionA = codeHost.startCodeSession("codeSessionA")
eventLog.takeEvent("codeSessionA.start()")
eventLog.takeEvent("codeSessionA.app.uis[0].start()")
- codeHost.session!!.appService.uis.single().addWidget("hello")
+ codeSessionA.appService.uis.single().addWidget("hello")
eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)")
assertThat(view1.children.single().value.label).isEqualTo("hello")
@@ -71,12 +71,12 @@ class TreehouseAppContentTest {
content.preload(FakeOnBackPressedDispatcher(), uiConfiguration)
eventLog.assertNoEvents()
- codeHost.session = FakeCodeSession("codeSessionA", eventLog)
+ val codeSessionA = codeHost.startCodeSession("codeSessionA")
eventLog.takeEvent("codeSessionA.start()")
eventLog.takeEvent("codeSessionA.app.uis[0].start()")
// Guest code can add widgets before a TreehouseView is bound!
- codeHost.session!!.appService.uis.single().addWidget("hello")
+ codeSessionA.appService.uis.single().addWidget("hello")
eventLog.assertNoEvents()
val view1 = FakeTreehouseView("view1")
@@ -91,7 +91,7 @@ class TreehouseAppContentTest {
fun session_preload_bind_addWidget_unbind() = runTest {
val content = treehouseAppContent()
- codeHost.session = FakeCodeSession("codeSessionA", eventLog)
+ val codeSessionA = codeHost.startCodeSession("codeSessionA")
eventLog.takeEvent("codeSessionA.start()")
content.preload(FakeOnBackPressedDispatcher(), uiConfiguration)
@@ -101,7 +101,7 @@ class TreehouseAppContentTest {
content.bind(view1)
eventLog.assertNoEvents()
- codeHost.session!!.appService.uis.single().addWidget("hello")
+ codeSessionA.appService.uis.single().addWidget("hello")
eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)")
assertThat(view1.children.single().value.label).isEqualTo("hello")
@@ -113,14 +113,14 @@ class TreehouseAppContentTest {
fun session_bind_addWidget_unbind() = runTest {
val content = treehouseAppContent()
- codeHost.session = FakeCodeSession("codeSessionA", eventLog)
+ val codeSessionA = codeHost.startCodeSession("codeSessionA")
eventLog.takeEvent("codeSessionA.start()")
val view1 = FakeTreehouseView("view1")
content.bind(view1)
eventLog.takeEvent("codeSessionA.app.uis[0].start()")
- codeHost.session!!.appService.uis.single().addWidget("hello")
+ codeSessionA.appService.uis.single().addWidget("hello")
eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)")
assertThat(view1.children.single().value.label).isEqualTo("hello")
@@ -137,15 +137,13 @@ class TreehouseAppContentTest {
content.bind(view1)
eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)")
- val codeSessionA = FakeCodeSession("codeSessionA", eventLog)
- codeHost.session = codeSessionA
+ val codeSessionA = codeHost.startCodeSession("codeSessionA")
codeSessionA.appService.uis.single().addWidget("helloA")
eventLog.takeEvent("codeSessionA.start()")
eventLog.takeEvent("codeSessionA.app.uis[0].start()")
eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)")
- val codeSessionB = FakeCodeSession("codeSessionB", eventLog)
- codeHost.session = codeSessionB
+ val codeSessionB = codeHost.startCodeSession("codeSessionB")
eventLog.takeEvent("codeSessionA.cancel()")
eventLog.takeEvent("codeSessionB.start()")
eventLog.takeEvent("codeSessionB.app.uis[0].start()")
@@ -174,7 +172,7 @@ class TreehouseAppContentTest {
eventLog.assertNoEvents()
// Code that arrives after a preloaded UI unbinds doesn't do anything.
- codeHost.session = FakeCodeSession("codeSessionA", eventLog)
+ codeHost.startCodeSession("codeSessionA")
eventLog.takeEvent("codeSessionA.start()")
}
@@ -190,7 +188,7 @@ class TreehouseAppContentTest {
eventLog.assertNoEvents()
// Code that arrives after a bound UI unbinds doesn't do anything.
- codeHost.session = FakeCodeSession("codeSessionA", eventLog)
+ codeHost.startCodeSession("codeSessionA")
eventLog.takeEvent("codeSessionA.start()")
}
@@ -202,14 +200,14 @@ class TreehouseAppContentTest {
fun session_bind_addWidget_unbind_bind_unbind() = runTest {
val content = treehouseAppContent()
- codeHost.session = FakeCodeSession("codeSessionA", eventLog)
+ val codeSessionA = codeHost.startCodeSession("codeSessionA")
eventLog.takeEvent("codeSessionA.start()")
val view1 = FakeTreehouseView("view1")
content.bind(view1)
eventLog.takeEvent("codeSessionA.app.uis[0].start()")
- codeHost.session!!.appService.uis.single().addWidget("helloA")
+ codeSessionA.appService.uis.single().addWidget("helloA")
eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)")
assertThat(view1.children.single().value.label).isEqualTo("helloA")
@@ -219,7 +217,7 @@ class TreehouseAppContentTest {
content.bind(view1)
eventLog.takeEvent("codeSessionA.app.uis[1].start()")
- codeHost.session!!.appService.uis.last().addWidget("helloB")
+ codeSessionA.appService.uis.last().addWidget("helloB")
eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)")
assertThat(view1.children.single().value.label).isEqualTo("helloB")
@@ -233,10 +231,10 @@ class TreehouseAppContentTest {
val view1 = FakeTreehouseView("view1")
content.bind(view1)
- codeHost.session = FakeCodeSession("codeSessionA", eventLog)
+ val codeSessionA = codeHost.startCodeSession("codeSessionA")
eventLog.clear()
- val backCancelable = codeHost.session!!.appService.uis.single().addBackHandler(true)
+ val backCancelable = codeSessionA.appService.uis.single().addBackHandler(true)
view1.onBackPressedDispatcher.onBack()
eventLog.takeEvent("codeSessionA.app.uis[0].onBackPressed()")
@@ -257,10 +255,10 @@ class TreehouseAppContentTest {
val view1 = FakeTreehouseView("view1")
content.bind(view1)
- codeHost.session = FakeCodeSession("codeSessionA", eventLog)
+ val codeSessionA = codeHost.startCodeSession("codeSessionA")
eventLog.clear()
- val backCancelable = codeHost.session!!.appService.uis.single().addBackHandler(false)
+ val backCancelable = codeSessionA.appService.uis.single().addBackHandler(false)
view1.onBackPressedDispatcher.onBack()
eventLog.assertNoEvents()
@@ -276,14 +274,12 @@ class TreehouseAppContentTest {
val view1 = FakeTreehouseView("view1")
content.bind(view1)
- val codeSessionA = FakeCodeSession("codeSessionA", eventLog)
- codeHost.session = codeSessionA
+ val codeSessionA = codeHost.startCodeSession("codeSessionA")
codeSessionA.appService.uis.single().addBackHandler(true)
assertThat(view1.onBackPressedDispatcher.callbacks).isNotEmpty()
- val codeSessionB = FakeCodeSession("codeSessionB", eventLog)
- codeHost.session = codeSessionB
+ codeHost.startCodeSession("codeSessionB")
// When we close codeSessionA, its back handlers are released with it.
assertThat(view1.onBackPressedDispatcher.callbacks).isEmpty()
@@ -297,14 +293,14 @@ class TreehouseAppContentTest {
fun session_bind_triggerException() = runTest {
val content = treehouseAppContent()
- codeHost.session = FakeCodeSession("codeSessionA", eventLog)
+ val codeSessionA = codeHost.startCodeSession("codeSessionA")
eventLog.takeEvent("codeSessionA.start()")
val view1 = FakeTreehouseView("view1")
content.bind(view1)
eventLog.takeEvent("codeSessionA.app.uis[0].start()")
- codeHost.handleUncaughtException(Exception("boom!"))
+ codeSessionA.handleUncaughtException(Exception("boom!"))
eventLog.takeEventsInAnyOrder(
"codeSessionA.app.uis[0].close()",
"codeListener.onUncaughtException(view1, kotlin.Exception: boom!)",
@@ -318,17 +314,17 @@ class TreehouseAppContentTest {
fun triggerException_bind_session() = runTest {
val content = treehouseAppContent()
- codeHost.session = FakeCodeSession("codeSessionA", eventLog)
+ val codeSessionA = codeHost.startCodeSession("codeSessionA")
eventLog.takeEvent("codeSessionA.start()")
- codeHost.handleUncaughtException(Exception("boom!"))
+ codeSessionA.handleUncaughtException(Exception("boom!"))
eventLog.takeEvent("codeSessionA.cancel()")
val view1 = FakeTreehouseView("view1")
content.bind(view1)
eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)")
- codeHost.session = FakeCodeSession("codeSessionB", eventLog)
+ codeHost.startCodeSession("codeSessionB")
eventLog.takeEvent("codeSessionB.start()")
eventLog.takeEvent("codeSessionB.app.uis[0].start()")
@@ -340,21 +336,21 @@ class TreehouseAppContentTest {
fun sessionA_bind_triggerException_sessionB() = runTest {
val content = treehouseAppContent()
- codeHost.session = FakeCodeSession("codeSessionA", eventLog)
+ val codeSessionA = codeHost.startCodeSession("codeSessionA")
eventLog.takeEvent("codeSessionA.start()")
val view1 = FakeTreehouseView("view1")
content.bind(view1)
eventLog.takeEvent("codeSessionA.app.uis[0].start()")
- codeHost.handleUncaughtException(Exception("boom!"))
+ codeSessionA.handleUncaughtException(Exception("boom!"))
eventLog.takeEventsInAnyOrder(
"codeSessionA.app.uis[0].close()",
"codeListener.onUncaughtException(view1, kotlin.Exception: boom!)",
"codeSessionA.cancel()",
)
- codeHost.session = FakeCodeSession("codeSessionB", eventLog)
+ codeHost.startCodeSession("codeSessionB")
eventLog.takeEvent("codeSessionB.start()")
eventLog.takeEvent("codeSessionB.app.uis[0].start()")
@@ -370,13 +366,13 @@ class TreehouseAppContentTest {
fun sessionA_preload_triggerException_bind() = runTest {
val content = treehouseAppContent()
- codeHost.session = FakeCodeSession("codeSessionA", eventLog)
+ val codeSessionA = codeHost.startCodeSession("codeSessionA")
eventLog.takeEvent("codeSessionA.start()")
content.preload(FakeOnBackPressedDispatcher(), uiConfiguration)
eventLog.takeEvent("codeSessionA.app.uis[0].start()")
- codeHost.handleUncaughtException(Exception("boom!"))
+ codeSessionA.handleUncaughtException(Exception("boom!"))
eventLog.takeEvent("codeSessionA.app.uis[0].close()")
eventLog.takeEvent("codeSessionA.cancel()")
diff --git a/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/AppLifecycle.kt b/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/AppLifecycle.kt
index 0542d2da96..657ffa77a4 100644
--- a/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/AppLifecycle.kt
+++ b/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/AppLifecycle.kt
@@ -27,6 +27,7 @@ public interface AppLifecycle : ZiplineService {
public fun sendFrame(timeNanos: Long)
+ /** Platform features to the guest application. */
public interface Host : ZiplineService {
public fun requestFrame()