hotwired / turbo-android

Android framework for making Turbo native apps
MIT License
408 stars 51 forks source link

How to localize bundled path configuration for multi-language app? #270

Closed sedubois closed 1 year ago

sedubois commented 1 year ago

The documentation instructs to store a Path Configuration JSON in the project; what is the proper way to store several of these, one per supported language, or alternately how to make sure the strings and URLs contained in this file get localized?

donnfelker commented 1 year ago

One way this could be done is by building many config files and endpoints to handle your various languages. In Jumpstart Pro Android we have this method in the MainSessionNavHostFragment.kt:

override val pathConfigurationLocation: TurboPathConfiguration.Location
        get() = TurboPathConfiguration.Location(
            assetFilePath = LOCAL_CONFIG_FILE,
            remoteFileUrl = REMOTE_CONFIG_URL
        )

Instead of using constants, you could build a path based on the System locale of the device.

Examples:

The server would have multiple remote endpoints that might be similar in nature:

Then your method might look like this (pseudocode):

override val pathConfigurationLocation: TurboPathConfiguration.Location
        get() = TurboPathConfiguration.Location(
            assetFilePath = localConfigFor(Locale.getDefault().language),
            remoteFileUrl = remoteConfigFor(Locale.getDefault().language)
        )

fun localConfigFor(language: String?): String {
  return "configuration_$language.json"
}

fun remoteConfigFor(language: String?): String {
  return "/android/configuration_$language.json"
}

Then, in your various remote configs you'd set up different paths for different languages/etc if you needed to do so.

Would that work?

sedubois commented 1 year ago

Thanks @donnfelker! Based on your idea I moved the config to the translation file:

    override val pathConfigurationLocation: TurboPathConfiguration.Location
        get() = TurboPathConfiguration.Location(
            assetFilePath = getString(R.string.local_config_path),
            remoteFileUrl = remoteUrl(R.string.remote_config_path)
        )

    fun Fragment.remoteUrl(@StringRes locationId: Int): String {
        return BASE_URL + getString(locationId)
    }
<!-- res/values/strings.xml -->
<resources>
    ...
    <string name="local_config_path">json/configuration.json</string>
    <string name="remote_config_path">/turbo/android/path_configuration.json</string>
</resources>

<!-- res/values-fr-rFR/strings.xml -->
<resources>
    ...
    <string name="local_config_path">json/configuration_fr.json</string>
    <string name="remote_config_path">/fr/turbo/android/path_configuration.json</string>
</resources>
sedubois commented 1 year ago

The above works for loading the correct path config at boot, but it leaves the app in an incoherent state when the user changes their language: the new string resources get loaded but the tabs are still in the old language.

The code below listens to when the user clicks the switcher, waits for the translated page to load, then attempts to change the language client-side.

Server-side:

<% I18n.with_locale(new_locale) do %>
    <%= link_to "switch to new language", url_in_new_locale,
              data: { controller: "language",
                      action: "language#setLanguage",
                      'language-tag-value': I18n.locale } %>
<% end %>
// javascript/controllers/index.js
import LanguageController from "./language_controller.js"
application.register("language", LanguageController)
// javascript/controllers/language_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static get values() {
    return { tag: String }
  }

  setLanguage() {
    // update client right after the web page has switched to the new language
    document.addEventListener('turbo:load', () => {
      window.TurboNativeBridge.postMessage("setLanguage", { language: this.tagValue })
    }, { once: true })
  }
}

Client-side:

// build.gradle.kts
dependencies {
    ...
    implementation(libs.androidx.appcompat)
    implementation(libs.androidx.appcompatResources)
}
# gradle/lib.versions.toml
androidXAppCompat = "1.6.1"
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidXAppCompat" }
androidx-appcompatResources = { group = "androidx.appcompat", name = "appcompat-resources", version.ref = "androidXAppCompat" }
    // Trigger this code when receiving the `setLanguage` JS event
    // e.g. with Jumpstart Pro Android, add this to `MainSessionNavHostFragment`
    // and bind it in bindNativeBridge(webView: TurboWebView)
    // with `nativeAppJavaScriptInterface.onSetLanguage = ::onSetLanguage`
    private fun onSetLanguage(tag: String) {
        AppCompatDelegate.setApplicationLocales(forLanguageTags(tag))

        // TODO: how to reload path config and update the navigation tabs and toolbar?

        // This does not seem to do anything:
        // reset()

        // This does switch the language of the navigation tabs and toolbar,
        // BUT the app gets minimized and the Android home screen is shown
        // Handler(Looper.getMainLooper()).post(::reset)

        // This "restarts" the app while keeping it visible, BUT:
        // - goes back to the initial screen (current page is lost)
        // - keeps the old web view running, according to what I see in the chrome debugger
        // val name = ComponentName(context ?: return, MainActivity::class.java)
        // val intent = makeRestartActivityTask(name)
        // startActivity(intent)
    }

@donnfelker would you know how to cleanly refresh the app language (see issues mentioned in the above code)?

NB: The above in-app language switching could be combined with Android 13's new app language settings screen. But it would also be necessary to restart the app when the language is changed there. How to do that?

