One thing I have wanted for a while in my Hotwire Native apps is the ability to switch environments (stage, production, etc.) without having to rebuild the app
This can be useful for things like:
Testing different environments without having to rebuild the app.
Using the currently published app in a different environment.
Note: I don't go into detail on how to create a Hotwire Native app.
There are great resources available for that, like Joe Masilotti's Blog or William Kennedy.
Or more recently, the awesome Hotwire Native Book by Joe Masilotti.
Web
The basic idea is to display a list of environments with their respective URLs.
When the user selects an environment, we will send the selected URL via a JS Bridge Component to the Hotwire Native app.
<div data-controller="native--base-url">
<button data-action="click->native--base-url#updateBaseURL" data-native--base-url-url-param="http://192.168.1.42:3000">Local</button>
<button data-action="click->native--base-url#updateBaseURL" data-native--base-url-url-param="https://example.com">Production</button>
</div>
Note: In a real-world scenario, you may want to store the possible URLs somewhere, iterate over them and highlight which one is currently selected. But for the sake of simplicity, this will do for now.
Bridge Component:
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
// Connects to data-controller="native--base-url"
export default class extends BridgeComponent {
static component = "base-url"
updateBaseURL({ params: { url } }) {
this.send("updateBaseURL", { url })
}
}
On the Hotwire Native side, we need to handle the message from the JS Bridge and save the selected URL so that we can use it as the base URL for upcoming requests. To store the URL in Android we use SharedPreferences, and for iOS, we can use UserDefaults.
Let's take a look at the code for both platforms.
Hotwire Native Android
First some preparation, we need a way to store the passed URL between app sessions.
Here's how a simple class for accessing SharedPreferences might look:
SharedPreferencesAccess.kt:
object SharedPreferencesAccess {
const val SHARED_PREFERENCES_FILE_KEY = "MobileAppData"
const val BASE_URL_KEY = "BaseURL"
fun setBaseURL(context: Context, baseURL: String) {
val editor = getPreferences(context).edit()
editor.putString(BASE_URL_KEY, baseURL)
editor.apply()
}
fun getBaseURL(context: Context?): String {
return getPreferences(context!!).getString(BASE_URL_KEY, "") ?: ""
}
private fun getPreferences(context: Context): SharedPreferences {
return context.getSharedPreferences(SHARED_PREFERENCES_FILE_KEY, Context.MODE_PRIVATE)
}
}
After that, we need a way to build our URLs based on the selected environment and whether we have a saved URL or not
I use a viewmodel called EndpointModel for that:
class EndpointModel(application: Application):AndroidViewModel(application) {
private var baseURL: String
init {
this.baseURL = loadBaseURL()
}
fun setBaseURL(url: String) {
this.baseURL = url
}
private fun loadBaseURL(): String {
val savedURL = SharedPreferencesAccess.getBaseURL(getApplication<Application>().applicationContext)
// Here is the basic idea of this article.
// If we have a saved URL, we use it.
if (savedURL.isNotEmpty()) {
return savedURL
}
// Otherwise we use the default URL based on the build type.
if (BuildConfig.DEBUG) {
return LOCAL_URL
}
return PRODUCTION_URL
}
val startURL: String
get() { return "$baseURL/home" }
val pathConfigurationURL: String
get() {return "$baseURL/api/v1/android/path_configuration.json"}
}
The used constants are defined in a separate file called Constants.kt.
Constants.kt:
const val PRODUCTION_URL = "https://myapp.com"
const val LOCAL_URL = "http://192.168.1.42:3000"
Ok, so far so good.
We have stored the selected URL and have a way to build our URLs based on if we have a saved URL or not.
Now we simply need to tell Hotwire Native to use the selected URL as the start location.
Per default, Hotwire Native expects a startLocation
to be defined in the MainActivity.
To access the endpointModel, we have to initialize it first. This can be done in our โMainApplicationโ class (In the Hotwire Native Demo project, this class is called DemoApplication\
):
class MainApplication : Application() {
val endpointModel: EndpointModel by lazy {
ViewModelProvider.AndroidViewModelFactory.getInstance(this)
.create(EndpointModel::class.java)
}
override fun onCreate() {
super.onCreate()
// Load the path configuration
Hotwire.loadPathConfiguration(
context = this,
location = PathConfiguration.Location(
assetFilePath = "json/configuration.json",
remoteFileUrl = endpointModel.pathConfigurationURL
)
)
}
}
With the endpointModel in place, we can use it to define the startLocation
in the MainActivity:
class MainActivity : HotwireActivity() {
lateinit var endpointModel: EndpointModel
override fun onCreate(savedInstanceState: Bundle?) {
this.endpointModel = (application as MainApplication).endpointModel
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
override fun navigatorConfigurations() = listOf(
NavigatorConfiguration(
name = "main",
startLocation = endpointModel.startURL,
navigatorHostId = R.id.main_nav_host
)
)
}
Now we have all the pieces in place to dynamically switch between different environments in our Hotwire Native app.
The only thing missing is the Bridge Component which handles the message from the web and updates the base URL:
class BaseURLComponent(
name: String,
private val hotwireDelegate: BridgeDelegate<HotwireDestination>
) : BridgeComponent<HotwireDestination>(name, hotwireDelegate) {
private val fragment: Fragment
get() = hotwireDelegate.destination.fragment
override fun onReceive(message: Message) {
when (message.event) {
"updateBaseURL" -> updateBaseURL(message)
else -> Log.w("BaseURLComponent", "Unknown event for message: $message")
}
}
private fun updateBaseURL(message: Message) {
val data = message.data<MessageData>() ?: return
val url = data.url
// Save the new base URL to SharedPreferences
SharedPreferencesAccess.setBaseURL(fragment.requireContext(), url)
// Apply the new base URL and reset the navigators
val mainActivity = fragment.activity as? MainActivity
mainActivity?.endpointModel?.setBaseURL(url)
mainActivity?.delegate?.resetNavigators()
}
@Serializable
data class MessageData(
@SerialName("url") val url: String
)
}
Done ๐
This should outline all the steps needed to switch between different environments in a Hotwire Native Android app.
But there is one catch. When you try this, you may notice that environment switch only works when you restart the app.
Problem is that Hotwire Native doesn't recognize the new base URL as an 'in-app navigation' and will open the URLs with the new base URL in an external browser.
This is because the AppNavigationRouteDecisionHandler
doesn't know about the new base URL. Not sure if this is expected behavior or a bug in Hotwire Native Android to be honest. But we easily can fix this by adding a custom Router.RouteDecisionHandler
. You can learn more about route handlers at the official documentation.
class NavigationDecisionHandler : Router.RouteDecisionHandler {
override val name = "app-navigation-router"
override val decision = Router.Decision.NAVIGATE
override fun matches(
location: String,
configuration: NavigatorConfiguration
): Boolean {
val baseURL = MainApplication().endpointModel.homeURL
return baseURL.toUri().host == location.toUri().host
}
override fun handle(
location: String,
configuration: NavigatorConfiguration,
activity: HotwireActivity
) {
// No-op
}
}
The only missing piece now, is to inform Hotwire Native about our new router and register it together with the default routers we wanna use. This can be done in the MainApplication.kt file, where we have the other Hotwire Native configuration as well:
// Register route decision handlers
// https://native.hotwired.dev/android/reference#handling-url-routes
Hotwire.registerRouteDecisionHandlers(
NavigationDecisionHandler(),
AppNavigationRouteDecisionHandler(),
BrowserRouteDecisionHandler()
)
Aaand now we are done ๐๐.
This time for real.
The iOS side is a bit easier, I promise.
Hotwire Native iOS
The key idea is the same: save the selected URL and use it as the base URL for upcoming requests. A basic UserDefaultsAccess class could look like this:
UserDefaultsAccess:
import Foundation
class UserDefaultsAccess {
static let KEY_BASE_URL = "BaseURL"
private init(){}
static func setBaseURL(url: String) {
UserDefaults.standard.set(url, forKey: KEY_BASE_URL)
}
static func getBaseURL() -> String {
return UserDefaults.standard.string(forKey: KEY_BASE_URL) ?? ""
}
}
Now we need a way to build our URLโs based on the selected environment and whether we have a saved URL or not.
Similar to the Android side, I created a class called Endpoint that contains this logic.
Endpoint:
// Learn more about this Endpoint class at this Joe Masilotti's Blog post: https://masilotti.com/turbo-ios/tips-and-tricks/
import Foundation
class Endpoint {
static let instance = Endpoint()
private var baseURL: URL {
let baseURL = UserDefaultsAccess.getBaseURL()
// Same as in Android.
// If we have a saved URL, we use it.
if !baseURL.isEmpty {
return URL(string: baseURL)!
}
// Otherwise we use the default URL based on the current environment.
switch Environment.current {
case .development:
return URL(string: "http://192.168.1.42:3000")!
case .production:
return URL(string: "https://myapp.com")!
}
}
private init() {}
var start: URL {
return baseURL.appendingPathComponent("/home")
}
var pathConfiguration: URL {
return baseURL.appendingPathComponent("/api/v1/ios/path_configuration.json")
}
}
Wherever you make the initial request:
func didStart() {
navigator.route(Endpoint.instance.start)
}
With that in place, we can implement the Bridge Component that handles the message from the web and updates the base URL.
import Foundation
import HotwireNative
import UIKit
final class BaseURLComponent: BridgeComponent {
override class var name: String { "base-url" }
override func onReceive(message: Message) {
guard let event = Event(rawValue: message.event) else {
return
}
switch event {
case .updateBaseURL:
handleupdateBaseURL(message: message)
}
}
// MARK: Private
private func handleupdateBaseURL(message: Message) {
guard let data: MessageData = message.data() else { return }
let url = data.url
UserDefaultsAccess.setBaseURL(url: url)
HotwireCentral.instance.resetNavigator()
}
}
// MARK: Events
private extension BaseURLComponent {
enum Event: String {
case updateBaseURL
}
}
// MARK: Message data
private extension BaseURLComponent {
struct MessageData: Decodable {
let url: String
}
}
Almost finished. The Bridge Component calls resetNavigator
to apply the new base URL and reset the navigators. The job of resetNavigator
is to set the new PathConfiguration properties, based on the new base URL, and reinitialize the Navigator to apply the new settings.
This function doesn't exist yet, so we need to add it to the HotwireCentral class:
func resetNavigator() {
self.pathConfiguration = PathConfiguration(sources: [
.server(Endpoint.instance.pathConfiguration)
])
self.navigator = Navigator(pathConfiguration: pathConfiguration)
navigator.route(Endpoint.instance.start)
}
That's it! ๐
This way you can easily switch between different environments in your Hotwire Native app without having to rebuild the app.
You can find the full code from this post in this PR: https://github.com/leonvogt/example-42/pull/1
If you have any questions, feedback or ideas on how to improve this approach, feel free to reach out!
Top comments (0)