Шпион всматривается в экраны
Несколько лет назад я работал над 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-разработчик, так что под влиянием спешки и усталости выбрал вроде бы рабочий вариант, не уделив достаточно внимания изучению лучших практик или выяснению, получаю ли я конкретно код языка. Кроме того, представленный фрагмент программы — это упрощённый вид более сложной системы, включающей другие сервисы, обработку персистентности и прочее, что сделало причину проблемы менее очевидной. Спасибо!
Автор: Дмитрий Брайт