Last active
July 6, 2022 04:23
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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