Skip to content

Commit

Permalink
Merge pull request #12 from hotwired/router
Browse files Browse the repository at this point in the history
Add the concept of a dedicated Router and custom Routes
  • Loading branch information
jayohms authored Mar 20, 2024
2 parents 612ff64 + 03d2d81 commit efb32a0
Show file tree
Hide file tree
Showing 24 changed files with 432 additions and 107 deletions.
3 changes: 3 additions & 0 deletions core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")

// Browser
implementation("androidx.browser:browser:1.7.0")

// Exported AndroidX dependencies
api("androidx.appcompat:appcompat:1.6.1")
api("androidx.core:core-ktx:1.12.0")
Expand Down
36 changes: 36 additions & 0 deletions core/src/main/kotlin/dev/hotwire/core/config/Hotwire.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package dev.hotwire.core.config

import android.content.Context
import androidx.fragment.app.Fragment
import dev.hotwire.core.bridge.BridgeComponent
import dev.hotwire.core.bridge.BridgeComponentFactory
import dev.hotwire.core.navigation.routing.BrowserRoute
import dev.hotwire.core.navigation.routing.AppNavigationRoute
import dev.hotwire.core.navigation.routing.Router
import dev.hotwire.core.turbo.config.TurboPathConfiguration
import kotlin.reflect.KClass

