Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the concept of a dedicated Router and custom Routes #12

Merged
merged 4 commits into from
Mar 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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