Шпион всматривается в экраны

Несколько лет назад я работал над Lipo Manager, добавляя кое-какие долгожданные функции. Это довольно простое приложение, но вполне достаточное для управления батареями LiPos. Некоторые из вносимых мной изменений отвечали запросу сообщества. Это были визуальные доработки, оптимизация, мультиязычность, обновления зависимостей и исправление периодически возникавших исключений нулевого указателя.

Со всеми этими задачами я справился за день и, проведя несколько тестов, выпустил новую версию...

▍ «Не могу войти в приложение»


Спустя несколько дней мне в Telegram написал один из пользователей:


«Я обновил телефон, и приложение перестало работать».

Хмм…

Каждый разработчик знает, что для устранения бага сначала его нужно попытаться воспроизвести, для чего желательно знать среду, в которой он происходит. Я запросил у пользователя информацию о его системе, которую он с готовностью предоставил.

Первым делом я хотел знать, не являлась ли его версия Android слишком новой (бетой) или слишком старой. Мне нужно было проверить, не произошла ли ошибка в версии, с которой я приложение не тестировал, и нет ли проблем с библиотекой, которую использует приложение. К моему удивлению, его телефон работал на Android 13. Именно та версия и API, с которыми я в основном всё и тестировал.

Нужно было копать глубже.

▍ Проверка логов в Play Console


Google предоставляет разработчикам множество инструментов для управления приложениями, опубликованными в Play Store. Один из них — это Android Vitals. Он собирает информацию о каждом установленном приложении, и в случае исключений, сохраняет все трейсы выполнения, делая их доступными для разработчика наряду со множеством дополнительных деталей.

Я не буду давать комментарий по поводу того, нарушает ли это конфиденциальность, но при возникновении проблем будет весьма непрактичным просить пользователя подключиться к телефону через ADB (Android Debug Bridge), извлечь трейсы и отправить эту информацию вам. Так что в целом это очень полезный инструмент.

Полученные с устройств пары пользователей логи показали следующее:

java.lang.RuntimeException: Unable to start activity ComponentInfo{com.dropvoid.lipomanager/com.dropvoid.lipomanager.MainActivity}: android.database.sqlite.SQLiteException: not an error (code 0 SQLITE_OK)
	at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3782)
	...
	at android.app.ActivityThread.main(ActivityThread.java:8176)
	at java.lang.reflect.Method.invoke(Native Method)
	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:552)
	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:971)
Caused by: android.database.sqlite.SQLiteException: not an error (code 0 SQLITE_OK)
	at android.database.sqlite.SQLiteConnection.nativeRegisterLocalizedCollators(Native Method)
	at android.database.sqlite.SQLiteConnection.setLocaleFromConfiguration(SQLiteConnection.java:460)
	at android.database.sqlite.SQLiteConnection.open(SQLiteConnection.java:272)
	...
	at android.database.sqlite.SQLiteDatabase.open(SQLiteDatabase.java:1067)
	at android.database.sqlite.SQLiteDatabase.openDatabase(SQLiteDatabase.java:931)
	at android.database.sqlite.SQLiteDatabase.openDatabase(SQLiteDatabase.java:920)
	at android.database.sqlite.SQLiteOpenHelper.getDatabaseLocked(SQLiteOpenHelper.java:373)
	at android.database.sqlite.SQLiteOpenHelper.getWritableDatabase(SQLiteOpenHelper.java:316)
	at com.orm.SugarDb.getDB(SugarDb.java:38)
	at com.orm.SugarRecord.getSugarDataBase(SugarRecord.java:35)
	at com.orm.SugarRecord.find(SugarRecord.java:201)
	at com.orm.SugarRecord.listAll(SugarRecord.java:127)
	at com.dropvoid.lipomanager.services.BatteryService.loadAllBatteries(BatteryService.java:21)
	...

Проблема с базой данных? Сначала я подумал, что проблема может быть связана с обновлением SugarORM. Эта библиотека объектно-реляционного отображения используется приложением для управления базой данных. Однако, поскольку у меня никаких проблем не было, я сомневался, что проблема непосредственно в ней. Что же являлось причиной?

