Skip to content

Repository files navigation

ChargeKG для Android

Карта зарядных станций Кыргызстана: SPARK, We way, EVION, Charge24. Показывает, где прямо сейчас свободны порты, чтобы не приезжать к занятой зарядке.

Приложение только читает данные из ChargeKG API: оно ничего не продаёт, не заряжает и не связано с сетями. Веб-версия — chargekg.com.

Установка

В Google Play приложения нет. APK берётся из релизов; дальше приложение обновляет себя само — при запуске проверяет GitHub и предлагает поставить новую версию.

Google Play Protect и первая установка

На телефонах с сервисами 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-Match0 байт (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; получены разбором манифестов.
  • Открыть конкретную станцию умеет только SPARKspark://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.

Выпуск версии

  1. Поднять versionCode и versionName в app/build.gradle.kts.
  2. Записать изменения в CHANGELOG.md.
  3. Поставить тег vX.Y.Zровно равный versionName с приставкой v. Расхождение даёт вечное «доступно обновление»; workflow это проверяет.
  4. 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"

Отдельно стоит пройти: отказ в доступе к геопозиции (приложение остаётся рабочим), авиарежим (карта из кэша с пометкой «занятость неизвестна»), смену языка и тёмную тему.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages