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
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">
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
What's Your Reaction?