DEV Community

Cover image for Hotwire Native - Switch Environments
Leon Vogt
Leon Vogt

Posted on

Hotwire Native - Switch Environments

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>
Enter fullscreen mode Exit fullscreen mode

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 })
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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"}
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
            )
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        )
    )
}
Enter fullscreen mode Exit fullscreen mode

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
    )
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
)
Enter fullscreen mode Exit fullscreen mode

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) ?? ""
  }
}
Enter fullscreen mode Exit fullscreen mode

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

Wherever you make the initial request:

func didStart() {
  navigator.route(Endpoint.instance.start)
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)