The Jerusalem PostIraq braces for renewed Iran conflict after 1,000 strikes on Kurdistan, 600 militia attacksESPN DeportesO'Shaquie Foster derrota al 'Vaquero' NavarretePunchPolice arrest two robbery suspects in BauchiInquirerBOC vows crackdown on smuggling after Zambales cigarette haulESPNBrewers 'chip away' at Rays again, rally to take 2-0 lead in World Series한겨레미군, 중간선거 후 이란 공격 계획 보고…짙어지는 전운Bollywood HungamaTiger Shroff’s spokesperson DENIES reports of actor’s statement to Mumbai Police: “This news is completely untrue”ZDF heuteAktuelle Pressemitteilungen des ZDF20 Minuten«Die meisten Ängste stammen aus der Sci-Fi-Literatur»Observador DesportoPorto isolado, Sporting joga hoje e há El Clásico na La LigaRadio-Canada« Ce sont les personnes les plus gentilles » : Obama se moque des tarifs contre le CanadaVilaWebEl discurs del retorn de Comín: “Per alt que sigui el preu, la independència és possible”
The Daily Newsstand · Free, Always
Sunday, October 25, 2026

Где тебя поймут по‑русски: как собрал карту для эмигрантов на Kotlin, PostGIS и пиксельной графике

Translate

Всем привет! Меня зовут Евгений!
Я десять лет занимаюсь тестированием: финтех, крипта, сложные микросервисные архитектуры. До недавнего времени я был QA Lead в сервисе транспорта компании 2Gis. Именно мы отвечали за алгоритмистику построения маршрутов. Поэтому с темой гео‑сервисов я знаком не понаслышке.

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


SpeakMap — это карта мест, где вас обслужат по‑русски: стоматолог, нотариус, автосервис, парикмахер, сантехник на выезде. Сейчас раскатка на Белград и Нови‑Сад. В будущем хочу масштабировать на Тайланд и Аргентину. Под капотом Kotlin Multiplatform, Jetpack Compose, MapLibre, Ktor, PostgreSQL с PostGIS и собственная пиксельная дизайн‑система.

О том как появилась идея, почему продукт держится на статистике, а не на карте, какой стек выбран и почему, и на какие грабли я наступил я сейчас расскажу.
Включая день, когда прод лёг из‑за изменённого комментария в SQL‑файле.

  • Проблема: информация «где говорят по‑русски» живёт в телеграм‑чатах релокантов, и каждый новый человек ищет её заново.

  • Ценность продукта не в карте, а в достоверности метки «здесь говорят по‑русски». Доверие считается нижней границей интервала Уилсона.

  • Стек: Kotlin везде, KMP для общего слоя, Compose на Android, Ktor на бэкенде. PostgreSQL + PostGIS, MapLibre и свои векторные тайлы PMTiles вместо Google Maps ради офлайна и своего стиля.

  • Первая оценка работы: 2,5 года при 8 часах в неделю. Скоуп урезали в разы: без аккаунтов, без фото, один язык, два города. Это не считая того, что я на Kotlin писал только автотесты.
    Но благо есть Дух Машины с которым все сделалось за 47 дней:)

  • Около 45 000 строк кода и больше сотни тестовых файлов. Инфраструктура стоит как один сервер.

  • Самое сложное было не в коде: правила Google Play, доверие к пользовательским данным и приватность частных мастеров.

Откуда взялась идея

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

  • объяснить врачу симптомы;

  • оформить документы у юриста или нотариуса;

  • понять, что именно сломалось в машине;

  • подстричься так, как хотел, а не как понял мастер;

  • вызвать сантехника и договориться о цене.

Google Maps знает, что на углу есть стоматология. Но он не знает, что администратор там говорит по‑русски, а врач учился в Питере. Эта информация живёт в телеграм‑чатах релокантов, в бесконечных постах «посоветуйте русскоязычного…». Каждый новый человек ищет её заново, а через полгода половина ответов устаревает.

Отсюда родилась простая формулировка продукта: альтернатива поиску по чатам и Google Maps, когда нужно место, где говорят на твоём языке.

Первым городом стал Белград. В нём большая и плотная русскоязычная диаспора, хорошее покрытие OpenStreetMap и живые чаты. Город компактный: полторы сотни мест реально обойти ногами. Следом открылся Нови‑Сад.

Ценности: что продукт обещает и чего не делает

Карт много. Справочников тоже. Ключевая ценность SpeakMap это достоверность одной метки: «здесь вас поймут по‑русски». Если метка врёт, человек тратит полчаса на дорогу и больше приложение не открывает. Поэтому механика подтверждения не фича на потом, а ядро продукта.

Из этого выросли несколько принципов, которые я зафиксировал письменно до первой строки кода:

  1. Доверие не продаётся. Ни за деньги, ни за донат нельзя получить метку, место в выдаче или статус «подтверждено». На странице поддержки проекта это написано прямо.

  2. Работа без регистрации. Проголосовать «да, здесь говорят» можно сразу после того, как тебе ответили на кассе. Форма регистрации в этот момент убивает конверсию голоса, а без голосов у карты нет данных о доверии.

  3. Никакой аналитики. В приложении нет трекеров, и политика конфиденциальности это обещает. Ошибки собирает Sentry, больше ничего.

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

  5. Приватность частных специалистов. Точный адрес сантехника, который работает из дома, не хранится даже в базе. Об этом ниже, это отдельная история.