▍ «Одиссея» началась


Хорошо. Нам нужно просто воспроизвести проблему и отследить её вплоть до изначального бага, так? Элементарно. Банальщина. Много времени не займёт.

15 часов спустя

(К сожалению) всё оказалось в норме. Ничто не могло вызвать сбой. Этого я и ожидал.

Поиск в интернете ситуацию особо не прояснял, так как все указывали на причины, вроде проблем с правами доступа к файлам или сбоев синхронизации. Но в моём случае ничто из этого не было похоже на причину и особого смысла не имело. Хотя уверенности у меня ни в чём не было, так как воспроизвести проблему не получалось.

А поскольку воспроизвести её не получалось, то и откатить изменения я не мог. В первую очередь потому что не знал, давно ли эта проблема вообще появилась.

В Google тоже предоставили мне версию Android, модель телефона и так далее. Но даже с помощью эмуляции точных прежних характеристик мне не удалось что-либо выяснить.

К этому моменту я уже попробовал сломать приложение всеми возможными способами и, как это ни парадоксально, меня даже бесило, что оно такое надёжное. Я повреждал базу данных; вставлял тысячи записей; изолировал всё, что связано с БД, от остального приложения; многократно симулировал смену версий…но ничто из этого не вызывало проблему, которая меня интересовала.

Метод BatteryService.loadAllBatteries() был слишком прост и не содержал особой логики. Кроме того, при старте приложения он запускался одним из первых, поэтому поводов для возникновения состояния гонки или чего-то подобного здесь было мало.

С каждым разом степень моего отчаяния росла, и я ещё раз заглянул в логи ADB. Это сообщение казалось всё более оскорбительным:

SQLiteException: not an Error. <i>Всё в порядке</i>.

Тут я понял, что застрял. Я потратил слишком много времени на всё это и начал испытывать чувство поражения, не зная, что ещё пробовать.

Решив на этом остановиться, я начал всё закрывать…

▍ Поворот сюжета


Мне оставалось закрыть всего несколько окон, когда перед моими глазами оказалась страница Google Play. Тогда я решил последний раз взглянуть на детали устройства того пользователя с мыслью, что мог упустить какой-то нюанс. После всех этих страданий я уже почти заучил их на память: устройство, версии, трейсы, установленные приложения, функциональность, страна…страна?

Вот страну пользователя я как раз проглядел, и она оставалась единственным фактором, который я совсем не рассматривал. Какое значение могло иметь то, что пользователь русский? Неужели русский телефон чем-то отличался? Что ж, попробуем провести ещё несколько проверок. Мало ли…
  • Меняем язык телефона на русский.


    Боже, помоги мне, когда нужно будет сменить его обратно на английский.
  • Открываем приложение…

Неужели! Вот оно, наконец!

▍ Врата отворились


Испытывая смесь негодования и радости, я смог воспроизвести проблему. Теперь я получил дополнительную информацию для продолжения расследования, но это было уже не важно. Я понимал, что проблема заключалась в кодировке символов, и хотел просто заставить приложение исправно работать.

Теперь запрос к Google быстро привёл меня к этому вопросу на Stack Overflow.

В моём коде одной из первых процедур шла загрузка приложения с использованием языка устройства. И при наличии нужного файла с переводом, программа загружалась именно на этом языке.