object Hotwire {
Expand All @@ -13,8 +18,39 @@ object Hotwire {
internal var registeredFragmentDestinations: List<KClass<out Fragment>> = emptyList()
private set

internal var router = Router(listOf(
AppNavigationRoute(),
BrowserRoute()
))

val config: HotwireConfig = HotwireConfig()

/**
* The base url of your web app.
*/
var appUrl: String = ""

/**
* The path configuration that defines your navigation rules.
*/
val pathConfiguration = TurboPathConfiguration()

/**
* Loads the [TurboPathConfiguration] JSON file(s) from the provided location to
* configure navigation rules.
*/
fun loadPathConfiguration(context: Context, location: TurboPathConfiguration.Location) {
pathConfiguration.load(context, location)
}

/**
* Registers the [Router.Route] instances that determine whether to route location
* urls within in-app navigation or with alternative custom behaviors.
*/
fun registerRoutes(routes: List<Router.Route>) {
router = Router(routes)
}

/**
* Register bridge components that the app supports. Every possible bridge
* component, wrapped in a [BridgeComponentFactory], must be provided here.
Expand Down
6 changes: 0 additions & 6 deletions core/src/main/kotlin/dev/hotwire/core/config/HotwireConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,6 @@ class HotwireConfig internal constructor() {
WebView.setWebContentsDebuggingEnabled(value)
}

/**
* The location of your [TurboPathConfiguration] JSON file(s) to configure
* navigation rules.
*/
var pathConfigurationLocation = TurboPathConfiguration.Location()

/**
* Provides a standard substring to be included in your WebView's user agent
* to identify itself as a Hotwire Native app.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package dev.hotwire.core.navigation.routing

import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri

class AppNavigationRoute : Router.Route {
override val name = "app-navigation"

override val result = Router.RouteResult.NAVIGATE

override fun matches(location: String): Boolean {
return appUrl.toUri().host == location.toUri().host
}

override fun perform(location: String, activity: AppCompatActivity) {
// No-op
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package dev.hotwire.core.navigation.routing

import android.content.ActivityNotFoundException
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
import dev.hotwire.core.logging.logError

class BrowserRoute : Router.Route {
override val name = "browser"

override val result = Router.RouteResult.STOP

override fun matches(location: String): Boolean {
return appUrl.toUri().host != location.toUri().host
}

override fun perform(location: String, activity: AppCompatActivity) {
val intent = Intent(Intent.ACTION_VIEW, location.toUri())

try {
activity.startActivity(intent)
} catch (e: ActivityNotFoundException) {
logError("BrowserRoute", e)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package dev.hotwire.core.navigation.routing

import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.net.toUri
import com.google.android.material.R
import dev.hotwire.core.turbo.util.colorFromThemeAttr

class BrowserTabRoute : Router.Route {
override val name = "browser-tab"

override val result = Router.RouteResult.STOP

override fun matches(location: String): Boolean {
return appUrl.toUri().host != location.toUri().host
}

override fun perform(location: String, activity: AppCompatActivity) {
val color = activity.colorFromThemeAttr(R.attr.colorSurface)
val colorParams = CustomTabColorSchemeParams.Builder()
.setToolbarColor(color)
.setNavigationBarColor(color)
.build()

CustomTabsIntent.Builder()
.setShowTitle(true)
.setShareState(CustomTabsIntent.SHARE_STATE_ON)
.setUrlBarHidingEnabled(false)
.setDefaultColorSchemeParams(colorParams)
.build()
.launchUrl(activity, location.toUri())
}
}
78 changes: 78 additions & 0 deletions core/src/main/kotlin/dev/hotwire/core/navigation/routing/Router.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package dev.hotwire.core.navigation.routing

import androidx.appcompat.app.AppCompatActivity
import dev.hotwire.core.config.Hotwire
import dev.hotwire.core.logging.logEvent
import dev.hotwire.core.navigation.routing.Router.Route

/**
* Routes location urls within in-app navigation or with custom behaviors
* provided in [Route] instances.
*/
class Router(private val routes: List<Route>) {

/**
* An interface to implement to provide custom route behaviors in your app.
*/
interface Route {
/**
* The configured app url. You can use this to determine if a location
* exists on the same domain.
*/
val appUrl get() = Hotwire.appUrl

/**
* The route name used in debug logging.
*/
val name: String

/**
* To permit in-app navigation when the location matches this route,
* return [RouteResult.NAVIGATE]. To prevent in-app navigation return
* [RouteResult.STOP].
*/
val result: RouteResult

/**
* Determines whether the location matches this route. Use your own custom
* rules based on the location's domain, protocol, path, or any other
* factors.
*/
fun matches(location: String): Boolean

/**
* Perform custom routing behavior when a match is found. For example,
* open an external browser or app for external domain urls.
*/
fun perform(location: String, activity: AppCompatActivity)
}

enum class RouteResult {
/**
* Permit in-app navigation with your app's domain urls.
*/
NAVIGATE,

/**
* Prevent in-app navigation. Always use this for external domain urls.
*/
STOP
}

internal fun route(location: String, activity: AppCompatActivity): RouteResult {
routes.forEach { route ->
if (route.matches(location)) {
logEvent("routeMatch", listOf(
"route" to route.name,
"location" to location
))

route.perform(location, activity)
return route.result
}
}

logEvent("noRouteForLocation", location)
return RouteResult.STOP
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import java.net.URL
* Provides the ability to load, parse, and retrieve url path
* properties from the app's JSON configuration file.
*/
class TurboPathConfiguration(context: Context) {
class TurboPathConfiguration {
private val cachedProperties: HashMap<String, TurboPathConfigurationProperties> = hashMapOf()

internal val loader = TurboPathConfigurationLoader(context.applicationContext)
internal var loader: TurboPathConfigurationLoader? = null

@SerializedName("rules")
internal var rules: List<TurboPathConfigurationRule> = emptyList()
Expand Down Expand Up @@ -55,11 +55,14 @@ class TurboPathConfiguration(context: Context) {

/**
* Loads and parses the specified configuration file(s) from their local
* and/or remote locations. You should not need to call this directly
* outside of testing.
* and/or remote locations.
*/
fun load(location: Location) {
loader.load(location) {
internal fun load(context: Context, location: Location) {
if (loader == null) {
loader = TurboPathConfigurationLoader(context.applicationContext)
}

loader?.load(location) {
cachedProperties.clear()
rules = it.rules
settings = it.settings
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package dev.hotwire.core.turbo.config

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
Expand Down Expand Up @@ -30,29 +32,41 @@ internal class TurboPathConfigurationLoader(val context: Context) : CoroutineSco
loadCachedConfigurationForUrl(url, onCompletion)

launch {
repository.getRemoteConfiguration(url)?.let {
onCompletion(load(it))
cacheConfigurationForUrl(url, load(it))
repository.getRemoteConfiguration(url)?.let { json ->
load(json)?.let {
logEvent("remotePathConfigurationLoaded", url)
onCompletion(it)
cacheConfigurationForUrl(url, it)
}
}
}
}

private fun loadBundledAssetConfiguration(filePath: String, onCompletion: (TurboPathConfiguration) -> Unit) {
val configuration = repository.getBundledConfiguration(context, filePath)
onCompletion(load(configuration))
val json = repository.getBundledConfiguration(context, filePath)
load(json)?.let {
logEvent("bundledPathConfigurationLoaded", filePath)
onCompletion(it)
}
}

private fun loadCachedConfigurationForUrl(url: String, onCompletion: (TurboPathConfiguration) -> Unit) {
repository.getCachedConfigurationForUrl(context, url)?.let {
onCompletion(load(it))
repository.getCachedConfigurationForUrl(context, url)?.let { json ->
load(json)?.let {
logEvent("cachedPathConfigurationLoaded", url)
onCompletion(it)
}
}
}

private fun cacheConfigurationForUrl(url: String, pathConfiguration: TurboPathConfiguration) {
repository.cacheConfigurationForUrl(context, url, pathConfiguration)
}

private fun load(json: String): TurboPathConfiguration {
return json.toObject(object : TypeToken<TurboPathConfiguration>() {})
private fun load(json: String) = try {
json.toObject(object : TypeToken<TurboPathConfiguration>() {})
} catch(e: Exception) {
logError("pathConfiguredFailedToParse", e)
null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.Intent
import android.os.Bundle
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.IdRes
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
Expand All @@ -13,7 +14,9 @@ import androidx.navigation.fragment.FragmentNavigator
import androidx.navigation.fragment.findNavController
import androidx.navigation.navOptions
import androidx.navigation.ui.R
import dev.hotwire.core.turbo.config.TurboPathConfiguration
import dev.hotwire.core.config.Hotwire
import dev.hotwire.core.config.Hotwire.pathConfiguration
import dev.hotwire.core.navigation.routing.Router
import dev.hotwire.core.turbo.config.TurboPathConfigurationProperties
import dev.hotwire.core.turbo.config.context
import dev.hotwire.core.turbo.delegates.TurboFragmentDelegate
Expand All @@ -24,8 +27,6 @@ import dev.hotwire.core.turbo.fragments.TurboWebFragment
import dev.hotwire.core.turbo.session.TurboSession
import dev.hotwire.core.turbo.session.TurboSessionNavHostFragment
import dev.hotwire.core.turbo.visit.TurboVisitOptions
import java.net.MalformedURLException
import java.net.URL

/**
* The primary interface that a navigable Fragment implements to provide the library with
Expand Down Expand Up @@ -132,18 +133,16 @@ interface TurboNavDestination {
}

/**
* Gets whether the new location should be navigated to from the current destination. Override
* to provide your own custom rules based on the location's domain, protocol, path, or any other
* factors. (e.g. external domain urls or mailto: links should not be sent through the normal
* Turbo navigation flow).
* Determines whether the new location should be routed within in-app navigation from the
* current destination. By default, the registered [Router.Route] instances are used to
* determine routing logic. You can override the global behavior for a specific destination,
* but it's recommend to use dedicated [Router.Route] instances for routing logic.
*/
fun shouldNavigateTo(newLocation: String): Boolean {
return try {
URL(newLocation)
true
} catch (e: MalformedURLException) {
false
}
fun route(newLocation: String): Router.RouteResult {
return Hotwire.router.route(
location = newLocation,
activity = fragment.requireActivity() as AppCompatActivity
)
}

/**
Expand Down Expand Up @@ -236,9 +235,6 @@ interface TurboNavDestination {
private val navigator: TurboNavigator
get() = delegate().navigator

private val pathConfiguration: TurboPathConfiguration
get() = session.pathConfiguration

/**
* Retrieve the nav controller indirectly from the parent NavHostFragment,
* since it's only available when the fragment is attached to its parent.
Expand Down
Loading

0 comments on commit efb32a0

Please sign in to comment.