Чего SpeakMap сознательно не делает: бронирование, оплату, чат с бизнесом, отзывы о качестве обслуживания. Каждая из этих функций удваивает сложность и уводит от главного вопроса «где меня поймут». Отзывы особенно: они превращают продукт в очередной отзовик и тянут за собой антифрод связанный с этим.

Арифметика: план на два с половиной года

Проект стартовал 3 сентября 2026 года с полноценной спецификации: шесть городов в трёх странах, кабинет владельца бизнеса, верификация по звонку, очередь модерации, фото, часы работы, три языка. Красиво, правильно и очень дорого по реализации.

Я посчитал честно. Полный первый релиз по спецификации 1200 человеко‑часов на команду; соло, без коммуникаций, но с переключением между Android и бэкендом 1000 часов.
У меня было около 8 часов в неделю, то есть примерно 360 часов в год.

900 ч ÷ 360 ч/год ≈ 2,5 года до релиза

Два с половиной года без единого пользователя это не долгий проект. Это проект, который не доживёт. Для продукта, ценность которого в свежести данных, особенно.

Поэтому скоуп резался безжалостно:

Было в спецификации

Стало в релизе

Шесть городов: Белград, Нови‑Сад, Стамбул, Бангкок, Паттайя, Пхукет

Белград и Нови‑Сад. Остальные лежат в справочнике выключенными

Русский, украинский, казахский

Только русский. Выбор языка спрятан за флагом, а не удалён

Аккаунты, OTP, удаление аккаунта

Нет аккаунтов вообще. Голоса и жалобы анонимно, по устройству

Мастер добавления места из четырёх шагов

Кнопка «+», булавка на карте и Google форма

Кабинет владельца, claim, верификация

Владельцы пишут через форму

Отдельно про «Добавить место». Нативный экран с проверкой дублей и черновиками самая дорогая фича спецификации. Вместо неё приложение ставит булавку в центр карты, человек двигает карту под ней и жмёт «Заполнить форму». Открывается Google‑форма, где город и ссылка на точку в OpenStreetMap уже подставлены. Ответ импортируется скриптом form_to_import.py.

Подвох у этого решения ровно один: если пересоздать вопрос в форме, у поля сменится номер entry.*, и предзаполнение молча пропадёт. Ни ошибки, ни лога. Проверяется только открытием формы из приложения.

Что я не резал, хотя соблазн был: Kotlin Multiplatform, Room как источник правды и уровни доверия к метке. Первое втрое дороже переделывать потом. Второе даёт офлайн бесплатно. Третье и есть продукт.

Архитектура целиком

Система состоит из четырёх частей: Android‑приложение, общий KMP‑слой, бэкенд на одном сервере и сайт на Cloudflare Workers.

┌──────────────────────────── Android ────────────────────────────┐
│ feature/map · onboarding · settings      (Jetpack Compose)        │
│ core/designsystem · map (MapLibre) · location · ui                │
├──────────────────────────── shared (KMP) ───────────────────────┤
│ model · network (Ktor Client) · database (Room KMP) · domain      │
└───────────────┬───────────────────────────────┬──────────────────┘
                │ HTTPS / JSON                  │ PMTiles (range-запросы)
                ▼                               ▼
┌──────────────────────── сервер (Docker) ─────────────────────────┐
│ Caddy (общий для всех приложений сервера)                         │
│   ├─ api: Ktor, модульный монолит                                 │
│   │    places · trust · moderation · notify · platform           │
│   ├─ /tiles: файлы PMTiles + latest.json                          │
│   └─ db: PostgreSQL 16 + PostGIS 3.4 (только 127.0.0.1)           │
└──────────────────────────────────────────────────────────────────┘
                ▲
┌───────────────┴─── Cloudflare Worker ───┐   ┌──── вне системы ────┐
│ /place/{id}, /city/{slug}, /privacy,     │   │ Sentry (EU)          │
│ App Links, og:image 1200×630             │   │ Resend — письма      │
└──────────────────────────────────────────┘   │ Google Forms         │
                                                └──────────────────────┘

Главное правило разделения кода звучит одной фразой: всё, что не рисует и не трогает железо, живёт в shared/. Модели, сеть, база и бизнес‑логика пишутся один раз. UI остаётся нативным: Compose на Android, а в будущем SwiftUI на iOS.

На бэкенде модульный монолит: один деплой, но с жёсткими границами между модулями. Микросервисы на старте это налог без выгоды: распределённые транзакции, пять пайплайнов и docker‑compose из восьми контейнеров для локальной разработки.

Стек и почему именно он

Каждое крупное решение оформлено как ADR в docs/adr/, всего их двенадцать. Для соло‑проекта это кажется бюрократией, но спустя месяц ADR будет единственное, что отвечает на вопрос «зачем я это сделал».

Слой

Выбор

Почему

Язык

Kotlin 2.4 везде

Один язык на клиенте и сервере: модели и формат ошибок описываются один раз

Общий слой

Kotlin Multiplatform

