diff --git a/core/src/main/kotlin/dev/hotwire/core/config/Hotwire.kt b/core/src/main/kotlin/dev/hotwire/core/config/Hotwire.kt index 6d1ab6b..d8fac7d 100644 --- a/core/src/main/kotlin/dev/hotwire/core/config/Hotwire.kt +++ b/core/src/main/kotlin/dev/hotwire/core/config/Hotwire.kt @@ -31,11 +31,6 @@ object Hotwire { val config: HotwireConfig = HotwireConfig() - /** - * The base url of your web app. - */ - var appUrl: String = "" - /** * The path configuration that defines your navigation rules. */ diff --git a/core/src/main/kotlin/dev/hotwire/core/navigation/activities/HotwireActivity.kt b/core/src/main/kotlin/dev/hotwire/core/navigation/activities/HotwireActivity.kt new file mode 100644 index 0000000..a974825 --- /dev/null +++ b/core/src/main/kotlin/dev/hotwire/core/navigation/activities/HotwireActivity.kt @@ -0,0 +1,16 @@ +package dev.hotwire.core.navigation.activities + +import androidx.appcompat.app.AppCompatActivity +import dev.hotwire.core.navigation.session.SessionConfiguration +import dev.hotwire.core.navigation.session.SessionNavHostFragment + +/** + * Interface that should be implemented by any Activity using Turbo. Ensures that the + * Activity provides a [HotwireActivityDelegate] so the framework can initialize the + * [SessionNavHostFragment] hosted in your Activity's layout resource. + */ +interface HotwireActivity { + val delegate: HotwireActivityDelegate + val appCompatActivity: AppCompatActivity + fun sessionConfigurations(): List +} diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/delegates/HotwireActivityDelegate.kt b/core/src/main/kotlin/dev/hotwire/core/navigation/activities/HotwireActivityDelegate.kt similarity index 63% rename from core/src/main/kotlin/dev/hotwire/core/turbo/delegates/HotwireActivityDelegate.kt rename to core/src/main/kotlin/dev/hotwire/core/navigation/activities/HotwireActivityDelegate.kt index a5cb06b..eacd3b4 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/delegates/HotwireActivityDelegate.kt +++ b/core/src/main/kotlin/dev/hotwire/core/navigation/activities/HotwireActivityDelegate.kt @@ -1,29 +1,27 @@ -package dev.hotwire.core.turbo.delegates +package dev.hotwire.core.navigation.activities import android.os.Bundle import androidx.activity.OnBackPressedCallback import androidx.annotation.IdRes -import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.navigation.NavController +import dev.hotwire.core.navigation.session.SessionConfiguration +import dev.hotwire.core.navigation.session.SessionNavHostFragment import dev.hotwire.core.turbo.nav.HotwireNavDestination import dev.hotwire.core.turbo.observers.HotwireActivityObserver -import dev.hotwire.core.turbo.session.SessionNavHostFragment import dev.hotwire.core.turbo.visit.VisitOptions /** - * Initializes the Activity for Turbo navigation and provides all the hooks for an - * Activity to communicate with Turbo (and vice versa). + * Initializes the Activity for Hotwire navigation and provides all the hooks for an + * Activity to communicate with Hotwire Native (and vice versa). * * @property activity The Activity to bind this delegate to. * @property currentNavHostFragmentId The resource ID of the [SessionNavHostFragment] * instance hosted in your Activity's layout resource. */ @Suppress("unused", "MemberVisibilityCanBePrivate") -class HotwireActivityDelegate( - val activity: AppCompatActivity, - currentNavHostFragmentId: Int -) { +class HotwireActivityDelegate(val activity: HotwireActivity) { + private val appCompatActivity = activity.appCompatActivity private val navHostFragments = mutableMapOf() private val onBackPressedCallback = object : OnBackPressedCallback(enabled = true) { @@ -32,22 +30,28 @@ class HotwireActivityDelegate( } } - /** - * Gets or sets the currently active resource ID of the [SessionNavHostFragment] - * instance hosted in your Activity's layout resource. If you use multiple nav host - * fragments in your app (such as for bottom tabs), you must update this whenever - * the currently active nav host fragment changes. - */ - var currentNavHostFragmentId = currentNavHostFragmentId + private var currentNavHostFragmentId = activity.sessionConfigurations().first().navHostFragmentId set(value) { field = value - updateOnBackPressedCallback(currentSessionNavHostFragment.navController) + updateOnBackPressedCallback(currentNavHostFragment.navController) } + /** + * Initializes the Activity with a BackPressedDispatcher that properly + * handles Fragment navigation with the back button. + */ + init { + appCompatActivity.lifecycle.addObserver(HotwireActivityObserver()) + appCompatActivity.onBackPressedDispatcher.addCallback( + owner = appCompatActivity, + onBackPressedCallback = onBackPressedCallback + ) + } + /** * Gets the Activity's currently active [SessionNavHostFragment]. */ - val currentSessionNavHostFragment: SessionNavHostFragment + val currentNavHostFragment: SessionNavHostFragment get() = navHostFragment(currentNavHostFragmentId) /** @@ -58,31 +62,25 @@ class HotwireActivityDelegate( get() = currentFragment as HotwireNavDestination? /** - * Registers the provided nav host fragment and initializes the - * Activity with a BackPressedDispatcher that properly handles Fragment - * navigation with the back button. + * Sets the currently active session in your Activity. If you use multiple + * [SessionNavHostFragment] instances in your app (such as for bottom tabs), + * you must update this whenever the current session changes. */ - init { - registerNavHostFragment(currentNavHostFragmentId) - activity.lifecycle.addObserver(HotwireActivityObserver()) - activity.onBackPressedDispatcher.addCallback(activity, onBackPressedCallback) + fun setCurrentSession(sessionConfiguration: SessionConfiguration) { + currentNavHostFragmentId = sessionConfiguration.navHostFragmentId } - /** - * Provides the ability to register additional nav host fragments. - * - * @param navHostFragmentId - * @return - */ - fun registerNavHostFragment(@IdRes navHostFragmentId: Int): SessionNavHostFragment { - return findNavHostFragment(navHostFragmentId).also { - if (navHostFragments[navHostFragmentId] == null) { - navHostFragments[navHostFragmentId] = it - listenToDestinationChanges(it.navController) - } + internal fun registerNavHostFragment(navHostFragment: SessionNavHostFragment) { + if (navHostFragments[navHostFragment.id] == null) { + navHostFragments[navHostFragment.id] = navHostFragment + listenToDestinationChanges(navHostFragment.navController) } } + internal fun unregisterNavHostFragment(navHostFragment: SessionNavHostFragment) { + navHostFragments.remove(navHostFragment.id) + } + /** * Finds the nav host fragment associated with the provided resource ID. * @@ -163,22 +161,17 @@ class HotwireActivityDelegate( } private fun updateOnBackPressedCallback(navController: NavController) { - if (navController == currentSessionNavHostFragment.navController) { + if (navController == currentNavHostFragment.navController) { onBackPressedCallback.isEnabled = navController.previousBackStackEntry != null } } private val currentFragment: Fragment? get() { - return if (currentSessionNavHostFragment.isAdded && !currentSessionNavHostFragment.isDetached) { - currentSessionNavHostFragment.childFragmentManager.primaryNavigationFragment + return if (currentNavHostFragment.isAdded && !currentNavHostFragment.isDetached) { + currentNavHostFragment.childFragmentManager.primaryNavigationFragment } else { null } } - - private fun findNavHostFragment(@IdRes navHostFragmentId: Int): SessionNavHostFragment { - return activity.supportFragmentManager.findFragmentById(navHostFragmentId) as? SessionNavHostFragment - ?: throw IllegalStateException("No SessionNavHostFragment found with ID: $navHostFragmentId") - } } diff --git a/core/src/main/kotlin/dev/hotwire/core/navigation/routing/AppNavigationRoute.kt b/core/src/main/kotlin/dev/hotwire/core/navigation/routing/AppNavigationRoute.kt index 3bf2207..8623d7f 100644 --- a/core/src/main/kotlin/dev/hotwire/core/navigation/routing/AppNavigationRoute.kt +++ b/core/src/main/kotlin/dev/hotwire/core/navigation/routing/AppNavigationRoute.kt @@ -1,18 +1,26 @@ package dev.hotwire.core.navigation.routing -import androidx.appcompat.app.AppCompatActivity import androidx.core.net.toUri +import dev.hotwire.core.navigation.activities.HotwireActivity +import dev.hotwire.core.navigation.session.SessionConfiguration 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 matches( + location: String, + sessionConfiguration: SessionConfiguration + ): Boolean { + return sessionConfiguration.startLocation.toUri().host == location.toUri().host } - override fun handle(location: String, activity: AppCompatActivity) { + override fun handle( + location: String, + sessionConfiguration: SessionConfiguration, + activity: HotwireActivity + ) { // No-op } } diff --git a/core/src/main/kotlin/dev/hotwire/core/navigation/routing/BrowserRoute.kt b/core/src/main/kotlin/dev/hotwire/core/navigation/routing/BrowserRoute.kt index 52c1f27..6d8bac2 100644 --- a/core/src/main/kotlin/dev/hotwire/core/navigation/routing/BrowserRoute.kt +++ b/core/src/main/kotlin/dev/hotwire/core/navigation/routing/BrowserRoute.kt @@ -2,24 +2,32 @@ 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.lib.logging.logError +import dev.hotwire.core.navigation.activities.HotwireActivity +import dev.hotwire.core.navigation.session.SessionConfiguration 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 matches( + location: String, + sessionConfiguration: SessionConfiguration + ): Boolean { + return sessionConfiguration.startLocation.toUri().host != location.toUri().host } - override fun handle(location: String, activity: AppCompatActivity) { + override fun handle( + location: String, + sessionConfiguration: SessionConfiguration, + activity: HotwireActivity + ) { val intent = Intent(Intent.ACTION_VIEW, location.toUri()) try { - activity.startActivity(intent) + activity.appCompatActivity.startActivity(intent) } catch (e: ActivityNotFoundException) { logError("BrowserRoute", e) } diff --git a/core/src/main/kotlin/dev/hotwire/core/navigation/routing/BrowserTabRoute.kt b/core/src/main/kotlin/dev/hotwire/core/navigation/routing/BrowserTabRoute.kt index c04cc6d..7b8dc3c 100644 --- a/core/src/main/kotlin/dev/hotwire/core/navigation/routing/BrowserTabRoute.kt +++ b/core/src/main/kotlin/dev/hotwire/core/navigation/routing/BrowserTabRoute.kt @@ -1,10 +1,11 @@ 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.navigation.activities.HotwireActivity +import dev.hotwire.core.navigation.session.SessionConfiguration import dev.hotwire.core.turbo.util.colorFromThemeAttr class BrowserTabRoute : Router.Route { @@ -12,12 +13,19 @@ class BrowserTabRoute : Router.Route { override val result = Router.RouteResult.STOP - override fun matches(location: String): Boolean { - return appUrl.toUri().host != location.toUri().host + override fun matches( + location: String, + sessionConfiguration: SessionConfiguration + ): Boolean { + return sessionConfiguration.startLocation.toUri().host != location.toUri().host } - override fun handle(location: String, activity: AppCompatActivity) { - val color = activity.colorFromThemeAttr(R.attr.colorSurface) + override fun handle( + location: String, + sessionConfiguration: SessionConfiguration, + activity: HotwireActivity + ) { + val color = activity.appCompatActivity.colorFromThemeAttr(R.attr.colorSurface) val colorParams = CustomTabColorSchemeParams.Builder() .setToolbarColor(color) .setNavigationBarColor(color) @@ -29,6 +37,6 @@ class BrowserTabRoute : Router.Route { .setUrlBarHidingEnabled(false) .setDefaultColorSchemeParams(colorParams) .build() - .launchUrl(activity, location.toUri()) + .launchUrl(activity.appCompatActivity, location.toUri()) } } diff --git a/core/src/main/kotlin/dev/hotwire/core/navigation/routing/Router.kt b/core/src/main/kotlin/dev/hotwire/core/navigation/routing/Router.kt index 6865d11..d5cda93 100644 --- a/core/src/main/kotlin/dev/hotwire/core/navigation/routing/Router.kt +++ b/core/src/main/kotlin/dev/hotwire/core/navigation/routing/Router.kt @@ -1,8 +1,8 @@ package dev.hotwire.core.navigation.routing -import androidx.appcompat.app.AppCompatActivity -import dev.hotwire.core.config.Hotwire import dev.hotwire.core.lib.logging.logEvent +import dev.hotwire.core.navigation.activities.HotwireActivity +import dev.hotwire.core.navigation.session.SessionConfiguration import dev.hotwire.core.navigation.routing.Router.Route /** @@ -15,12 +15,6 @@ class Router(private val routes: List) { * 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. */ @@ -38,13 +32,20 @@ class Router(private val routes: List) { * rules based on the location's domain, protocol, path, or any other * factors. */ - fun matches(location: String): Boolean + fun matches( + location: String, + sessionConfiguration: SessionConfiguration + ): Boolean /** * Handle custom routing behavior when a match is found. For example, * open an external browser or app for external domain urls. */ - fun handle(location: String, activity: AppCompatActivity) + fun handle( + location: String, + sessionConfiguration: SessionConfiguration, + activity: HotwireActivity + ) } enum class RouteResult { @@ -59,15 +60,19 @@ class Router(private val routes: List) { STOP } - internal fun route(location: String, activity: AppCompatActivity): RouteResult { + internal fun route( + location: String, + sessionConfiguration: SessionConfiguration, + activity: HotwireActivity + ): RouteResult { routes.forEach { route -> - if (route.matches(location)) { + if (route.matches(location, sessionConfiguration)) { logEvent("routeMatch", listOf( "route" to route.name, "location" to location )) - route.handle(location, activity) + route.handle(location, sessionConfiguration, activity) return route.result } } diff --git a/core/src/main/kotlin/dev/hotwire/core/navigation/session/SessionConfiguration.kt b/core/src/main/kotlin/dev/hotwire/core/navigation/session/SessionConfiguration.kt new file mode 100644 index 0000000..dea5e27 --- /dev/null +++ b/core/src/main/kotlin/dev/hotwire/core/navigation/session/SessionConfiguration.kt @@ -0,0 +1,9 @@ +package dev.hotwire.core.navigation.session + +import androidx.annotation.IdRes + +data class SessionConfiguration( + val name: String, + val startLocation: String, + @IdRes val navHostFragmentId: Int, +) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/session/SessionNavHostFragment.kt b/core/src/main/kotlin/dev/hotwire/core/navigation/session/SessionNavHostFragment.kt similarity index 77% rename from core/src/main/kotlin/dev/hotwire/core/turbo/session/SessionNavHostFragment.kt rename to core/src/main/kotlin/dev/hotwire/core/navigation/session/SessionNavHostFragment.kt index 464fcb3..1ff8a38 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/session/SessionNavHostFragment.kt +++ b/core/src/main/kotlin/dev/hotwire/core/navigation/session/SessionNavHostFragment.kt @@ -1,28 +1,24 @@ -package dev.hotwire.core.turbo.session +package dev.hotwire.core.navigation.session import android.content.Context import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.findNavController import dev.hotwire.core.bridge.Bridge import dev.hotwire.core.config.Hotwire import dev.hotwire.core.config.Hotwire.pathConfiguration +import dev.hotwire.core.navigation.activities.HotwireActivity import dev.hotwire.core.turbo.nav.HotwireNavDestination import dev.hotwire.core.turbo.nav.TurboNavGraphBuilder +import dev.hotwire.core.turbo.session.Session import dev.hotwire.core.turbo.views.TurboWebView -abstract class SessionNavHostFragment : NavHostFragment() { - /** - * The name of the [Session] instance, which is helpful for debugging - * purposes. This is arbitrary, but must be unique in your app. - */ - abstract val sessionName: String +open class SessionNavHostFragment : NavHostFragment() { + private val activity get() = requireActivity() as HotwireActivity - /** - * The url of a starting location when your app starts up. - */ - abstract val startLocation: String + val sessionConfiguration get() = activity.sessionConfigurations().first { + id == it.navHostFragmentId + } /** * The [Session] instance that is shared with all destinations that are @@ -33,13 +29,23 @@ abstract class SessionNavHostFragment : NavHostFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + activity.delegate.registerNavHostFragment(this) + createNewSession() initControllerGraph() } + override fun onDestroy() { + super.onDestroy() + activity.delegate.unregisterNavHostFragment(this) + } + internal fun createNewSession() { - val activity = requireActivity() as AppCompatActivity - session = Session(sessionName, activity, onCreateWebView(activity)) + session = Session( + sessionName = sessionConfiguration.name, + activity = activity.appCompatActivity, + webView = onCreateWebView(activity.appCompatActivity) + ) onSessionCreated() } @@ -94,7 +100,7 @@ abstract class SessionNavHostFragment : NavHostFragment() { private fun initControllerGraph() { navController.apply { graph = TurboNavGraphBuilder( - startLocation = startLocation, + startLocation = sessionConfiguration.startLocation, pathConfiguration = pathConfiguration, navController = findNavController() ).build( diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/activities/HotwireActivity.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/activities/HotwireActivity.kt deleted file mode 100644 index 7b91932..0000000 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/activities/HotwireActivity.kt +++ /dev/null @@ -1,13 +0,0 @@ -package dev.hotwire.core.turbo.activities - -import dev.hotwire.core.turbo.delegates.HotwireActivityDelegate -import dev.hotwire.core.turbo.session.SessionNavHostFragment - -/** - * Interface that should be implemented by any Activity using Turbo. Ensures that the - * Activity provides a [HotwireActivityDelegate] so the framework can initialize the - * [SessionNavHostFragment] hosted in your Activity's layout resource. - */ -interface HotwireActivity { - var delegate: HotwireActivityDelegate -} diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/delegates/TurboFragmentDelegate.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/delegates/TurboFragmentDelegate.kt index 00d3a75..fe978d3 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/delegates/TurboFragmentDelegate.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/delegates/TurboFragmentDelegate.kt @@ -17,7 +17,7 @@ import dev.hotwire.core.turbo.util.displayBackButtonAsCloseIcon class TurboFragmentDelegate(private val navDestination: HotwireNavDestination) { private val fragment = navDestination.fragment private val location = navDestination.location - private val sessionName = navDestination.sessionNavHostFragment.sessionName + private val sessionName = navDestination.session.sessionName internal val sessionViewModel = SessionViewModel.get(sessionName, fragment.requireActivity()) internal val fragmentViewModel = TurboFragmentViewModel.get(location, fragment) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/delegates/TurboNestedFragmentDelegate.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/delegates/TurboNestedFragmentDelegate.kt index 776598d..cdf5d39 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/delegates/TurboNestedFragmentDelegate.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/delegates/TurboNestedFragmentDelegate.kt @@ -4,8 +4,8 @@ import android.os.Bundle import androidx.annotation.IdRes import androidx.fragment.app.Fragment import androidx.navigation.NavController +import dev.hotwire.core.navigation.session.SessionNavHostFragment import dev.hotwire.core.turbo.nav.HotwireNavDestination -import dev.hotwire.core.turbo.session.SessionNavHostFragment import dev.hotwire.core.turbo.visit.VisitOptions /** diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/nav/HotwireNavDestination.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/nav/HotwireNavDestination.kt index 8c83122..9fbebb3 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/nav/HotwireNavDestination.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/nav/HotwireNavDestination.kt @@ -4,7 +4,6 @@ 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 @@ -16,7 +15,9 @@ import androidx.navigation.navOptions import dev.hotwire.core.R import dev.hotwire.core.config.Hotwire import dev.hotwire.core.config.Hotwire.pathConfiguration +import dev.hotwire.core.navigation.activities.HotwireActivity import dev.hotwire.core.navigation.routing.Router +import dev.hotwire.core.navigation.session.SessionNavHostFragment import dev.hotwire.core.turbo.config.PathConfigurationProperties import dev.hotwire.core.turbo.config.context import dev.hotwire.core.turbo.delegates.TurboFragmentDelegate @@ -25,7 +26,6 @@ import dev.hotwire.core.turbo.fragments.TurboFragment import dev.hotwire.core.turbo.fragments.TurboFragmentViewModel import dev.hotwire.core.turbo.fragments.TurboWebFragment import dev.hotwire.core.turbo.session.Session -import dev.hotwire.core.turbo.session.SessionNavHostFragment import dev.hotwire.core.turbo.visit.VisitAction import dev.hotwire.core.turbo.visit.VisitOptions @@ -142,7 +142,8 @@ interface HotwireNavDestination { fun route(newLocation: String): Router.RouteResult { return Hotwire.router.route( location = newLocation, - activity = fragment.requireActivity() as AppCompatActivity + sessionConfiguration = sessionNavHostFragment.sessionConfiguration, + activity = fragment.requireActivity() as HotwireActivity ) } diff --git a/core/src/test/kotlin/dev/hotwire/core/navigation/routing/AppNavigationRouteTest.kt b/core/src/test/kotlin/dev/hotwire/core/navigation/routing/AppNavigationRouteTest.kt index c650cf7..1354ec9 100644 --- a/core/src/test/kotlin/dev/hotwire/core/navigation/routing/AppNavigationRouteTest.kt +++ b/core/src/test/kotlin/dev/hotwire/core/navigation/routing/AppNavigationRouteTest.kt @@ -1,8 +1,7 @@ package dev.hotwire.core.navigation.routing -import dev.hotwire.core.config.Hotwire +import dev.hotwire.core.navigation.activities.SessionConfiguration import org.junit.Assert.* -import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -10,11 +9,11 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class AppNavigationRouteTest { private val route = AppNavigationRoute() - - @Before - fun setup() { - Hotwire.appUrl = "https://my.app.com" - } + private val sessionConfig = SessionConfiguration( + name = "test", + startLocation = "https://my.app.com", + navHostFragmentId = 0 + ) @Test fun `matching result navigates`() { @@ -24,18 +23,18 @@ class AppNavigationRouteTest { @Test fun `url on app domain matches`() { val url = "https://my.app.com/page" - assertTrue(route.matches(url)) + assertTrue(route.matches(url, sessionConfig)) } @Test fun `url without subdomain does not match`() { val url = "https://app.com/page" - assertFalse(route.matches(url)) + assertFalse(route.matches(url, sessionConfig)) } @Test fun `masqueraded url does not match`() { val url = "https://app.my.com@fake.domain" - assertFalse(route.matches(url)) + assertFalse(route.matches(url, sessionConfig)) } } diff --git a/core/src/test/kotlin/dev/hotwire/core/navigation/routing/BrowserRouteTest.kt b/core/src/test/kotlin/dev/hotwire/core/navigation/routing/BrowserRouteTest.kt index 1d7f079..8296024 100644 --- a/core/src/test/kotlin/dev/hotwire/core/navigation/routing/BrowserRouteTest.kt +++ b/core/src/test/kotlin/dev/hotwire/core/navigation/routing/BrowserRouteTest.kt @@ -1,8 +1,7 @@ package dev.hotwire.core.navigation.routing -import dev.hotwire.core.config.Hotwire +import dev.hotwire.core.navigation.activities.SessionConfiguration import org.junit.Assert.* -import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -10,11 +9,11 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class BrowserRouteTest { private val route = BrowserRoute() - - @Before - fun setup() { - Hotwire.appUrl = "https://my.app.com" - } + private val sessionConfig = SessionConfiguration( + name = "test", + startLocation = "https://my.app.com", + navHostFragmentId = 0 + ) @Test fun `matching result stops navigation`() { @@ -24,18 +23,18 @@ class BrowserRouteTest { @Test fun `url on external domain matches`() { val url = "https://external.com/page" - assertTrue(route.matches(url)) + assertTrue(route.matches(url, sessionConfig)) } @Test fun `url without subdomain matches`() { val url = "https://app.com/page" - assertTrue(route.matches(url)) + assertTrue(route.matches(url, sessionConfig)) } @Test fun `url on app domain does not match`() { val url = "https://my.app.com/page" - assertFalse(route.matches(url)) + assertFalse(route.matches(url, sessionConfig)) } } diff --git a/core/src/test/kotlin/dev/hotwire/core/navigation/routing/BrowserTabRouteTest.kt b/core/src/test/kotlin/dev/hotwire/core/navigation/routing/BrowserTabRouteTest.kt index 58c39d7..e995851 100644 --- a/core/src/test/kotlin/dev/hotwire/core/navigation/routing/BrowserTabRouteTest.kt +++ b/core/src/test/kotlin/dev/hotwire/core/navigation/routing/BrowserTabRouteTest.kt @@ -1,8 +1,7 @@ package dev.hotwire.core.navigation.routing -import dev.hotwire.core.config.Hotwire +import dev.hotwire.core.navigation.activities.SessionConfiguration import org.junit.Assert.* -import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -10,11 +9,11 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class BrowserTabRouteTest { private val route = BrowserTabRoute() - - @Before - fun setup() { - Hotwire.appUrl = "https://my.app.com" - } + private val sessionConfig = SessionConfiguration( + name = "test", + startLocation = "https://my.app.com", + navHostFragmentId = 0 + ) @Test fun `matching result stops navigation`() { @@ -24,18 +23,18 @@ class BrowserTabRouteTest { @Test fun `url on external domain matches`() { val url = "https://external.com/page" - assertTrue(route.matches(url)) + assertTrue(route.matches(url, sessionConfig)) } @Test fun `url without subdomain matches`() { val url = "https://app.com/page" - assertTrue(route.matches(url)) + assertTrue(route.matches(url, sessionConfig)) } @Test fun `url on app domain does not match`() { val url = "https://my.app.com/page" - assertFalse(route.matches(url)) + assertFalse(route.matches(url, sessionConfig)) } } diff --git a/demo/src/main/kotlin/dev/hotwire/demo/DemoApplication.kt b/demo/src/main/kotlin/dev/hotwire/demo/DemoApplication.kt index c16747e..4ec6337 100644 --- a/demo/src/main/kotlin/dev/hotwire/demo/DemoApplication.kt +++ b/demo/src/main/kotlin/dev/hotwire/demo/DemoApplication.kt @@ -30,9 +30,6 @@ class DemoApplication : Application() { Hotwire.config.debugLoggingEnabled = BuildConfig.DEBUG Hotwire.config.webViewDebuggingEnabled = BuildConfig.DEBUG - // Set app url - Hotwire.appUrl = Urls.appUrl - // Loads the path configuration Hotwire.loadPathConfiguration( context = this, diff --git a/demo/src/main/kotlin/dev/hotwire/demo/Urls.kt b/demo/src/main/kotlin/dev/hotwire/demo/Urls.kt index f05de52..09cbe19 100644 --- a/demo/src/main/kotlin/dev/hotwire/demo/Urls.kt +++ b/demo/src/main/kotlin/dev/hotwire/demo/Urls.kt @@ -10,7 +10,9 @@ object Urls { // Remote demo server private const val remoteUrl = "https://turbo-native-demo.glitch.me" - val appUrl = if (useLocalDev) localDevUrl else remoteUrl + // Base app url + private val appUrl = if (useLocalDev) localDevUrl else remoteUrl + val homeUrl = appUrl val signInUrl = "$appUrl/signin" val numbersUrl = "$appUrl/numbers" diff --git a/demo/src/main/kotlin/dev/hotwire/demo/main/MainActivity.kt b/demo/src/main/kotlin/dev/hotwire/demo/main/MainActivity.kt index 2a43957..100a05c 100644 --- a/demo/src/main/kotlin/dev/hotwire/demo/main/MainActivity.kt +++ b/demo/src/main/kotlin/dev/hotwire/demo/main/MainActivity.kt @@ -2,17 +2,26 @@ package dev.hotwire.demo.main import android.os.Bundle import androidx.appcompat.app.AppCompatActivity -import dev.hotwire.core.turbo.activities.HotwireActivity -import dev.hotwire.core.turbo.delegates.HotwireActivityDelegate +import dev.hotwire.core.navigation.activities.HotwireActivity +import dev.hotwire.core.navigation.activities.HotwireActivityDelegate +import dev.hotwire.core.navigation.session.SessionConfiguration import dev.hotwire.demo.R +import dev.hotwire.demo.Urls class MainActivity : AppCompatActivity(), HotwireActivity { - override lateinit var delegate: HotwireActivityDelegate + override val delegate by lazy { HotwireActivityDelegate(this) } + override val appCompatActivity = this override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - delegate = HotwireActivityDelegate(this, R.id.main_nav_host) } + + override fun sessionConfigurations() = listOf( + SessionConfiguration( + name = "main", + startLocation = Urls.homeUrl, + navHostFragmentId = R.id.main_nav_host + ) + ) } diff --git a/demo/src/main/kotlin/dev/hotwire/demo/main/MainSessionNavHostFragment.kt b/demo/src/main/kotlin/dev/hotwire/demo/main/MainSessionNavHostFragment.kt deleted file mode 100644 index 70668a0..0000000 --- a/demo/src/main/kotlin/dev/hotwire/demo/main/MainSessionNavHostFragment.kt +++ /dev/null @@ -1,10 +0,0 @@ -package dev.hotwire.demo.main - -import dev.hotwire.core.turbo.session.SessionNavHostFragment -import dev.hotwire.demo.Urls - -@Suppress("unused") -class MainSessionNavHostFragment : SessionNavHostFragment() { - override val sessionName = "main" - override val startLocation = Urls.homeUrl -} diff --git a/demo/src/main/res/layout/activity_main.xml b/demo/src/main/res/layout/activity_main.xml index 2b09e08..7fe08a1 100644 --- a/demo/src/main/res/layout/activity_main.xml +++ b/demo/src/main/res/layout/activity_main.xml @@ -9,7 +9,7 @@ diff --git a/docs/CONFIGURE-APP.md b/docs/CONFIGURE-APP.md index c3f4d73..9820061 100644 --- a/docs/CONFIGURE-APP.md +++ b/docs/CONFIGURE-APP.md @@ -1,3 +1,18 @@ # Configure your App - TODO \ No newline at end of file +## Create an Application instance +To customize your app you'll want to set a handful of configuration options before your `HotwireActivity` instance is created by the system. It's recommended to create your own `Application` instance and place the configuration code there. + +**`DemoApplication.kt`:** +```kotlin +class DemoApplication : Application() { + override fun onCreate() { + super.onCreate() + + // Set configuration options + } +} +``` + +Refer to the demo [`DemoApplication`](../demo/src/main/kotlin/dev/hotwire/demo/DemoApplication.kt) as an example. Don't forget to reference the name of your `Application` instance in the app's [`AndroidManifest.xml`](../demo/src/main/AndroidManifest.xml) file, otherwise it won't be invoked on app startup. + diff --git a/docs/QUICK-START.md b/docs/QUICK-START.md index bfcca41..d49d3f9 100644 --- a/docs/QUICK-START.md +++ b/docs/QUICK-START.md @@ -2,35 +2,19 @@ ## Contents -1. [Create a NavHostFragment](#create-a-navhostfragment) +1. [Introduction to NavHostFragments](#introduction-to-navhostfragments) 1. [Create an Activity](#create-an-activity) 1. [Configure your app](#configure-your-app) 1. [Create a Path Configuration](#create-a-path-configuration) 1. [Navigation](#navigation) 1. [Advanced Options](#advanced-options) +2. [Local Development](#local-development) -## Create a NavHostFragment +## Introduction to NavHostFragments A [`NavHostFragment`](https://developer.android.com/reference/androidx/navigation/fragment/NavHostFragment) is a component available in [Android Jetpack](https://developer.android.com/jetpack) and is primarily responsible for providing "an area in your layout for self-contained navigation to occur." -The Hotwire extension of this class, `SessionNavHostFragment`, along with being responsible for self-contained `HotwireFragment` navigation, also manages a `Sesssion` and a `TurboWebView` instance. You will need to implement a couple things for this abstract class: - -- The name of the `Session` (this is arbitrary, but must be unique in your app) -- The url of a starting location when your app starts up. Note: if you're running your app locally without HTTPS, you'll need to adjust your `android:usesCleartextTraffic` settings in the `debug/AndroidManifest.xml` (or use an Android Network security configuration), and target [`10.0.2.2` instead of `localhost`](https://developer.android.com/studio/run/emulator-networking) when using an emulator. - -In its simplest form, the implementation of your `SessionNavHostFragment` will look like: - -**`MainSessionNavHostFragment`:** -```kotlin -import dev.hotwire.core.turbo.session.SessionNavHostFragment - -class MainSessionNavHostFragment : SessionNavHostFragment() { - override val sessionName = "main" - override val startLocation = "https://turbo-native-demo.glitch.me/" -} -``` - -Refer to the demo [`MainSessionNavHostFragment`](../demo/src/main/kotlin/dev/hotwire/demo/main/MainSessionNavHostFragment.kt) for an example. +The Hotwire extension of this class, `SessionNavHostFragment`, along with being responsible for self-contained `HotwireFragment` navigation, also manages a `Sesssion` and a `TurboWebView` instance. You will need to place an instance of a `SessionNavHostFragment` in your main `Activity` layout. ## Create an Activity @@ -38,7 +22,7 @@ It's strongly recommended to use a single-Activity architecture in your app. Gen ### Create the HotwireActivity layout resource -You need to create a layout resource file that your `HotwireActivity` will use to host the `SessionNavHostFragment` that you created above. +You need to create a layout resource file that your `HotwireActivity` will use to host the `SessionNavHostFragment`. Android Jetpack provides a [`FragmentContainerView`](https://developer.android.com/reference/androidx/fragment/app/FragmentContainerView) to contain `NavHostFragment` navigation. In its simplest form, your Activity layout file will look like: @@ -53,7 +37,7 @@ Android Jetpack provides a [`FragmentContainerView`](https://developer.android.c @@ -63,58 +47,46 @@ Android Jetpack provides a [`FragmentContainerView`](https://developer.android.c Refer to the demo [`activity_main.xml`](../demo/src/main/res/layout/activity_main.xml) for an example. -### Create the HotwireActivity class +### Implement the HotwireActivity interface -A Hotwire Activity is straightforward and needs to implement the [`HotwireActivity`](../core/src/main/kotlin/dev/hotwire/core/turbo/activities/HotwireActivity.kt) interface in order to provide a [`HotwireActivityDelegate`](../core/src/main/kotlin/dev/hotwire/core/turbo/delegates/HotwireActivityDelegate.kt). +A Hotwire `Activity` is straightforward and needs to implement the [`HotwireActivity`](../core/src/main/kotlin/dev/hotwire/core/turbo/activities/HotwireActivity.kt) interface in order to provide a [`HotwireActivityDelegate`](../core/src/main/kotlin/dev/hotwire/core/turbo/delegates/HotwireActivityDelegate.kt) and `Session` configuration information. -Your Activity should extend Android Jetpack's [`AppCompatActivity`](https://developer.android.com/reference/androidx/appcompat/app/AppCompatActivity). In its simplest form, your Activity will look like: +Your Activity should extend Android Jetpack's [`AppCompatActivity`](https://developer.android.com/reference/androidx/appcompat/app/AppCompatActivity). + +You'll need to provide at least one `SessionConfiguration` instance (one for each `SessionNavHostFragment` that exists in our Activity layout). This includes: +- The `name` of the `Session` (this is arbitrary and helpful for debugging purposes, but each must be unique in your app) +- The `startLocation` url when your app starts up. Note: if you're running your app locally without HTTPS, see the [local development](#local-development) section. +- The `navHostFragmentId`, which refers to the resource ID of the `SessionNavHostFragment` in your Activity layout. + +In its simplest form, your Activity will look like: **`MainActivity.kt`:** ```kotlin class MainActivity : AppCompatActivity(), HotwireActivity { - override lateinit var delegate: HotwireActivityDelegate + override val delegate by lazy { HotwireActivityDelegate(this) } + override val appCompatActivity = this override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) - - delegate = HotwireActivityDelegate(this, R.id.main_nav_host) } + + override fun sessionConfigurations() = listOf( + SessionConfiguration( + name = "main", + startLocation = Urls.homeUrl, + navHostFragmentId = R.id.main_nav_host + ) + ) } ``` -_Note that `R.layout.activity_main` refers to the Activity layout file that you already created. `R.id.main_nav_host` refers to the `MainSessionNavHostFragment` that you created, hosted in the layout file._ +_Note that `R.layout.activity_main` refers to the Activity layout file that you already created. `R.id.main_nav_host` refers to the `SessionNavHostFragment` that placed in the layout file._ Refer to the demo [`MainActivity`](../demo/src/main/kotlin/dev/hotwire/demo/main/MainActivity.kt) as an example. (Don't forget to add your Activity to your app's [`AndroidManifest.xml`](../demo/src/main/AndroidManifest.xml) file.) ## Configure your App -At a minimum, you'll want to set a handful configuration options before your `HotwireActivity` instance is created by the system. It's recommended to create your own `Application` instance and place the configuration code there. The configuration op - -**`DemoApplication.kt`:** -```kotlin -class DemoApplication : Application() { - override fun onCreate() { - super.onCreate() - - // Configure debugging - Hotwire.config.debugLoggingEnabled = BuildConfig.DEBUG - Hotwire.config.webViewDebuggingEnabled = BuildConfig.DEBUG - - // Set the base url where your web app exists - Hotwire.appUrl = "https://turbo-native-demo.glitch.me/" - - // Set the user agent for every WebView request that is made. There's a - // `userAgentSubstring()` helper available that you should include as - // part of your user agent so the app is properly identified as a Hotwire - // Native app on your server. - Hotwire.config.userAgent = "Demo App; ${Hotwire.config.userAgentSubstring()}" - } -} -``` - -Refer to the demo [`DemoApplication`](../demo/src/main/kotlin/dev/hotwire/demo/DemoApplication.kt) as an example. (Don't forget to reference your `Application` instance in the app's [`AndroidManifest.xml`](../demo/src/main/AndroidManifest.xml) file.) - See the documentation to learn more about [configuring your app](CONFIGURE-APP.md). ## Create a Path Configuration @@ -128,3 +100,25 @@ See the documentation to learn about [navigating between destinations](NAVIGATIO ## Advanced Options See the documentation to [learn about the advanced options available](ADVANCED-OPTIONS.md). + +## Local Development + +If you're running your web app locally without HTTPS, you'll need to adjust the `android:usesCleartextTraffic` setting in the `AndroidManifest.xml` file (or use an Android Network security configuration). It's highly recommended to only allow `http` traffic in the `debug` manifest file, which only applies the setting to `debug` builds, not `release` builds. It'll look like this: + +**`src/debug/AndroidManifest.xml`:** +```xml + + + + + + + +``` + +Refer to the demo [`debug/AndroidManifest.xml`](../demo/src/debug/AndroidManifest.xml) as an example. + +If you're using an emulator, target [`10.0.2.2` instead of `localhost`](https://developer.android.com/studio/run/emulator-networking) as the loopback interface.