From 8f30cf475f2c28cf661353bb48c56e8350c02544 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Mon, 23 Mar 2026 13:39:28 -0400 Subject: [PATCH 01/25] Initial spike at providing a path configuration loadState to observe from apps --- .../core/turbo/config/PathConfiguration.kt | 9 ++++++ .../config/PathConfigurationLoadState.kt | 28 +++++++++++++++++++ .../turbo/config/PathConfigurationLoader.kt | 9 ++++++ 3 files changed, 46 insertions(+) create mode 100644 core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt index b7d890da..c5163dcc 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt @@ -8,6 +8,7 @@ import com.google.gson.annotations.SerializedName import dev.hotwire.core.turbo.nav.Presentation import dev.hotwire.core.turbo.nav.PresentationContext import dev.hotwire.core.turbo.nav.QueryStringPresentation +import kotlinx.coroutines.flow.StateFlow import java.net.URL /** @@ -19,6 +20,14 @@ class PathConfiguration { internal var loader: PathConfigurationLoader? = null + /** + * A [StateFlow] that emits the current state of the path configuration + * loading process. Observe this to know when the configuration has been + * loaded and from which source (bundled asset, cached remote, or fresh remote). + */ + val loadState: StateFlow + get() = loader!!.loadState + @SerializedName("rules") internal var rules: List = emptyList() diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt new file mode 100644 index 00000000..ca558ac6 --- /dev/null +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt @@ -0,0 +1,28 @@ +package dev.hotwire.core.turbo.config + +/** + * Represents the current state of the path configuration loading process. + * Observe [PathConfiguration.loadState] to receive updates as the configuration + * is loaded from each source. + */ +sealed class PathConfigurationLoadState { + /** + * The initial state before any configuration has been loaded. + */ + data object Idle : PathConfigurationLoadState() + + /** + * The configuration was loaded from the locally bundled asset file. + */ + data class BundledAssetLoaded(val config: PathConfiguration) : PathConfigurationLoadState() + + /** + * The configuration was loaded from a previously cached remote file. + */ + data class CachedRemoteLoaded(val config: PathConfiguration) : PathConfigurationLoadState() + + /** + * The configuration was freshly loaded from the remote server. + */ + data class RemoteLoaded(val config: PathConfiguration) : PathConfigurationLoadState() +} diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt index 296ca731..df1fcc3c 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt @@ -8,12 +8,18 @@ import dev.hotwire.core.turbo.util.dispatcherProvider import dev.hotwire.core.turbo.util.toObject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlin.coroutines.CoroutineContext internal class PathConfigurationLoader(val context: Context) : CoroutineScope { internal var repository = PathConfigurationRepository() + private val _loadState = MutableStateFlow(PathConfigurationLoadState.Idle) + val loadState: StateFlow = _loadState.asStateFlow() + override val coroutineContext: CoroutineContext get() = dispatcherProvider.io + Job() @@ -44,6 +50,7 @@ internal class PathConfigurationLoader(val context: Context) : CoroutineScope { load(json)?.let { logEvent("remotePathConfigurationLoaded", url) onCompletion(it) + _loadState.value = PathConfigurationLoadState.RemoteLoaded(it) cacheConfigurationForUrl(url, it) } } @@ -58,6 +65,7 @@ internal class PathConfigurationLoader(val context: Context) : CoroutineScope { load(json)?.let { logEvent("bundledPathConfigurationLoaded", filePath) onCompletion(it) + _loadState.value = PathConfigurationLoadState.BundledAssetLoaded(it) } } @@ -69,6 +77,7 @@ internal class PathConfigurationLoader(val context: Context) : CoroutineScope { load(json)?.let { logEvent("cachedPathConfigurationLoaded", url) onCompletion(it) + _loadState.value = PathConfigurationLoadState.CachedRemoteLoaded(it) } } } From caff8faef58e26a0d4531385d748c649a316ce7e Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Mon, 23 Mar 2026 14:36:04 -0400 Subject: [PATCH 02/25] Update the PathConfigurationLoader so it doesn't hold onto a context --- .../core/turbo/config/PathConfiguration.kt | 11 ++++------- .../core/turbo/config/PathConfigurationLoader.kt | 15 ++++++++++----- .../core/turbo/config/PathConfigurationTest.kt | 6 +++--- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt index c5163dcc..eb89dd35 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt @@ -18,7 +18,8 @@ import java.net.URL class PathConfiguration { private val cachedProperties: HashMap = hashMapOf() - internal var loader: PathConfigurationLoader? = null + @Transient + internal var loader = PathConfigurationLoader() /** * A [StateFlow] that emits the current state of the path configuration @@ -26,7 +27,7 @@ class PathConfiguration { * loaded and from which source (bundled asset, cached remote, or fresh remote). */ val loadState: StateFlow - get() = loader!!.loadState + get() = loader.loadState @SerializedName("rules") internal var rules: List = emptyList() @@ -82,11 +83,7 @@ class PathConfiguration { location: Location, options: LoaderOptions ) { - if (loader == null) { - loader = PathConfigurationLoader(context.applicationContext) - } - - loader?.load(location, options) { + loader.load(context.applicationContext, location, options) { cachedProperties.clear() rules = it.rules + historicalLocationRules settings = it.settings diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt index df1fcc3c..bc88643b 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt @@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlin.coroutines.CoroutineContext -internal class PathConfigurationLoader(val context: Context) : CoroutineScope { +internal class PathConfigurationLoader : CoroutineScope { internal var repository = PathConfigurationRepository() private val _loadState = MutableStateFlow(PathConfigurationLoadState.Idle) @@ -24,26 +24,28 @@ internal class PathConfigurationLoader(val context: Context) : CoroutineScope { get() = dispatcherProvider.io + Job() fun load( + context: Context, location: PathConfiguration.Location, options: PathConfiguration.LoaderOptions, onCompletion: (PathConfiguration) -> Unit ) { location.assetFilePath?.let { - loadBundledAssetConfiguration(it, onCompletion) + loadBundledAssetConfiguration(context, it, onCompletion) } location.remoteFileUrl?.let { - downloadRemoteConfiguration(it, options, onCompletion) + downloadRemoteConfiguration(context, it, options, onCompletion) } } private fun downloadRemoteConfiguration( + context: Context, url: String, options: PathConfiguration.LoaderOptions, onCompletion: (PathConfiguration) -> Unit ) { // Always load the previously cached version first, if available - loadCachedConfigurationForUrl(url, onCompletion) + loadCachedConfigurationForUrl(context, url, onCompletion) launch { repository.getRemoteConfiguration(url, options)?.let { json -> @@ -51,13 +53,14 @@ internal class PathConfigurationLoader(val context: Context) : CoroutineScope { logEvent("remotePathConfigurationLoaded", url) onCompletion(it) _loadState.value = PathConfigurationLoadState.RemoteLoaded(it) - cacheConfigurationForUrl(url, it) + cacheConfigurationForUrl(context, url, it) } } } } private fun loadBundledAssetConfiguration( + context: Context, filePath: String, onCompletion: (PathConfiguration) -> Unit ) { @@ -70,6 +73,7 @@ internal class PathConfigurationLoader(val context: Context) : CoroutineScope { } private fun loadCachedConfigurationForUrl( + context: Context, url: String, onCompletion: (PathConfiguration) -> Unit ) { @@ -83,6 +87,7 @@ internal class PathConfigurationLoader(val context: Context) : CoroutineScope { } private fun cacheConfigurationForUrl( + context: Context, url: String, pathConfiguration: PathConfiguration ) { diff --git a/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt b/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt index 9b54bac9..a35e4aa9 100644 --- a/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt +++ b/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt @@ -73,7 +73,7 @@ class PathConfigurationTest : BaseRepositoryTest() { @Test fun remoteConfigurationIsFetched() { - pathConfiguration.loader = PathConfigurationLoader(context).apply { + pathConfiguration.loader = PathConfigurationLoader().apply { repository = mockRepository } @@ -90,7 +90,7 @@ class PathConfigurationTest : BaseRepositoryTest() { @Test fun validConfigurationIsCached() { - pathConfiguration.loader = PathConfigurationLoader(context).apply { + pathConfiguration.loader = PathConfigurationLoader().apply { repository = mockRepository } @@ -110,7 +110,7 @@ class PathConfigurationTest : BaseRepositoryTest() { @Test fun malformedConfigurationIsNotCached() { - pathConfiguration.loader = PathConfigurationLoader(context).apply { + pathConfiguration.loader = PathConfigurationLoader().apply { repository = mockRepository } From 0b576488308c8a87da13dc75ad88996085038d5f Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Mon, 23 Mar 2026 17:09:40 -0400 Subject: [PATCH 03/25] Observe the loadState to update the PathConfiguration data --- .../core/turbo/config/PathConfiguration.kt | 27 ++++++++-- .../turbo/config/PathConfigurationLoader.kt | 52 ++++++++----------- 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt index eb89dd35..ed661485 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt @@ -8,7 +8,10 @@ import com.google.gson.annotations.SerializedName import dev.hotwire.core.turbo.nav.Presentation import dev.hotwire.core.turbo.nav.PresentationContext import dev.hotwire.core.turbo.nav.QueryStringPresentation +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import java.net.URL /** @@ -17,6 +20,7 @@ import java.net.URL */ class PathConfiguration { private val cachedProperties: HashMap = hashMapOf() + private var observerJob: Job? = null @Transient internal var loader = PathConfigurationLoader() @@ -83,11 +87,8 @@ class PathConfiguration { location: Location, options: LoaderOptions ) { - loader.load(context.applicationContext, location, options) { - cachedProperties.clear() - rules = it.rules + historicalLocationRules - settings = it.settings - } + observeLoadState() + loader.load(context.applicationContext, location, options) } /** @@ -122,6 +123,22 @@ class PathConfiguration { else -> "${url.path}?${url.query}" } } + + private fun observeLoadState() { + observerJob?.cancel() + observerJob = loader.loadState.onEach { state -> + val config = when (state) { + is PathConfigurationLoadState.BundledAssetLoaded -> state.config + is PathConfigurationLoadState.CachedRemoteLoaded -> state.config + is PathConfigurationLoadState.RemoteLoaded -> state.config + is PathConfigurationLoadState.Idle -> return@onEach + } + + cachedProperties.clear() + rules = config.rules + historicalLocationRules + settings = config.settings + }.launchIn(loader) + } } typealias PathConfigurationProperties = HashMap diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt index bc88643b..ebd5ad94 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt @@ -26,66 +26,58 @@ internal class PathConfigurationLoader : CoroutineScope { fun load( context: Context, location: PathConfiguration.Location, - options: PathConfiguration.LoaderOptions, - onCompletion: (PathConfiguration) -> Unit + options: PathConfiguration.LoaderOptions ) { location.assetFilePath?.let { - loadBundledAssetConfiguration(context, it, onCompletion) + loadBundledAssetConfiguration(context, it) } - location.remoteFileUrl?.let { - downloadRemoteConfiguration(context, it, options, onCompletion) - } - } - - private fun downloadRemoteConfiguration( - context: Context, - url: String, - options: PathConfiguration.LoaderOptions, - onCompletion: (PathConfiguration) -> Unit - ) { - // Always load the previously cached version first, if available - loadCachedConfigurationForUrl(context, url, onCompletion) + location.remoteFileUrl?.let { url -> + loadCachedConfigurationForUrl(context, url) - launch { - repository.getRemoteConfiguration(url, options)?.let { json -> - load(json)?.let { - logEvent("remotePathConfigurationLoaded", url) - onCompletion(it) - _loadState.value = PathConfigurationLoadState.RemoteLoaded(it) - cacheConfigurationForUrl(context, url, it) - } + launch { + downloadRemoteConfigurationForUrl(context, url, options) } } } private fun loadBundledAssetConfiguration( context: Context, - filePath: String, - onCompletion: (PathConfiguration) -> Unit + filePath: String ) { val json = repository.getBundledConfiguration(context, filePath) load(json)?.let { logEvent("bundledPathConfigurationLoaded", filePath) - onCompletion(it) _loadState.value = PathConfigurationLoadState.BundledAssetLoaded(it) } } private fun loadCachedConfigurationForUrl( context: Context, - url: String, - onCompletion: (PathConfiguration) -> Unit + url: String ) { repository.getCachedConfigurationForUrl(context, url)?.let { json -> load(json)?.let { logEvent("cachedPathConfigurationLoaded", url) - onCompletion(it) _loadState.value = PathConfigurationLoadState.CachedRemoteLoaded(it) } } } + private suspend fun downloadRemoteConfigurationForUrl( + context: Context, + url: String, + options: PathConfiguration.LoaderOptions + ) { + repository.getRemoteConfiguration(url, options)?.let { json -> + load(json)?.let { + logEvent("remotePathConfigurationLoaded", url) + _loadState.value = PathConfigurationLoadState.RemoteLoaded(it) + cacheConfigurationForUrl(context, url, it) + } + } + } + private fun cacheConfigurationForUrl( context: Context, url: String, From f0d7d8401210cc5e271db9b3609608c59ca9fde4 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 25 Mar 2026 05:40:50 -0400 Subject: [PATCH 04/25] Observe the load state in init --- .../core/turbo/config/PathConfiguration.kt | 48 ++++++++++++------- .../turbo/config/PathConfigurationLoader.kt | 23 ++++----- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt index ed661485..d1199280 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt @@ -5,13 +5,15 @@ import android.content.Context import android.net.Uri import androidx.core.net.toUri import com.google.gson.annotations.SerializedName +import dev.hotwire.core.logging.logEvent import dev.hotwire.core.turbo.nav.Presentation import dev.hotwire.core.turbo.nav.PresentationContext import dev.hotwire.core.turbo.nav.QueryStringPresentation -import kotlinx.coroutines.Job +import dev.hotwire.core.turbo.util.dispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import java.net.URL /** @@ -20,7 +22,7 @@ import java.net.URL */ class PathConfiguration { private val cachedProperties: HashMap = hashMapOf() - private var observerJob: Job? = null + private val scope: CoroutineScope = CoroutineScope(dispatcherProvider.main + SupervisorJob()) @Transient internal var loader = PathConfigurationLoader() @@ -78,6 +80,10 @@ class PathConfiguration { val httpHeaders: Map = emptyMap() ) + init { + observeLoadState() + } + /** * Loads and parses the specified configuration file(s) from their local * and/or remote locations. @@ -87,8 +93,11 @@ class PathConfiguration { location: Location, options: LoaderOptions ) { - observeLoadState() - loader.load(context.applicationContext, location, options) + logEvent("pathConfigurationLoading", location.toString()) + + scope.launch { + loader.load(context.applicationContext, location, options) + } } /** @@ -125,19 +134,22 @@ class PathConfiguration { } private fun observeLoadState() { - observerJob?.cancel() - observerJob = loader.loadState.onEach { state -> - val config = when (state) { - is PathConfigurationLoadState.BundledAssetLoaded -> state.config - is PathConfigurationLoadState.CachedRemoteLoaded -> state.config - is PathConfigurationLoadState.RemoteLoaded -> state.config - is PathConfigurationLoadState.Idle -> return@onEach + scope.launch { + loader.loadState.collect { state -> + val config = when (state) { + is PathConfigurationLoadState.BundledAssetLoaded -> state.config + is PathConfigurationLoadState.CachedRemoteLoaded -> state.config + is PathConfigurationLoadState.RemoteLoaded -> state.config + is PathConfigurationLoadState.Idle -> return@collect + } + + cachedProperties.clear() + rules = config.rules + historicalLocationRules + settings = config.settings + + logEvent("pathConfigurationUpdated", "Rules: ${rules.size} Settings: ${settings.size}") } - - cachedProperties.clear() - rules = config.rules + historicalLocationRules - settings = config.settings - }.launchIn(loader) + } } } diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt index ebd5ad94..44d02c28 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt @@ -4,26 +4,18 @@ import android.content.Context import com.google.gson.reflect.TypeToken import dev.hotwire.core.logging.logError import dev.hotwire.core.logging.logEvent -import dev.hotwire.core.turbo.util.dispatcherProvider import dev.hotwire.core.turbo.util.toObject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlin.coroutines.CoroutineContext -internal class PathConfigurationLoader : CoroutineScope { +internal class PathConfigurationLoader { internal var repository = PathConfigurationRepository() private val _loadState = MutableStateFlow(PathConfigurationLoadState.Idle) val loadState: StateFlow = _loadState.asStateFlow() - override val coroutineContext: CoroutineContext - get() = dispatcherProvider.io + Job() - - fun load( + suspend fun load( context: Context, location: PathConfiguration.Location, options: PathConfiguration.LoaderOptions @@ -34,10 +26,7 @@ internal class PathConfigurationLoader : CoroutineScope { location.remoteFileUrl?.let { url -> loadCachedConfigurationForUrl(context, url) - - launch { - downloadRemoteConfigurationForUrl(context, url, options) - } + downloadRemoteConfigurationForUrl(context, url, options) } } @@ -45,6 +34,8 @@ internal class PathConfigurationLoader : CoroutineScope { context: Context, filePath: String ) { + logEvent("bundledPathConfigurationLoading", filePath) + val json = repository.getBundledConfiguration(context, filePath) load(json)?.let { logEvent("bundledPathConfigurationLoaded", filePath) @@ -56,6 +47,8 @@ internal class PathConfigurationLoader : CoroutineScope { context: Context, url: String ) { + logEvent("cachedPathConfigurationLoading", url) + repository.getCachedConfigurationForUrl(context, url)?.let { json -> load(json)?.let { logEvent("cachedPathConfigurationLoaded", url) @@ -69,6 +62,8 @@ internal class PathConfigurationLoader : CoroutineScope { url: String, options: PathConfiguration.LoaderOptions ) { + logEvent("remotePathConfigurationLoading", url) + repository.getRemoteConfiguration(url, options)?.let { json -> load(json)?.let { logEvent("remotePathConfigurationLoaded", url) From f6b0d0f5830afb666ceea068e966117e2206a5aa Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 25 Mar 2026 07:41:35 -0400 Subject: [PATCH 05/25] Extract the serializable data into a PathConfigurationData class so just the data is observed --- .../core/turbo/config/PathConfiguration.kt | 53 +++++++------------ .../turbo/config/PathConfigurationData.kt | 45 ++++++++++++++++ .../config/PathConfigurationLoadState.kt | 6 +-- .../turbo/config/PathConfigurationLoader.kt | 4 +- .../config/PathConfigurationRepository.kt | 2 +- .../navigation/navigator/NavigatorRuleTest.kt | 8 +++ 6 files changed, 77 insertions(+), 41 deletions(-) create mode 100644 core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationData.kt diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt index d1199280..d5339d06 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt @@ -4,7 +4,6 @@ import android.annotation.SuppressLint import android.content.Context import android.net.Uri import androidx.core.net.toUri -import com.google.gson.annotations.SerializedName import dev.hotwire.core.logging.logEvent import dev.hotwire.core.turbo.nav.Presentation import dev.hotwire.core.turbo.nav.PresentationContext @@ -14,7 +13,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import java.net.URL /** * Provides the ability to load, parse, and retrieve url path @@ -23,9 +21,10 @@ import java.net.URL class PathConfiguration { private val cachedProperties: HashMap = hashMapOf() private val scope: CoroutineScope = CoroutineScope(dispatcherProvider.main + SupervisorJob()) + private var observingLoadState = false - @Transient internal var loader = PathConfigurationLoader() + internal var data = PathConfigurationData() /** * A [StateFlow] that emits the current state of the path configuration @@ -35,16 +34,11 @@ class PathConfiguration { val loadState: StateFlow get() = loader.loadState - @SerializedName("rules") - internal var rules: List = emptyList() - /** * Gets the top-level settings specified in the app's path configuration. * The settings are map of key/value `String` items. */ - @SerializedName("settings") - var settings: PathConfigurationSettings = PathConfigurationSettings() - private set + val settings get() = data.settings /** * Represents the location of the app's path configuration JSON file(s). @@ -80,10 +74,6 @@ class PathConfiguration { val httpHeaders: Map = emptyMap() ) - init { - observeLoadState() - } - /** * Loads and parses the specified configuration file(s) from their local * and/or remote locations. @@ -95,6 +85,11 @@ class PathConfiguration { ) { logEvent("pathConfigurationLoading", location.toString()) + if (!observingLoadState) { + observingLoadState = true + observeLoadState() + } + scope.launch { loader.load(context.applicationContext, location, options) } @@ -112,42 +107,30 @@ class PathConfiguration { fun properties(location: String): PathConfigurationProperties { cachedProperties[location]?.let { return it } - val properties = PathConfigurationProperties() - val path = path(location) - - for (rule in rules) { - if (rule.matches(path)) properties.putAll(rule.properties) - } - + val properties = data.properties(location) cachedProperties[location] = properties return properties } - private fun path(location: String): String { - val url = URL(location) - - return when (url.query) { - null -> url.path - else -> "${url.path}?${url.query}" - } - } - private fun observeLoadState() { scope.launch { loader.loadState.collect { state -> val config = when (state) { - is PathConfigurationLoadState.BundledAssetLoaded -> state.config - is PathConfigurationLoadState.CachedRemoteLoaded -> state.config - is PathConfigurationLoadState.RemoteLoaded -> state.config + is PathConfigurationLoadState.BundledAssetLoaded -> state.configuration + is PathConfigurationLoadState.CachedRemoteLoaded -> state.configuration + is PathConfigurationLoadState.RemoteLoaded -> state.configuration is PathConfigurationLoadState.Idle -> return@collect } cachedProperties.clear() - rules = config.rules + historicalLocationRules - settings = config.settings + data = config - logEvent("pathConfigurationUpdated", "Rules: ${rules.size} Settings: ${settings.size}") + logEvent("pathConfigurationUpdated", listOf( + "Source" to state.javaClass.simpleName, + "Rules" to data.rules.size, + "Settings" to data.settings.size + )) } } } diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationData.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationData.kt new file mode 100644 index 00000000..55aacc21 --- /dev/null +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationData.kt @@ -0,0 +1,45 @@ +package dev.hotwire.core.turbo.config + +import com.google.gson.annotations.SerializedName +import java.net.URL + +class PathConfigurationData internal constructor( + @SerializedName("rules") + internal val rules: List = emptyList(), + + /** + * Gets the top-level settings specified in the app's path configuration. + * The settings are map of key/value `String` items. + */ + @SerializedName("settings") + val settings: PathConfigurationSettings = PathConfigurationSettings() +) { + /** + * Retrieve the path properties based on the cascading rules in your + * path configuration. + * + * @param location The absolute url to match against the configuration's + * rules. Only the url's relative path will be used to find the matching + * regex rules. + * @return The map of key/value `String` properties + */ + fun properties(location: String): PathConfigurationProperties { + val properties = PathConfigurationProperties() + val path = path(location) + + for (rule in rules + historicalLocationRules) { + if (rule.matches(path)) properties.putAll(rule.properties) + } + + return properties + } + + private fun path(location: String): String { + val url = URL(location) + + return when (url.query) { + null -> url.path + else -> "${url.path}?${url.query}" + } + } +} diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt index ca558ac6..099f746e 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt @@ -14,15 +14,15 @@ sealed class PathConfigurationLoadState { /** * The configuration was loaded from the locally bundled asset file. */ - data class BundledAssetLoaded(val config: PathConfiguration) : PathConfigurationLoadState() + data class BundledAssetLoaded(val configuration: PathConfigurationData) : PathConfigurationLoadState() /** * The configuration was loaded from a previously cached remote file. */ - data class CachedRemoteLoaded(val config: PathConfiguration) : PathConfigurationLoadState() + data class CachedRemoteLoaded(val configuration: PathConfigurationData) : PathConfigurationLoadState() /** * The configuration was freshly loaded from the remote server. */ - data class RemoteLoaded(val config: PathConfiguration) : PathConfigurationLoadState() + data class RemoteLoaded(val configuration: PathConfigurationData) : PathConfigurationLoadState() } diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt index 44d02c28..fd0fdcc3 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt @@ -76,13 +76,13 @@ internal class PathConfigurationLoader { private fun cacheConfigurationForUrl( context: Context, url: String, - pathConfiguration: PathConfiguration + pathConfiguration: PathConfigurationData ) { repository.cacheConfigurationForUrl(context, url, pathConfiguration) } private fun load(json: String) = try { - json.toObject(object : TypeToken() {}) + json.toObject(object : TypeToken() {}) } catch(e: Exception) { logError("pathConfiguredFailedToParse", e) null diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationRepository.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationRepository.kt index 3958df12..c5433f20 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationRepository.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationRepository.kt @@ -47,7 +47,7 @@ internal class PathConfigurationRepository { fun cacheConfigurationForUrl( context: Context, url: String, - pathConfiguration: PathConfiguration + pathConfiguration: PathConfigurationData ) { prefs(context).edit { putString(url, pathConfiguration.toJson()) diff --git a/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt b/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt index 4369d059..b170a1e4 100644 --- a/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt +++ b/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt @@ -14,6 +14,9 @@ import androidx.navigation.ui.R import androidx.test.core.app.ApplicationProvider import dev.hotwire.core.turbo.config.PathConfiguration import dev.hotwire.core.turbo.config.PathConfiguration.Location +import dev.hotwire.core.turbo.config.PathConfigurationLoadState +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import dev.hotwire.core.turbo.nav.Presentation import dev.hotwire.core.turbo.nav.PresentationContext import dev.hotwire.core.turbo.nav.QueryStringPresentation @@ -79,6 +82,11 @@ class NavigatorRuleTest { options = PathConfiguration.LoaderOptions() ) } + + // Wait for the async config load to complete + runBlocking { + pathConfiguration.loadState.first { it !is PathConfigurationLoadState.Idle } + } } @Test From 6e6065b1c1fb1bef48835f76c61ac11f27de9c2b Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 25 Mar 2026 08:57:30 -0400 Subject: [PATCH 06/25] Use an Unconfined dispatcher to collect the load state and fix tests --- .../core/turbo/config/PathConfiguration.kt | 38 ++++++++++--------- .../config/PathConfigurationRepositoryTest.kt | 4 +- .../turbo/config/PathConfigurationTest.kt | 2 +- navigation-fragments/build.gradle.kts | 1 + .../hotwire/navigation/CoroutinesTestRule.kt | 25 ++++++++++++ .../navigation/navigator/NavigatorRuleTest.kt | 14 +++---- 6 files changed, 55 insertions(+), 29 deletions(-) create mode 100644 navigation-fragments/src/test/kotlin/dev/hotwire/navigation/CoroutinesTestRule.kt diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt index d5339d06..9d388127 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt @@ -10,8 +10,12 @@ import dev.hotwire.core.turbo.nav.PresentationContext import dev.hotwire.core.turbo.nav.QueryStringPresentation import dev.hotwire.core.turbo.util.dispatcherProvider import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch /** @@ -114,25 +118,23 @@ class PathConfiguration { } private fun observeLoadState() { - scope.launch { - loader.loadState.collect { state -> - val config = when (state) { - is PathConfigurationLoadState.BundledAssetLoaded -> state.configuration - is PathConfigurationLoadState.CachedRemoteLoaded -> state.configuration - is PathConfigurationLoadState.RemoteLoaded -> state.configuration - is PathConfigurationLoadState.Idle -> return@collect - } - - cachedProperties.clear() - data = config - - logEvent("pathConfigurationUpdated", listOf( - "Source" to state.javaClass.simpleName, - "Rules" to data.rules.size, - "Settings" to data.settings.size - )) + loader.loadState.onEach { state -> + val config = when (state) { + is PathConfigurationLoadState.BundledAssetLoaded -> state.configuration + is PathConfigurationLoadState.CachedRemoteLoaded -> state.configuration + is PathConfigurationLoadState.RemoteLoaded -> state.configuration + is PathConfigurationLoadState.Idle -> return@onEach } - } + + cachedProperties.clear() + data = config + + logEvent("pathConfigurationUpdated", listOf( + "Source" to state.javaClass.simpleName, + "Rules" to data.rules.size, + "Settings" to data.settings.size + )) + }.flowOn(Dispatchers.Unconfined).launchIn(scope) } } diff --git a/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationRepositoryTest.kt b/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationRepositoryTest.kt index 058a5250..a7759358 100644 --- a/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationRepositoryTest.kt +++ b/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationRepositoryTest.kt @@ -88,8 +88,8 @@ class PathConfigurationRepositoryTest : BaseRepositoryTest() { } } - private fun load(json: String?): PathConfiguration? { - return json?.toObject(object : TypeToken() {}) + private fun load(json: String?): PathConfigurationData? { + return json?.toObject(object : TypeToken() {}) } private fun json(): String { diff --git a/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt b/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt index a35e4aa9..12a33b74 100644 --- a/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt +++ b/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt @@ -58,7 +58,7 @@ class PathConfigurationTest : BaseRepositoryTest() { @Test fun assetConfigurationIsLoaded() { - assertThat(pathConfiguration.rules.size).isGreaterThan(0) + assertThat(pathConfiguration.data.rules.size).isGreaterThan(0) } @Test diff --git a/navigation-fragments/build.gradle.kts b/navigation-fragments/build.gradle.kts index 5991810b..941ea215 100644 --- a/navigation-fragments/build.gradle.kts +++ b/navigation-fragments/build.gradle.kts @@ -98,6 +98,7 @@ dependencies { testImplementation("androidx.test:core:1.6.1") // Robolectric testImplementation("org.assertj:assertj-core:3.26.3") testImplementation("androidx.navigation:navigation-testing:2.8.9") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.1") testImplementation("org.robolectric:robolectric:4.14.1") testImplementation("org.mockito:mockito-core:5.14.2") testImplementation("com.nhaarman:mockito-kotlin:1.6.0") diff --git a/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/CoroutinesTestRule.kt b/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/CoroutinesTestRule.kt new file mode 100644 index 00000000..80d63645 --- /dev/null +++ b/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/CoroutinesTestRule.kt @@ -0,0 +1,25 @@ +package dev.hotwire.navigation + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +class CoroutinesTestRule( + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(TestCoroutineScheduler()) +) : TestWatcher() { + + override fun starting(description: Description) { + super.starting(description) + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + super.finished(description) + Dispatchers.resetMain() + } +} diff --git a/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt b/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt index b170a1e4..4b2fa782 100644 --- a/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt +++ b/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt @@ -14,17 +14,16 @@ import androidx.navigation.ui.R import androidx.test.core.app.ApplicationProvider import dev.hotwire.core.turbo.config.PathConfiguration import dev.hotwire.core.turbo.config.PathConfiguration.Location -import dev.hotwire.core.turbo.config.PathConfigurationLoadState -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking import dev.hotwire.core.turbo.nav.Presentation import dev.hotwire.core.turbo.nav.PresentationContext import dev.hotwire.core.turbo.nav.QueryStringPresentation import dev.hotwire.core.turbo.visit.VisitAction import dev.hotwire.core.turbo.visit.VisitOptions +import dev.hotwire.navigation.CoroutinesTestRule import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -71,6 +70,10 @@ class NavigatorRuleTest { } } + @Rule + @JvmField + var coroutinesTestRule = CoroutinesTestRule() + @Before fun setup() { context = ApplicationProvider.getApplicationContext() @@ -82,11 +85,6 @@ class NavigatorRuleTest { options = PathConfiguration.LoaderOptions() ) } - - // Wait for the async config load to complete - runBlocking { - pathConfiguration.loadState.first { it !is PathConfigurationLoadState.Idle } - } } @Test From 0808825417dd7e441f771c216156dfa40f15d520 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 25 Mar 2026 09:32:56 -0400 Subject: [PATCH 07/25] Use the IO scope to load the path config from the disk/network --- .../core/turbo/config/PathConfiguration.kt | 9 ++-- .../turbo/config/PathConfigurationTest.kt | 41 +++++++++++++++++++ .../navigation/navigator/NavigatorRuleTest.kt | 8 ++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt index 9d388127..342da5b8 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt @@ -10,10 +10,8 @@ import dev.hotwire.core.turbo.nav.PresentationContext import dev.hotwire.core.turbo.nav.QueryStringPresentation import dev.hotwire.core.turbo.util.dispatcherProvider import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -24,7 +22,8 @@ import kotlinx.coroutines.launch */ class PathConfiguration { private val cachedProperties: HashMap = hashMapOf() - private val scope: CoroutineScope = CoroutineScope(dispatcherProvider.main + SupervisorJob()) + private val loadingScope: CoroutineScope = CoroutineScope(dispatcherProvider.io + SupervisorJob()) + private val observerScope: CoroutineScope = CoroutineScope(dispatcherProvider.main + SupervisorJob()) private var observingLoadState = false internal var loader = PathConfigurationLoader() @@ -94,7 +93,7 @@ class PathConfiguration { observeLoadState() } - scope.launch { + loadingScope.launch { loader.load(context.applicationContext, location, options) } } @@ -134,7 +133,7 @@ class PathConfiguration { "Rules" to data.rules.size, "Settings" to data.settings.size )) - }.flowOn(Dispatchers.Unconfined).launchIn(scope) + }.launchIn(observerScope) } } diff --git a/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt b/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt index 12a33b74..5d7e0df5 100644 --- a/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt +++ b/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt @@ -6,6 +6,7 @@ import androidx.test.core.app.ApplicationProvider import com.google.gson.annotations.SerializedName import com.google.gson.reflect.TypeToken import com.nhaarman.mockito_kotlin.any +import com.nhaarman.mockito_kotlin.doReturn import com.nhaarman.mockito_kotlin.eq import com.nhaarman.mockito_kotlin.mock import com.nhaarman.mockito_kotlin.never @@ -18,7 +19,9 @@ import dev.hotwire.core.turbo.nav.Presentation import dev.hotwire.core.turbo.nav.PresentationContext import dev.hotwire.core.turbo.util.toJson import dev.hotwire.core.turbo.util.toObject +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import org.junit.Before @@ -193,6 +196,44 @@ class PathConfigurationTest : BaseRepositoryTest() { assertThat((pathConfiguration.properties("$url/custom/tabs").getTabs()?.first()?.label)).isEqualTo("Tab 1") } + @Test + fun bundledAssetIsLoadedBeforeCachedRemote() { + val remoteUrl = "$url/demo/configurations/android-v1.json" + val bundledJson = """{ "settings": {}, "rules": [{"patterns": [".+"], "properties": {"context": "default"}}] }""" + val cachedJson = """{ "settings": {}, "rules": [{"patterns": [".+"], "properties": {"context": "default"}}, {"patterns": ["/new$"], "properties": {"context": "modal"}}] }""" + + val loader = PathConfigurationLoader().apply { + repository = mock { + on { getBundledConfiguration(any(), eq("json/test-configuration.json")) } doReturn bundledJson + on { getCachedConfigurationForUrl(any(), eq(remoteUrl)) } doReturn cachedJson + } + } + + val collectedStates = mutableListOf() + + runBlocking { + val job = launch(Dispatchers.Unconfined) { + loader.loadState.collect { + collectedStates.add(it) + } + } + + loader.load( + context = context, + location = Location( + assetFilePath = "json/test-configuration.json", + remoteFileUrl = remoteUrl + ), + options = LoaderOptions() + ) + + job.cancel() + } + + assertThat(collectedStates.map { it.javaClass.simpleName }) + .containsExactly("Idle", "BundledAssetLoaded", "CachedRemoteLoaded") + } + // Extension functions to show support for deserializing custom properties/settings private fun PathConfigurationProperties.getTabs(): List? { diff --git a/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt b/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt index 4b2fa782..e7e310a3 100644 --- a/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt +++ b/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt @@ -14,6 +14,9 @@ import androidx.navigation.ui.R import androidx.test.core.app.ApplicationProvider import dev.hotwire.core.turbo.config.PathConfiguration import dev.hotwire.core.turbo.config.PathConfiguration.Location +import dev.hotwire.core.turbo.config.PathConfigurationLoadState +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import dev.hotwire.core.turbo.nav.Presentation import dev.hotwire.core.turbo.nav.PresentationContext import dev.hotwire.core.turbo.nav.QueryStringPresentation @@ -85,6 +88,11 @@ class NavigatorRuleTest { options = PathConfiguration.LoaderOptions() ) } + + // Wait for the async config load on IO to complete + runBlocking { + pathConfiguration.loadState.first { it !is PathConfigurationLoadState.Idle } + } } @Test From 9a2910ad0e413018ff63965b2a4b80cebdf7a47f Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 25 Mar 2026 09:49:13 -0400 Subject: [PATCH 08/25] Only load the bundled configuration if the cached configuration isn't available or can't parse --- .../turbo/config/PathConfigurationLoader.kt | 33 +++++--- .../turbo/config/PathConfigurationTest.kt | 78 +++++++++++++++++-- 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt index fd0fdcc3..f1c416e3 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt @@ -20,12 +20,22 @@ internal class PathConfigurationLoader { location: PathConfiguration.Location, options: PathConfiguration.LoaderOptions ) { - location.assetFilePath?.let { - loadBundledAssetConfiguration(context, it) + // Attempt to load the cached remote configuration for the url, if available + val cachedLoaded = if (location.remoteFileUrl != null) { + loadCachedConfigurationForUrl(context, location.remoteFileUrl) + } else { + false } + // Only load the bundled config if a cached config is not available + if (!cachedLoaded) { + location.assetFilePath?.let { + loadBundledAssetConfiguration(context, it) + } + } + + // Load a fresh remote config from the server location.remoteFileUrl?.let { url -> - loadCachedConfigurationForUrl(context, url) downloadRemoteConfigurationForUrl(context, url, options) } } @@ -46,14 +56,19 @@ internal class PathConfigurationLoader { private fun loadCachedConfigurationForUrl( context: Context, url: String - ) { + ): Boolean { logEvent("cachedPathConfigurationLoading", url) - repository.getCachedConfigurationForUrl(context, url)?.let { json -> - load(json)?.let { - logEvent("cachedPathConfigurationLoaded", url) - _loadState.value = PathConfigurationLoadState.CachedRemoteLoaded(it) - } + val json = repository.getCachedConfigurationForUrl(context, url) + val config = json?.let { load(it) } + + return if (config != null) { + logEvent("cachedPathConfigurationLoaded", url) + _loadState.value = PathConfigurationLoadState.CachedRemoteLoaded(config) + true + } else { + logEvent("cachedPathConfigurationFailedToLoad", url) + false } } diff --git a/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt b/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt index 5d7e0df5..7ed71ba5 100644 --- a/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt +++ b/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt @@ -197,7 +197,7 @@ class PathConfigurationTest : BaseRepositoryTest() { } @Test - fun bundledAssetIsLoadedBeforeCachedRemote() { + fun cachedConfigurationSkipsBundled() { val remoteUrl = "$url/demo/configurations/android-v1.json" val bundledJson = """{ "settings": {}, "rules": [{"patterns": [".+"], "properties": {"context": "default"}}] }""" val cachedJson = """{ "settings": {}, "rules": [{"patterns": [".+"], "properties": {"context": "default"}}, {"patterns": ["/new$"], "properties": {"context": "modal"}}] }""" @@ -213,9 +213,7 @@ class PathConfigurationTest : BaseRepositoryTest() { runBlocking { val job = launch(Dispatchers.Unconfined) { - loader.loadState.collect { - collectedStates.add(it) - } + loader.loadState.collect { collectedStates.add(it) } } loader.load( @@ -231,7 +229,77 @@ class PathConfigurationTest : BaseRepositoryTest() { } assertThat(collectedStates.map { it.javaClass.simpleName }) - .containsExactly("Idle", "BundledAssetLoaded", "CachedRemoteLoaded") + .containsExactly("Idle", "CachedRemoteLoaded") + } + + @Test + fun malformedCachedConfigurationFallsBackToBundled() { + val remoteUrl = "$url/demo/configurations/android-v1.json" + val bundledJson = """{ "settings": {}, "rules": [{"patterns": [".+"], "properties": {"context": "default"}}] }""" + + val loader = PathConfigurationLoader().apply { + repository = mock { + on { getBundledConfiguration(any(), eq("json/test-configuration.json")) } doReturn bundledJson + on { getCachedConfigurationForUrl(any(), eq(remoteUrl)) } doReturn "malformed-json" + } + } + + val collectedStates = mutableListOf() + + runBlocking { + val job = launch(Dispatchers.Unconfined) { + loader.loadState.collect { collectedStates.add(it) } + } + + loader.load( + context = context, + location = Location( + assetFilePath = "json/test-configuration.json", + remoteFileUrl = remoteUrl + ), + options = LoaderOptions() + ) + + job.cancel() + } + + assertThat(collectedStates.map { it.javaClass.simpleName }) + .containsExactly("Idle", "BundledAssetLoaded") + } + + @Test + fun noCachedConfigurationLoadsBundled() { + val remoteUrl = "$url/demo/configurations/android-v1.json" + val bundledJson = """{ "settings": {}, "rules": [{"patterns": [".+"], "properties": {"context": "default"}}] }""" + + val loader = PathConfigurationLoader().apply { + repository = mock { + on { getBundledConfiguration(any(), eq("json/test-configuration.json")) } doReturn bundledJson + on { getCachedConfigurationForUrl(any(), eq(remoteUrl)) } doReturn null + } + } + + val collectedStates = mutableListOf() + + runBlocking { + val job = launch(Dispatchers.Unconfined) { + loader.loadState.collect { collectedStates.add(it) } + } + + loader.load( + context = context, + location = Location( + assetFilePath = "json/test-configuration.json", + remoteFileUrl = remoteUrl + ), + options = LoaderOptions() + ) + + job.cancel() + } + + assertThat(collectedStates.map { it.javaClass.simpleName }) + .containsExactly("Idle", "BundledAssetLoaded") } // Extension functions to show support for deserializing custom properties/settings From 0cb0c005c60477384fac22f187ee4b7fbddbcc16 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 25 Mar 2026 10:23:24 -0400 Subject: [PATCH 09/25] Clean up tests --- .../turbo/config/PathConfigurationTest.kt | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt b/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt index 7ed71ba5..cc60d907 100644 --- a/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt +++ b/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt @@ -199,20 +199,18 @@ class PathConfigurationTest : BaseRepositoryTest() { @Test fun cachedConfigurationSkipsBundled() { val remoteUrl = "$url/demo/configurations/android-v1.json" - val bundledJson = """{ "settings": {}, "rules": [{"patterns": [".+"], "properties": {"context": "default"}}] }""" - val cachedJson = """{ "settings": {}, "rules": [{"patterns": [".+"], "properties": {"context": "default"}}, {"patterns": ["/new$"], "properties": {"context": "modal"}}] }""" val loader = PathConfigurationLoader().apply { repository = mock { - on { getBundledConfiguration(any(), eq("json/test-configuration.json")) } doReturn bundledJson - on { getCachedConfigurationForUrl(any(), eq(remoteUrl)) } doReturn cachedJson + on { getBundledConfiguration(any(), eq("json/test-configuration.json")) } doReturn BUNDLED_JSON + on { getCachedConfigurationForUrl(any(), eq(remoteUrl)) } doReturn CACHED_JSON } } val collectedStates = mutableListOf() runBlocking { - val job = launch(Dispatchers.Unconfined) { + val job = launch(Dispatchers.Main) { loader.loadState.collect { collectedStates.add(it) } } @@ -235,11 +233,10 @@ class PathConfigurationTest : BaseRepositoryTest() { @Test fun malformedCachedConfigurationFallsBackToBundled() { val remoteUrl = "$url/demo/configurations/android-v1.json" - val bundledJson = """{ "settings": {}, "rules": [{"patterns": [".+"], "properties": {"context": "default"}}] }""" val loader = PathConfigurationLoader().apply { repository = mock { - on { getBundledConfiguration(any(), eq("json/test-configuration.json")) } doReturn bundledJson + on { getBundledConfiguration(any(), eq("json/test-configuration.json")) } doReturn BUNDLED_JSON on { getCachedConfigurationForUrl(any(), eq(remoteUrl)) } doReturn "malformed-json" } } @@ -247,7 +244,7 @@ class PathConfigurationTest : BaseRepositoryTest() { val collectedStates = mutableListOf() runBlocking { - val job = launch(Dispatchers.Unconfined) { + val job = launch(Dispatchers.Main) { loader.loadState.collect { collectedStates.add(it) } } @@ -270,11 +267,10 @@ class PathConfigurationTest : BaseRepositoryTest() { @Test fun noCachedConfigurationLoadsBundled() { val remoteUrl = "$url/demo/configurations/android-v1.json" - val bundledJson = """{ "settings": {}, "rules": [{"patterns": [".+"], "properties": {"context": "default"}}] }""" val loader = PathConfigurationLoader().apply { repository = mock { - on { getBundledConfiguration(any(), eq("json/test-configuration.json")) } doReturn bundledJson + on { getBundledConfiguration(any(), eq("json/test-configuration.json")) } doReturn BUNDLED_JSON on { getCachedConfigurationForUrl(any(), eq(remoteUrl)) } doReturn null } } @@ -282,7 +278,7 @@ class PathConfigurationTest : BaseRepositoryTest() { val collectedStates = mutableListOf() runBlocking { - val job = launch(Dispatchers.Unconfined) { + val job = launch(Dispatchers.Main) { loader.loadState.collect { collectedStates.add(it) } } @@ -302,6 +298,10 @@ class PathConfigurationTest : BaseRepositoryTest() { .containsExactly("Idle", "BundledAssetLoaded") } + private fun load(json: String): PathConfigurationData { + return json.toObject(object : TypeToken() {}) + } + // Extension functions to show support for deserializing custom properties/settings private fun PathConfigurationProperties.getTabs(): List? { @@ -321,4 +321,9 @@ class PathConfigurationTest : BaseRepositoryTest() { @SerializedName("marketing_site") val marketingSite: String, @SerializedName("demo_site") val demoSite: String ) + + companion object { + private const val BUNDLED_JSON = """{ "settings": {}, "rules": [{"patterns": [".+"], "properties": {"context": "default"}}] }""" + private const val CACHED_JSON = """{ "settings": {}, "rules": [{"patterns": [".+"], "properties": {"context": "default"}}, {"patterns": ["/new$"], "properties": {"context": "modal"}}] }""" + } } From 65a55ce3006da4ef143ea9f8bf03aa45f3b23785 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 25 Mar 2026 11:53:18 -0400 Subject: [PATCH 10/25] Load the path configuration after enabling debug logging --- .../kotlin/dev/hotwire/demo/DemoApplication.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/demo/src/main/kotlin/dev/hotwire/demo/DemoApplication.kt b/demo/src/main/kotlin/dev/hotwire/demo/DemoApplication.kt index c7595761..6d7505c3 100644 --- a/demo/src/main/kotlin/dev/hotwire/demo/DemoApplication.kt +++ b/demo/src/main/kotlin/dev/hotwire/demo/DemoApplication.kt @@ -24,15 +24,6 @@ class DemoApplication : Application() { } private fun configureApp() { - // Loads the path configuration - Hotwire.loadPathConfiguration( - context = this, - location = PathConfiguration.Location( - assetFilePath = "json/path-configuration.json", - remoteFileUrl = "${Demo.current.url}/configurations/android_v1.json" - ) - ) - // Set the default fragment destination Hotwire.defaultFragmentDestination = WebFragment::class @@ -56,5 +47,14 @@ class DemoApplication : Application() { Hotwire.config.webViewDebuggingEnabled = BuildConfig.DEBUG Hotwire.config.jsonConverter = KotlinXJsonConverter() Hotwire.config.applicationUserAgentPrefix = "Hotwire Demo;" + + // Loads the path configuration + Hotwire.loadPathConfiguration( + context = this, + location = PathConfiguration.Location( + assetFilePath = "json/path-configuration.json", + remoteFileUrl = "${Demo.current.url}/configurations/android_v1.json" + ) + ) } } From 60a421b7cdfcf48f88d4578d39c9a6e860ef1c94 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 25 Mar 2026 12:01:12 -0400 Subject: [PATCH 11/25] Implement .equals() and .hashCode() for PathConfigurationData so its equality can be determined --- .../core/turbo/config/PathConfigurationData.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationData.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationData.kt index 55aacc21..90c4b499 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationData.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationData.kt @@ -42,4 +42,17 @@ class PathConfigurationData internal constructor( else -> "${url.path}?${url.query}" } } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PathConfigurationData) return false + + return rules == other.rules && settings == other.settings + } + + override fun hashCode(): Int { + var result = rules.hashCode() + result = 31 * result + settings.hashCode() + return result + } } From d320eabcea838b03b496b06c5a2ccfec4d0487e8 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 25 Mar 2026 12:20:00 -0400 Subject: [PATCH 12/25] Use a sealed class for Loading so you don't have to check each individual loaded type --- .../core/turbo/config/PathConfiguration.kt | 25 ++++++++--------- .../config/PathConfigurationLoadState.kt | 27 ++++++++++++------- .../turbo/config/PathConfigurationLoader.kt | 6 ++--- 3 files changed, 31 insertions(+), 27 deletions(-) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt index 342da5b8..8ec50184 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt @@ -118,21 +118,18 @@ class PathConfiguration { private fun observeLoadState() { loader.loadState.onEach { state -> - val config = when (state) { - is PathConfigurationLoadState.BundledAssetLoaded -> state.configuration - is PathConfigurationLoadState.CachedRemoteLoaded -> state.configuration - is PathConfigurationLoadState.RemoteLoaded -> state.configuration - is PathConfigurationLoadState.Idle -> return@onEach + if (state is PathConfigurationLoadState.Loaded) { + cachedProperties.clear() + data = state.configuration + + logEvent( + "pathConfigurationUpdated", listOf( + "Source" to state.javaClass.simpleName, + "Rules" to data.rules.size, + "Settings" to data.settings.size + ) + ) } - - cachedProperties.clear() - data = config - - logEvent("pathConfigurationUpdated", listOf( - "Source" to state.javaClass.simpleName, - "Rules" to data.rules.size, - "Settings" to data.settings.size - )) }.launchIn(observerScope) } } diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt index 099f746e..207f55f5 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt @@ -12,17 +12,24 @@ sealed class PathConfigurationLoadState { data object Idle : PathConfigurationLoadState() /** - * The configuration was loaded from the locally bundled asset file. + * The configuration was successfully loaded from a source. Check the + * specific subclass to determine the source: [BundledAssetLoaded], + * [CachedRemoteLoaded], or [RemoteLoaded]. */ - data class BundledAssetLoaded(val configuration: PathConfigurationData) : PathConfigurationLoadState() + sealed class Loaded(val configuration: PathConfigurationData) : PathConfigurationLoadState() { + /** + * The configuration was loaded from the locally bundled asset file. + */ + class BundledAssetLoaded(configuration: PathConfigurationData) : Loaded(configuration) - /** - * The configuration was loaded from a previously cached remote file. - */ - data class CachedRemoteLoaded(val configuration: PathConfigurationData) : PathConfigurationLoadState() + /** + * The configuration was loaded from a previously cached remote file. + */ + class CachedRemoteLoaded(configuration: PathConfigurationData) : Loaded(configuration) - /** - * The configuration was freshly loaded from the remote server. - */ - data class RemoteLoaded(val configuration: PathConfigurationData) : PathConfigurationLoadState() + /** + * The configuration was freshly loaded from the remote server. + */ + class RemoteLoaded(configuration: PathConfigurationData) : Loaded(configuration) + } } diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt index f1c416e3..c8529d85 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt @@ -49,7 +49,7 @@ internal class PathConfigurationLoader { val json = repository.getBundledConfiguration(context, filePath) load(json)?.let { logEvent("bundledPathConfigurationLoaded", filePath) - _loadState.value = PathConfigurationLoadState.BundledAssetLoaded(it) + _loadState.value = PathConfigurationLoadState.Loaded.BundledAssetLoaded(it) } } @@ -64,7 +64,7 @@ internal class PathConfigurationLoader { return if (config != null) { logEvent("cachedPathConfigurationLoaded", url) - _loadState.value = PathConfigurationLoadState.CachedRemoteLoaded(config) + _loadState.value = PathConfigurationLoadState.Loaded.CachedRemoteLoaded(config) true } else { logEvent("cachedPathConfigurationFailedToLoad", url) @@ -82,7 +82,7 @@ internal class PathConfigurationLoader { repository.getRemoteConfiguration(url, options)?.let { json -> load(json)?.let { logEvent("remotePathConfigurationLoaded", url) - _loadState.value = PathConfigurationLoadState.RemoteLoaded(it) + _loadState.value = PathConfigurationLoadState.Loaded.RemoteLoaded(it) cacheConfigurationForUrl(context, url, it) } } From b8750ee6c64b40748525a3a8e8cc0af0fd036c19 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 25 Mar 2026 12:59:56 -0400 Subject: [PATCH 13/25] Load the bundled or cached configuration synchronously so that the previous behavior doesn't change. --- .../core/turbo/config/PathConfiguration.kt | 62 +++---- .../config/PathConfigurationLoadState.kt | 8 +- .../turbo/config/PathConfigurationLoader.kt | 68 +++----- .../turbo/config/PathConfigurationTest.kt | 153 ++++++++++-------- .../navigation/navigator/NavigatorRuleTest.kt | 5 - 5 files changed, 145 insertions(+), 151 deletions(-) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt index 8ec50184..81135860 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt @@ -11,9 +11,9 @@ import dev.hotwire.core.turbo.nav.QueryStringPresentation import dev.hotwire.core.turbo.util.dispatcherProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch /** @@ -23,8 +23,7 @@ import kotlinx.coroutines.launch class PathConfiguration { private val cachedProperties: HashMap = hashMapOf() private val loadingScope: CoroutineScope = CoroutineScope(dispatcherProvider.io + SupervisorJob()) - private val observerScope: CoroutineScope = CoroutineScope(dispatcherProvider.main + SupervisorJob()) - private var observingLoadState = false + private val _loadState = MutableStateFlow(PathConfigurationLoadState.Idle) internal var loader = PathConfigurationLoader() internal var data = PathConfigurationData() @@ -35,13 +34,14 @@ class PathConfiguration { * loaded and from which source (bundled asset, cached remote, or fresh remote). */ val loadState: StateFlow - get() = loader.loadState + get() = _loadState.asStateFlow() /** * Gets the top-level settings specified in the app's path configuration. * The settings are map of key/value `String` items. */ - val settings get() = data.settings + val settings: PathConfigurationSettings + get() = synchronized(this) { data.settings } /** * Represents the location of the app's path configuration JSON file(s). @@ -88,13 +88,18 @@ class PathConfiguration { ) { logEvent("pathConfigurationLoading", location.toString()) - if (!observingLoadState) { - observingLoadState = true - observeLoadState() + val appContext = context.applicationContext + + loader.loadCachedOrBundledConfiguration(appContext, location)?.let { + applyLoadedState(it) } loadingScope.launch { - loader.load(context.applicationContext, location, options) + location.remoteFileUrl?.let { url -> + loader.loadRemoteConfigurationForUrl(appContext, url, options)?.let { + applyLoadedState(it) + } + } } } @@ -108,29 +113,28 @@ class PathConfiguration { * @return The map of key/value `String` properties */ fun properties(location: String): PathConfigurationProperties { - cachedProperties[location]?.let { return it } + synchronized(this) { + cachedProperties[location]?.let { return it } - val properties = data.properties(location) - cachedProperties[location] = properties + val properties = data.properties(location) + cachedProperties[location] = properties - return properties + return properties + } } - private fun observeLoadState() { - loader.loadState.onEach { state -> - if (state is PathConfigurationLoadState.Loaded) { - cachedProperties.clear() - data = state.configuration - - logEvent( - "pathConfigurationUpdated", listOf( - "Source" to state.javaClass.simpleName, - "Rules" to data.rules.size, - "Settings" to data.settings.size - ) - ) - } - }.launchIn(observerScope) + private fun applyLoadedState(state: PathConfigurationLoadState.Loaded) = synchronized(this) { + cachedProperties.clear() + data = state.configuration + _loadState.value = state + + logEvent( + "pathConfigurationUpdated", listOf( + "Source" to state.javaClass.simpleName, + "Rules" to data.rules.size, + "Settings" to data.settings.size + ) + ) } } diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt index 207f55f5..e7f522c7 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt @@ -16,20 +16,20 @@ sealed class PathConfigurationLoadState { * specific subclass to determine the source: [BundledAssetLoaded], * [CachedRemoteLoaded], or [RemoteLoaded]. */ - sealed class Loaded(val configuration: PathConfigurationData) : PathConfigurationLoadState() { + sealed class Loaded(open val configuration: PathConfigurationData) : PathConfigurationLoadState() { /** * The configuration was loaded from the locally bundled asset file. */ - class BundledAssetLoaded(configuration: PathConfigurationData) : Loaded(configuration) + data class BundledAssetLoaded(override val configuration: PathConfigurationData) : Loaded(configuration) /** * The configuration was loaded from a previously cached remote file. */ - class CachedRemoteLoaded(configuration: PathConfigurationData) : Loaded(configuration) + data class CachedRemoteLoaded(override val configuration: PathConfigurationData) : Loaded(configuration) /** * The configuration was freshly loaded from the remote server. */ - class RemoteLoaded(configuration: PathConfigurationData) : Loaded(configuration) + data class RemoteLoaded(override val configuration: PathConfigurationData) : Loaded(configuration) } } diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt index c8529d85..36af6512 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoader.kt @@ -5,86 +5,70 @@ import com.google.gson.reflect.TypeToken import dev.hotwire.core.logging.logError import dev.hotwire.core.logging.logEvent import dev.hotwire.core.turbo.util.toObject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow internal class PathConfigurationLoader { internal var repository = PathConfigurationRepository() - private val _loadState = MutableStateFlow(PathConfigurationLoadState.Idle) - val loadState: StateFlow = _loadState.asStateFlow() - - suspend fun load( + fun loadCachedOrBundledConfiguration( context: Context, - location: PathConfiguration.Location, - options: PathConfiguration.LoaderOptions - ) { + location: PathConfiguration.Location + ): PathConfigurationLoadState.Loaded? { // Attempt to load the cached remote configuration for the url, if available - val cachedLoaded = if (location.remoteFileUrl != null) { + if (location.remoteFileUrl != null) { loadCachedConfigurationForUrl(context, location.remoteFileUrl) - } else { - false + ?.let { return it } } - // Only load the bundled config if a cached config is not available - if (!cachedLoaded) { - location.assetFilePath?.let { - loadBundledAssetConfiguration(context, it) - } - } - - // Load a fresh remote config from the server - location.remoteFileUrl?.let { url -> - downloadRemoteConfigurationForUrl(context, url, options) - } + // Fall back to the bundled config when a cached config is not available + return location.assetFilePath?.let { loadBundledAssetConfiguration(context, it) } } private fun loadBundledAssetConfiguration( context: Context, filePath: String - ) { + ): PathConfigurationLoadState.Loaded.BundledAssetLoaded? { logEvent("bundledPathConfigurationLoading", filePath) val json = repository.getBundledConfiguration(context, filePath) - load(json)?.let { + return load(json)?.let { logEvent("bundledPathConfigurationLoaded", filePath) - _loadState.value = PathConfigurationLoadState.Loaded.BundledAssetLoaded(it) + PathConfigurationLoadState.Loaded.BundledAssetLoaded(it) } } private fun loadCachedConfigurationForUrl( context: Context, url: String - ): Boolean { + ): PathConfigurationLoadState.Loaded.CachedRemoteLoaded? { logEvent("cachedPathConfigurationLoading", url) val json = repository.getCachedConfigurationForUrl(context, url) val config = json?.let { load(it) } - return if (config != null) { - logEvent("cachedPathConfigurationLoaded", url) - _loadState.value = PathConfigurationLoadState.Loaded.CachedRemoteLoaded(config) - true - } else { + return if (config == null) { logEvent("cachedPathConfigurationFailedToLoad", url) - false + null + } else { + logEvent("cachedPathConfigurationLoaded", url) + PathConfigurationLoadState.Loaded.CachedRemoteLoaded(config) } } - private suspend fun downloadRemoteConfigurationForUrl( + suspend fun loadRemoteConfigurationForUrl( context: Context, url: String, options: PathConfiguration.LoaderOptions - ) { + ): PathConfigurationLoadState.Loaded.RemoteLoaded? { logEvent("remotePathConfigurationLoading", url) - repository.getRemoteConfiguration(url, options)?.let { json -> - load(json)?.let { - logEvent("remotePathConfigurationLoaded", url) - _loadState.value = PathConfigurationLoadState.Loaded.RemoteLoaded(it) - cacheConfigurationForUrl(context, url, it) - } + val config = repository.getRemoteConfiguration(url, options)?.let { json -> load(json) } + + return if (config == null) { + null + } else { + logEvent("remotePathConfigurationLoaded", url) + cacheConfigurationForUrl(context, url, config) + PathConfigurationLoadState.Loaded.RemoteLoaded(config) } } diff --git a/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt b/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt index cc60d907..82a8c71e 100644 --- a/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt +++ b/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt @@ -19,9 +19,7 @@ import dev.hotwire.core.turbo.nav.Presentation import dev.hotwire.core.turbo.nav.PresentationContext import dev.hotwire.core.turbo.util.toJson import dev.hotwire.core.turbo.util.toObject -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import org.junit.Before @@ -64,6 +62,18 @@ class PathConfigurationTest : BaseRepositoryTest() { assertThat(pathConfiguration.data.rules.size).isGreaterThan(0) } + @Test + fun assetConfigurationIsAvailableImmediatelyAfterLoadReturns() { + val freshConfig = PathConfiguration() + freshConfig.load( + context = context, + location = Location(assetFilePath = "json/test-configuration.json"), + options = options + ) + + assertThat(freshConfig.properties("$url/new").context).isEqualTo(PresentationContext.MODAL) + } + @Test fun presentationContext() { assertThat(pathConfiguration.properties("$url/home").context).isEqualTo( @@ -200,102 +210,103 @@ class PathConfigurationTest : BaseRepositoryTest() { fun cachedConfigurationSkipsBundled() { val remoteUrl = "$url/demo/configurations/android-v1.json" - val loader = PathConfigurationLoader().apply { - repository = mock { - on { getBundledConfiguration(any(), eq("json/test-configuration.json")) } doReturn BUNDLED_JSON - on { getCachedConfigurationForUrl(any(), eq(remoteUrl)) } doReturn CACHED_JSON + val config = PathConfiguration().apply { + loader = PathConfigurationLoader().apply { + repository = mock { + on { getBundledConfiguration(any(), eq("json/test-configuration.json")) } doReturn BUNDLED_JSON + on { getCachedConfigurationForUrl(any(), eq(remoteUrl)) } doReturn CACHED_JSON + } } } - val collectedStates = mutableListOf() - - runBlocking { - val job = launch(Dispatchers.Main) { - loader.loadState.collect { collectedStates.add(it) } - } - - loader.load( - context = context, - location = Location( - assetFilePath = "json/test-configuration.json", - remoteFileUrl = remoteUrl - ), - options = LoaderOptions() - ) - - job.cancel() - } + config.load( + context = context, + location = Location( + assetFilePath = "json/test-configuration.json", + remoteFileUrl = remoteUrl + ), + options = LoaderOptions() + ) - assertThat(collectedStates.map { it.javaClass.simpleName }) - .containsExactly("Idle", "CachedRemoteLoaded") + assertThat(config.loadState.value).isEqualTo( + PathConfigurationLoadState.Loaded.CachedRemoteLoaded(load(CACHED_JSON)) + ) } @Test fun malformedCachedConfigurationFallsBackToBundled() { val remoteUrl = "$url/demo/configurations/android-v1.json" - val loader = PathConfigurationLoader().apply { - repository = mock { - on { getBundledConfiguration(any(), eq("json/test-configuration.json")) } doReturn BUNDLED_JSON - on { getCachedConfigurationForUrl(any(), eq(remoteUrl)) } doReturn "malformed-json" + val config = PathConfiguration().apply { + loader = PathConfigurationLoader().apply { + repository = mock { + on { getBundledConfiguration(any(), eq("json/test-configuration.json")) } doReturn BUNDLED_JSON + on { getCachedConfigurationForUrl(any(), eq(remoteUrl)) } doReturn "malformed-json" + } } } - val collectedStates = mutableListOf() - - runBlocking { - val job = launch(Dispatchers.Main) { - loader.loadState.collect { collectedStates.add(it) } - } - - loader.load( - context = context, - location = Location( - assetFilePath = "json/test-configuration.json", - remoteFileUrl = remoteUrl - ), - options = LoaderOptions() - ) - - job.cancel() - } + config.load( + context = context, + location = Location( + assetFilePath = "json/test-configuration.json", + remoteFileUrl = remoteUrl + ), + options = LoaderOptions() + ) - assertThat(collectedStates.map { it.javaClass.simpleName }) - .containsExactly("Idle", "BundledAssetLoaded") + assertThat(config.loadState.value).isEqualTo( + PathConfigurationLoadState.Loaded.BundledAssetLoaded(load(BUNDLED_JSON)) + ) } @Test fun noCachedConfigurationLoadsBundled() { val remoteUrl = "$url/demo/configurations/android-v1.json" - val loader = PathConfigurationLoader().apply { - repository = mock { - on { getBundledConfiguration(any(), eq("json/test-configuration.json")) } doReturn BUNDLED_JSON - on { getCachedConfigurationForUrl(any(), eq(remoteUrl)) } doReturn null + val config = PathConfiguration().apply { + loader = PathConfigurationLoader().apply { + repository = mock { + on { getBundledConfiguration(any(), eq("json/test-configuration.json")) } doReturn BUNDLED_JSON + on { getCachedConfigurationForUrl(any(), eq(remoteUrl)) } doReturn null + } } } - val collectedStates = mutableListOf() - - runBlocking { - val job = launch(Dispatchers.Main) { - loader.loadState.collect { collectedStates.add(it) } - } + config.load( + context = context, + location = Location( + assetFilePath = "json/test-configuration.json", + remoteFileUrl = remoteUrl + ), + options = LoaderOptions() + ) - loader.load( - context = context, - location = Location( - assetFilePath = "json/test-configuration.json", - remoteFileUrl = remoteUrl - ), - options = LoaderOptions() - ) + assertThat(config.loadState.value).isEqualTo( + PathConfigurationLoadState.Loaded.BundledAssetLoaded(load(BUNDLED_JSON)) + ) + } - job.cancel() + @Test + fun loadStateAndDataStayInSync() { + val remoteUrl = "$url/demo/configurations/android-v1.json" + val config = PathConfiguration().apply { + loader = PathConfigurationLoader().apply { + repository = mock { + on { getCachedConfigurationForUrl(any(), eq(remoteUrl)) } doReturn CACHED_JSON + } + } } - assertThat(collectedStates.map { it.javaClass.simpleName }) - .containsExactly("Idle", "BundledAssetLoaded") + config.load( + context = context, + location = Location(remoteFileUrl = remoteUrl), + options = LoaderOptions() + ) + + val state = config.loadState.value as PathConfigurationLoadState.Loaded.CachedRemoteLoaded + assertThat(config.data).isEqualTo(state.configuration) + assertThat(config.properties("$url/new").context).isEqualTo(PresentationContext.MODAL) } private fun load(json: String): PathConfigurationData { diff --git a/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt b/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt index e7e310a3..0774063f 100644 --- a/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt +++ b/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt @@ -88,11 +88,6 @@ class NavigatorRuleTest { options = PathConfiguration.LoaderOptions() ) } - - // Wait for the async config load on IO to complete - runBlocking { - pathConfiguration.loadState.first { it !is PathConfigurationLoadState.Idle } - } } @Test From 6bf6c3e1723441fb0c3cad714ebe851eccc9dd15 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 25 Mar 2026 13:17:51 -0400 Subject: [PATCH 14/25] Don't use a separate data instance to hold state --- .../core/turbo/config/PathConfiguration.kt | 17 +++++++++-------- .../core/turbo/config/PathConfigurationTest.kt | 7 ++++--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt index 81135860..5c85f989 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt @@ -24,24 +24,23 @@ class PathConfiguration { private val cachedProperties: HashMap = hashMapOf() private val loadingScope: CoroutineScope = CoroutineScope(dispatcherProvider.io + SupervisorJob()) private val _loadState = MutableStateFlow(PathConfigurationLoadState.Idle) + private val defaultConfiguration = PathConfigurationData() internal var loader = PathConfigurationLoader() - internal var data = PathConfigurationData() /** * A [StateFlow] that emits the current state of the path configuration * loading process. Observe this to know when the configuration has been * loaded and from which source (bundled asset, cached remote, or fresh remote). */ - val loadState: StateFlow - get() = _loadState.asStateFlow() + val loadState: StateFlow = _loadState.asStateFlow() /** * Gets the top-level settings specified in the app's path configuration. * The settings are map of key/value `String` items. */ val settings: PathConfigurationSettings - get() = synchronized(this) { data.settings } + get() = synchronized(this) { currentConfiguration.settings } /** * Represents the location of the app's path configuration JSON file(s). @@ -116,7 +115,7 @@ class PathConfiguration { synchronized(this) { cachedProperties[location]?.let { return it } - val properties = data.properties(location) + val properties = currentConfiguration.properties(location) cachedProperties[location] = properties return properties @@ -125,17 +124,19 @@ class PathConfiguration { private fun applyLoadedState(state: PathConfigurationLoadState.Loaded) = synchronized(this) { cachedProperties.clear() - data = state.configuration _loadState.value = state logEvent( "pathConfigurationUpdated", listOf( "Source" to state.javaClass.simpleName, - "Rules" to data.rules.size, - "Settings" to data.settings.size + "Rules" to state.configuration.rules.size, + "Settings" to state.configuration.settings.size ) ) } + + private val currentConfiguration: PathConfigurationData + get() = (loadState.value as? PathConfigurationLoadState.Loaded)?.configuration ?: defaultConfiguration } typealias PathConfigurationProperties = HashMap diff --git a/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt b/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt index 82a8c71e..56549ad7 100644 --- a/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt +++ b/core/src/test/kotlin/dev/hotwire/core/turbo/config/PathConfigurationTest.kt @@ -59,7 +59,8 @@ class PathConfigurationTest : BaseRepositoryTest() { @Test fun assetConfigurationIsLoaded() { - assertThat(pathConfiguration.data.rules.size).isGreaterThan(0) + val state = pathConfiguration.loadState.value as PathConfigurationLoadState.Loaded + assertThat(state.configuration.rules.size).isGreaterThan(0) } @Test @@ -288,7 +289,7 @@ class PathConfigurationTest : BaseRepositoryTest() { } @Test - fun loadStateAndDataStayInSync() { + fun loadStateAndPropertiesStayInSync() { val remoteUrl = "$url/demo/configurations/android-v1.json" val config = PathConfiguration().apply { loader = PathConfigurationLoader().apply { @@ -305,7 +306,7 @@ class PathConfigurationTest : BaseRepositoryTest() { ) val state = config.loadState.value as PathConfigurationLoadState.Loaded.CachedRemoteLoaded - assertThat(config.data).isEqualTo(state.configuration) + assertThat(state.configuration).isEqualTo(load(CACHED_JSON)) assertThat(config.properties("$url/new").context).isEqualTo(PresentationContext.MODAL) } From a3143f64fe337625eb0061096faf0e7e9a837744 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 25 Mar 2026 13:29:35 -0400 Subject: [PATCH 15/25] Clean up getting the current configuration --- .../dev/hotwire/core/turbo/config/PathConfiguration.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt index 5c85f989..77cc73ba 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt @@ -5,6 +5,8 @@ import android.content.Context import android.net.Uri import androidx.core.net.toUri import dev.hotwire.core.logging.logEvent +import dev.hotwire.core.turbo.config.PathConfigurationLoadState.Idle +import dev.hotwire.core.turbo.config.PathConfigurationLoadState.Loaded import dev.hotwire.core.turbo.nav.Presentation import dev.hotwire.core.turbo.nav.PresentationContext import dev.hotwire.core.turbo.nav.QueryStringPresentation @@ -23,8 +25,7 @@ import kotlinx.coroutines.launch class PathConfiguration { private val cachedProperties: HashMap = hashMapOf() private val loadingScope: CoroutineScope = CoroutineScope(dispatcherProvider.io + SupervisorJob()) - private val _loadState = MutableStateFlow(PathConfigurationLoadState.Idle) - private val defaultConfiguration = PathConfigurationData() + private val _loadState = MutableStateFlow(Idle) internal var loader = PathConfigurationLoader() @@ -122,7 +123,7 @@ class PathConfiguration { } } - private fun applyLoadedState(state: PathConfigurationLoadState.Loaded) = synchronized(this) { + private fun applyLoadedState(state: Loaded) = synchronized(this) { cachedProperties.clear() _loadState.value = state @@ -136,7 +137,7 @@ class PathConfiguration { } private val currentConfiguration: PathConfigurationData - get() = (loadState.value as? PathConfigurationLoadState.Loaded)?.configuration ?: defaultConfiguration + get() = (_loadState.value as? Loaded)?.configuration ?: PathConfigurationData() } typealias PathConfigurationProperties = HashMap From 851e7e4ee59566b304ab0de5ee1fb3bbdaeb0a81 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 25 Mar 2026 14:10:30 -0400 Subject: [PATCH 16/25] Cancel the previous load() request if it's called again --- .../dev/hotwire/core/turbo/config/PathConfiguration.kt | 7 +++++-- .../core/turbo/config/PathConfigurationRepository.kt | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt index 77cc73ba..de6f5142 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt @@ -11,6 +11,7 @@ import dev.hotwire.core.turbo.nav.Presentation import dev.hotwire.core.turbo.nav.PresentationContext import dev.hotwire.core.turbo.nav.QueryStringPresentation import dev.hotwire.core.turbo.util.dispatcherProvider +import kotlinx.coroutines.Job import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow @@ -24,8 +25,9 @@ import kotlinx.coroutines.launch */ class PathConfiguration { private val cachedProperties: HashMap = hashMapOf() - private val loadingScope: CoroutineScope = CoroutineScope(dispatcherProvider.io + SupervisorJob()) private val _loadState = MutableStateFlow(Idle) + private val loadingScope: CoroutineScope = CoroutineScope(dispatcherProvider.io + SupervisorJob()) + private var loadingJob: Job? = null internal var loader = PathConfigurationLoader() @@ -94,7 +96,8 @@ class PathConfiguration { applyLoadedState(it) } - loadingScope.launch { + loadingJob?.cancel() + loadingJob = loadingScope.launch { location.remoteFileUrl?.let { url -> loader.loadRemoteConfigurationForUrl(appContext, url, options)?.let { applyLoadedState(it) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationRepository.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationRepository.kt index c5433f20..583ce9d1 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationRepository.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationRepository.kt @@ -7,6 +7,7 @@ import dev.hotwire.core.logging.logError import dev.hotwire.core.turbo.http.HotwireHttpClient import dev.hotwire.core.turbo.util.dispatcherProvider import dev.hotwire.core.turbo.util.toJson +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.withContext import okhttp3.Request @@ -69,6 +70,8 @@ internal class PathConfigurationRepository { null } } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { logError("remotePathConfigurationException", e) null From 048ab4833a5df643527f0f898b76e173f776b405 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Fri, 27 Mar 2026 06:43:02 -0400 Subject: [PATCH 17/25] Rename Idle -> NotLoaded --- .../kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt | 4 ++-- .../hotwire/core/turbo/config/PathConfigurationLoadState.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt index de6f5142..ecb700ad 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt @@ -5,7 +5,7 @@ import android.content.Context import android.net.Uri import androidx.core.net.toUri import dev.hotwire.core.logging.logEvent -import dev.hotwire.core.turbo.config.PathConfigurationLoadState.Idle +import dev.hotwire.core.turbo.config.PathConfigurationLoadState.NotLoaded import dev.hotwire.core.turbo.config.PathConfigurationLoadState.Loaded import dev.hotwire.core.turbo.nav.Presentation import dev.hotwire.core.turbo.nav.PresentationContext @@ -25,7 +25,7 @@ import kotlinx.coroutines.launch */ class PathConfiguration { private val cachedProperties: HashMap = hashMapOf() - private val _loadState = MutableStateFlow(Idle) + private val _loadState = MutableStateFlow(NotLoaded) private val loadingScope: CoroutineScope = CoroutineScope(dispatcherProvider.io + SupervisorJob()) private var loadingJob: Job? = null diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt index e7f522c7..7029f8a3 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt @@ -9,7 +9,7 @@ sealed class PathConfigurationLoadState { /** * The initial state before any configuration has been loaded. */ - data object Idle : PathConfigurationLoadState() + data object NotLoaded : PathConfigurationLoadState() /** * The configuration was successfully loaded from a source. Check the From 81b37a805436df4ce0aded0c8c0cf68d5b88db53 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Fri, 27 Mar 2026 11:26:27 -0400 Subject: [PATCH 18/25] Use a data class with the @ConsistentCopyVisibility annotation to avoid exposing the internal rules --- .../core/turbo/config/PathConfigurationData.kt | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationData.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationData.kt index 90c4b499..708eb3ee 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationData.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationData.kt @@ -3,7 +3,8 @@ package dev.hotwire.core.turbo.config import com.google.gson.annotations.SerializedName import java.net.URL -class PathConfigurationData internal constructor( +@ConsistentCopyVisibility +data class PathConfigurationData internal constructor( @SerializedName("rules") internal val rules: List = emptyList(), @@ -42,17 +43,4 @@ class PathConfigurationData internal constructor( else -> "${url.path}?${url.query}" } } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is PathConfigurationData) return false - - return rules == other.rules && settings == other.settings - } - - override fun hashCode(): Int { - var result = rules.hashCode() - result = 31 * result + settings.hashCode() - return result - } } From ffb8306336c72aef56ae6dade8ef9ee639f47a6d Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Fri, 27 Mar 2026 11:29:03 -0400 Subject: [PATCH 19/25] Use if/else instead of when --- .../dev/hotwire/core/turbo/config/PathConfigurationData.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationData.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationData.kt index 708eb3ee..f54b5519 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationData.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationData.kt @@ -38,9 +38,10 @@ data class PathConfigurationData internal constructor( private fun path(location: String): String { val url = URL(location) - return when (url.query) { - null -> url.path - else -> "${url.path}?${url.query}" + return if (url.query == null) { + url.path + } else { + "${url.path}?${url.query}" } } } From d847641f1bfc0f299f9703a12e29de16506b5fc1 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Fri, 27 Mar 2026 11:34:27 -0400 Subject: [PATCH 20/25] Cancel the previous job before loading the bundled/cached config --- .../dev/hotwire/core/turbo/config/PathConfiguration.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt index ecb700ad..fd3e2d6f 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt @@ -5,14 +5,14 @@ import android.content.Context import android.net.Uri import androidx.core.net.toUri import dev.hotwire.core.logging.logEvent -import dev.hotwire.core.turbo.config.PathConfigurationLoadState.NotLoaded import dev.hotwire.core.turbo.config.PathConfigurationLoadState.Loaded +import dev.hotwire.core.turbo.config.PathConfigurationLoadState.NotLoaded import dev.hotwire.core.turbo.nav.Presentation import dev.hotwire.core.turbo.nav.PresentationContext import dev.hotwire.core.turbo.nav.QueryStringPresentation import dev.hotwire.core.turbo.util.dispatcherProvider -import kotlinx.coroutines.Job import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -91,12 +91,12 @@ class PathConfiguration { logEvent("pathConfigurationLoading", location.toString()) val appContext = context.applicationContext + loadingJob?.cancel() loader.loadCachedOrBundledConfiguration(appContext, location)?.let { applyLoadedState(it) } - loadingJob?.cancel() loadingJob = loadingScope.launch { location.remoteFileUrl?.let { url -> loader.loadRemoteConfigurationForUrl(appContext, url, options)?.let { From 3b7a1f0b81aa8b85e85aa7c534ca677eed0a5983 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Fri, 27 Mar 2026 11:43:42 -0400 Subject: [PATCH 21/25] Use sealed interfaces to simplify state setup --- .../turbo/config/PathConfigurationLoadState.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt index 7029f8a3..2d614df8 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationLoadState.kt @@ -5,31 +5,33 @@ package dev.hotwire.core.turbo.config * Observe [PathConfiguration.loadState] to receive updates as the configuration * is loaded from each source. */ -sealed class PathConfigurationLoadState { +sealed interface PathConfigurationLoadState { /** * The initial state before any configuration has been loaded. */ - data object NotLoaded : PathConfigurationLoadState() + data object NotLoaded : PathConfigurationLoadState /** * The configuration was successfully loaded from a source. Check the * specific subclass to determine the source: [BundledAssetLoaded], * [CachedRemoteLoaded], or [RemoteLoaded]. */ - sealed class Loaded(open val configuration: PathConfigurationData) : PathConfigurationLoadState() { + sealed interface Loaded : PathConfigurationLoadState { + val configuration: PathConfigurationData + /** * The configuration was loaded from the locally bundled asset file. */ - data class BundledAssetLoaded(override val configuration: PathConfigurationData) : Loaded(configuration) + data class BundledAssetLoaded(override val configuration: PathConfigurationData) : Loaded /** * The configuration was loaded from a previously cached remote file. */ - data class CachedRemoteLoaded(override val configuration: PathConfigurationData) : Loaded(configuration) + data class CachedRemoteLoaded(override val configuration: PathConfigurationData) : Loaded /** * The configuration was freshly loaded from the remote server. */ - data class RemoteLoaded(override val configuration: PathConfigurationData) : Loaded(configuration) + data class RemoteLoaded(override val configuration: PathConfigurationData) : Loaded } } From 41a4aad56914008b831dc2fdb73d327b6e899f7e Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Fri, 27 Mar 2026 12:13:52 -0400 Subject: [PATCH 22/25] Upgrade OkHttp and implement the okhttp-coroutines dependency so fetching the remote configuration is cancellable --- core/build.gradle.kts | 7 ++--- .../config/PathConfigurationRepository.kt | 26 +++++++++---------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 81dd4c4c..4b685495 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -87,8 +87,9 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") // Networking/API - implementation("com.squareup.okhttp3:okhttp:4.12.0") - implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + implementation("com.squareup.okhttp3:okhttp:5.3.2") + implementation("com.squareup.okhttp3:okhttp-coroutines:5.3.2") + implementation("com.squareup.okhttp3:logging-interceptor:5.3.2") // Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1") @@ -108,7 +109,7 @@ dependencies { testImplementation("org.robolectric:robolectric:4.14.1") testImplementation("org.mockito:mockito-core:5.14.2") testImplementation("com.nhaarman:mockito-kotlin:1.6.0") - testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2") testImplementation("junit:junit:4.13.2") } diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationRepository.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationRepository.kt index 583ce9d1..18c4cd48 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationRepository.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfigurationRepository.kt @@ -10,6 +10,7 @@ import dev.hotwire.core.turbo.util.toJson import kotlinx.coroutines.CancellationException import kotlinx.coroutines.withContext import okhttp3.Request +import okhttp3.coroutines.executeAsync internal class PathConfigurationRepository { private val cacheFile = "turbo" @@ -25,10 +26,7 @@ internal class PathConfigurationRepository { } val request = requestBuilder.build() - - return withContext(dispatcherProvider.io) { - issueRequest(request) - } + return issueRequest(request) } fun getBundledConfiguration( @@ -55,13 +53,13 @@ internal class PathConfigurationRepository { } } - private fun issueRequest(request: Request): String? { - return try { - val call = HotwireHttpClient.instance.newCall(request) + private suspend fun issueRequest(request: Request): String? = try { + val call = HotwireHttpClient.instance.newCall(request) - call.execute().use { response -> + call.executeAsync().use { response -> + withContext(dispatcherProvider.io) { if (response.isSuccessful) { - response.body?.string() + response.body.string() } else { logError( "remotePathConfigurationFailure", @@ -70,12 +68,12 @@ internal class PathConfigurationRepository { null } } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - logError("remotePathConfigurationException", e) - null } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logError("remotePathConfigurationException", e) + null } private fun prefs(context: Context): SharedPreferences { From 7fb79740a97ebe3f10bb2e08a1b74e98863388df Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Fri, 27 Mar 2026 13:05:15 -0400 Subject: [PATCH 23/25] Make PathConfiguration have an internal constructor since using it outside of the global instance is an error --- .../core/turbo/config/PathConfiguration.kt | 2 +- .../navigation/navigator/NavigatorRuleTest.kt | 18 ++++++------------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt index fd3e2d6f..e697b8d7 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt @@ -23,7 +23,7 @@ import kotlinx.coroutines.launch * Provides the ability to load, parse, and retrieve url path * properties from the app's JSON configuration file. */ -class PathConfiguration { +class PathConfiguration internal constructor() { private val cachedProperties: HashMap = hashMapOf() private val _loadState = MutableStateFlow(NotLoaded) private val loadingScope: CoroutineScope = CoroutineScope(dispatcherProvider.io + SupervisorJob()) diff --git a/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt b/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt index 0774063f..7bfe4233 100644 --- a/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt +++ b/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt @@ -12,11 +12,8 @@ import androidx.navigation.navOptions import androidx.navigation.testing.TestNavHostController import androidx.navigation.ui.R import androidx.test.core.app.ApplicationProvider -import dev.hotwire.core.turbo.config.PathConfiguration +import dev.hotwire.core.config.Hotwire import dev.hotwire.core.turbo.config.PathConfiguration.Location -import dev.hotwire.core.turbo.config.PathConfigurationLoadState -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking import dev.hotwire.core.turbo.nav.Presentation import dev.hotwire.core.turbo.nav.PresentationContext import dev.hotwire.core.turbo.nav.QueryStringPresentation @@ -38,7 +35,7 @@ import org.robolectric.annotation.Config class NavigatorRuleTest { private lateinit var context: Context private lateinit var controller: TestNavHostController - private lateinit var pathConfiguration: PathConfiguration + private val pathConfiguration get() = Hotwire.config.pathConfiguration private val homeUrl = "https://hotwired.dev/home" private val newHomeUrl = "https://hotwired.dev/new-home" @@ -81,13 +78,10 @@ class NavigatorRuleTest { fun setup() { context = ApplicationProvider.getApplicationContext() controller = buildControllerWithGraph() - pathConfiguration = PathConfiguration().apply { - load( - context = context, - location = Location(assetFilePath = "json/test-configuration.json"), - options = PathConfiguration.LoaderOptions() - ) - } + Hotwire.loadPathConfiguration( + context = context, + location = Location(assetFilePath = "json/test-configuration.json") + ) } @Test From 3fcb5c54c1051b77a425af7feec5f5ded32f10a3 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Fri, 27 Mar 2026 13:20:31 -0400 Subject: [PATCH 24/25] Remove unneeded CoroutinesTestRule --- navigation-fragments/build.gradle.kts | 1 - .../hotwire/navigation/CoroutinesTestRule.kt | 25 ------------------- .../navigation/navigator/NavigatorRuleTest.kt | 6 ----- 3 files changed, 32 deletions(-) delete mode 100644 navigation-fragments/src/test/kotlin/dev/hotwire/navigation/CoroutinesTestRule.kt diff --git a/navigation-fragments/build.gradle.kts b/navigation-fragments/build.gradle.kts index 941ea215..5991810b 100644 --- a/navigation-fragments/build.gradle.kts +++ b/navigation-fragments/build.gradle.kts @@ -98,7 +98,6 @@ dependencies { testImplementation("androidx.test:core:1.6.1") // Robolectric testImplementation("org.assertj:assertj-core:3.26.3") testImplementation("androidx.navigation:navigation-testing:2.8.9") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.1") testImplementation("org.robolectric:robolectric:4.14.1") testImplementation("org.mockito:mockito-core:5.14.2") testImplementation("com.nhaarman:mockito-kotlin:1.6.0") diff --git a/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/CoroutinesTestRule.kt b/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/CoroutinesTestRule.kt deleted file mode 100644 index 80d63645..00000000 --- a/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/CoroutinesTestRule.kt +++ /dev/null @@ -1,25 +0,0 @@ -package dev.hotwire.navigation - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.TestCoroutineScheduler -import kotlinx.coroutines.test.TestDispatcher -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.rules.TestWatcher -import org.junit.runner.Description - -class CoroutinesTestRule( - private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(TestCoroutineScheduler()) -) : TestWatcher() { - - override fun starting(description: Description) { - super.starting(description) - Dispatchers.setMain(testDispatcher) - } - - override fun finished(description: Description) { - super.finished(description) - Dispatchers.resetMain() - } -} diff --git a/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt b/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt index 7bfe4233..863f7a32 100644 --- a/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt +++ b/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/navigator/NavigatorRuleTest.kt @@ -19,11 +19,9 @@ import dev.hotwire.core.turbo.nav.PresentationContext import dev.hotwire.core.turbo.nav.QueryStringPresentation import dev.hotwire.core.turbo.visit.VisitAction import dev.hotwire.core.turbo.visit.VisitOptions -import dev.hotwire.navigation.CoroutinesTestRule import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.Before -import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -70,10 +68,6 @@ class NavigatorRuleTest { } } - @Rule - @JvmField - var coroutinesTestRule = CoroutinesTestRule() - @Before fun setup() { context = ApplicationProvider.getApplicationContext() From 0ed305e1ef54987990685931d48855cb6049cf75 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Fri, 27 Mar 2026 13:25:56 -0400 Subject: [PATCH 25/25] Use lowercase log event keys for consistency with other logging --- .../dev/hotwire/core/turbo/config/PathConfiguration.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt index e697b8d7..562907a0 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/config/PathConfiguration.kt @@ -132,9 +132,9 @@ class PathConfiguration internal constructor() { logEvent( "pathConfigurationUpdated", listOf( - "Source" to state.javaClass.simpleName, - "Rules" to state.configuration.rules.size, - "Settings" to state.configuration.settings.size + "source" to state.javaClass.simpleName, + "rules" to state.configuration.rules.size, + "settings" to state.configuration.settings.size ) ) }