iOS в планах. Переписывать сеть, БД и логику на втором заходе 2–3 месяца. Цена сейчас около недели

UI

Jetpack Compose

Не Compose Multiplatform: карта и системные диалоги всё равно нативные, а UX платформы важнее экономии

Сеть

Ktor Client 3.5

Retrofit не мультиплатформенный и прибил бы сеть к Android. Ktor тот же, что на бэкенде

Локальная БД

Room KMP + bundled SQLite

Room источник правды: офлайн, отсутствие мигания при повороте, очередь голосов

DI

Hilt, только на Android

shared/* отдаёт фабрики с обычными конструкторами. Koin ради формальной «общей» DI не тащим

Карта

MapLibre Native 13.6

Офлайн‑карта, полный контроль стиля, один style JSON для Android и iOS, без Google Play Services

Тайлы

Свой PMTiles из сборок Protomaps

Ноль денег, не растёт с числом пользователей

Бэкенд

Ktor 3 на JVM 21

Модульный монолит

БД

PostgreSQL 16 + PostGIS 3.4, pg_trgm

Настоящие гео‑индексы и фильтры одним запросом

Доступ к БД

jOOQ без кодогенерации

SQL для PostGIS важнее ORM‑магии; генератору нужна живая база при сборке

Миграции

Flyway, 17 штук

Плюс тест, который запрещает менять выпущенные миграции (об этом в граблях)

Сайт

Cloudflare Workers

Страницы мест для тех, у кого нет приложения, App Links, превью ссылок

Ошибки

Sentry, регион EU

Crashlytics требует Google Play Services

Почта

Resend

Письма модератору с кнопками «Опубликовать / Скрыть»

Почему не Firebase. Firestore не умеет гео‑запросы: поиск по радиусу через geohash разворачивается в девять запросов, а «язык + категория + радиус» не собирается без дофильтрации на клиенте. Модерация, скоринг доверия и объединение дублей — реляционные операции. И стоимость чтений на карте, где каждое движение пальца это сотни документов, непредсказуема.

Почему не Google Maps. Конечно же потому что API Google maps стоит денег, но нет.
Google не берёт плату за загрузку карт в мобильном SDK, лимита нет. Решили три других довода:

  • Офлайн. Условия Google запрещают заранее скачивать и долго хранить их карту. Офлайн‑пакет города карта, метки и телефоны в роуминге или метро с Google был бы невозможен.

  • Стиль. Карта Google показывает все заведения подряд, включая конкурентов наших меток. Приглушённую пиксельную подложку, на которой видны только наши булавки, на ней не сделать.

  • Независимость. Google Maps SDK требует Google Play Services, а для iOS нужен отдельный SDK со своим внешним видом. MapLibre ест один и тот же стиль на обеих платформах, а тайлы лежат на нашем сервере.

Деньги появились бы, если брать тайлы Google внутрь MapLibre: 100 000 тайлов в месяц бесплатно, дальше 0,60 $ за тысячу. При тысяче активных пользователей это уже сотни долларов в месяц. Свой файл PMTiles стоит ноль при любой аудитории.

Что исключено полностью. Российские геосервисы Яндекс.Карты, MapKit, 2ГИС ни как SDK, ни как источник тайлов, ни как геокодер. Их преимущество в покрытии касается СНГ, а все города запуска лежат за его пределами. Плюс ряд рисков которых в продукте для релокантов быть не должно.

Карта и гео‑запросы

Считает база, а не приложение

Классическая ошибка гео‑приложений: выгрузить все места города и фильтровать по расстоянию в Kotlin.
Кажется, что правильнее отдать это PostGIS: область отсекается оператором &&, радиус ST_DWithin, языки и категории тем же запросом.

Самая дорогая мелочь здесь это разница между двумя похожими строками:

-- использует GiST-индекс
WHERE ST_DWithin(p.location, ST_MakePoint(:lng, :lat)::geography, :radius_m)

-- НЕ использует индекс: расстояние считается до каждой строки
WHERE ST_Distance(p.location, ST_MakePoint(:lng, :lat)::geography) < :radius_m

Замер на 30 000 мест: 9,5 мс против 2 623 мс. Разница почти в триста раз из‑за одной функции.

Вторая ловушка это частичный индекс places_location_gix с предикатом WHERE status = 'published'. Условие в запросе обязано совпадать с ним буквально. Напишете его иначе и планировщик перестанет видеть индекс, и никто не заметит до первого роста базы. Поэтому у репозитория есть функция, которая возвращает не строки, а план запроса, и тест проверяет в нём Index Scan.

Четыре правила между пальцем и сервером

Карта отдаёт новую видимую область на каждом кадре, десятки раз в секунду. Между этим потоком и сервером в общем KMP‑слое стоят четыре оператора Flow, и каждый закрывает свой способ сломать экран:

combine(viewports, filters) { viewport, active -> step(viewport, active) }
    .debounce(350.milliseconds)              // палец остановился
    .distinctUntilChanged { loaded, next ->   // уже покрыто загруженным?
        loaded.alreadyCovers(next)
    }
    .flatMapLatest { step -> load(step) }     // отменить устаревший запрос
  1. debounce. без него одно движение через город даёт полсотни запросов, из которых нужен последний.

  2. Запас вокруг области. Запрашиваем на 25% шире, чем видно. Пока новая область лежит внутри загруженной, в сеть не ходим.

  3. distinctUntilChanged сравнивает не «то же самое?», а «покрыто ли уже?».

  4. flatMapLatest отменяет предыдущий запрос. Без него медленный ответ на старую область приходит после быстрого на новую и затирает его. Всё «работает», а метки стоят не там, куда смотрит пользователь.

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

Дубли и турецкая «I»

Пользовательские данные гарантированно породят «Стоматология Дента», «Дента» и «DENTA» в одной точке. Ключ дедупликации name_normalized строится через ICU: транслитерация в латиницу, удаление форм собственности и категорийных слов, а дальше сходство триграммами pg_trgm в радиусе.

Одно правило в этом коде оплачено заранее: lowercase(Locale.ROOT), никогда не lowercase() без аргумента. На машине с турецкой локалью заглавная I превращается в ı без точки. Один и тот же файл, залитый с ноутбука и с сервера, дал бы разные ключи — и дубли, которые не покажет ни один отчёт. Стамбул в списке городов на будущее, турецких названий будет много.

А гонку при одновременном добавлении одного места закрывает не SELECT … FOR UPDATE, а pg_advisory_xact_lock по клеткам гео‑сетки.

Доверие к метке: нижняя граница Уилсона

Это сердце продукта, поэтому разберу подробно.

В карточке каждого места есть вопрос «Вас здесь поняли по‑русски?» и кнопки «Да» / «Нет». Подход подсчета в котором считает доля «да» от всех голосов не работает. Он ломается на малых выборках: место с одним «да» получает долю 1.0, ровно как место с сорока «да». По доле они неразличимы и карта поставит обоим «Подтверждено». А первое подтвердил ровно один человек. Возможно, сам владелец с двух телефонов.

Нижняя граница доверительного интервала Уилсона отвечает на другой вопрос: «какая доля точно не хуже этой, если бы мы опросили всех?» При z = 1,96 (95%):

w = \frac{\hat p + \frac{z^2}{2n} - z\sqrt{\frac{\hat p(1-\hat p)}{n} + \frac{z^2}{4n^2}}}{1 + \frac{z^2}{n}}

В коде это двадцать строк:

fun lowerBound(positive: Int, negative: Int, z: Double = 1.96): Double {
    val n = positive + negative
    if (n == 0) return 0.0
    val p = positive.toDouble() / n
    val z2 = z * z
    val centre = p + z2 / (2.0 * n)
    val margin = z * sqrt(p * (1 - p) / n + z2 / (4.0 * n * n))
    return ((centre - margin) / (1 + z2 / n)).coerceIn(0.0, 1.0)
}

Что это даёт на практике:

Голоса

Доля «да»

Нижняя граница Уилсона

Бейдж

1 «да» из 1

1,00

0,21

Не подтверждено

3 «да» из 3

1,00

≈0,44

Скорее да

12 «да» из 12

1,00

≈0,76

Подтверждено

40 «да» из 40

1,00

0,91

Подтверждено

Пороги 0,40 для «Скорее да» и 0,75 для «Подтверждено». Малая выборка штрафуется автоматически, без ручного правила «показывать бейдж с N голосов», которое пришлось бы подбирать для каждого города.

Почему 95%, а не 90%? Это не статистическая строгость, а цена ошибки. Человек, пришедший в место по ложному бейджу, теряет полчаса и доверие к приложению. Место, которому бейдж достался на голос позже, не теряет ничего. Асимметрия в пользу осторожности.

Ещё два правила:

  • Спорная метка прячется. Если опровержений больше, чем подтверждений и голосов не меньше пяти, метка исчезает. Показывать её как «Не подтверждено» нельзя: это читается как «пока никто не проверил», а тут проверили и сказали «нет». Это правило обязано совпадать с SQL‑выдачей, иначе человек найдёт место по фильтру «русский» и не увидит в нём русского языка.

  • Пороги живут в одном месте — на сервере. Если бы Android, будущий iOS и веб‑панель считали их сами, место оказалось бы «Подтверждено» в выдаче и «Скорее да» сразу после голоса.

Голос без аккаунта и защита от накрутки

Аккаунтов нет, поэтому голос привязан к идентификатору устройства. Очевидная дыра: пять POST‑запросов с разными X-Device-Id с одного адреса прятали русский язык у любого места.

Закрыл её отпечатком сети. Пересчёт берёт не больше одного «да» и одного «нет» с подсети /24 для IPv4 (или /48 для IPv6) на одну метку. Сам адрес и даже префикс не хранятся, только SHA-256 от префикса с солью, сгенерированной внутри базы. По выгрузке голосов сеть перебором не подобрать.

Голос, отправленный без сети, кладётся в очередь Room и уходит позже через WorkManager. Раньше он отправлялся из корутины карточки и пропадал, если карточку смахнули до ответа сервера.

Тайлы за ноль рублей и офлайн

Свой PMTiles вместо облачного провайдера

Карте нужна подложка, картинка мира под метками. Я сравнил три варианта:

  • MapTiler Cloud. Бесплатный тариф, только для личного и некоммерческого использования, приложение в Google Play под это не подходит. Следующий тариф уже 30 $ в месяц за 25 000 сессий. Расход, который растёт ровно тогда, когда появляется успех.

  • OpenFreeMap. Бесплатно и без ключей, но за сервисом стоит один человек и никакого SLA. Отдать внешнему сервису то, без чего главный экран превращается в серый прямоугольник, — плохая экономия.

  • Свой файл PMTiles. Один файл, который отдаёт уже работающий Caddy. Никакого нового демона.

Выбран третий. Из ежедневной сборки Protomaps (вся планета — 137,8 ГБ) pmtiles extract вырезает нужную область:

Область

Зумы

Размер

Время выгрузки

Белград

0–15

27 МБ

11 с

Сербия целиком

0–15

604 МБ

45 с

Весь мир

0–8

528 МБ

~5 мин

MapLibre читает PMTiles нативно range‑запросами. Расход в ноль: файл лежит на диске сервера, трафик входит в тариф хостинга. Обновление через tiles-update.sh по cron раз в месяц: дома и улицы не переезжают.

Одна неочевидная для меня деталь: файл называется с датой (serbia-20260905.pmtiles) и кешируется надолго, а рядом лежит latest.json с коротким кешем. Если бы имя было постоянным, клиент с закешированным заголовком старого файла запросил бы диапазон из нового и получил мусор вместо тайла. Неизменяемое имя исключает это по построению.

Офлайн, который сначала был невозможен

Сначала я не планировал офлайн карты. К тому же оказалось, что MapLibre не умеет офлайн‑загрузку для PMTiles‑источников. А офлайн релоканту нужен: роуминг, метро, цокольный этаж салона маникюра.

Обходной путь оказался простым. MapLibre умеет читать PMTiles не только по HTTPS, но и с диска через pmtiles://file://…. Значит, можно скачать файл самому:

  1. Сервер собирает офлайн‑пакет городов запуска, около 32 МБ и указывает его в latest.json.

  2. OfflineSyncWorker раз в неделю по Wi‑Fi качает пакет, шрифты подписей и снимок мест каждого города в Room.

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

  4. Если на диске есть и тайлы, и шрифты, стиль карты собирается из локальных файлов. Если нет, то карта ходит в сеть, как раньше.

Заодно карта стала быстрее и перестала тратить трафик.

Мастера на выезде: как показать на карте того, у кого нет адреса

После первых тестов выяснилось, что заметная часть спроса у пользователей это не заведения, а частные мастера: сантехник, электрик, клининг, мастер маникюра на дому, фотограф, репетитор. У них нет офиса. Их «адрес» это квартира, и публиковать его нельзя.

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

1. Точного адреса нет даже в базе. Город делится на клетки примерно 1,5 × 1,5 км (0,0135° широты на 0,019° долготы). При импорте база мастера сразу переносится в центр своей клетки. Отдельная миграция перенесла туда же тех, кто уже был в базе. Утечь нечему: точного дома не знает никто, включая сервер.

object ProGrid {
    const val CELL_LAT: Double = 0.0135  // ≈1,5 км везде
    const val CELL_LON: Double = 0.019   // ≈1,5 км на широте Белграда (в Бангкоке ≈2 км)

    /** Центр клетки, в которую попадает точка. */
    fun snap(lat: Double, lon: Double): Pair<Double, Double> =
        center(lat, CELL_LAT) to center(lon, CELL_LON)

    private fun center(value: Double, step: Double): Double =
        floor(value / step) * step + step / 2
}

