Карта зарядных станций Кыргызстана: SPARK, We way, EVION, Charge24. Показывает, где прямо сейчас свободны порты, чтобы не приезжать к занятой зарядке.
Приложение только читает данные из ChargeKG API: оно ничего не продаёт, не заряжает и не связано с сетями. Веб-версия — chargekg.com.
В Google Play приложения нет. APK берётся из релизов; дальше приложение обновляет себя само — при запуске проверяет GitHub и предлагает поставить новую версию.
На телефонах с сервисами Google установка показывает предупреждение «Harmful app blocked — this app can put your device or data at risk», и поставить приложение можно только через «More details» → «Install anyway».
Это не признак проблемы в коде. Play Protect так реагирует на сочетание, по
которому не может судить о репутации: APK подписан собственным ключом, не
проходил через Play, и просит REQUEST_INSTALL_PACKAGES, чтобы обновлять
себя. Ровно такая же картина у любого приложения, распространяемого мимо
магазина.
Практические следствия:
- предупреждение появляется и при первой установке, и при каждом обновлении;
- отключать Play Protect ради этого не нужно и не стоит советовать;
- со временем репутация подписи накапливается, но рассчитывать на это нельзя;
- если однажды предупреждение станет неприемлемым, единственный настоящий
выход — публикация в Google Play, и тогда обновления пойдут через него, а
UpdateCheckerиREQUEST_INSTALL_PACKAGESиз проекта уйдут.
Его открывают раз в неделю или раз в месяц, когда нужно найти зарядку. Отсюда всё остальное:
-
APK меньше 2 МБ. Обновление человек качает руками, возможно с мобильного интернета. CI роняет сборку, если APK перевалил за 3 МБ.
-
Ничего не работает в фоне. Ни уведомлений, ни служб, ни автозапуска, ни WorkManager. Приложение живёт, пока открыто.
-
Разрешений четыре, и только одно из них человек видит:
Разрешение Зачем INTERNETзапросы к API ACCESS_COARSE_LOCATION/ACCESS_FINE_LOCATIONкнопка «где я»; отказ ничего не ломает — точкой отсчёта становится центр карты REQUEST_INSTALL_PACKAGESустановка своего же обновления Нет ни
POST_NOTIFICATIONS, ни фоновой геолокации, ни доступа к хранилищу.
- Обновление карты идёт за
/v1/status— там одни счётчики занятости. Полный/v1/stationsнужен при первом запуске и когда состав станций разошёлся: появился незнакомый идентификатор или у станции изменилось число портов. Живые цифры: полный ответ 404 КБ, с gzip 40 КБ, занятость — 6 КБ, повторный запрос сIf-None-Match— 0 байт (304). - Полный список запрашивается только беспараметрическим
/v1/stations. Сервер держит готовый слепок в памяти сразу в двух видах — обычном и gzip — и отдаёт его байтами сETag. Любой параметр (network,since) уводит запрос на сборку из базы, где нет ниETag, ни сжатия. Фильтры применяются на устройстве. - Дельта по
?since=намеренно не используется: условие отбора смотрит на время обновления занятости, а оно меняется у всех станций каждый цикл синхронизации — дельта вернула бы почти всё, ещё и несжатым. - brotli подключён через
okhttp-brotli: Cloudflare перед API отдаёт его сам, а OkHttp без библиотеки просит только gzip. Полный список приезжает 39 КБ вместо 53. Плата — +111 КБ к APK, то есть к каждому обновлению; после появления/v1/statusвыигрыш почти исчез, и это стоит пересмотреть, если размер станет поджимать. - Периодического опроса нет. Сессия длится минуту-две, таймер только жёг бы трафик; обновление данных — по кнопке.
- Последний ответ лежит файлом в
filesDir, поэтому карта рисуется до того, как ответит сеть, и работает вовсе без неё.
status_staleне прячет станцию. Когда занятость недостоверна,free/busy/offlineприходятnull, и показывается «занятость неизвестна». Адрес, разъёмы и цены не протухают, а спрятать существующую зарядку вреднее, чем не показать число свободных портов.nullи0— разные вещи. «Неизвестно» и «нет свободных» нельзя подменять друг другом: это ровно то, ради чего карту открывают.- Данные из кэша отдаются с принудительно обнулённой занятостью. Показать
вчерашние «3 свободно» как сегодняшние — соврать. Но при ответе
304кэш перечитывается заново, уже без обнуления: сервер подтвердил, что слепок тот же, значит занятость в нём актуальна. Занятость хранится отдельным файлом со своимETag— только поэтому ответу304на/v1/statusможно верить. - Новый полный список стирает сохранённую занятость. Она относилась к
прежнему составу станций, и её
ETagподтверждал бы чужие числа. - Расхождение состава уводит за полным списком. Незнакомый идентификатор
или изменившееся
totalозначают, что поменялся и список разъёмов. Наложить занятость на старый список — значит рисовать кольцо метки по старому числу портов, молча и неделями. - Фильтры хранят исключения, а не выбранное. Иначе при появлении новой сети человек не увидит её никогда — молча, без признаков поломки.
- Фильтрация живёт в одном месте —
domain/Filters.kt::matches, и она же переводится в параметры/v1/nearest. На сайте эта логика продублирована, и её приходится править парами; здесь так делать не надо. - Тип разъёма и мощность проверяются на одном коннекторе. Иначе выдача предложит станцию, где нужный разъём медленный, а быстрый — чужой.
- Полностью выключенный фильтр не отправляется на сервер. Пустой
network=сервер понимает как «все сети», и список показал бы станции, которых на карте нет. - Язык по умолчанию — системный, запасной — английский. Пока человек не
выбрал язык сам, берётся локаль телефона, если это
ru,kyилиen; иначе английский — незнакомая локаль это чаще всего приезжий, которому русский не поможет. Выбор в настройках перебивает систему навсегда. Системная локаль читается изResources.getSystem(), а не изLocale.getDefault():withLocale()подменяет её при запуске, и на второй заходgetDefault()вернул бы уже собственный выбор приложения. - Схемы ссылок в чужие приложения не выдумывать. Проверенные значения — в
util/NavApps.ktи вdata/Models.kt; получены разбором манифестов. - Открыть конкретную станцию умеет только SPARK —
spark://charging-station/<uuid>, проверено на устройстве на трёх станциях. У остальных ссылки нет, и это выяснено, а не предположено: Charge24 не открыл карточку ни на одном из десяти перебранных путей (в его веб-версии вообще нет маршрута станции, а «поделиться» сделано только для чеков); у We way единственный фильтр висит на схеме Firebase Dynamic Links, такую ссылку снаружи не собрать; у EVION в манифесте нет ни одного фильтраVIEW. Когда ссылки нет, кнопка открывает приложение и честно говорит об этом — иначе человек ищет свою точку глазами на чужой карте. - Релиз несёт ровно один APK.
UpdateCheckerберёт первый*.apkиз ассетов, поэтому ABI-сплиты и App Bundle сломали бы обновление молча. - Тесты разбора JSON идут под Robolectric.
org.jsonвandroid.jar— заглушка, и сisReturnDefaultValues = trueеё методы возвращаютnullвместо падения.
Взято ровно четыре: Compose + Material3, osmdroid, OkHttp и org.json (он
входит в Android). Намеренно не взято: Hilt, Room, Retrofit,
kotlinx-serialization, DataStore, WorkManager, play-services-*,
material-icons-extended. Причины — в комментариях app/build.gradle.kts:
каждая стоила бы сотни килобайт при нулевой пользе на шести экранах и одном
кэшируемом документе.
./gradlew assembleDebug testDebugUnitTest lintОтладочная сборка по умолчанию ходит на боевой сервер, чтобы работать на живом
телефоне без правок. Против локального charge-server в эмуляторе:
./gradlew installDebug -PchargekgApi=http://10.0.2.2:8080Ключ лежит вне репозитория: ~/.android-keys/chargekg-release.jks
(RSA 4096, срок до 2056 года, алиас chargekg). Пароль — в
keystore.properties, тоже вне git.
Этот ключ незаменим. Android разрешает обновить приложение только APK, подписанным тем же ключом. Потеряете его — обновлять станет нечем: всем, кто установил приложение, придётся сносить его и ставить заново, теряя настройки. Копию ключа и пароль держите там же, где остальные важные пароли.
Подпись — схемами v2 и v3; v1 не нужен, его понимал только Android до 7.0, а у
нас minSdk 26. Именно v3 однажды позволит сменить ключ без переустановки у
всех — поэтому он включён явно.
Релиз требует keystore.properties рядом с settings.gradle.kts:
storeFile=/путь/release.jks
storePassword=…
keyAlias=…
keyPassword=…Файл в .gitignore. Без него assembleRelease падает намеренно — неподписанный
релиз не встанет поверх установленного и сломает обновление. Для локальной
проверки размера есть -PallowUnsignedRelease.
- Поднять
versionCodeиversionNameвapp/build.gradle.kts. - Записать изменения в
CHANGELOG.md. - Поставить тег
vX.Y.Z— ровно равныйversionNameс приставкойv. Расхождение даёт вечное «доступно обновление»; workflow это проверяет. git push --tags— дальшеrelease.ymlсоберёт, подпишет и опубликует APK.
Нужны секреты репозитория: KEYSTORE_BASE64, KEYSTORE_PASSWORD, KEY_ALIAS,
KEY_PASSWORD.
adb shell dumpsys package com.chargekg.app | grep -A6 'requested permissions'
adb shell am start -a android.intent.action.VIEW -d "chargekg://station/spark:1"Отдельно стоит пройти: отказ в доступе к геопозиции (приложение остаётся рабочим), авиарежим (карта из кэша с пометкой «занятость неизвестна»), смену языка и тёмную тему.