From 7a96d431bd719ab78c0cc60cdbfd691580aa0646 Mon Sep 17 00:00:00 2001 From: Jesse Wilson Date: Sun, 29 Oct 2023 15:55:30 -0400 Subject: [PATCH] CodeListener.onUncaughtException This new API is called on host views when the guest code fails with an uncaught exception. This wires in a CoroutineExceptionHandler in the host code that cancels the current Zipline instance and updates all UIs its currently serving, if any. There is not yet any support for exceptions that occur in async code, or any mechanism to restart the Zipline instance after a crash. --- .../app/cash/redwood/treehouse/CodeHost.kt | 1 + .../cash/redwood/treehouse/CodeListener.kt | 19 ++++ .../cash/redwood/treehouse/TreehouseApp.kt | 60 ++++++++---- .../redwood/treehouse/TreehouseAppContent.kt | 27 ++++++ .../redwood/treehouse/TreehouseDispatchers.kt | 13 ++- .../AbstractTreehouseDispatchersTest.kt | 3 +- .../app/cash/redwood/treehouse/EventLog.kt | 13 +++ .../cash/redwood/treehouse/FakeCodeHost.kt | 18 +++- .../redwood/treehouse/FakeCodeListener.kt | 18 +++- .../treehouse/TreehouseAppContentTest.kt | 94 +++++++++++++++++++ .../android/views/EmojiSearchActivity.kt | 16 +++- .../android/views/ExceptionView.kt | 84 +++++++++++++++++ .../emojisearch/presenter/EmojiSearch.kt | 8 +- 13 files changed, 339 insertions(+), 35 deletions(-) create mode 100644 samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/ExceptionView.kt 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 9285d413f4..da0ced995c 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 @@ -30,6 +30,7 @@ internal interface CodeHost { interface Listener { fun codeSessionChanged(next: CodeSession) + fun uncaughtException(exception: Throwable) } /** diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeListener.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeListener.kt index 426d78a04a..60ff444f35 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeListener.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeListener.kt @@ -32,4 +32,23 @@ public open class CodeListener { * @param initial true if this is the first code loaded for this view's current content. */ public open fun onCodeLoaded(view: TreehouseView<*>, initial: Boolean) {} + + /** + * Invoked when the application powering [view] fails with an uncaught exception. This function + * should display an error UI. + * + * Typical implementations call [TreehouseView.reset] and display an error placeholder. + * Development builds may show more diagnostic information than production builds. + * + * When a Treehouse app fails, its current Zipline instance is canceled so no further code will + * execute. A new Zipline will start when new code available. + * + * This condition is not permanent! If new code is loaded after an error, [onCodeLoaded] will be + * called. + */ + public open fun onUncaughtException( + view: TreehouseView<*>, + e: Throwable, + ) { + } } 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 39b5f7469b..b9184c404e 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 @@ -23,7 +23,10 @@ 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.coroutines.CoroutineContext 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 @@ -46,7 +49,11 @@ public class TreehouseApp private constructor( private val appScope: CoroutineScope, public val spec: Spec, ) { - public val dispatchers: TreehouseDispatchers = factory.dispatchers + private val codeHost = ZiplineCodeHost() + + public val dispatchers: TreehouseDispatchers = + TreehouseDispatchersWithExceptionHandler(factory.dispatchers, codeHost.exceptionHandler) + private val eventPublisher = RealEventPublisher(factory.eventListener, this) private var started = false @@ -54,14 +61,6 @@ public class TreehouseApp private constructor( /** Only accessed on [TreehouseDispatchers.zipline]. */ private var closed = false - private val codeHost = ZiplineCodeHost( - appScope = appScope, - dispatchers = dispatchers, - eventPublisher = eventPublisher, - frameClock = factory.frameClock, - stateStore = factory.stateStore, - ) - /** * Returns the current zipline attached to this host, or null if Zipline hasn't loaded yet. The * returned value will be invalid when new code is loaded. @@ -125,10 +124,14 @@ 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 = dispatchers.zipline, + dispatcher = dispatcher, manifestVerifier = factory.manifestVerifier, httpClient = factory.httpClient, eventListener = eventPublisher.ziplineEventListener, @@ -183,15 +186,14 @@ public class TreehouseApp private constructor( eventPublisher.appCanceled() } - private class ZiplineCodeHost( - private val appScope: CoroutineScope, - private val dispatchers: TreehouseDispatchers, - private val eventPublisher: EventPublisher, - private val frameClock: FrameClock, - override val stateStore: StateStore, - ) : CodeHost { - override var session: ZiplineCodeSession? = null + private class TreehouseDispatchersWithExceptionHandler( + val delegate: TreehouseDispatchers, + exceptionHandler: CoroutineExceptionHandler, + ) : TreehouseDispatchers by delegate { + override val zipline = delegate.zipline + exceptionHandler + } + private inner class ZiplineCodeHost : CodeHost { /** * Contents that this app is currently responsible for. * @@ -199,6 +201,26 @@ public class TreehouseApp private constructor( */ private val listeners = mutableListOf>() + override val stateStore: StateStore = factory.stateStore + + override var session: ZiplineCodeSession? = null + + /** Propagates exceptions on the Zipline dispatcher to the listeners. */ + val exceptionHandler = object : CoroutineExceptionHandler { + override val key: CoroutineContext.Key<*> + get() = CoroutineExceptionHandler.Key + + override fun handleException(context: CoroutineContext, exception: Throwable) { + appScope.launch(dispatchers.ui) { + for (listener in listeners) { + listener.uncaughtException(exception) + } + codeHost.session?.cancel() + codeHost.session = null + } + } + } + override fun newServiceScope(): CodeHost.ServiceScope { val ziplineScope = ZiplineScope() @@ -232,7 +254,7 @@ public class TreehouseApp private constructor( dispatchers = dispatchers, eventPublisher = eventPublisher, appScope = appScope, - frameClock = frameClock, + frameClock = factory.frameClock, sessionScope = sessionScope, appService = appService, zipline = zipline, 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 863f0843ed..d1a8ef1096 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 @@ -223,6 +223,33 @@ internal class TreehouseAppContent( stateFlow.value = State(viewState, nextCodeState) } + /** + * 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) { + 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 + + // Cancel the UI binding to the crashed code. + previousCodeState.viewContentCodeBinding.cancel() + + // If there's a UI, give it the error to display. + val view = (viewState as? ViewState.Bound)?.view + if (view != null) { + codeListener.onUncaughtException(view, exception) + } + + val nextCodeState = CodeState.Idle() + stateFlow.value = State(viewState, nextCodeState) + } + /** This function may only be invoked on [TreehouseDispatchers.ui]. */ private fun startViewCodeContentBinding( codeSession: CodeSession, 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 88be6596a9..3fdf2753e3 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,21 +15,24 @@ */ 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: * - * * [ui] is the [CoroutineDispatcher] that runs on the platform's UI thread. - * * [zipline] is where downloaded code executes. + * * [ui] executes dispatched tasks on the platform's UI thread. + * * [zipline] executes dispatched tasks on the thread where downloaded code executes. * * This class makes it easier to specify invariants on which dispatcher is expected for which work. */ @ObjCName("TreehouseDispatchers", exact = true) public interface TreehouseDispatchers { - public val ui: CoroutineDispatcher - public val zipline: CoroutineDispatcher + /** Must contain a non-null [kotlinx.coroutines.CoroutineDispatcher]. */ + public val ui: CoroutineContext + + /** Must contain a non-null [kotlinx.coroutines.CoroutineDispatcher]. */ + public val zipline: CoroutineContext /** * Confirm that this is being called on the UI thread. diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/AbstractTreehouseDispatchersTest.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/AbstractTreehouseDispatchersTest.kt index 8e6a968a45..cb0576d306 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/AbstractTreehouseDispatchersTest.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/AbstractTreehouseDispatchersTest.kt @@ -23,7 +23,6 @@ import kotlin.coroutines.CoroutineContext import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertFailsWith -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope @@ -51,7 +50,7 @@ abstract class AbstractTreehouseDispatchersTest { uncaughtExceptionsCancelTheirJob(treehouseDispatchers.zipline) } - private suspend fun uncaughtExceptionsCancelTheirJob(dispatcher: CoroutineDispatcher) { + private suspend fun uncaughtExceptionsCancelTheirJob(dispatcher: CoroutineContext) { val exceptionCollector = ExceptionCollector() val failingJob = supervisorScope { diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/EventLog.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/EventLog.kt index d254e8ccc5..284c9d2ddb 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/EventLog.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/EventLog.kt @@ -16,6 +16,7 @@ package app.cash.redwood.treehouse import assertk.assertThat +import assertk.assertions.containsExactlyInAnyOrder import assertk.assertions.isEqualTo import kotlinx.coroutines.channels.Channel @@ -35,6 +36,18 @@ class EventLog { assertThat(takeEvent()).isEqualTo(event) } + /** + * Take all the events in [events], in any order. Use this when events published are dependent on + * dispatch order. + */ + suspend fun takeEventsInAnyOrder(vararg events: String) { + val actual = mutableListOf() + while (actual.size < events.size) { + actual += takeEvent() + } + assertThat(actual).containsExactlyInAnyOrder(*events) + } + fun assertNoEvents() { val received = events.tryReceive() check(received.isFailure) { 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 7d996185a1..5589bd67b2 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 @@ -20,15 +20,16 @@ internal class FakeCodeHost : CodeHost { override var session: CodeSession? = null set(value) { - require(value != null) - val previous = field previous?.cancel() - value.start() - for (listener in listeners) { - listener.codeSessionChanged(value) + if (value != null) { + value.start() + for (listener in listeners) { + listener.codeSessionChanged(value) + } } + field = value } @@ -54,6 +55,13 @@ internal class FakeCodeHost : CodeHost { } } + fun triggerException(exception: Throwable) { + for (listener in listeners) { + listener.uncaughtException(exception) + } + session = null + } + override fun addListener(listener: CodeHost.Listener) { listeners += listener } diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeListener.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeListener.kt index 1f4bca8f2d..5b338d94f8 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeListener.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeListener.kt @@ -18,11 +18,25 @@ package app.cash.redwood.treehouse class FakeCodeListener( private val eventLog: EventLog, ) : CodeListener() { - override fun onInitialCodeLoading(view: TreehouseView<*>) { + override fun onInitialCodeLoading( + view: TreehouseView<*>, + ) { eventLog += "codeListener.onInitialCodeLoading($view)" } - override fun onCodeLoaded(view: TreehouseView<*>, initial: Boolean) { + override fun onCodeLoaded( + view: TreehouseView<*>, + initial: Boolean, + ) { eventLog += "codeListener.onCodeLoaded($view, initial = $initial)" } + + override fun onUncaughtException( + view: TreehouseView<*>, + e: Throwable, + ) { + // Canonicalize "java.lang.Exception(boom!)" to "kotlin.Exception(boom!)". + val exceptionString = e.toString().replace("java.lang.", "kotlin.") + eventLog += "codeListener.onUncaughtException($view, $exceptionString)" + } } 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 db5d195560..c05ae8f6b6 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 @@ -293,6 +293,100 @@ class TreehouseAppContentTest { eventLog.takeEvent("codeSessionB.app.uis[0].close()") } + @Test + fun session_bind_triggerException() = runTest { + val content = treehouseAppContent() + + codeHost.session = FakeCodeSession("codeSessionA", eventLog) + eventLog.takeEvent("codeSessionA.start()") + + val view1 = FakeTreehouseView("view1") + content.bind(view1) + eventLog.takeEvent("codeSessionA.app.uis[0].start()") + + codeHost.triggerException(Exception("boom!")) + eventLog.takeEventsInAnyOrder( + "codeSessionA.app.uis[0].close()", + "codeListener.onUncaughtException(view1, kotlin.Exception: boom!)", + "codeSessionA.cancel()", + ) + + content.unbind() + } + + @Test + fun triggerException_bind_session() = runTest { + val content = treehouseAppContent() + + codeHost.session = FakeCodeSession("codeSessionA", eventLog) + eventLog.takeEvent("codeSessionA.start()") + + codeHost.triggerException(Exception("boom!")) + eventLog.takeEvent("codeSessionA.cancel()") + + val view1 = FakeTreehouseView("view1") + content.bind(view1) + eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + + codeHost.session = FakeCodeSession("codeSessionB", eventLog) + eventLog.takeEvent("codeSessionB.start()") + eventLog.takeEvent("codeSessionB.app.uis[0].start()") + + content.unbind() + eventLog.takeEvent("codeSessionB.app.uis[0].close()") + } + + @Test + fun sessionA_bind_triggerException_sessionB() = runTest { + val content = treehouseAppContent() + + codeHost.session = FakeCodeSession("codeSessionA", eventLog) + eventLog.takeEvent("codeSessionA.start()") + + val view1 = FakeTreehouseView("view1") + content.bind(view1) + eventLog.takeEvent("codeSessionA.app.uis[0].start()") + + codeHost.triggerException(Exception("boom!")) + eventLog.takeEventsInAnyOrder( + "codeSessionA.app.uis[0].close()", + "codeListener.onUncaughtException(view1, kotlin.Exception: boom!)", + "codeSessionA.cancel()", + ) + + codeHost.session = FakeCodeSession("codeSessionB", eventLog) + eventLog.takeEvent("codeSessionB.start()") + eventLog.takeEvent("codeSessionB.app.uis[0].start()") + + content.unbind() + eventLog.takeEvent("codeSessionB.app.uis[0].close()") + } + + /** + * Exceptions don't notify codeListeners for preloads because there's no view to show an error on. + * But they do end the current code session. + */ + @Test + fun sessionA_preload_triggerException_bind() = runTest { + val content = treehouseAppContent() + + codeHost.session = FakeCodeSession("codeSessionA", eventLog) + eventLog.takeEvent("codeSessionA.start()") + + content.preload(FakeOnBackPressedDispatcher(), uiConfiguration) + eventLog.takeEvent("codeSessionA.app.uis[0].start()") + + codeHost.triggerException(Exception("boom!")) + eventLog.takeEvent("codeSessionA.app.uis[0].close()") + eventLog.takeEvent("codeSessionA.cancel()") + + val view1 = FakeTreehouseView("view1") + content.bind(view1) + eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + + content.unbind() + } + private fun TestScope.treehouseAppContent(): TreehouseAppContent { return TreehouseAppContent( codeHost = codeHost, diff --git a/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt b/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt index 2005665e95..7567a5d479 100644 --- a/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt +++ b/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt @@ -18,11 +18,14 @@ package com.example.redwood.emojisearch.android.views import android.annotation.SuppressLint import android.os.Bundle import android.util.Log +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.widget.LinearLayout import androidx.activity.ComponentActivity import androidx.core.view.WindowCompat import app.cash.redwood.compose.AndroidUiDispatcher.Companion.Main import app.cash.redwood.layout.view.ViewRedwoodLayoutWidgetFactory import app.cash.redwood.lazylayout.view.ViewRedwoodLazyLayoutWidgetFactory +import app.cash.redwood.treehouse.CodeListener import app.cash.redwood.treehouse.EventListener import app.cash.redwood.treehouse.TreehouseApp import app.cash.redwood.treehouse.TreehouseAppFactory @@ -78,11 +81,22 @@ class EmojiSearchActivity : ComponentActivity() { } treehouseLayout = TreehouseLayout(this, widgetSystem, onBackPressedDispatcher).apply { - treehouseContentSource.bindWhenReady(this, treehouseApp) + treehouseContentSource.bindWhenReady(this, treehouseApp, codeListener) } setContentView(treehouseLayout) } + private val codeListener: CodeListener = object : CodeListener() { + override fun onUncaughtException(view: TreehouseView<*>, e: Throwable) { + val treehouseLayout = view as TreehouseLayout + treehouseLayout.reset() + treehouseLayout.addView( + ExceptionView(treehouseLayout, e), + LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT), + ) + } + } + private val appEventListener: EventListener = object : EventListener() { private var success = true private var snackbar: Snackbar? = null diff --git a/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/ExceptionView.kt b/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/ExceptionView.kt new file mode 100644 index 0000000000..bc24fc11f8 --- /dev/null +++ b/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/ExceptionView.kt @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2023 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.redwood.emojisearch.android.views + +import android.annotation.SuppressLint +import android.graphics.Color +import android.text.TextUtils +import android.view.Gravity.CENTER +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.LinearLayout +import androidx.appcompat.widget.AppCompatTextView +import app.cash.redwood.treehouse.TreehouseLayout +import app.cash.redwood.ui.Density +import app.cash.redwood.ui.dp + +/** + * Renders an emoji, plus the first line of the exception message, centered and wrapped. The view + * has a light-yellow background. + * + * ``` + * 🦨 + * app.cash.zipline.ZiplineException + * RuntimeException + * boom! + * ``` + */ +@SuppressLint("ViewConstructor") +internal class ExceptionView( + parent: TreehouseLayout, + private val exception: Throwable, +) : LinearLayout(parent.context) { + + init { + orientation = VERTICAL + gravity = CENTER + setBackgroundColor(Color.argb(255, 255, 250, 225)) + + addView( + AppCompatTextView(context).apply { + textAlignment = TEXT_ALIGNMENT_CENTER + setTextColor(Color.BLACK) + textSize = 40f + text = "🦨" + }, + ) + + addView( + AppCompatTextView(context).apply { + textAlignment = TEXT_ALIGNMENT_CENTER + setTextColor(Color.BLACK) + textSize = 16f + text = exception.toString().substringBefore("\n").replace(": ", "\n") + ellipsize = TextUtils.TruncateAt.END + }, + ) + } + + override fun generateDefaultLayoutParams(): LayoutParams { + return LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { + with(Density(resources)) { + setMargins( + 10f.dp.toPxInt(), + 5f.dp.toPxInt(), + 10f.dp.toPxInt(), + 5f.dp.toPxInt(), + ) + } + } + } +} diff --git a/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt b/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt index b822a18a5c..5f1a5da19b 100644 --- a/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt +++ b/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt @@ -137,7 +137,13 @@ private fun LazyColumn( TextInput( state = TextFieldState(searchTerm.text), hint = "Search", - onChange = { searchTerm = it }, + onChange = { textFieldState -> + // Make it easy to trigger a crash to manually test exception handling! + if (textFieldState.text == "crash") { + throw RuntimeException("boom!") + } + searchTerm = textFieldState + }, ) LazyColumn( refreshing = refreshing,