Те же числа зашиты в SQL‑миграцию, и их совпадение проверяет отдельный тест: разойдутся и мастер на карте окажется в соседней клетке.

2. API не отдаёт координаты мастера нигде. Мастера исключены из выборки мест по области и радиусу, из офлайн‑снимка и из карточки места. У модели Pro нет полей широты и долготы. За этим следит тест с говорящим названием: «базу мастера не отдаёт ни карта, ни радиус, ни офлайн‑снимок, ни карточка места».

3. На карте клетка, а не точка. В центре клетки стоит булавка со смайликом и числом мастеров. Нажатие рисует круг выезда: по кругу на каждый радиус мастеров клетки, пунктиром. Круг примерный, со сдвигом до километра, и это написано прямо в интерфейсе.

Грабли с радиусом

Радиус выезда пережил три итерации. Сначала у пользователя был свой радиус поиска, а мастер находился при пересечении двух кругов. Потом «точка пользователя внутри зоны мастера». Потом снова пересечение.

Каждый раз на тесте всплывал один и тот же сценарий: тестовый мастер Иван с выездом 10 км находился при поиске в радиусе 20 км. Формально это верно, круги пересекаются. Но человек читает это как «Иван ко мне приедет», а Иван не приедет.

Итог: радиус задаёт только мастер (5, 10, 15 или 20 км). У пользователя шага радиуса нет вовсе. Самое простое решение оказалось единственным, которое не врёт.