donnfelker commented 1 year ago

@sedubois You will need to respond to Activity#onConfigurationChanged most likely. From there you'll need to reload the views and/or tell Compose to re-render/recompose itself. More info: https://stackoverflow.com/a/33889985/5210 - I'm assuming this would be in MainActivity in JSP Android.

This might require some additional work to get the config to reload. In the MainSessionNavHostFragment you will probably need to make some changes to the onSessionCreated method and tell it to reload the session config somehow. I havent dove too deep into it, but those are a couple things I'd look at.

sedubois commented 1 year ago

Thanks @donnfelker! I got something working. At the moment I'm not even adding a MainActivity#onConfigurationChanged override because it wasn't really necessary. Also in the beginning I didn't understand that for onConfigurationChanged to actually be called, I would have needed to add android:configChanges="layoutDirection|locale" to <activity ...></activity> in AndroidManifest.xml.

Here is the outline of the solution:

  1. Unlike stated above, in this implementation the server does not switch language before initiating the switch client-side. Instead, the user's click just triggers a JS event sending the native app the newly desired language and the URL or the new page, without performing any actual navigation.
  2. when receiving this message, the JS bridge saves the URL to SharedPreferences (any better way to transmit that info?), then initiates a change of the Android app locale using AppCompatDelegate.setApplicationLocales.
  3. At this stage, the Android app's resources change to the new language, so if the UI is showing e.g. a string with getString(R.string.my_string), it will properly change to the new language. However, the web page, top and bottom tab bar still need to be changed.
  4. As android:configChanges="layoutDirection|locale" isn't added in the Android manifest, it means that instead the whole activity will get recreated and MainActivity#onCreate and onStart will get called.
  5. In MainActivity#onCreate, the code needs to be adapted to instruct mainActivityViewModel to reload its tabs based on the path configuration in the new locale.
  6. As the tab data may still be loading and the activity is still initializing, it's too early to already navigate to the new URL. Instead, the viewModel state can observe the tab data in a lifecycle-aware manner (...observe(this) { ... }). The code passed in the block will then only be executed when the activity is properly recreated.
  7. At this point, the URL can be retrieved from SharedPreferences and passed to Turbo to change the actual page contents to the new language. Thanks to the fact that the page contents are changed after the native app locale is changed, the top bar will be in a correct state. In practice thanks to this, the menu icon will be visible instead of a back arrow.
  8. Now the language/state of all the different elements have been as expected:
    • the web page contents proper
    • the Android resources
    • the path configuration
    • the bottom tab bar
    • the top bar with title and menu/back icon
  9. The setup also works when switching from the app language selection in Android settings. The Android resources will get reloaded in the new language, which will recreate the activity. In this case there is no URL saved in SharedPreferences so the app can just navigate to a default localized page, which can be configured as a string in the translation files.

Rails:

<% I18n.with_locale(new_locale) do %>
    <%= link_to "switch to new language", url_in_new_locale,
              data: { controller: "language",
                      action: "language#setLanguage",
                      'language-tag-value': I18n.locale, 'language-url-value': url_in_new_locale } %>
<% end %>
// javascript/controllers/index.js
import LanguageController from "./language_controller.js"
application.register("language", LanguageController)
// javascript/controllers/language_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static get values() {
    return { tag: String, url: String }
  }

  setLanguage(event) {
    event.preventDefault()
    event.stopImmediatePropagation()
    window.TurboNativeBridge.postMessage("setLanguage", { language: this.tagValue, url: this.urlValue })
  }
}

Android:

// NativeAppJavaScriptInterface.kt
var onSetLanguage: (tag: String, url: String) -> Unit = { _, _ -> }

fun postMessage(jsonData: String?) {
    ...
    when (val command = json.optString("name")) {
        ...
        SET_LANGUAGE -> onSetLanguage(json.optString("language"), json.optString("url"))
    }
}
// MainSessionNavHosFragment.kt
        nativeAppJavaScriptInterface.onSetLanguage = { language, url ->
            (activity as? MainActivity)?.let {
                it.languageSwitchingUrl = url
                Handler(Looper.getMainLooper()).post {
                    AppCompatDelegate.setApplicationLocales(forLanguageTags(language))
                }
            }
        }
// Constants.kt
const val LANGUAGE_SWITCH_URL_KEY = "language-switch-url"
// MainActivity.kt
    @Inject lateinit var sharedPreferences: SharedPreferences

    var languageSwitchingUrl: String?
        get() = sharedPreferences.getString(LANGUAGE_SWITCH_URL_KEY, null)
        set(value) = sharedPreferences.edit().putString(LANGUAGE_SWITCH_URL_KEY, value).apply()

    ...

    override fun onCreate(savedInstanceState: Bundle?) {
        ... 
        mainActivityViewModel.loadNavItems().observe(this) {
            delegate.navigate(languageSwitchingUrl ?: startUrl)
            languageSwitchingUrl = null
        }
        ...
    }
// MainActivityViewModel.kt
    fun loadNavItems(): MutableLiveData<List<NavigationTabData>> {
        return liveDataNavItems.apply { value = jumpstartConfiguration.getBottomMenuItems() }
    }