Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8f30cf4
Initial spike at providing a path configuration loadState to observe …
jayohms Mar 23, 2026
caff8fa
Update the PathConfigurationLoader so it doesn't hold onto a context
jayohms Mar 23, 2026
0b57648
Observe the loadState to update the PathConfiguration data
jayohms Mar 23, 2026
f0d7d84
Observe the load state in init
jayohms Mar 25, 2026
f6b0d0f
Extract the serializable data into a PathConfigurationData class so j…
jayohms Mar 25, 2026
6e6065b
Use an Unconfined dispatcher to collect the load state and fix tests
jayohms Mar 25, 2026
0808825
Use the IO scope to load the path config from the disk/network
jayohms Mar 25, 2026
9a2910a
Only load the bundled configuration if the cached configuration isn't…
jayohms Mar 25, 2026
0cb0c00
Clean up tests
jayohms Mar 25, 2026
65a55ce
Load the path configuration after enabling debug logging
jayohms Mar 25, 2026
60a421b
Implement .equals() and .hashCode() for PathConfigurationData so its …
jayohms Mar 25, 2026
d320eab
Use a sealed class for Loading so you don't have to check each indivi…
jayohms Mar 25, 2026
b8750ee
Load the bundled or cached configuration synchronously so that the pr…
jayohms Mar 25, 2026
6bf6c3e
Don't use a separate data instance to hold state
jayohms Mar 25, 2026
a3143f6
Clean up getting the current configuration
jayohms Mar 25, 2026
851e7e4
Cancel the previous load() request if it's called again
jayohms Mar 25, 2026
048ab48
Rename Idle -> NotLoaded
jayohms Mar 27, 2026
81b37a8
Use a data class with the @ConsistentCopyVisibility annotation to avo…
jayohms Mar 27, 2026
ffb8306
Use if/else instead of when
jayohms Mar 27, 2026
d847641
Cancel the previous job before loading the bundled/cached config
jayohms Mar 27, 2026
3b7a1f0
Use sealed interfaces to simplify state setup
jayohms Mar 27, 2026
41a4aad
Upgrade OkHttp and implement the okhttp-coroutines dependency so fetc…
jayohms Mar 27, 2026
7fb7974
Make PathConfiguration have an internal constructor since using it ou…
jayohms Mar 27, 2026
3fcb5c5
Remove unneeded CoroutinesTestRule
jayohms Mar 27, 2026
0ed305e
Use lowercase log event keys for consistency with other logging
jayohms Mar 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,46 @@ 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.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 java.net.URL
import dev.hotwire.core.turbo.util.dispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
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<String, PathConfigurationProperties> = hashMapOf()
private val _loadState = MutableStateFlow<PathConfigurationLoadState>(NotLoaded)
private val loadingScope: CoroutineScope = CoroutineScope(dispatcherProvider.io + SupervisorJob())
Comment thread
jayohms marked this conversation as resolved.
private var loadingJob: Job? = null

internal var loader: PathConfigurationLoader? = null
internal var loader = PathConfigurationLoader()