Пиксельная дизайн‑система

Сначала дизайн был в карминовых тонах и выглядел как ещё одно приложение‑справочник.
Но я миллениал, а значит фанат пикселей.

Правила стиля короткие:

  • Пиксельные только графика и форма: иконки 7×7, маркеры‑булавки 13×17, иллюстрации онбординга, рамки.

  • Текст не пиксельный: весь интерфейс набран Rubik, иначе его невозможно читать.

  • Скруглений нет. Рамка 3 px, жёсткая тень 4×4 px без размытия, у основной кнопки — «фаска» светлым и тёмным оттенком акцента.

Картинки как текст

Все иконки и иллюстрации нарисованы не в Figma, а текстом прямо в Kotlin. Вот булавка — знак приложения, где K — обводка, F — заливка, W ‑‑облачко реплики:

..KKKKKKKKK..
.KFFFFFFFFFK.
KFFFWWWWWFFFK
KFFWWWWWWWFFK
KFFWFWFWFWFFK
KFFWWWWWWWFFK
KFFFWWWWWFFFK
KFFFWFFFFFFFK
KFFFFFFFFFFFK
KFFFFFFFFFFFK
KFFFFFFFFFFFK
.KFFFFFFFFFK.
..KFFFFFFFK..
...KFFFFFK...
....KFFFK....
.....KFK.....
......K......

