Skip to content

Instantly share code, notes, and snippets.

@nimaiwalsh
Last active July 6, 2022 04:23
Show Gist options
  • Save nimaiwalsh/6620085cd5432b8f1eb861f008a52750 to your computer and use it in GitHub Desktop.
Save nimaiwalsh/6620085cd5432b8f1eb861f008a52750 to your computer and use it in GitHub Desktop.
Android ViewModel StringProvider. Used to provide strings in a ViewModel without injecting context into the ViewModel.
import android.content.Context
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
/** Provides access to localized strings and text using the resources from the application context. */
class AndroidStringsProvider @Inject constructor(
private val context: Context
): StringsProvider {
override fun get(@StringRes resId: Int): String {
return context.getString(resId)
}
override fun get(@StringRes resId: Int, vararg formatArgs: Any?): String {
return context.getString(resId, *formatArgs)
}
override fun getQuantity(@PluralsRes resId: Int, quantity: Int): String {
return context.resources.getQuantityString(resId, quantity)
}
override fun getQuantity(@PluralsRes resId: Int, quantity: Int, vararg formatArgs: Any?): String {
return context.resources.getQuantityString(resId, quantity, *formatArgs)
}
}
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import com.skedulo.core.presentation.R
import java.lang.reflect.Field
import java.lang.reflect.Modifier
/**
* Mock implementation of [StringsProvider] that returns easily identifiable strings for the requested resource Ids.
* Use with [localizedString] and [localizedPlural] in tests to validate that the correct string resource and format args were specified:
* ```
* // Implementation
* class SomeViewModel(val strings: StringsProvider, name: String?) {
* val title = if (title.isNotNullOrBlank()) strings.get(R.string.title_name, name) else strings.get(R.string.title_generic)
* }
*
* // Test
* val someViewModel = SomeViewModel(MockStringsProvider, "Bob")
* someViewModel.title.shouldBe(localizedString(R.string.title_name, "Bob"))
* ```
*/
object MockStringsProvider: StringsProvider {
private val stringResourceNameMap: Map<Int, String> by lazy {
val staticFields = getStaticFields(R.string::class.java)
staticFields.associateBy({ it.get(null) as Int }, { requireNotNull(it.name) })
}
private val pluralResourceNameMap: Map<Int, String> by lazy {
val staticFields = getStaticFields(R.plurals::class.java)
staticFields.associateBy({ it.get(null) as Int }, { requireNotNull(it.name) })
}
private fun getStaticFields(clazz: Class<*>): List<Field> {
return clazz.declaredFields.filter { Modifier.isStatic(it.modifiers) && !Modifier.isPrivate(it.modifiers) }
}
override fun get(@StringRes resId: Int): String {
val name = requireNotNull(stringResourceNameMap[resId]) { "Invalid string resId: $resId" }
return "R.string.$name"
}
override fun get(@StringRes resId: Int, vararg formatArgs: Any?): String {
val name = requireNotNull(stringResourceNameMap[resId]) { "Invalid string resId: $resId"}
return "R.string.$name(args=${formatArgs.joinToString(",")})"
}
override fun getQuantity(@PluralsRes resId: Int, quantity: Int): String {
val name = requireNotNull(pluralResourceNameMap[resId]) { "Invalid plural resId: $resId"}
return "R.plural.$name(quantity=$quantity)"
}
override fun getQuantity(@PluralsRes resId: Int, quantity: Int, vararg formatArgs: Any?): String {
val name = requireNotNull(pluralResourceNameMap[resId]) { "Invalid plural resId: $resId"}
return "R.plural.$name(quantity=$quantity, args=${formatArgs.joinToString(",")})"
}
}
/** Use with [MockStringsProvider] to validate strings returned by [StringsProvider.get]. */
fun localizedString(@StringRes resId: Int): String {
return MockStringsProvider.get(resId)
}
/** Use with [MockStringsProvider] to validate formatted strings returned by [StringsProvider.get]. */
fun localizedString(@StringRes resId: Int, vararg formatArgs: Any?): String {
return MockStringsProvider.get(resId, *formatArgs)
}
/** Use with [MockStringsProvider] to validate plural strings returned by [StringsProvider.getQuantity]. */
fun localizedPlural(@PluralsRes resId: Int, quantity: Int): String {
return MockStringsProvider.getQuantity(resId, quantity)
}
/** Use with [MockStringsProvider] to validate formatted plural strings returned by [StringsProvider.getQuantity]. */
fun localizedPlural(@PluralsRes resId: Int, quantity: Int, vararg formatArgs: Any?): String {
return MockStringsProvider.getQuantity(resId, quantity, *formatArgs)
}
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
/**
* Provides access to localized strings and text.
*
* Often, selecting and formatting a string to display in the view is a business logic decision, e.g:
* If _a_ show message 1 formatted with _x_, if _b_ and !_c_, show message 2 formatted with _y_, otherwise message 3.
*
* This kind of logic is often intricate and benefits greatly from being unit tested to ensure it's correctness.
*
* Unfortunately, due to nature of Android SDK, retrieving localized string resources requires access to a Context,
* which is not something the domain or view models should have access to.
* To work around this there are two options:
* * A - Pass all the required data for making the decision to the view.
* * B - Create an abstraction which allows accessing localized strings without knowing about the Android Context.
*
* Option A has several drawback:
* * Requires passing many diverse values from multiple domain models to the view so it can implement the logic.
* * Representing these value with strong cardinality can require complex data structures.
* * It's difficult to unit test this logic in the views.
* * It's ties the view to a specific use case, when they should be working in more generic terms like "title", "description", "body", etc.
*
* This leaves Option B as the preferred option:
* * View models are easy to, and are already unit tested.
* * View models already have access the domain models required to implement the logic.
* * Views can remain generic and reusable.
*
* @see AndroidStringsProvider for the implementation
*/
interface StringsProvider {
/** Returns a localized string for the given resource ID. */
fun get(@StringRes resId: Int): String
/**
* Returns a localized formatted string for the given resource ID,
* substituting the format arguments as defined in [java.util.Formatter].
*/
fun get(@StringRes resId: Int, vararg formatArgs: Any?): String
/** Returns a localized string for given resource ID, with grammatically correct pluralization for the given quantity. */
fun getQuantity(@PluralsRes resId: Int, quantity: Int): String
/**
* Returns a localized string for given resource ID, with grammatically correct pluralization for the given quantity,
* substituting the format arguments as defined in [java.util.Formatter].
*/
fun getQuantity(@PluralsRes resId: Int, quantity: Int, vararg formatArgs: Any?): String
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment