Доступность означает, что любую задачу в приложении можно выполнить, не видя экрана, без точных касаний и при крупном шрифте: подписанные контролы, понятный порядок фокуса, зоны касания 48dp, достаточный контраст и масштабируемый текст.
Скринридеры: TalkBack читает дерево узлов доступности, поэтому у каждого контрола должны быть метка, роль и состояние, а порядок чтения должен быть осмысленным.
Switch Access и клавиатура: всё, что делается только жестом (свайп, перетаскивание, долгое нажатие), должно иметь фокусируемый контрол или кастомное действие доступности.
Слабое зрение: текст в sp, переживающий масштаб шрифта 200%, контраст 4.5:1 для основного текста и никакой информации, которая передаётся только цветом.
Моторика и вестибулярные нарушения: зоны касания не меньше 48x48dp и уважение к системной настройке «Удалить анимации».
Во многих странах это ещё и требование закона (European Accessibility Act, с июня 2025 года). Проверяйте через TalkBack на реальном устройстве, Accessibility Scanner и автоматические проверки.
AndroidMiddle
Доступность и локализация в Android
TalkBack, contentDescription, semantics в Compose, plurals, язык приложения, RTL, масштаб шрифта
Закрыто вопросов
0 / 21
- 0Повторить
Вопросы
contentDescription задаёт текст, который TalkBack читает для элемента без видимого текста. Кнопке-иконке нужна одна короткая строка из ресурсов, называющая действие: на Icon в Compose или android:contentDescription на ImageButton.
Compose: описание ставится на Icon, а не на IconButton: clickable кнопки сливает дочерние элементы, поэтому TalkBack говорит «Поделиться, кнопка».
**null не заглушка**: contentDescription = null означает «декор, слушать нечего»; внутри clickable без другого текста остаётся просто «кнопка».
Views: android:contentDescription="@string/share" на ImageButton; lint-проверка ContentDescription ловит пропуск. Из кода задавайте описание, только когда метка меняется вместе с состоянием.
Называйте действие, а не картинку: «Поделиться», а не «три точки», и без слова «кнопка» в тексте (роль объявляется отдельно). Всегда строковый ресурс, чтобы текст дошёл до переводчика.
Все строки, которые видит пользователь, держите в res/values/strings.xml, переводы кладите в папки values-<язык>, а грамматику отдайте ресурсам: позиционные плейсхолдеры, <plurals>, разметка через start/end, форматирование с учётом локали и поддержка per-app language.
Папка по умолчанию: в values/ лежит исходный язык, и там должны быть все ключи: ключ, которого нет в values-de, молча возьмётся из дефолта, а ключ, которого нет в values/, уронит приложение.
Квалификаторы и плейсхолдеры: values-ru, values-pt-rBR; %1$s, %2$d, чтобы переводчик мог менять порядок, и никогда не собирайте фразу через +.
Грамматика и разметка: <plurals> для форм множественного числа, android:supportsRtl="true" с start/end, даты и деньги через DateTimeFormatter и NumberFormat.
Per-app language и инструменты: locales_config.xml плюс AppCompatDelegate.setApplicationLocales(); lint-проверки HardcodedText и MissingTranslation и псевдолокали en-XA и ar-XB.
Декор помечается contentDescription = null в Compose или importantForAccessibility="no" во View; целое поддерево убирается через Modifier.clearAndSetSemantics { } или noHideDescendants. Скрывать стоит только то, о чём нечего сказать; дубли обычно решаются группировкой.
Как убрать элемент
Compose: Image(painter, contentDescription = null) вообще не выставляет свойство описания, поэтому узел не появляется и TalkBack проходит мимо. Это штатная пометка декоративного элемента, а не способ заглушить предупреждение.
Modifier.clearAndSetSemantics { } стирает semantics всего поддерева и подменяет их тем, что вы напишете внутри; это аналог noHideDescendants в Compose.
Modifier.semantics { hideFromAccessibility() } (переименованный экспериментальный invisibleToUser()) прячет один узел от служб доступности, но оставляет его semantics для тестов.
Views: importantForAccessibility="no" убирает саму view, но оставляет её детей; "noHideDescendants" убирает view вместе со всем поддеревом; "yes" принудительно показывает то, что фреймворк пропустил бы; auto действует по умолчанию и показывает всё, у чего есть текст, описание или обработчик клика.
Группировка, а не скрытие
contentDescription на контейнере не прячет его детей, поэтому дубли решаются группировкой: слейте их в одну остановку через Modifier.semantics(mergeDescendants = true) { }, а во Views используйте android:screenReaderFocusable="true" на контейнере плюс importantForAccessibility="no" на каждом ребёнке.
Правило: null и no означают «здесь говорить не о чем». Описание означает «здесь ровно одна сущность, и вот она».
Дайте каждому контролу зону касания 48x48dp с 8dp между зонами и держите контраст основного текста не ниже 4.5:1: зона вырастает вокруг иконки 24dp без перерисовки, а контраст зависит от реальных цветов за пикселем.
Зоны касания
Иконка остаётся 24dp, а зона вокруг неё вырастает до 48dp. Material 3 в Compose делает это за вас для своих контролов: IconButton, Checkbox, RadioButton и Switch применяют Modifier.minimumInteractiveComponentSize(), который резервирует место, не меняя визуальный размер.
Настраивается через LocalMinimumInteractiveComponentSize, который заменил устаревший булев LocalMinimumInteractiveComponentEnforcement; передача Dp.Unspecified отключает контроль размера, и это должно быть осознанным решением, а не случайностью.
В своём composable всё решает порядок модификаторов. Modifier.size(24.dp).clickable { } даёт зону 24dp; Modifier.clickable { }.padding(12.dp).size(24.dp) даёт 48dp. Padding, объявленный после clickable, лежит внутри зоны касания.
Во Views: minWidth/minHeight по 48dp либо TouchDelegate, с оговоркой, что делегат увеличивает только область касания, но не границы узла, которые читает служба, и не рамку, которую TalkBack рисует вокруг сфокусированного элемента.
Контраст
4.5:1 для текста меньше 24sp (18.7sp жирного), 3:1 для более крупного и 3:1 для иконок, границ полей и индикаторов фокуса.
Плейсхолдеры и подсказки тоже считаются текстом, и они обязаны проходить порог; серый на 2.8:1 оказывается самым частым провалом. Отключённые контролы под требование не подпадают, но это не повод отключать их чаще.
У текста поверх фото или градиента единого коэффициента нет. Подложите скрим и меряйте по худшему пикселю, который может оказаться сзади.
Правило: контраст меряется относительно того, что реально лежит за пикселем, а зона касания меряется относительно подушечки пальца, а не относительно иконки.
mergeDescendants превращает поддерево в одну остановку фокуса, clearAndSetSemantics целиком его заменяет, stateDescription переименовывает произносимое состояние («Подписан» вместо «вкл.»), а role говорит, что это за контрол.
Слияние и очистка
Modifier.semantics(mergeDescendants = true) { } схлопывает поддерево в одну остановку фокуса и склеивает текст детей. Modifier.clickable уже так делает, поэтому карточка занимает одну остановку, а не шесть.
Слияние останавливается на ребёнке, который сливает потомков сам. Карточка со встроенной кнопкой «Подписаться» остаётся двумя узлами, и это правильно: до кнопки надо как-то добираться.
Modifier.clearAndSetSemantics { } целиком заменяет поддерево (для графика или самописного индикатора, где внутренности только шумят); а contentDescription на сливающем узле объединяется с описаниями детей, поэтому проверяйте, что TalkBack реально читает.
Оба дерева существуют одновременно: службы читают слитое, а тест может запросить неслитое через useUnmergedTree = true.
Описание одного контрола
role = Role.Button, Role.Checkbox, Role.Switch, Role.Tab сообщают службе, что это за элемент; heading() добавляет текст в ротор заголовков TalkBack.
stateDescription переопределяет произносимое состояние («Подписан» вместо «вкл.») и не трогает метку, поэтому TalkBack перечитывает только изменившуюся часть.
onClick(label = "открыть статью") переименовывает действие по умолчанию: TalkBack предложит «двойное касание, чтобы открыть статью» вместо «активировать».
Правило: contentDescription называет, что это; stateDescription сообщает, что происходит прямо сейчас; role задаёт, какого рода это элемент; а слияние решает, сколько остановок пользователь сделает по пути.
TalkBack делает две остановки: строка («Wi-Fi», без состояния) и Switch («Вкл., переключатель», без метки). Сделайте всю строку одним toggleable-контролом с Role.Switch и передайте в Switch onCheckedChange = null.
Почему две остановки: clickable сливает дочерние элементы в один узел, но слияние останавливается на ребёнке, который сам интерактивен, а Switch с ненулевым onCheckedChange именно такой.
Почему у строки нет состояния: у clickable нет ни роли, ни состояния переключения, поэтому TalkBack не знает, что строка работает как переключатель и включён ли он.
Исправление: Modifier.toggleable(value = checked, role = Role.Switch, onValueChange = onToggle) на строке и onCheckedChange = null у Switch. Переключатель становится чисто визуальным и вливается в строку: одна остановка, где метка, состояние и роль читаются вместе, а вся строка становится зоной касания.
Тот же приём в других местах: строки с Checkbox получают Role.Checkbox; строкам с радиокнопками нужны Modifier.selectable(role = Role.RadioButton) на каждой строке и Modifier.selectableGroup() на родителе.
Кастомные действия доступности: у каждой возможности, доступной только жестом, должно быть именованное действие на узле, и TalkBack покажет его в меню действий.
Как их добавить
Views: ViewCompat.addAccessibilityAction(row, getString(R.string.delete)) { _, _ -> delete(id); true }. Возвращает id, который можно передать в ViewCompat.removeAccessibilityAction при переиспользовании строки.
ViewCompat.replaceAccessibilityAction(view, ACTION_CLICK, label, command) переименовывает действие по умолчанию, а не добавляет второе такое же: TalkBack скажет «двойное касание, чтобы открыть статью».
Compose: Modifier.semantics { customActions = listOf(CustomAccessibilityAction(label) { onDelete(); true }) }.
Для смены порядка не пытайтесь выставить наружу перетаскивание. Дайте «Переместить вверх» и «Переместить вниз»: drag одинаково недоступен и TalkBack, и Switch Access, а два действия ничего не стоят.
Стандартные действия лучше кастомных
expand/collapse, dismiss, ACTION_SCROLL_FORWARD и ACTION_LONG_CLICK локализуются системой и понятны любой службе, включая те, о которых вы не слышали. Кастомное действие несёт только ту подпись, которую вы написали, и на том языке, на котором написали, поэтому она берётся из strings.xml, как любой пользовательский текст.
Две ловушки. Действие должно висеть на том узле, на который пользователь реально встаёт: если повесите его на внутренний элемент слитой строки, в меню оно не появится. И само меню зачитывается по одному пункту, так что после примерно пяти действий пользоваться им уже нельзя.
Правило: всё, до чего можно добраться только свайпом, перетаскиванием, долгим нажатием или щипком, не существует. Дайте этому имя и повесьте на узел.
Используйте live region: узел сам озвучивается при изменении своего текста, а фокус не переезжает. В Android 16 announceForAccessibility и TYPE_ANNOUNCEMENT объявили устаревшими: они перебивают, не повторяются по запросу и по-разному ведут себя в разных службах.
Live region
Views: ViewCompat.setAccessibilityLiveRegion(view, ACCESSIBILITY_LIVE_REGION_POLITE). Polite встаёт в очередь за тем, что читается сейчас; assertive перебивает и годится только для того, на что надо реагировать немедленно.
Compose: Modifier.semantics { liveRegion = LiveRegionMode.Polite } на том composable, чей текст меняется. У материального Snackbar это уже сделано; у самописного Box в духе тоста этого нет.
Регион озвучивается на изменение, поэтому узел должен остаться в дереве и принять новый текст. Если убрать его и добавить другой, получится другое событие, и часто оно не произносит ничего.
Почему API объявлений убрали
announceForAccessibility перебивает, не повторяется по запросу, не оставляет на экране ничего, к чему можно вернуться, и по-разному ведёт себя в разных вспомогательных технологиях. Документация предлагает взамен live region, а для валидации отдельный канал ошибок: setError у TextView или у AccessibilityNodeInfo вместе с CONTENT_CHANGE_TYPE_ERROR, и Modifier.semantics { error("Введите корректный e-mail") } в Compose; это привязывает сообщение к полю, а не выкрикивает его в пустоту.
Ловушка кроется в live region на значении, которое меняется каждый кадр: проценты прогресса превращают polite в пулемёт. Озвучивайте крупными шагами или оставьте число на узле, чтобы пользователь перечитал его сам.
Правило: лучше поменять то, к чему пользователь может вернуться навигацией, чем говорить ему в ухо.
Шторка не модальна в дереве узлов, и никто не возвращает фокус на кнопку, открывшую её: используйте настоящее модальное окно (ModalBottomSheet, Dialog) или скройте содержимое под ней, а при закрытии верните фокус на открывшую кнопку.
Сделать шторку единственным, что есть на экране
ModalBottomSheet и Dialog рисуются в собственном окне, поэтому фреймворк исключает содержимое под ними, а TalkBack воспринимает шторку как новую панель. У шторки, собранной из Box поверх экрана, нет ни того, ни другого.
Если она всё-таки самописная: Modifier.semantics { paneTitle = "Фильтры" } на шторке (во Views это ViewCompat.setAccessibilityPaneTitle) заставит TalkBack объявить её при появлении, а Modifier.clearAndSetSemantics { } на содержимом под ней (во Views то же делает importantForAccessibility="noHideDescendants") собственно и не даст свайпу уйти в список.
Шторки Material объявляют заголовок панели, а не отбирают фокус, и копировать надо именно это поведение: сообщить, а не захватить.
Порядок и возврат фокуса
По умолчанию порядок обхода геометрический (сверху вниз, от начала к концу) в обоих случаях. Переопределяют его android:accessibilityTraversalBefore/After во Views и isTraversalGroup = true на контейнере плюс traversalIndex на детях в Compose; isTraversalGroup заменил устаревший флаг isContainer в Compose 1.5.
При закрытии верните фокус туда, откуда он пришёл. Во Views надёжный путь: performAccessibilityAction(ACTION_ACCESSIBILITY_FOCUS, null) на открывшей view; в Compose FocusRequester.requestFocus() на ней остаётся best effort, потому что он двигает фокус ввода, а фокус доступности TalkBack за ним следует не всегда.
Ловушка: traversalIndex упорядочивает элементы только внутри одной traversal-группы, а подсказка обхода, указывающая в другой контейнер или образующая цикл, игнорируется. Перестановка порядка остаётся крайней мерой; надёжнее разложить элементы в том порядке, в котором их надо читать.
Правило: модальное окно представляет собой место, а не слой. Если из неё можно выйти свайпом, она не модальная.
Настройке размера шрифта следует только текст в sp. С Android 14 масштаб доходит до 200% и стал нелинейным, так что контейнеры с текстом должны уметь расти, а пересчёт единиц должен идти через системную кривую.
Масштабирование с Android 14
И android:textSize="16dp", и fontSize в Compose, собранный из Dp, выключают настройку: XML-вариант ловит lint-проверка SpUsage, вариант в Compose не ловит никто.
Android 14 (API 34) поднял потолок до 200% и сделал кривую нелинейной: мелкий текст растёт почти полностью, а крупный заголовок растёт заметно меньше. Подпись в 12sp и заголовок в 32sp больше не масштабируются одинаково.
Поэтому формула sp * fontScale * density теперь неверна. Считайте через TypedValue.applyDimension(COMPLEX_UNIT_SP, …) и обратно через TypedValue.deriveDimension(…) (API 34+); в Compose with(LocalDensity.current) { 16.sp.toDp() } уже использует правильную кривую.
Что ломается первым
Фиксированные высоты (layout_height="56dp", Modifier.height(56.dp)) и любой minHeight, который кто-то тихо превратил в настоящую высоту. Нужен wrap_content или Modifier.heightIn(min = 56.dp).
maxLines = 1 с многоточием на подписи, которую пользователь обязан прочитать: слова просто исчезают вместо переноса.
Два текста рядом в жёстком Row: дайте одному weight(1f) или разложите их вертикально через FlowRow после порога.
Правило: sp для всего, где есть слова, dp для того, что вы рисуете, и никакой фиксированной высоты у блока с текстом. Проверяйте через @Preview(fontScale = 2f) и adb shell settings put system font_scale 2.0 раньше, чем это сделает QA.
Автоматические проверки доказывают, что подпись, размер таргета и контраст существуют; понять, значит ли подпись хоть что-нибудь и можно ли довести сценарий до конца, способен только человек с включённым TalkBack.
Что покрывает автоматика
Accessibility Scanner (приложение из Play Store, запускается на устройстве) проверяет по одному экрану: отсутствующие подписи, дубликаты подписей, таргеты меньше 48dp, контраст текста, кликабельные элементы внутри кликабельных.
Espresso: AccessibilityChecks.enable().setRunChecksFromRootView(true) из androidx.test.espresso:espresso-accessibility прогоняет те же правила Accessibility Test Framework перед каждым взаимодействием, а setSuppressingResultMatcher позволяет нести одно известное нарушение, не выключая всё сразу.
Инструментальные тесты Compose: enableAccessibilityChecks() на правиле (экспериментальный: нужен @OptIn(ExperimentalTestApi::class)) запускает ATF по собранной иерархии, а дерево semantics проверяется напрямую, например через assertContentDescriptionEquals, assert(hasClickAction()), assertHeightIsAtLeast(48.dp).
Ещё дешевле: lint-проверки ContentDescription и ClickableViewAccessibility и pre-launch report в Play Console, который гоняет ATF на живых устройствах.
Что находит только человек
Подписи, которые есть, но ничего не сообщают: «image», «button1», имя файла иконки.
Порядок чтения, формально корректный и при этом бессмысленный, и фокус, улетающий в никуда при открытии диалога или bottom sheet.
Объявления, которые не срабатывают никогда или срабатывают так часто, что перебивают пользователя.
Можно ли вообще обнаружить кастомное действие и проходится ли сценарий свайпами, без точного двойного тапа.
Мысленная модель: автопроверка работает как проверка орфографии. Она доказывает, что слова на месте, но никогда не доказывает, что предложение осмысленно. Закладывайте один ручной прогон с TalkBack на каждую фичу, с включённой шторкой экрана (screen curtain).
Лайк переключается только внутри OnTouchListener, который поглощает все события, а TalkBack, Switch Access и клавиатура касаний не присылают: они вызывают performClick(), у которого нет click-листенера, поэтому для них кнопка ничего не делает.
Почему так происходит: при включённом TalkBack двойное касание активирует сфокусированный узел через ACTION_CLICK, и view отвечает на него вызовом performClick(). Switch Access делает то же самое, а Enter или центральная кнопка D-pad на сфокусированной view тоже заканчиваются performClick(). Ни один из этих путей не проходит через onTouch, а это единственное место, где переключается лайк.
Исправление: touch-листенер только анимирует и возвращает false, поэтому собственный onTouchEvent view по-прежнему превращает касание в performClick(), а действие переезжает в setOnClickListener (единственный путь, до которого доходит любой способ ввода). Бонус: клик фреймворка учитывает touch slop и отменяется, если палец съехал с кнопки; самописный ACTION_UP срабатывал в любом случае.
Ещё проще: StateListAnimator на нажатом состоянии (android:stateListAnimator) даёт то же сжатие вообще без кода обработки касаний; в Compose это clickable плюс interactionSource.collectIsPressedAsState().
Как поймать в следующий раз: lint-проверка ClickableViewAccessibility предупреждает об OnTouchListener, который никогда не вызывает performClick(). click() в Espresso инжектит настоящие касания, поэтому UI-тест проходит; баг видно только с TalkBack, с клавиатуры или через performAccessibilityAction(ACTION_CLICK, null) в тесте.
Узла не существует, пока служба доступности его не запросила: фреймворк собирает объекты AccessibilityNodeInfo по требованию из дерева View, а у всего, что вы нарисовали, а не разложили, узла нет, пока вы его не создадите.
Каждая View даёт один узел через onInitializeAccessibilityNodeInfo, который переопределяют не наследованием, а делегатом: ViewCompat.setAccessibilityDelegate.
RecyclerView ставит собственный делегат. Узел списка несёт CollectionInfo (число строк и столбцов), а каждая привязанная строка несёт CollectionItemInfo со своим индексом; отсюда и берётся «в списке, элемент 3 из 40». Узлы есть только у привязанных строк, а общее число приходит из адаптера, поэтому оно скачет при подгрузке страницы.
Кастомная View, рисующая десять зон на одной канве, остаётся одним узлом: фреймворк не знает, что у канвы есть части. ExploreByTouchHelper из androidx.customview даёт виртуальное дерево узлов: getVisibleVirtualViews, onPopulateNodeForVirtualView, onPerformActionForVirtualView.
В Compose ExploreByTouchHelper нет: поставьте Modifier.semantics { contentDescription = summary } на Canvas ради краткого описания либо положите поверх по одному прозрачному Box на столбец, у каждого свой Modifier.semantics { contentDescription = label } и clickable; так каждый столбец становится semantics-узлом.
Целый экран на Compose умещается в одну AndroidComposeView. Её делегат обходит слитое дерево semantics и синтезирует по виртуальному узлу на каждый semantics-узел: в Compose дерево узлов И ЕСТЬ semantics, отдельной View на composable не существует.
Ловушка кроется в инструментах, которые ходят по дереву View: для них экран на Compose выглядит как один лист. Поэтому тесты Compose проверяют semantics, а не view-матчеры, и поэтому проверка доступности в Espresso останавливается на ComposeView, а не спускается внутрь.
Правило: если это не View и не semantics-узел, ни одна вспомогательная технология его не увидит, как бы отчётливо он ни был нарисован.
cart_count должен быть ресурсом <plurals>: в русском разные формы слова для 1, 2–4 и 5+. А moved требует позиционных плейсхолдеров: два голых %s дают ошибку сборки aapt2, к тому же русская фраза ставит папку первой.
Позиционные аргументы
При более чем одной подстановке aapt2 требует позиционную форму %1$s, %2$d; два голых %s дают ошибку сборки. Даже один %s опасен: переводчик не сможет его переставить, когда появится второй аргумент.
Номер задаёт идентичность аргумента, поэтому перевод вправе поставить %2$s первым. Литеральный процент пишется как %%, а апостроф как \'.
Оборачивайте каждый плейсхолдер в <xliff:g id="name" example="Anna">%1$s</xliff:g>: инструмент перевода зафиксирует его и покажет переводчику пример значения.
Plurals
Элементы <plurals> содержат категории CLDR, а не числа: zero, one, two, few, many, other. Какие из них существуют, зависит от языка, а не от вашей строки: в английском только one и other; русскому нужны one (1, 21, 31), few (2–4, 22–24), many (0, 5–20, 25–30) и other для дробных; в арабском используются все шесть.
getQuantityString(R.plurals.cart_count, count, count) передаёт count дважды намеренно: первый выбирает категорию, второй подставляется в %1$d. Передадите один раз (getQuantityString(R.plurals.cart_count, count)), и форматирования не будет: пользователь увидит буквальное %1$d items in cart. В Compose это pluralStringResource(R.plurals.cart_count, count, count).
<item quantity="zero"> в английском файле оказывается мёртвым кодом: в английском нет категории zero, ноль попадёт в other. Для настоящего пустого экрана нужна отдельная строка, выбираемая в коде.
Никогда не собирайте предложение из кусков: другой язык может склонять существительное вокруг числа или ставить само число совсем в другое место.
Правило: если в предложение попадает число, имя или дата, то предложение целиком живёт в strings.xml с позиционными плейсхолдерами, а всё считаемое оформляется как plural. Забытое поймают lint-проверки ImpliedQuantity и StringFormatMatches.
Русские. Начиная с API 24 у пользователя упорядоченный LocaleList, и Android проходит его целиком, прежде чем откатиться к values/ без квалификатора, так что второй выбор пользователя побеждает ваш язык по умолчанию.
Как идёт подбор
Для каждой записи списка система пробует точное совпадение квалификатора, затем цепочку родителей этой локали: en-GB → en-001 → en, es-MX → es-419 → es. Украинский здесь не совпадает ни с чем, русский совпадает с values-ru, и поиск на этом заканчивается.
Близкое попадание тоже считается: пользователь с fr-CH найдёт values-fr-rFR, а не свалится в дефолт, как это делал API 23; именно это всех и удивляло.
Поэтому указывать регион у исходного языка значит закладывать мину. Английский живёт в values/; values-en-rGB нужен только для слов, которые действительно отличаются (colour, postcode). Для письменностей используется форма BCP-47: values-b+sr+Latn, values-b+zh+Hans.
В values/ должна быть определена каждая строка. Узнать, что это не так, можно двумя способами: lint-проверкой MissingDefaultResource или падением при обращении к ресурсу.
Как урезать и как прочитать обратно
В свежих версиях AGP androidResources.localeFilters (замена resourceConfigurations/resConfigs) оставляет только перечисленные локали, вырезая их и из AppCompat, и из Play Services. Пользователь с отфильтрованным языком молча получит дефолт.
Читайте действующую локаль из конфигурации (resources.configuration.locales[0] или LocalConfiguration.current.locales[0] в Compose) и никогда из Locale, захваченного один раз при старте процесса: смена языка приложения или конфигурации сдвинет её у вас под ногами.
Правило: безквалификаторная папка хранит ваш исходный язык, остальные языки получают простой языковой квалификатор, а регион добавляется только тогда, когда он действительно меняет слова.
Объявите поставляемые локали в locales_config.xml и меняйте язык только через AppCompatDelegate.setApplicationLocales(): на Android 13+ вызов уходит в системный LocaleManager, ниже 13 AppCompat сам сохраняет и применяет выбор.
Как это собирается
res/xml/locales_config.xml перечисляет локали, которые вы поставляете, а android:localeConfig="@xml/locales_config" в <application> указывает на него. AGP может сгенерировать файл сам: androidResources.generateLocaleConfig = true. Именно объявление добавляет ваше приложение в Настройки → Система → Языки → Языки приложений на Android 13+.
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags("ru")): один вызов на все версии API. На 33+ он делегирует системному LocaleManager, который сохраняет выбор на стороне системы и пересоздаёт активити; ниже 33 AppCompat хранит его сам, для чего нужна включённая meta-data autoStoreLocales у AppLocalesMetadataHolderService.
Читайте обратно через AppCompatDelegate.getApplicationLocales(). Пустой список означает «следовать системе»; именно это состояние нужно строке «Системный» в вашем переключателе, и это не то же самое, что текущий системный язык.
Что всё равно ломается
Языковые сплиты App Bundle: устройство ставит только те языки, которые запросило, поэтому переключение на язык, которого у него не было, покажет строки по умолчанию. Сначала запросите его через SplitInstallManager.startInstall(… addLanguage(locale) …) или выключите сплит: bundle { language { enableSplit = false } }.
Всё, что рисует не ваше приложение: текст пуша, собранный на сервере, страница в сторе, android:label. Отправляйте локаль приложения на бэкенд при каждом изменении.
Правило: locales_config.xml служит контрактом, setApplicationLocales остаётся единственным писателем, а всему, что отрисовано вне вашего процесса, нужно сообщать отдельно.
.format берёт локаль устройства по умолчанию, поэтому на телефоне с русским или немецким языком в URL уходит lat=55,755800 с запятой, которую сервер не может распарсить, и поиск падает. Текст, который читает машина, нужно форматировать с Locale.ROOT.
Почему так происходит: String.format(vararg args) в Kotlin делегирует в Java-шный String.format, а тот берёт Locale.getDefault(). В русском, немецком, французском и большинстве европейских языков десятичным разделителем служит запятая; арабская и персидская локали меняют сами цифры (lat=٥٥٫٧٥٥٨٠٠). На английском телефоне разработчика и на CI-эмуляторе этого не видно.
Исправление: .format(Locale.ROOT, …) даёт 55.755800 на любом устройстве. Ещё лучше отдать сериализацию значения построителю URL: parameter("lat", lat) в Ktor или addQueryParameter("lat", lat.toString()) в OkHttp; Double.toString() не зависит от локали, а построитель заодно экранирует параметры.
Тот же баг в других местах: JSON или CSV, собранные вручную, ключи кэша, параметры аналитики, SimpleDateFormat для даты на сервер, uppercase(Locale.getDefault()) на ключе enum (турецкая İ с точкой ломает valueOf). Локаль пользователя нужна для текста, который читает человек; всё, что парсит машина, использует Locale.ROOT.
Как поймать в следующий раз: юнит-тест, который вызывает Locale.setDefault(Locale.forLanguageTag("ru")) перед проверкой URL, падает с первого запуска; lint-проверка DefaultLocale тоже помечает известные ей вызовы с неявной локалью.
С android:supportsRtl="true" фреймворк зеркалит атрибуты вёрстки и drawable с автозеркалированием, и это весь список: всё, что вы позиционировали абсолютно, нарисовали сами или положили как обычный направленный ассет, останется смотреть туда, куда вы его нарисовали.
Что зеркалится само
Вёрстка: paddingStart/paddingEnd, layout_marginStart/End, gravity="start", drawableStart, layout_constraintStart_toStartOf в ConstraintLayout. В Compose Modifier.padding(start = …), Arrangement.Start, Modifier.offset и placeRelative учитывают направление; absolutePadding, Alignment.Absolute.*, absoluteOffset и place намеренно его не учитывают.
Drawable с android:autoMirrored="true": векторы и nine-patch. В Compose берите Icons.AutoMirrored.Filled.ArrowBack; старый Icons.Filled.ArrowBack объявлен устаревшим ровно по этой причине.
Что не зеркалится никогда
Canvas и собственная разметка: читайте LocalLayoutDirection.current (или View.getLayoutDirection()) и переворачивайте координаты руками. Вашему шеврону нужен autoMirrored на ассете либо graphicsLayer { scaleX = -1f } при отрисовке.
Иконки, не связанные с направлением чтения: элементы управления плеером, логотип, галочка, изображение физического объекта. Зеркалить play и перемотку значит допустить классический перегиб: таймлайн идёт одинаково во всех языках.
Двунаправленные фрагменты внутри фразы: номер заказа, телефон, URL или латинское название бренда в арабском тексте утаскивают свою пунктуацию не на тот край. Оборачивайте значение в BidiFormatter.getInstance().unicodeWrap(value); он обернёт фрагмент направляющими символами, и тот сохранит собственное направление.
Проверяйте это до того, как появится хоть одна арабская строка: соберите с isPseudoLocalesEnabled и запустите в ar-XB либо включите «Force RTL» в настройках разработчика (adb shell settings put global debug.force_rtl 1).
Всегда идите через форматтер, знающий про локаль (DateTimeFormatter.ofLocalizedDate, NumberFormat.getCurrencyInstance с явной Currency, NumberFormat.parse), потому что написанные руками паттерны и toDouble() ломаются на других цифрах и разделителях.
Даты и время
Форматы для отображения даёт DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).withZone(zone) или DateFormat.getBestDateTimePattern(locale, "MMMd"), когда нужен конкретный набор полей. Паттерн, написанный руками, годится только для машинного вывода.
SimpleDateFormat("dd/MM/yyyy") без локали берёт пользовательскую: арабская локаль вроде ar-EG нарисует цифры как ٢٠٢٦. Если нужен стабильный формат, явно передавайте Locale.ROOT.
Храните Instant или миллисекунды эпохи и переводите их на границе через ZoneId.systemDefault(), перечитывая при каждой отрисовке: часовой пояс устройства меняется прямо во время сессии в самолёте. Ниже API 26 для java.time нужен core library desugaring.
Числа, валюта и списки
NumberFormat.getCurrencyInstance(locale) решает, какие разделители и где стоит символ; Currency.getInstance("EUR") решает, что это за деньги. Локаль не должна выбирать валюту: немец, который смотрит на цену в долларах, всё равно платит доллары.
Разбирайте через NumberFormat.parse, а не String.toDouble(): и 1.234,50, и ١٢٣٤ считаются легальным пользовательским вводом, и оба бросят исключение.
Остальное закрывает android.icu.text: RelativeDateTimeFormatter для «3 дня назад», ListFormatter (API 29+) для «a, b и c», MeasureFormat для единиц измерения и MessageFormat, когда фразу не выразить через <plurals>.
Правило: ни один + и ни один написанный вами разделитель не должны касаться числа или даты, которые прочтёт человек. Если вы написали строку-паттерн для человеческого вывода, то, скорее всего, это баг на языке, которого вы не знаете.
Всё, кроме самих переводов, можно сделать заранее: псевдолокали доказывают, что вёрстка выживет, lint доказывает, что не осталось захардкоженных строк, а заморозка строк определяет, что именно получат переводчики.
До отправки строк
Включите псевдолокали: buildTypes { debug { isPseudoLocalesEnabled = true } } добавит en-XA (с диакритикой, примерно на 30% длиннее, в скобках, так что всякое обрезание становится видно, а любая захардкоженная строка остаётся демонстративно обычным ASCII) и ar-XB, RTL с перевёрнутым текстом.
Заставьте lint ронять сборку на HardcodedText, StringFormatInvalid, StringFormatMatches и ImpliedQuantity, а в CI запускайте lintRelease с abortOnError = true. MissingTranslation начинает приносить пользу в день, когда приедет первая локаль.
Дайте переводчику контекст, которого нет в самой строке: комментарий <!-- --> над ней и <xliff:g id="name" example="Anna">%1$s</xliff:g> вокруг каждого плейсхолдера. Ключи, названия событий аналитики и отладочный текст помечайте translatable="false".
Сама неделя
Заморозьте строки на конкретную дату, отдайте values/strings.xml и примите, что всё добавленное позже уедет на языке по умолчанию: откат происходит молча, поэтому ведите список опоздавшего.
Снимайте скриншоты основных экранов в en-XA и ar-XB прямо в CI (Paparazzi или Roborazzi, либо @Preview(locale = "en-rXA")), чтобы обрезанный текст был падающим диффом, а не багом из Берлина.
Помните о том, чего в strings.xml нет вовсе: android:label, страница в Play, текст пуша, собранный на сервере, и всё, что рисуют виджет или Wear-поверхность.
Правило: локаль, которую нельзя протестировать до появления перевода, вы выпустите сломанной. Весь фокус в en-XA.