Буква тут это роль цвета, а не сам цвет. Одна и та же картинка рисуется в светлой и тёмной теме через палитру токенов. Диф в Git читается глазами, а новая иконка это десять строк текста.

Три технических правила, каждое оплачено багом:

  • Масштаб только в целое число раз и без сглаживания. Иначе пиксели размываются. Поэтому метки на карте не растут с зумом.

  • Рисовать через Box + drawBehind, а не Canvas. У Canvas область рисования нулевая при defaultMinSize, и картинка просто не появляется.

  • Контраст проверяется тестом. При вёрстке оказалось, что акцент #bd4c0b на фоне даёт 4,1:1 при нужных 4,5:1, а серый цвет «Не подтверждено» 3,3:1. Цвета поправили, а ComponentContrastTest теперь не даст их сломать.

Цвета уровней доверия выбраны так, чтобы не спорить с акцентом. «Скорее да» намеренно горчично‑жёлтый, а не янтарный: янтарный сливался с оранжевым.

Отдельная проблема оказалось в любимой темной теме. Вода в ней изначально была чуть светлее суши, и город выглядел затопленным. Чинилось это не только цветом: fill‑слои стиля пришлось фильтровать по типу геометрии.

Инфраструктура: один сервер и семь пайплайнов

Весь прод это одна виртуальная машина в Hetzner с Docker Compose: PostgreSQL с PostGIS и API на Ktor. Перед ними стоит общий Caddy, через который на том же сервере живут и другие мои проекты. Разносить базу и приложение по разным машинам имеет смысл, когда одной из них не хватает ресурсов. При нагрузке первого релиза второй сервер добавил бы сетевую задержку, второй набор ключей и вторую точку отказа, ничего не дав взамен.

Порт базы и ufw

Одна строка в docker-compose.yml достойна отдельного абзаца:

ports:
  - "127.0.0.1:5432:5432"   # не "5432:5432"!

Запись без адреса означает 0.0.0.0. Docker публикует порты своими правилами iptables, которые обрабатываются раньше ufw. База оказалась бы открыта интернету при живом и правильно настроенном фаерволе, а ufw status показывал бы, что всё в порядке. Порт вообще публикуется только ради SSH‑туннеля с ноутбука.

Ещё две мелочи из того же файла. Логи контейнеров ограничены пятью файлами по 10 МБ: на диске в 40 ГБ рост логов это вопрос недель, и кончается он тем, что база не может записать WAL. А пояс сервера UTC: время места хранится вместе с местом, и логи с разных машин сравниваются без арифметики.

CI/CD

GitHub Actions, семь workflow: клиент, бэкенд, деплой, релиз, сборка APK, сайт и утилиты. У каждого свой paths‑фильтр, чтобы правка сайта не гоняла сборку Android.

