Accessibility means every task in the app can be finished without seeing the screen, without precise touch and at large text sizes: labelled controls, a sensible focus order, 48dp touch targets, enough contrast and text that scales.
Screen readers: TalkBack reads the accessibility node tree, so every control needs a label, a role and a state, in a sensible reading order.
Switch Access and keyboards: anything done by a gesture alone (swipe, drag, long press) also needs a focusable control or a custom accessibility action.
Low vision: text in sp that survives 200% font scale, 4.5:1 contrast for body text, and no information carried by colour alone.
Motor and vestibular needs: touch targets of at least 48x48dp, and respect the system "Remove animations" setting.
It is also a legal requirement in many markets (the European Accessibility Act, since June 2025). Verify with TalkBack on a real device, Accessibility Scanner and automated checks.
AndroidMiddle
Android Accessibility & Localization
TalkBack, contentDescription, Compose semantics, plurals, per-app language, RTL, font scaling
Questions closed
0 / 21
- 0Review
Questions
contentDescription is the text TalkBack reads for an element that has no visible text. An icon-only button needs one short string resource that names the action: on the Icon in Compose, or android:contentDescription on an ImageButton.
Compose: the description goes on the Icon, not on the IconButton: the button's clickable merges its children, so TalkBack says "Share, button".
**null is not a placeholder**: contentDescription = null means "decorative, nothing to hear"; inside a clickable with no other text it leaves just "button".
Views: android:contentDescription="@string/share" on the ImageButton; lint's ContentDescription check flags a missing one. Set it from code only when the label changes with state.
Name the action, not the picture: "Share", never "three dots", and no "button" in the text (the role is announced separately). Always a string resource, so it reaches a translator.
Keep every user-facing string in res/values/strings.xml, put translations into values-<language> folders, and let resources handle the grammar: positional placeholders, <plurals>, start/end layouts, locale-aware formatting and per-app language support.
Default folder: values/ holds the source language and must contain every key: a key missing from values-de falls back silently, one missing from values/ crashes.
Qualifiers and placeholders: values-ru, values-pt-rBR; %1$s, %2$d so a translator can reorder them, and never build a sentence with +.
Grammar and layout: <plurals> for plural forms, android:supportsRtl="true" with start/end, dates and money through DateTimeFormatter and NumberFormat.
Per-app language and tooling: locales_config.xml plus AppCompatDelegate.setApplicationLocales(); lint HardcodedText and MissingTranslation, and pseudolocales en-XA and ar-XB.
Mark decoration with contentDescription = null in Compose or importantForAccessibility="no" in Views; drop a whole subtree with Modifier.clearAndSetSemantics { } or noHideDescendants. Hide only what has nothing to say; duplicates are usually a grouping problem.
Taking something out
Compose: Image(painter, contentDescription = null) emits no description property at all, so no node is exposed and TalkBack walks straight past it. That is the intended marker for decoration, not a way to silence a warning.
Modifier.clearAndSetSemantics { } wipes a whole subtree's semantics and replaces them with whatever you set inside: the Compose equivalent of noHideDescendants.
Modifier.semantics { hideFromAccessibility() } (the rename of the old experimental invisibleToUser()) hides one node from assistive technologies while keeping its semantics for tests.
Views: importantForAccessibility="no" drops the view but keeps its children; "noHideDescendants" drops the view and everything under it; "yes" forces in a view the framework would skip; auto, the default, exposes anything with text, a description or a click listener.
Grouping, not hiding
A contentDescription on a container does not hide its children, so duplicates are a grouping problem: merge them into one stop with Modifier.semantics(mergeDescendants = true) { }, or in Views android:screenReaderFocusable="true" on the container plus importantForAccessibility="no" on each child.
Rule of thumb: null and no mean "there is nothing here to say". A description means "there is exactly one thing here, and this is it".
Give every control a 48x48dp touch target with 8dp between targets, and keep body text at 4.5:1 contrast: the target can grow around a 24dp icon without redrawing it, while contrast depends on the real colours behind the pixel.
Touch targets
The icon stays 24dp; the target around it grows to 48dp. Material 3 in Compose already does this for its own controls: IconButton, Checkbox, RadioButton and Switch apply Modifier.minimumInteractiveComponentSize(), which reserves the space without resizing the visual.
LocalMinimumInteractiveComponentSize is the knob (it replaced the deprecated boolean LocalMinimumInteractiveComponentEnforcement), and providing Dp.Unspecified switches enforcement off, which should be a decision rather than an accident.
In your own composable the modifier order decides everything. Modifier.size(24.dp).clickable { } is a 24dp target; Modifier.clickable { }.padding(12.dp).size(24.dp) is 48dp. Padding declared after clickable sits inside the target.
In Views: 48dp minWidth/minHeight, or a TouchDelegate, with the caveat that a touch delegate grows only the touch area, not the node bounds a service reads or the rectangle TalkBack draws around the focused element.
Contrast
4.5:1 for text below 24sp (18.7sp bold), 3:1 above it, and 3:1 for icons, input borders and focus indicators.
Hint and placeholder text is text and has to pass; that grey at 2.8:1 is the usual failure. Disabled controls are exempt, which is not an invitation to disable more of them.
Text over a photo or a gradient has no single ratio. Put a scrim underneath and measure against the worst pixel that can end up behind it.
Rule of thumb: contrast is measured against what is actually behind the pixel, and a touch target is measured against a fingertip, not against the icon.
mergeDescendants turns a subtree into one focus stop, clearAndSetSemantics replaces it outright, stateDescription renames the spoken state ("Subscribed" instead of "on"), and role says what kind of control it is.
Merging and clearing
Modifier.semantics(mergeDescendants = true) { } collapses a subtree into one focus stop and concatenates the children's text. Modifier.clickable already does this, which is why a card is one stop and not six.
Merging stops at a child that merges on its own. A card with an inline "Follow" button stays two nodes, and that is correct: the user has to be able to reach the button.
Modifier.clearAndSetSemantics { } replaces a subtree outright, for a chart or custom gauge whose internals are noise; a contentDescription on a merging node is merged with the children's, so check what TalkBack really reads.
Both trees exist at once: services read the merged tree, and a test can ask for the unmerged one with useUnmergedTree = true.
Describing one control
role = Role.Button, Role.Checkbox, Role.Switch, Role.Tab tell the service what kind of thing it is; heading() puts a text node on TalkBack's headings rotor.
stateDescription overrides the spoken state ("Subscribed" instead of "on") and leaves the label alone, so TalkBack re-reads only the part that changed.
onClick(label = "open article") renames the default action, so TalkBack offers "double tap to open article" instead of "double tap to activate".
Rule of thumb: contentDescription is what it is, stateDescription is what it is doing right now, role is what kind of thing it is, and merging decides how many stops the user makes walking past it.
TalkBack makes two stops: the row ("Wi-Fi", no state) and the Switch ("On, switch", no label). Make the whole row one toggleable control with Role.Switch and pass onCheckedChange = null to the Switch.
Why two stops: clickable merges its children into one node, but merging stops at a child that is itself actionable, and a Switch with a non-null onCheckedChange is one.
Why the row has no state: clickable carries no role and no toggle state, so TalkBack cannot tell that the row is a switch or whether it is on.
The fix: Modifier.toggleable(value = checked, role = Role.Switch, onValueChange = onToggle) on the row and onCheckedChange = null on the Switch. The switch becomes purely visual and merges into the row: one stop that reads label, state and role together, and the whole row is the touch target.
Same pattern elsewhere: Checkbox rows use Role.Checkbox; radio rows use Modifier.selectable(role = Role.RadioButton) on each row and Modifier.selectableGroup() on the parent.
Custom accessibility actions: every gesture-only affordance needs a named action on the node, which TalkBack then offers from its actions menu.
Adding them
Views: ViewCompat.addAccessibilityAction(row, getString(R.string.delete)) { _, _ -> delete(id); true }. It returns an id you can pass to ViewCompat.removeAccessibilityAction when the row is rebound.
ViewCompat.replaceAccessibilityAction(view, ACTION_CLICK, label, command) renames the default action rather than adding a second one, so TalkBack says "double tap to open article".
Compose: Modifier.semantics { customActions = listOf(CustomAccessibilityAction(label) { onDelete(); true }) }.
For reordering, do not try to expose a drag. Expose "Move up" and "Move down": a drag is impossible for TalkBack and for Switch Access alike, and two extra actions cost nothing.
Prefer the standard actions
expand/collapse, dismiss, ACTION_SCROLL_FORWARD and ACTION_LONG_CLICK are localised by the system and understood by every service, including ones you have never heard of. A custom action is only the label you typed, in the language you typed it, so it comes from strings.xml like any other user-facing string.
Two traps. The action has to sit on the node the user actually focuses: put it on an inner element of a merged row and it never appears in the menu. And that menu is read aloud one item at a time, so past roughly five actions it stops being usable.
Rule of thumb: anything reachable only by a swipe, a drag, a long press or a pinch does not exist. Give it a name and hang it on the node.
Use a live region: the node speaks when its own text changes and focus never moves. Android 16 deprecated announceForAccessibility and TYPE_ANNOUNCEMENT because they interrupt, cannot be replayed and behave differently across services.
Live regions
Views: ViewCompat.setAccessibilityLiveRegion(view, ACCESSIBILITY_LIVE_REGION_POLITE). Polite queues behind whatever is being read; assertive interrupts it, and is only for something the user must act on now.
Compose: Modifier.semantics { liveRegion = LiveRegionMode.Polite } on the composable whose text changes. Material's Snackbar already does this for you; a toast-like Box you drew yourself does not.
The region speaks on a change, so the node has to stay in the tree and take new text. Removing it and adding a different one is a different event, and often says nothing at all.
Why the announcement API went away
announceForAccessibility interrupts, cannot be replayed, leaves nothing on screen to navigate back to, and behaves differently across assistive technologies. The documented replacements are live regions and, for validation, the error channel: setError on a TextView or on an AccessibilityNodeInfo with CONTENT_CHANGE_TYPE_ERROR, and Modifier.semantics { error("Enter a valid email") } in Compose, which ties the message to the field instead of shouting it into the room.
The trap is a live region on a value that changes every frame: a progress percentage turns polite into a machine gun. Announce in coarse steps, or leave the number on the node and let the user re-read it on demand.
Rule of thumb: prefer changing something the user can navigate back to over speaking at them.
The sheet is not modal in the node tree, and nothing returns focus to its opener: use a real modal window (ModalBottomSheet, Dialog) or hide the content behind it, then put focus back on the opener when it closes.
Making the sheet the only thing on screen
ModalBottomSheet and Dialog render in their own window, so the framework excludes the content behind them and TalkBack treats the sheet as a new pane. A sheet you built as a Box on top of the screen has neither property.
If you did build it yourself: Modifier.semantics { paneTitle = "Filters" } on the sheet (in Views, ViewCompat.setAccessibilityPaneTitle) makes TalkBack announce it when it appears, and Modifier.clearAndSetSemantics { } on the content behind it (importantForAccessibility="noHideDescendants" in Views) is what actually stops the swipe from walking into the list.
Material's own sheets announce a pane title rather than seizing focus, and that is the behaviour to copy: announce, do not grab.
Order, and getting focus back
Default order is geometric (top to bottom, start to end) in both. android:accessibilityTraversalBefore/After in Views, and isTraversalGroup = true on the container plus traversalIndex on its children in Compose, override it; isTraversalGroup replaced the deprecated isContainer flag in Compose 1.5.
On close, put focus back where it came from. In Views performAccessibilityAction(ACTION_ACCESSIBILITY_FOCUS, null) on the opener is the reliable route; in Compose FocusRequester.requestFocus() on the opener is best effort, because it moves input focus, which does not always move TalkBack's accessibility focus.
The trap is that traversalIndex only orders elements inside the same traversal group, and a traversal hint pointing across containers or forming a cycle is ignored. Reordering is a last resort; laying the thing out in the order it should be read is the fix that keeps working.
Rule of thumb: a modal is a place, not a layer. If the user can swipe out of it, it is not modal.
Only sp text follows the user's font-size setting. Since Android 14 that scale reaches 200% and is non-linear, so text containers must be able to grow and conversions must use the system curve.
Scaling since Android 14
android:textSize="16dp" and a Compose fontSize built out of a Dp both ignore the setting; lint's SpUsage catches the XML case, nothing catches the Compose one.
Android 14 (API 34) raised the ceiling to 200% and made the curve non-linear: small text grows almost in full, an already-large headline grows much less. A 12sp caption and a 32sp title no longer scale by the same factor.
That is why sp * fontScale * density is now wrong. Convert with TypedValue.applyDimension(COMPLEX_UNIT_SP, …) and back with TypedValue.deriveDimension(…) (API 34+); in Compose with(LocalDensity.current) { 16.sp.toDp() } already applies the right curve.
What breaks first
Fixed heights (layout_height="56dp", Modifier.height(56.dp)) and any minHeight someone quietly turned into a real height. Use wrap_content or Modifier.heightIn(min = 56.dp).
maxLines = 1 with ellipsis on a label the user must read: the words vanish instead of wrapping.
Two texts side by side in a rigid Row: give one weight(1f), or stack them past a threshold with FlowRow.
Rule of thumb: sp for anything with words in it, dp for everything you draw, and no fixed height on a box that contains text. Check it with @Preview(fontScale = 2f) and adb shell settings put system font_scale 2.0 before QA does.
Automated checks prove that a label, a touch target or a contrast ratio exists; only a person driving TalkBack can tell you whether the label means anything and whether the task can be completed.
What the automation covers
Accessibility Scanner (a Play Store app, run on device) audits one screen at a time: missing item labels, duplicated labels, touch targets under 48dp, text contrast, clickable items nested inside clickable items.
Espresso: AccessibilityChecks.enable().setRunChecksFromRootView(true) from androidx.test.espresso:espresso-accessibility runs the same Accessibility Test Framework rules before every view interaction, and setSuppressingResultMatcher lets you carry a known failure instead of disabling the lot.
Compose instrumented tests: enableAccessibilityChecks() on the rule (experimental: needs @OptIn(ExperimentalTestApi::class)) runs ATF over the composed hierarchy, and the semantics tree is directly assertable, such as assertContentDescriptionEquals, assert(hasClickAction()), assertHeightIsAtLeast(48.dp).
Cheaper still: lint's ContentDescription and ClickableViewAccessibility, and the Play Console pre-launch report, which runs ATF on real devices.
What only a human finds
Labels that exist and say nothing: "image", "button1", an icon's file name.
A reading order that is legal but senseless, and focus that lands nowhere when a dialog or bottom sheet opens.
Announcements that never fire, or fire so often they talk over the user.
Whether a custom action is discoverable at all, and whether the whole flow works with swipe navigation and no precise double-tap.
Mental model: the automated pass is a spell-checker. It proves the words are there, never that the sentence makes sense, so budget one manual TalkBack run per feature, screen curtain on.
The like is toggled only inside an OnTouchListener that consumes every event, but TalkBack, Switch Access and a keyboard send no touches; they call performClick(), which has no click listener, so for them the button does nothing.
Why it happens: with TalkBack on, a double-tap activates the focused node through ACTION_CLICK, and the view answers it with performClick(). Switch Access does the same, and Enter or D-pad centre on a focused view also ends in performClick(). None of them pass through onTouch, the only place the like is toggled.
The fix: the touch listener only animates and returns false, so the view's own onTouchEvent still turns the tap into performClick(), and the action moves to setOnClickListener, the one path every input method reaches. As a bonus the framework click respects touch slop and is cancelled when the finger slides off; the hand-rolled ACTION_UP fired anyway.
Simpler still: a StateListAnimator on the pressed state (android:stateListAnimator) gives the shrink with no touch code at all; in Compose it is clickable plus interactionSource.collectIsPressedAsState().
How to catch it: lint's ClickableViewAccessibility warns about an OnTouchListener that never calls performClick(). Espresso's click() injects real touches, so a UI test passes; only TalkBack, a keyboard, or performAccessibilityAction(ACTION_CLICK, null) in a test reveals it.
Nothing is a node until an accessibility service asks: the framework builds AccessibilityNodeInfo objects on demand from the View tree, and anything you painted rather than laid out has no node unless you make one.
Every View contributes one node through onInitializeAccessibilityNodeInfo, which you override by attaching a delegate with ViewCompat.setAccessibilityDelegate rather than by subclassing the View.
RecyclerView installs its own delegate. The list node carries CollectionInfo (row and column counts) and each bound row carries CollectionItemInfo with its index; that is the entire source of "in list, item 3 of 40". Only bound rows exist as nodes, and the total comes from the adapter, so it jumps when you append a page.
A custom View that paints ten hit areas onto one canvas is one node, because the framework has no idea the canvas has parts. ExploreByTouchHelper from androidx.customview gives you a virtual node tree: getVisibleVirtualViews, onPopulateNodeForVirtualView, onPerformActionForVirtualView.
In Compose there is no ExploreByTouchHelper: put Modifier.semantics { contentDescription = summary } on the Canvas for a summary, or lay one transparent Box per bar over it, each with its own Modifier.semantics { contentDescription = label } and clickable, so every bar becomes a semantics node.
A whole Compose screen is a single AndroidComposeView. Its accessibility delegate walks the merged semantics tree and synthesises one virtual node per semantics node: in Compose, semantics IS the node tree, and there is no View per composable.
The trap is tooling that walks Views: to it, a Compose screen is one leaf. That is why Compose tests assert on semantics rather than on view matchers, and why an Espresso accessibility check stops at the ComposeView instead of descending into it.
Rule of thumb: if it is not a View and not a semantics node, no assistive technology can see it, however clearly it is drawn.
cart_count has to be a <plurals> resource, because Russian needs different word forms for 1, 2–4 and 5+, and moved needs positional placeholders: two bare %s is an aapt2 build error, and the Russian sentence puts the folder first.
Positional arguments
With more than one substitution aapt2 requires the positional form %1$s, %2$d; two bare %s is a build error. Even a single %s is a trap, because a translator cannot move it once a second argument appears.
The index is the argument's identity, so a translation is free to write %2$s first. A literal percent is %%, an apostrophe is \'.
Wrap each placeholder in <xliff:g id="name" example="Anna">%1$s</xliff:g> so the translation tool locks it and shows the translator a sample value.
Plurals
<plurals> items carry CLDR categories, not numbers: zero, one, two, few, many, other. Which ones exist is a property of the language, never of your string. English uses one and other; Russian needs one (1, 21, 31), few (2–4, 22–24), many (0, 5–20, 25–30) and other for fractions; Arabic uses all six.
getQuantityString(R.plurals.cart_count, count, count) passes count twice on purpose: the first selects the category, the second fills %1$d. Pass it once, getQuantityString(R.plurals.cart_count, count), and nothing is formatted: the user sees a literal %1$d items in cart. In Compose it is pluralStringResource(R.plurals.cart_count, count, count).
<item quantity="zero"> in an English file is dead code: English has no zero category, so 0 takes other. A real empty-state line is a separate string chosen in code.
Never assemble a sentence from fragments: another language may inflect the noun around the number or put the number somewhere else entirely.
Rule: if a count, a name or a date goes into a sentence, the whole sentence lives in strings.xml with positional placeholders, and anything counted is a plural. Lint's ImpliedQuantity and StringFormatMatches catch the ones you forget.
Russian. Since API 24 the user has an ordered LocaleList, and Android walks all of it before falling back to the unqualified values/, so their second choice beats your default.
How the match runs
For each entry in the list the system tries an exact qualifier match, then that locale's parent chain: en-GB → en-001 → en, es-MX → es-419 → es. Ukrainian matches nothing here, Russian matches values-ru, and the search stops there.
A near miss also counts now: a fr-CH user finds values-fr-rFR instead of dropping to the default, which is what API 23 did and what surprised everyone.
That is why region-qualifying your source language is a bug waiting to happen. English belongs in values/; values-en-rGB is only for the words that genuinely differ (colour, postcode). Scripts use the BCP-47 form: values-b+sr+Latn, values-b+zh+Hans.
values/ must define every string. MissingDefaultResource in lint and a crash at lookup are the two ways you learn it did not.
Trimming, and reading it back
In recent AGP, androidResources.localeFilters (replacing resourceConfigurations/resConfigs) keeps only the locales you list, and it strips them out of AppCompat and Play Services too. A user whose language you filtered out silently gets the default.
Read the effective locale from the configuration (resources.configuration.locales[0], or LocalConfiguration.current.locales[0] in Compose), never from a Locale captured once at process start, because a per-app language change or a configuration change moves it under you.
Rule of thumb: the unqualified folder is your source language, every other language gets a plain language qualifier, and a region qualifier only when the region really changes the words.
Declare the shipped locales in locales_config.xml and change the language only through AppCompatDelegate.setApplicationLocales(): on Android 13+ it goes to the system LocaleManager, below 13 AppCompat stores and re-applies the choice itself.
Wiring it up
res/xml/locales_config.xml lists the locales you ship, and android:localeConfig="@xml/locales_config" on <application> points at it. AGP can write the file for you with androidResources.generateLocaleConfig = true. Declaring it is also what puts your app under Settings → System → Languages → App languages on Android 13+.
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags("ru")) is one call for every API level. On 33+ it delegates to the framework LocaleManager, which stores the choice system-side and recreates your activities; below 33 AppCompat stores it itself, which needs the autoStoreLocales meta-data on AppLocalesMetadataHolderService enabled.
Read it back with AppCompatDelegate.getApplicationLocales(). An empty list means "follow the system"; that is the state your switcher needs for its default row, and it is not the same as the current system language.
The two things that still break
App Bundle language splits: the device only installs the languages it asked for, so switching to one it never had shows your default strings. Request it first with SplitInstallManager.startInstall(… addLanguage(locale) …), or turn the split off with bundle { language { enableSplit = false } }.
Anything your app does not draw: push text composed on the server, the store listing, android:label. Send the app locale to the backend whenever it changes.
Rule of thumb: locales_config.xml is the contract, setApplicationLocales is the only writer, and everything rendered outside your process has to be told separately.
.format uses the device's default locale, so on a Russian or German phone the URL carries lat=55,755800, a comma the server cannot parse, and the search fails. Text that a machine reads must be formatted with Locale.ROOT.
Why it happens: Kotlin's String.format(vararg args) delegates to Java's String.format, which takes Locale.getDefault(). Russian, German, French and most of Europe write the decimal separator as a comma; some Arabic locales (ar-EG) and Persian (fa) change the digits themselves (lat=٥٥٫٧٥٥٨٠٠). The developer's English phone and the CI emulator never show it.
The fix: .format(Locale.ROOT, …) produces 55.755800 on every device. Better still, let the URL builder serialise the value: Ktor's parameter("lat", lat) or OkHttp's addQueryParameter("lat", lat.toString()); Double.toString() does not depend on the locale, and the builder also handles escaping.
Same bug elsewhere: hand-built JSON or CSV, cache keys, analytics parameters, SimpleDateFormat for a server date, uppercase(Locale.getDefault()) on an enum key (Turkish dotted İ breaks valueOf). The user's locale is for text a person reads; anything a machine parses uses Locale.ROOT.
How to catch it: a unit test that calls Locale.setDefault(Locale.forLanguageTag("ru")) before asserting the URL fails on the first run; lint's DefaultLocale check also flags implicit-locale calls it recognises.
With android:supportsRtl="true" the framework mirrors layout attributes and auto-mirrored drawables, and that is the whole list: anything you positioned absolutely, drew yourself, or shipped as a plain directional asset keeps pointing the way you drew it.
What mirrors on its own
Layout: paddingStart/paddingEnd, layout_marginStart/End, gravity="start", drawableStart, ConstraintLayout's layout_constraintStart_toStartOf. In Compose Modifier.padding(start = …), Arrangement.Start, Modifier.offset and placeRelative are all direction-aware; absolutePadding, Alignment.Absolute.*, absoluteOffset and place deliberately are not.
Drawables with android:autoMirrored="true": vectors and nine-patches. In Compose use Icons.AutoMirrored.Filled.ArrowBack; the old Icons.Filled.ArrowBack is deprecated for exactly this reason.
What never does
Canvas and custom layout: read LocalLayoutDirection.current (or View.getLayoutDirection()) and mirror the coordinates yourself. Your chevron needs autoMirrored on the asset, or a graphicsLayer { scaleX = -1f } when it is drawn.
Icons that are not about reading order: media transport controls, a logo, a checkmark, a picture of a physical object. Mirroring play and fast-forward is the classic overcorrection; a timeline runs the same way in every language.
Bidirectional runs inside a sentence: an order number, a phone number, a URL or a Latin brand name dropped into Arabic text drags its punctuation to the wrong end. Wrap the value with BidiFormatter.getInstance().unicodeWrap(value), which wraps the run in directional marks so it keeps its own direction.
Test it before a single Arabic string exists: build with isPseudoLocalesEnabled and run in ar-XB, or flip Developer options → Force RTL (adb shell settings put global debug.force_rtl 1).
Always go through a locale-aware formatter (DateTimeFormatter.ofLocalizedDate, NumberFormat.getCurrencyInstance with an explicit Currency, NumberFormat.parse), because hand-written patterns, pasted currency symbols and toDouble() break on other digits, separators and conventions.
Dates and times
Display formats come from DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).withZone(zone), or from DateFormat.getBestDateTimePattern(locale, "MMMd") when you need one specific set of fields. A pattern you typed yourself is only ever for machine-readable output.
SimpleDateFormat("dd/MM/yyyy") with no locale follows the user's: an Arabic locale such as ar-EG renders the digits as ٢٠٢٦. If you must have a stable format, pass Locale.ROOT explicitly.
Store an Instant or epoch millis and convert at the edge with ZoneId.systemDefault(), re-read per render: the device's zone changes mid-session on a flight. Below API 26 java.time needs core library desugaring.
Numbers, currency and lists
NumberFormat.getCurrencyInstance(locale) decides the separators and where the symbol goes; Currency.getInstance("EUR") decides what the money is. The locale must never pick the currency: a German looking at a dollar price still owes dollars.
Parse with NumberFormat.parse, never String.toDouble(): 1.234,50 and ١٢٣٤ are both legal user input, and both throw.
android.icu.text covers the rest: RelativeDateTimeFormatter for "3 days ago", ListFormatter (API 29+) for "a, b and c", MeasureFormat for units, and MessageFormat when a <plurals> cannot express the sentence.
Rule of thumb: no + and no separator you typed ever touches a number or a date a person will read. If you wrote a pattern string for human output, it is probably a bug in a language you do not speak.
Everything except the translations themselves can be done before they arrive: pseudolocales prove the layouts survive, lint proves nothing is hard-coded, and a string freeze decides what the translators are actually given.
Before the strings go out
Turn on pseudolocales: buildTypes { debug { isPseudoLocalesEnabled = true } } adds en-XA (accented, about 30% longer, bracketed, so every truncation shows up and every hard-coded string stays conspicuously plain ASCII) and ar-XB, which is RTL with reversed text.
Make lint fail the build on HardcodedText, StringFormatInvalid, StringFormatMatches and ImpliedQuantity, and run lintRelease in CI with abortOnError = true. MissingTranslation starts earning its keep the day the first locale lands.
Give the translator context they cannot get from the string: a <!-- --> comment above it, and <xliff:g id="name" example="Anna">%1$s</xliff:g> around every placeholder. Mark keys, analytics names and debug text translatable="false".
The week itself
Freeze on a date, hand over values/strings.xml, and accept that anything added afterwards ships in your default language; the fallback is silent, so keep the list of what missed the cut.
Screenshot the main screens in en-XA and ar-XB in CI (Paparazzi or Roborazzi, or @Preview(locale = "en-rXA")) so a truncation is a failing diff rather than a bug report from Berlin.
Remember what is not in strings.xml at all: android:label, the Play listing, server-composed push text, and anything a widget or Wear surface draws.
Rule of thumb: a locale you cannot test before its translation exists is a locale you will ship broken. en-XA is the whole trick.