@SerializedName("rules")
internal var rules: List<PathConfigurationRule> = emptyList()
/**
* 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<PathConfigurationLoadState> = _loadState.asStateFlow()

/**
* 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: PathConfigurationSettings
get() = synchronized(this) { currentConfiguration.settings }

/**
* Represents the location of the app's path configuration JSON file(s).
Expand Down Expand Up @@ -73,14 +88,21 @@ class PathConfiguration {
location: Location,
options: LoaderOptions
) {
if (loader == null) {
loader = PathConfigurationLoader(context.applicationContext)
logEvent("pathConfigurationLoading", location.toString())

val appContext = context.applicationContext
loadingJob?.cancel()

loader.loadCachedOrBundledConfiguration(appContext, location)?.let {
applyLoadedState(it)
}

loader?.load(location, options) {
cachedProperties.clear()
rules = it.rules + historicalLocationRules
settings = it.settings
loadingJob = loadingScope.launch {
location.remoteFileUrl?.let { url ->
loader.loadRemoteConfigurationForUrl(appContext, url, options)?.let {
Comment thread
jhutarek marked this conversation as resolved.
applyLoadedState(it)
}
}
}
}

Expand All @@ -94,28 +116,31 @@ 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 = PathConfigurationProperties()
val path = path(location)
val properties = currentConfiguration.properties(location)
cachedProperties[location] = properties

for (rule in rules) {
if (rule.matches(path)) properties.putAll(rule.properties)
return properties
}

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 applyLoadedState(state: Loaded) = synchronized(this) {
cachedProperties.clear()
_loadState.value = state

logEvent(
"pathConfigurationUpdated", listOf(
"source" to state.javaClass.simpleName,
"rules" to state.configuration.rules.size,
"settings" to state.configuration.settings.size
)
)
}

private val currentConfiguration: PathConfigurationData
get() = (_loadState.value as? Loaded)?.configuration ?: PathConfigurationData()
}

typealias PathConfigurationProperties = HashMap<String, Any>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package dev.hotwire.core.turbo.config

import com.google.gson.annotations.SerializedName
import java.net.URL

@ConsistentCopyVisibility
data class PathConfigurationData internal constructor(
@SerializedName("rules")
internal val rules: List<PathConfigurationRule> = 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 if (url.query == null) {
url.path
} else {
"${url.path}?${url.query}"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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 interface PathConfigurationLoadState {
/**
* The initial state before any configuration has been loaded.
*/
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 interface Loaded : PathConfigurationLoadState {
val configuration: PathConfigurationData

/**
* The configuration was loaded from the locally bundled asset file.
*/
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

/**
* The configuration was freshly loaded from the remote server.
*/
data class RemoteLoaded(override val configuration: PathConfigurationData) : Loaded
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,84 +4,84 @@ 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.launch
import kotlin.coroutines.CoroutineContext

internal class PathConfigurationLoader(val context: Context) : CoroutineScope {
internal class PathConfigurationLoader {
internal var repository = PathConfigurationRepository()

override val coroutineContext: CoroutineContext
get() = dispatcherProvider.io + Job()

fun load(
location: PathConfiguration.Location,
options: PathConfiguration.LoaderOptions,
onCompletion: (PathConfiguration) -> Unit
) {
location.assetFilePath?.let {
loadBundledAssetConfiguration(it, onCompletion)
fun loadCachedOrBundledConfiguration(
context: Context,
location: PathConfiguration.Location
): PathConfigurationLoadState.Loaded? {
// Attempt to load the cached remote configuration for the url, if available
if (location.remoteFileUrl != null) {
loadCachedConfigurationForUrl(context, location.remoteFileUrl)
?.let { return it }
}

location.remoteFileUrl?.let {
downloadRemoteConfiguration(it, options, onCompletion)
}
}

private fun downloadRemoteConfiguration(
url: String,
options: PathConfiguration.LoaderOptions,
onCompletion: (PathConfiguration) -> Unit
) {
// Always load the previously cached version first, if available
loadCachedConfigurationForUrl(url, onCompletion)

launch {
repository.getRemoteConfiguration(url, options)?.let { json ->
load(json)?.let {
logEvent("remotePathConfigurationLoaded", url)
onCompletion(it)
cacheConfigurationForUrl(url, it)
}
}
}
// Fall back to the bundled config when a cached config is not available
return location.assetFilePath?.let { loadBundledAssetConfiguration(context, it) }
}

private fun loadBundledAssetConfiguration(
filePath: String,
onCompletion: (PathConfiguration) -> Unit
) {
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)
onCompletion(it)
PathConfigurationLoadState.Loaded.BundledAssetLoaded(it)
}
}

private fun loadCachedConfigurationForUrl(
context: Context,
url: String
): PathConfigurationLoadState.Loaded.CachedRemoteLoaded? {
logEvent("cachedPathConfigurationLoading", url)

val json = repository.getCachedConfigurationForUrl(context, url)
val config = json?.let { load(it) }

return if (config == null) {
logEvent("cachedPathConfigurationFailedToLoad", url)
null
} else {
logEvent("cachedPathConfigurationLoaded", url)
PathConfigurationLoadState.Loaded.CachedRemoteLoaded(config)
}
}

suspend fun loadRemoteConfigurationForUrl(
context: Context,
url: String,
onCompletion: (PathConfiguration) -> Unit
) {
repository.getCachedConfigurationForUrl(context, url)?.let { json ->
load(json)?.let {
logEvent("cachedPathConfigurationLoaded", url)
onCompletion(it)
}
options: PathConfiguration.LoaderOptions
): PathConfigurationLoadState.Loaded.RemoteLoaded? {
logEvent("remotePathConfigurationLoading", url)

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)
}
}

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<PathConfiguration>() {})
json.toObject(object : TypeToken<PathConfigurationData>() {})
} catch(e: Exception) {
logError("pathConfiguredFailedToParse", e)
null
Expand Down
Loading
Loading