Деплой бэкенда:

  1. Пуш в main с изменениями в backend/** запускает те же ktlint, detekt и тесты на настоящем PostGIS, что и обычная проверка. Раньше образ собирался с -x test параллельно с проверками в прод уезжал код, который ещё никто не проверил.

  2. Образ уходит в GHCR с тегом, коротким SHA коммита, а не latest. По latest невозможно ответить, что сейчас в проде.

  3. На сервер CI заходит под отдельным пользователем deploy с forced command в authorized_keys: этим ключом нельзя сделать ничего, кроме деплоя.

  4. deploy.sh запоминает текущий образ, поднимает новый и до 150 секунд ждёт healthy. Не дождался — откат.

  5. Две выкатки одновременно запрещены через concurrency, а прерывать начатую нельзя: деплой, оборванный на середине, оставляет сервер в неизвестном состоянии.

Бэкапы базы уходят в Cloudflare R2, зашифрованные открытым ключом age. Закрытый ключ есть только у меня. Даже полный доступ к бакету не даёт прочитать данные.

Клиент проверяется ktlint, detekt, юнит‑тестами и Compose UI‑тестами карточки на Robolectric, покрытие считает Kover. Маппинг ProGuard уходит в Sentry, чтобы стектрейсы с прода были читаемыми. Отдельный job собирает shared/* под iOS‑таргеты: так проверяется, что в общий код не утёк android.*, хотя iOS‑приложения ещё нет. Зависимости обновляет Renovate.

Сайт на Cloudflare Workers

Самая приятная часть тут это превью ссылок. Когда человек кидает место в Telegram, должна появиться карточка, а не голый URL. У каждой страницы своя картинка 1200×630: булавка, «SpeakMap», название и город. PNG собирается прямо в Worker без единой зависимости: глифы Rubik и пиксельный знак заранее превращены в массивы скриптом на Python. Сборка картинки занимает около 9 мс CPU, результат кешируется на краю на сутки.

Грабли

Прод лёг из‑за комментария в SQL

В один из дней я выкатывал большой пакет исправлений после код‑ревью Cloud.
Деплой упал на старте приложения.

Причина: в уже применённой миграции V13 при рефакторинге поправили комментарий. Flyway хранит checksum каждой применённой миграции и при несовпадении отказывается стартовать. Для него изменённый комментарий то же самое, что изменённая схема.

Дальше сработал откат... и тоже не поднялся. В том же пакете из docker-compose.yml убрал переменную JWT_SECRET: новый код её больше не читает. А старый образ, на который шёл откат, без неё не стартует.

Хотфикс вернул V13 байт в байт и переменную. Выводов два,:

  • Тест MigrationsImmutableTest хранит SHA-256 каждой выпущенной миграции. Изменишь хоть пробел — CI красный.

  • Переменная, которую перестал читать новый код, удаляется не раньше, чем через релиз. Иначе откат упирается в ту же стену. Рядом с ней в compose теперь висит комментарий об этом.

Compose передаёт в контейнер не всё

Премодерация новых мест включается переменной PLACES_REQUIRE_REVIEW. Я завёл её в .env на сервере и некоторое время премодерация молча оставалась выключенной. Docker Compose передаёт в контейнер только перечисленные в environment переменные, а этой строки в compose‑файле не было. Ни ошибки, ни предупреждения. Теперь по умолчанию премодерация включена: одно оскорбительное название в сторе стоит дороже суток задержки.

Код‑ревью: что нашли в «работающем» коде

Перед публичным релизом я устроил полноценное код‑ревью всего проекта: с точки зрения безопасности, производительности и корректности. Это моя профессиональная территория, и находки оказались показательными. Всё в коде выглядело рабочим и проходило тесты:

Что было

Чем грозило

Rate limiter на ConcurrentHashMap с уборкой раз в окно

100 запросов в секунду с новыми UUID давали 8,6 млн ключей в сутки это потеря памяти

receiveText() читал тело запроса целиком

Параллельные запросы по 100 МБ съедали кучу JVM

Письмо модератору уходило на каждую жалобу

60 жалоб с новыми UUID выжигали суточную квоту почты за минуту

Защита от дублей через SELECT … FOR UPDATE

В тесте 39 пар одновременных добавлений из 40 заводили место дважды

Лимит на добавление списывался до проверки формы

Ответы 422 сжигали суточные попытки честного пользователя

Любая строка в пути офлайн‑снимка становилась ключом кеша

Кеш рос бесконечно

Дата подтверждения ставилась на любой голос

Голос «нет» делал метку «свежее»

Про FOR UPDATE стоит пояснить. Он блокирует существующие строки, а дубль это строка, которой ещё нет: блокировать нечего, и две транзакции спокойно проходят параллельно. Решение — pg_advisory_xact_lock по клеткам гео‑сетки вокруг новой точки. Ключи берутся в порядке возрастания, чтобы не словить дедлок.

Всего в ревью вышло 44 пункта: по безопасности, бэкенду, приложению, инфраструктуре и качеству кода. Большинство исправлено до релиза, остальные висят в бэклоге с меткой «после релиза».

Красный CI, который не видно локально

Android‑код я долго проверял без полной сборки Gradle: компилировал изменённые файлы отдельно. Однажды в одном пакете оказались две функции верхнего уровня languageName— новая и старая в соседнем файле. Частичная компиляция этого не увидела, а CI был красным несколько коммитов подряд. Правило с тех пор: перед новой функцией верхнего уровня grep по всему пакету и компиляция пакета целиком.

Мелкие, но злые

  • Compose и прокрутка. Обработчик, который поглощает все касания на контейнере с прокруткой, ломает прокрутку карточки. Лечится только тем, что так не делать.

  • Detekt как архитектор. Порог LongParameterList срабатывает уже на шести параметрах, а MapRoute() стоит ровно на пороге LongMethod в 60 строк. Любое добавление туда это вынос в отдельную функцию. Раздражает, но код экрана карты благодаря этому читаем.

  • «Только приблизительное местоположение». На Android 12+ пользователь может дать доступ к геопозиции с точностью 1–3 км. Приложение обязано работать и так.

Как это делалось: Человек и Дух Машины.

Несколько наблюдений, которые, кажется, полезны любому, кто работает так же.

1. Контекст должен жить в репозитории, а не в чате. У агента нет памяти между сессиями. Однажды урезанный план по двум задачам потерялся между чатами, и его пришлось восстанавливать. После этого появились правила: каждое решение записывается в docs/, в конце сессии три строки в журнал «сделал / следующее / застрял», а точка входа для новой сессии один файл «начать отсюда». Это ровно то же самое, что онбординг нового инженера в команду, только онбординг происходит каждый день.

2. ADR лучший промпт. Когда агент предлагает что‑то, противоречащее принятому решению, достаточно сослаться на ADR. Комментарии в коде объясняют не «что», а «почему так и что было до этого». Этот текст пишется для следующей сессии агента, а читать его удобно и мне.

3. Окружение главный ограничитель. Агент работал в Linux‑VM, где Android SDK нет, а холодный Gradle не укладывался в лимит одного вызова. Обходили так: ktlint и detekt запускались в облачном контейнере на изменённых файлах. Compose‑экраны компилировались на Compose Multiplatform desktop с заглушками Android‑ресурсов и рендерились в PNG, чтобы сверить с макетом в обеих темах. Android целиком собирал только CI. Отсюда и красный CI из раздела про грабли: частичная сборка это компромисс, и он однажды выстрелил.

Google Play: критический путь не в коде

Моей ошибкой было считать, что срок определяется написанием кода. У меня три вещи вне кода заняли больше нервов, чем сама разработка.

12 тестеров × 14 дней

Личные аккаунты разработчика, созданные после ноября 2023 года, обязаны пройти закрытое тестирование: минимум 12 тестеров, непрерывно подписанных 14 дней подряд. Только после этого можно подать заявку на production.

Главный приём тут сдвинуть закрытое тестирование влево. Ничто не требует, чтобы в закрытом треке лежала готовая сборка. Как только появилось хоть что‑то устанавливаемое, оно ушло в трек, и пошёл отсчёт. Двенадцать тестеров это двенадцать живых Google‑аккаунтов, которые надо попросить и которые не должны отписаться. Это две недели переписки, а не один вечер. Отдельное спасибо маме, что пользуется Android.

Но даже так всё прошло не с первого раза. Google отказал в доступе к production после первых 14 дней теста и потребовал ещё 14 дней закрытого тестирования.
Почему? Его ответ был примерно такой: «Потому»

Правила, которые выглядят мелочью

  • Приложение с пользовательским контентом обязано иметь жалобу на любой объект. Поэтому «Пожаловаться» есть в карточке каждого места и мастера, а рядом с голосованием ссылка на условия использования.

  • Data Safety описывает именно эту сборку. Анкета не пишется «на будущее»: заявленная реклама, которой нет, и незаявленная, которая есть ошибки одного класса. Перед каждой загрузкой анкета сверяется с кодом построчно.

  • Донаты это минное поле. Кнопка «Поддержать автора» со ссылкой на внешний сбор нарушает платёжную политику Play. Исключение есть только для подтверждённых некоммерческих организаций, и частное лицо под него не попадает. Решение: сбор только на сайте, а приложение о нём не знает ничего. Цена ошибки здесь не деньги, а аккаунт разработчика.

  • Реклама это не «добавить баннер». Рекламный SDK сам собирает и передаёт IP, идентификаторы и события, а это переписанная анкета Data Safety, экран согласия для ЕЭЗ и новая политика конфиденциальности. Поэтому в первом релизе рекламы нет, а когда появится, то одним неделимым пакетом.

Отказ от аккаунтов как упрощение для Play

Ещё одна выгода решения «без регистрации»: требование Play об удалении аккаунта из приложения и через веб касается только приложений с аккаунтами. Нет аккаунтов — нет ни OTP, ни экрана удаления, ни целого модуля.

Постепенный раскат по процентам я тоже не использую: при аудитории в сотни человек он не даёт никакого сигнала.

Итоги и что дальше

Первая честная оценка обещала релиз через два с половиной года. НО к концу октября 2026 го приложение уже в Google play. Это примерно семь недель с первого коммита.

Проект в цифрах на октябрь 2026:

Что

Сколько

Строк кода (Kotlin, SQL, JS, Python, с комментариями)

≈45 000

из них Android / бэкенд / общий KMP‑слой

≈16 500 / ≈18 000 / ≈5 800

Файлов с тестами

101

Миграций Flyway

17

ADR

12

Workflow в CI

7

Серверов

1

Расход на карты

0

Главные уроки, если коротко:

  1. Режьте скоуп, а не сроки. Закрытая урезанная фаза лучше идеальной незакрытой.

  2. Продукт на пользовательских данных держится на доверии, а доверие это статистика. Нижняя граница Уилсона это двадцать строк кода, которые отличают справочник от продукта.

  3. Приватность проектируется в данных, а не в API. Лучшая защита адреса мастера — не хранить его вовсе.

  4. Самые опасные баги не падают. Изменённый комментарий, переменная, не дошедшая до контейнера, удалённый тест, пропавшее предзаполнение формы и всё это «работало».

  5. Критический путь тут не код. Правила Google Play, тестеры и наполнение данными съедают не меньше времени, чем разработка.

Что дальше:

  • публичный релиз в Google Play;

  • новые города: Бангкок, Пхукет, Буэнос‑Айрес, Стамбул;

  • защита от накрутки голосов: вес голоса по возрасту устройства и Play Integrity в мягком режиме;

  • iOS‑клиент на SwiftUI поверх уже готового общего слоя.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.