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. Local Production 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().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(sav

Jan 20, 2025 - 17:40
 0
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.

 data-controller="native--base-url">
   data-action="click->native--base-url#updateBaseURL" data-native--base-url-url-param="http://192.168.1.42:3000">Local
   data-action="click->native--base-url#updateBaseURL" data-native--base-url-url-param="https://example.com">Production

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