public void updateAppLanguage(Context context) {
    String languageCode = Locale.getDefault().getDisplayLanguage();
    Locale locale = new Locale(languageCode);
    Locale.setDefault(locale);
    ...

Если устройство работает на русском, Locale.getDefault() возвращает "русский”, что по какой-то причине не нравится SQLite.

Итоговым решением стала ручная проверка этого конкретного случая:

public void updateAppLanguage(Context context) {
    String languageCode = Locale.getDefault().getDisplayLanguage();

    // Sql падает при запуске, когда язык установлен на «русский».
    // Меняем его на RU и устанавливаем вручную.
    if (languageCode.equals("русский")) {
        languageCode = "ru";
    }

    Locale locale = new Locale(languageCode);
    Locale.setDefault(locale);
    ...

Три строки кода исправили огромную проблему, которая принесла мне столько головной боли. Опять.

Я провёл дополнительные тесты и оказалось, что из почти 100 поддерживаемых Android языков только кодировка русского вызывала сбой SQLite. Заметьте, не китайского, который мы считаем одним из самых сложных (к тому же я проверил переключение на этот язык гораздо раньше всего остального).

После применения патча я, наконец, выкатил обновление, и все были счастливы.

Перевод
Ребята, приношу извинения за то, что приложение так долго не работало.

Я в течение многих часов пытался выяснить, в чём проблема, потому что воссоздать ошибку не получалось, а предоставленная Play Store информация была недостаточно ясной. Было похоже на какую-то чёрную магию.

Только сегодня утром я, НАКОНЕЦ, выяснил, в чём было дело. Приложение падает, когда язык телефона установлен на «Русский». Почему? Честно говоря, понятия не имею.
Похоже, его кодировка как-то влияет на запросы к базе данных.

Обновлённая версия будет готова через несколько часов :')
Ещё раз извиняюсь за задержку.

▍ Заключение


Этот баг стал, пожалуй, самым неприятным из всех, с какими мне приходилось иметь дело. Здесь я оказался под влиянием двух основных усложняющих факторов. Во-первых, я не знаком с нативной разработкой приложений. Во-вторых, сама ошибка сильно сбивала с толку, никак не проясняя своей причины. По правде говоря, я не уверен, кто конкретно виноват в этой проблеме: то ли я, так как не проверил кодировку символов, то ли Android/SugarORM, так как не учли этот случай.

Если вы начинаете разработку приложения, не советую использовать для хранения постоянных данных ORM. Android запустил собственный инструмент (ROOM). Возможно, если бы я знал это изначально, то и проблемы бы не возникло.

И напоследок. Если вы вдруг окажетесь в подобном недоумении при тщетных попытках найти баг в своей программе, то спросите, не говорит ли он по-русски.

▍ Обновление


После размещения этой истории на Hacker News я получил много обратной связи, и, благодаря сообществу, смог более глубоко рассмотреть проблему. В итоге стало ясно, что при реализации управления языками я допустил большую ошибку, которая и стала её причиной.

Я использовал getDisplayLanguage() вместо getLanguage(). Первая возвращает текстовую форму названия языка, а вторая — языковой код, который и должен использоваться в таких случаях. Можно сказать «чудо, что такое решение вообще сработало».

Ещё раз повторюсь, я не Android-разработчик, так что под влиянием спешки и усталости выбрал вроде бы рабочий вариант, не уделив достаточно внимания изучению лучших практик или выяснению, получаю ли я конкретно код языка. Кроме того, представленный фрагмент программы — это упрощённый вид более сложной системы, включающей другие сервисы, обработку персистентности и прочее, что сделало причину проблемы менее очевидной. Спасибо!

Telegram-канал со скидками, розыгрышами призов и новостями IT ?

Комментарии (16)


  1. Serge78rus
    12.05.2024 10:55
    +9

    В случае, описанном в статье, все таки имеет место быть не столько проблема локали, сколько ошибка разработчика. Странно, что он на нее не наткнулся на других языках, использующих отличный от латиницы алфавит.

    Далее немного оффтопа, но тоже про проблемы русской локали.

    В свое время, при обновлении GCC для AVR до версии 5.4.0 (из репозитория Debian 12) столкнулся с проблемой сборки своего древнего и давно работающего проекта. Ну мало ли, при обновлении версий чего не бывает, особенно при скачке сразу через несколько версий... Создал минимально возможный тестовый проект, но все равно, при попытке сборки, упорно выскакивала ошибка линкера:

    make all 
    Building target: test_c.elf
    Invoking: AVR C++ Linker
    avr-gcc -Wl,-Map,test_c.map,--cref -mrelax -Wl,--gc-sections -mmcu=atmega328p -o "test_c.elf" ./src/main.o   
    collect2: fatal error: ld terminated with signal 6 [Аварийный останов], core dumped
    compilation terminated.
    *** stack smashing detected ***: terminated
    make: *** [makefile:79: test_c.elf] Ошибка 1
    "make all" terminated with exit code 2. Build might be incomplete.
    

    В итоге, "всемогущим методом тыка" выяснил, что все нормально собирается, если при вызове компилятора отключить русскую локаль путем замены команды вызова компилятора

    avr-gcc

    на

    LANG="" avr-gcc

    Похоже, что разработчики avr-gcc с некоторого момента времени вообще перестали его тестировать на работу под русской локалью, ибо в противном случае ошибку просто невозможно было не заметить.


    1. sigprof
      12.05.2024 10:55
      +3

      Это на самом деле баг в binutils-avr, который существовал там по той причине, что версия binutils там на самом деле очень древняя (но патчи от Atmel есть только для этой древней версии); в апстриме binutils проблему починили ещё в 2017 году. В конце 2023 года баг внезапно пофиксили, но до Debian stable фикс пока ещё не дополз (зато вроде бы вошёл в Ubuntu 24.04 LTS).


  1. vadimk91
    12.05.2024 10:55
    +5

    Вспомнил фидошные времена: "Меня видно? ет!" :)

    А на днях, перекидывая текстовый файл с компа на телефон (1251 кодировка, Android 8) вдруг увидел, что при просмотре на телефоне в тексте пропали заглавные русские буквы, могут отображаться пробел, спецсимволы, английские.. удивился, давно такого не встречал.


    1. ogost
      12.05.2024 10:55
      +1

      Удивлён, что 1251 до сих пор где-то используется. Я думал везде уже на юникод перешли.


  1. CitizenOfDreams
    12.05.2024 10:55

    Наивный вопрос от не-программиста (ну то есть Hello World на экран выведу, лампочкой на Ардуине помигаю, "sudo apt-get" в терминале напечатаю одним пальцем).

    SQLite - это же база данных такая, я правильно понимаю? А зачем базе данных выводить ошибки на местных языках? И вообще знать, какая в системе установлена локаль?


    1. AlexMih
      12.05.2024 10:55

      Многие приложения считают ошибку работы с базой данных настолько маловероятным событием, что никак ее не перехватывают и не оборачивают. Предполагается, что если уж такое произошло, то это что-то уровня краха системы, и продолжать дальше нет смысла.

      Поэтому ошибка БД, коль уж она произойдет, необработанным исключением предстает пред ясные очи пользователя. И считается, что будет несколько лучше, если она будет на понятном языке, а не на иероглифах. Чтобы юзер мог хотя бы саппорту прочитать, что на экране написано.


      1. CitizenOfDreams
        12.05.2024 10:55
        +2

        Чтобы юзер мог хотя бы саппорту прочитать, что на экране написано.

        В данном случае, как я понимаю, приложение ничего юзеру не показывало, а молча не запускалось. Саппорту пришлось лезть в логи, где уже ему пришлось разбираться с незнакомым языком. Ну и ошибка "not en error, все в порядке" это отдельный показательный пример мышления программистов.


    1. speshuric
      12.05.2024 10:55
      +1

      Для баз данных не так важно "выводить ошибки на местных языках". Гораздо важнее правильно сортировать списки по алфавиту, причем не только в выводе но и внутри БД (в индексах). И часто получается, что символы к кодировке (в том числе в юникодных) в лучшем случае отсортированы для одного языка. А для той же кириллицы алфавитов разных языков целая куча.

      Могут быть другие моменты, типа привязки формата дат к локали, но это уже не должно на самом деле "утекать" в БД. В 1999 году, когда я только начинал программировать я замучался исправлять баг, который был связан именно с датами - я передавал данные из строки напрямую в SQL запрос и происходила путаница между форматами DD.MM.YYYY и MM/DD/YYYY. Очевидно, что в моём случае ошибка была в том, что в БД надо было передавать дату в формате ISO 8601 и не зависеть от локали.


      1. CitizenOfDreams
        12.05.2024 10:55

        Гораздо важнее правильно сортировать списки по алфавиту, причем не только в выводе но и внутри БД (в индексах).

        Так язык и кодировка данных в списках не обязательно соответствуют системной локали. На русской системе может быть список итальянских городов, а рядом с ним в той же базе - список арабских, которые вообще справа налево. Если их все сортировать как RU, получится в лучшем случае фигня.


  1. Fahrain
    12.05.2024 10:55
    +4

    Вот сколько я с локалями не сталкивался, всегда есть какие-то проблемы. И мой опыт говорит, что всегда должен быть промежуточный фильтр данных, хотя бы виде ассоциативного массива "данные1" => "данные2". И работать надо только через него, заранее забив вручную заведомо корректными парами значений.

    Причем это поголовно, везде... Берешь какую-нибудь гео-ip базу данных, пытаешься её данные связать со своими, вроде всё работает, проблем нет. А потом ты случайно узнаешь, что в одной базе города нормально англифицированы, а в другой часть из них почему-то транслитом. Причем не все! И когда звезды совпадают - ты ловишь проблему, что "город не найден" при том, что он заведомо есть в обоих базах.


  1. Panzerschrek
    12.05.2024 10:55
    +1

    Читая статью, всё ожидал проблему, когда на разных локалях разделитель целой и дробной частей десятичной записи чисел будет разным (точа или запятая). Сам не раз с такой проблемой сталкивался, когда какой-нибудь sscanf не работает как нужно. И даже когда сам был научен проблемами с ним, встречал аналогичные проблемы в чужом коде.


  1. sergio_deschino
    12.05.2024 10:55
    +1

    Сам столкнулся с подобным поведением у двух знакомых, когда они попросили помочь с активацией банк-клиента: приложение запускается, требует аутентификации через логин/пароль, подтверждение через смс и повторный вход невозможен, необходимо заново проходить авторизацию с танцами в виде логина/пароля/смс.

    Причём, ошибка выдается, что аккаунт заблокирован. Но в старый банк-клиент заходит и на сайт заходит. В общем пару часов я потратил, тех.поддержка ответила что все ок, никто не заблокирован.

    Сменил язык на телефоне, не верю глазам своим — заходит, привязал фейсайди для авторизации — заходит. Аггггрх!)

    Сменил язык обратно — все равно заходит. Зарепортил банку, посмотрим, что ответят


  1. mynameco
    12.05.2024 10:55
    +3

    У нас был случай. В игре был файл на русском, и файл не находился на компах без русской локали. Еще был случай, программист, вместо инвариант float, вписывал то что влокали, а потом, если надо, заменял запятую на точку. Оказалось в датской локали, это не работало, и пропадала часть после запятой. Что вызывало какие то странные баги в игре.


  1. Wesha
    12.05.2024 10:55
    +1

    Помню, в четвёртом Norton Commander просмотровщик текстовых файлов имел возможнось просмотра файлов в различных форматах, а также автодетект этих самых форматов . Так вот самая веселуха была в том, что если просматривался обычный текстовый файл с русскими буквами в нём, то он иногда детектировался как один из "не тех" форматов (не помню уже, какой) — и одной из особенностей этого формата было то, что последовательность байт, соответствующая русским буквам "оп", была каким-то служебным маркером и потому не показывалась.

    А потом неопытные юзера очень сильно удивлялись, когда просматривали какой-нибудь прайс-лист, в котором упоминались "соверменные европейские компьютеры" или что-нибудь типа того....


  1. u007
    12.05.2024 10:55

    В то же время где-то в недрах зависимостей:

    if (languageCode.equals("ru")) { halt(); }


  1. nsk-realty
    12.05.2024 10:55

    Когда американский программист узнает, что в мире существуют другие локали, у него в голове происходит такой разрыв мозга, что он пишет по этому поводу целую статью.