How to intercept resource loading globally?

Viewed 364

Background

I need to use a translations-SDK (Lokalise, docs here) that is intended to load strings resources from their servers.

This means that if you use getString , it will prefer what's on the server instead of what's on the app. This includes also the cases of inflation of layout XML files.

The problem

It seems that Android doesn't have a global resource handling that I can use. This is why the SDK says I should use one of these :

  1. For Activity, I can override the callback of attachBaseContext.
  2. For all other cases, that I need to get the resources of them, I can use LokaliseResources(context) .

Thing is, a lot of code in the app I work on doesn't involve an Activity. A lot of the UI on the app is floating (using SAW permission, AKA "System Alert Window").

This means that there is a lot of inflation of Views using just the Application class.

What I've tried

First I made a simple manager for this:

object TranslationsManager {
    var resources: LokaliseResources? = null

    @UiThread
    fun initOnAppOnCreate(context: App) {
        Lokalise.init(context, Keys.LOCALISE_SDK_TOKEN, Keys.LOCALISE_PROJECT_ID)
        Lokalise.updateTranslations()
        resources = LokaliseResources(context)
    }

    fun getResources(context: Context): Resources {
        return resources ?: context.resources
    }
}

I tried to perform various things using the library, but they crashed as it's not how the library works.

So these failed:

  1. For the getResources of the class that extends Application, I tried to return the one of the SDK

  2. Use attachBaseContext of the class that implements Application. This causes a crash since it needs to be initialized before, so I tried to initialize it right in this callback, but still got a crash.

  3. For LayoutInflater, I tried to use LayoutInflater.from(new ContextThemeWrapper(...)) , and override its getResources callback, but it didn't do anything.

  4. I tried to use Philology library by having this:

object MyPhilologyRepositoryFactory : PhilologyRepositoryFactory {
    override fun getPhilologyRepository(locale: Locale): PhilologyRepository {
        return object : PhilologyRepository {
            override fun getPlural(key: String, quantityString: String): CharSequence? {
                Log.d("AppLog", "getPlural $key")
                return TranslationsManager.resources?.getString(quantityString)
                    ?: super.getPlural(key, quantityString)
            }

            override fun getText(key: String): CharSequence? {
                Log.d("AppLog", "getText $key")
                return TranslationsManager.resources?.getString(key) ?: super.getText(key)
            }

            override fun getTextArray(key: String): Array<CharSequence>? {
                Log.d("AppLog", "getTextArray $key")
                TranslationsManager.resources?.getStringArray(key)?.let { stringArray ->
                    val result = Array<CharSequence>(stringArray.size) { index ->
                        stringArray[index]
                    }
                    return result
                }
                return super.getTextArray(key)
            }
        }
    }
}

And on the class that extends Application, use this:

Philology.init(MyPhilologyRepositoryFactory)
ViewPump.init(ViewPump.builder().addInterceptor(PhilologyInterceptor).build())

But when inflation was used in the app (and actually everywhere), I never saw that this code is being used, ever.

That being said, this is what I've succeeded:

1.For all Activities/Services, indeed I've added usage of attachBaseContext as the SDK says:

override fun attachBaseContext(newBase: Context) {
    super.attachBaseContext(LokaliseContextWrapper.wrap(newBase))
}

2.For all custom views, I've used what I've made:

override fun getResources(): Resources {
    return TranslationsManager.getResources(context)
}

Both of these took quite some time to find and add manually, one after another.

Sadly, still there seem to be some important cases.

I've found that at least for layout inflation (in the custom views, for example), the layout XML files don't take the resources from the SDK.

I've found an article "Taming Android Resources and LayoutInflater for string manipulation" from 2020 (cache here) saying I could use some trick of ContextThemeWrapper a bit more complex than what I tried, but sadly it lacks some important information (implementation of cloneInContext for example) that I've failed to use:

class CustomContextWrapper(
    private val base: Context,
    private val dynamicStringMap: Map<String, String>
) : ContextWrapper(base) {

    override fun getResources() = CustomResources(base.resources, dynamicStringMap)

    override fun getSystemService(name: String): Any? {
        if (Context.LAYOUT_INFLATER_SERVICE == name) {
            return CustomLayoutInflater(LayoutInflater.from(baseContext), this)
        }
        return super.getSystemService(name)
    }
}

class CustomLayoutInflater constructor(
    original: LayoutInflater,
    newContext: Context,
) : LayoutInflater(original, newContext) {
    override fun cloneInContext(p0: Context?): LayoutInflater {
        TODO("Not yet implemented")
    }

    override fun onCreateView(name: String, attrs: AttributeSet): View? {
        try {
            val view = createView(name, "android.widget.", attrs)
            if (view is TextView) {
                // Here we get original TextView and then return it after overriding text
                return overrideTextView(view, attrs)
            }
        } catch (e: ClassNotFoundException) {
        } catch (inflateException: InflateException) {
        }
        return super.onCreateView(name, attrs)
    }

    private fun overrideTextView(view: TextView, attrs: AttributeSet?): TextView {
        val typedArray =
            view.context.obtainStyledAttributes(attrs, intArrayOf(android.R.attr.text))
        val stringResource = typedArray.getResourceId(0, -1)
        view.text = view.resources.getText(stringResource)
        typedArray.recycle()
        return view
    }
}

However, it said I could use a library called "ViewPump" (here, and it actually suggested to use Philology library here) that will do the trick for me, and that from Android 30 we could use ResourcesProvider and ResourcesLoader classes. Sadly I couldn't find an example to use any of these for the purpose I'm working on.

The questions

  1. Is it really possible to use the trick that was mentioned on the article? What should be done to use it properly?

  2. How can I use the "ViewPump"/"Philology" library to achieve the same thing?

  3. Is there any way to offer resources globally instead of using all that I've mentioned? So that all resources will be using the translation SDK, no matter where and how I reach the resources ? This takes a lot of time already, as I need to go over many classes and add handling of resources myself...

  4. Will any of the above cover all cases? For example not just the inflation, but other cases such as TextView.setText(resId) ?

As for the new classes of Android API 30, because they are very new, I've decided to ask about them in a new post, here.


EDIT: Talking with Lokalise support, they said they already do use ViewPump, which means that it probably works in cases that don't match what I have.

1 Answers

I've found success with a combination of using ViewPump to wrap the context of the view being inflated with your ContextWrapper.

class ContextWrappingViewInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): InflateResult {
        val request = chain.request()

        val newRequest = request.toBuilder()
            .context(MyContextWrapper.wrap(request.context))
            .build()

        return chain.proceed(newRequest)
    }
}

However I haven't found a solution to force custom view attributes to use your context for free. The issue is that internally, styled attributes fetch their resources from what has already been cached internally via XML files. Meaning, the view's context doesn't come into it at all.

A workaround for this is to fetch the resource ID from styled attributes and then delegate the actual resource fetching to context.

fun TypedArray.getStringUsingContext(context: Context, index: Int): String? {
    if (hasValue(index)) {
        return getResourceId(index, 0)
            .takeIf { it != 0 }
            ?.let { context.getString(it) }
    }
    return null
}

Usage in CustomView:

init {
    context.obtainStyledAttributes(attrs, R.styleable.CustomView).use { array ->
        val myText = array.getStringUsingContext(context, R.styleable.CustomView_myText)
        ...
    }
}
Related