У стрика ровно пять состояний, и каждое — это число: сколько дней взято, сколько боксов
осталось за сегодня, сколько часов до сгорания, какая награда, сколько дней потеряно.
Сейчас эти числа запечены в JPEG на CDN. Здесь — что можно отправить уже на этой неделе
на контракте, который клиент привёз 14.08, и что стоит попросить дальше.
Очередь 1 · ждёт только мостОчередь 2 · 1 ключ + 1 лейаутТипов вёрстки · 5XML-лейаутов · 1Килобайт картинок в очереди 2 · 0
§ 01
Основание поменялось 14.08
PPTM-2899 «Интерактивный push с кнопкой» — Готово, вошёл в 1.17.0. Клиент умеет
большую картинку, круглую иконку и до трёх кнопок в ряд с заливкой или градиентом и
своим диплинком на каждой. Состав и оформление меняются из пейлоада, без релиза.
Это снимает блокер, из-за которого стояла рич-программа с 24.07 и карточка с кнопкой
по запросу CEO с 30.07. Но контракт — не наш.
Наша схема устарела — это надо сказать прямо
Полгода готовили layout: "card_cta" + thumb +
eyebrow + cta_text + cta_deeplink +
hub_cta. Клиент реализовал другую схему, и она универсальнее нашей:
три кнопки вместо одной, градиенты, свой цвет текста. Эти шесть ключей клиент не
читает. Всё ниже переписано под приехавший контракт.
Контракт, который реально работает
Data-only FCM. notification и buttons — JSON-строки внутри
data (двойная сериализация), image и icon —
соседние ключи в data, не внутри notification.
Поля кнопки: text (обязательное), link, colors
(один цвет — заливка, два и больше — градиент), text_color,
font_size. Максимум 3, лишние отбрасываются. Битый JSON в
buttons пуш не ломает — придёт обычным. Кнопки видны только в
развёрнутом виде.
§ 02
Две очереди
Очередь 1 — на этой неделе
Очередь 2 — нативное тело
Что даёт
картинка + иконка + до 3 тапабельных кнопок
полоска дней, живой отсчёт, прогресс-бар, правка на месте
Клиент
уже готово, 1.17.0
1 ключ + 1 лейаут
Мост
прокинуть 3 ключа, ~10 строк
+ 2 ключа тем же аллоу-листом
Наша работа
пересобрать шаблоны, сегмент по версии
+ 2 user-property, 2 журнея
Числа стрика
по-прежнему запечены в картинку
рисует Android
Вес ассетов
100–300 KB
0 KB
Очереди независимы: очередь 2 надстраивается над очередью 1 и ничего в ней не ломает.
Пуш без нового ключа рендерится ровно как в 1.17.0.
§ 03
Очередь 1: что уходит сразу после моста
Мост собирает исходящий Gorush-пуш из фиксированного набора полей и выбрасывает
незнакомые ключи — поэтому сегодня ни один ключ 1.17.0 до устройства не доезжает,
сколько бы мы их ни клали в шаблон. После проброса трёх ключей канал сразу получает
картинку, иконку и до трёх тапабельных кнопок: числа стрика при этом остаются
запечёнными в JPEG, но кнопка наконец становится настоящей, а не нарисованной.
Что нужно нашей стороне: пересобрать шаблоны под три ключа, локализацию 33 языков
переложить в buttons[].text, добавить сегмент по app_version.
Ассеты 2:1 и иконки у нас уже есть. Арм монтируется заменой одной ссылки
templateId в живом 50/50 по CTRL_AB_STRK_50.
Ограничение, которое надо держать в голове: числа остаются в картинке. Сумма
награды разная по тирам (1, 2, 3, 5, 10 USDT) — под каждый номинал пришлось бы
генерировать отдельный файл, поэтому в очереди 1 сумма живёт только в тексте.
Именно это и снимает очередь 2.
Два вопроса, которые надо закрыть до сборки
Схема диплинка в кнопке. В спеке примеры на swipix://feed, а у нас
правило constants.md: все ссылки — https://app.swipix.io/….
Там же в спеке: ссылка, не распознанная как диплинк, откроется во внутреннем браузере.
Нужно подтверждение, что https://app.swipix.io/<route> в кнопке
роутится нативно.
Трекинг тапа по кнопке. В контракте кнопки нет места под наш n8n-хаб: тап по телу
считаем через hub_open, тап по кнопке сейчас невидим — A/B по кнопке не
прочитать. Минимальное предложение: опциональное поле hub в объекте
кнопки, которое приложение дёргает тем же механизмом, что hub_open, с
campaign=<TEMPLATE>_b<N>. Альтернатива — событие в PostHog.
§ 04
Что Android умеет сверх этого
Инвентарь возможностей, а не пожелания: всё ниже — API, доступные приложению прямо
сейчас, и ничего из этого нельзя изобразить картинкой. Это и есть содержание очереди 2.
Автоматически показать уведомление раскрытым: setFullScreenIntent с
Android 14 доступен только звонилкам и будильникам, для остального — блок в Play.
Позицию в шторке задаёт ОС. Виджеты вне whitelist RemoteViews не
инфлейтятся вообще. Кастомный свёрнутый вид получает ~48dp на Android 12+ и первым
ломается на OEM-шкурках — поэтому свёрнутый оставляем стандартным.
§ 05
Сценарии стрика → пять типов
Сценариев мало, и это хорошо: пять видов покрывают весь жизненный цикл серии, а с точки
зрения клиента это один XML-лейаут, у которого переключается видимость блоков.
Тип
Сценарий и триггер
Шаблон
Статус
week
День засчитан · Streak Day Credited
PUSH_Strk_DayDone
живой
week
Онбординг · сегмент, pos = 0
PUSH_Strk_Onboard_P1…P3
живой
progress
N из 3 боксов сегодня · Streak Receipt Recorded + задержка
PUSH_Strk_BoxProgress
новый
timer
Серия сгорит сегодня · Day Credited вчера → Wait-For
PUSH_Strk_AtRisk
новый
reward
Чекпоинт взят · Streak Reward Unlocked
PUSH_Strk_RewardUnlocked
живой
broken
Серия прервалась · Streak Progress Reset
PUSH_Strk_Comeback
живой
Четыре события стрика проверены в cdp.user_events_v2 17.06.2026.
Два новых сценария — не новые события, а новые прочтения уже существующих.
§ 06
Очередь 2: макеты пяти типов
Слева — как уведомление приходит, в свёрнутом виде. Справа — после разворота. Тёмная
карточка — это наше тело; ряд кнопок под ней рисует уже существующий код 1.17.0, мы его
не трогаем. Макеты отрисованы 1 dp = 1 px на ширине 348 dp; на устройстве шрифт Roboto.
Обратите внимание на левую колонку: свёрнутый вид остаётся стандартным, но в трёх из
пяти случаев там работает нативная механика, для которой кастомный лейаут не нужен.
week
День засчитан
Streak Day Credited
Полоска — весь смысл этого вида: пользователь видит не «ты молодец», а точную геометрию
своей серии и сколько осталось до звезды. Ячейки — локальные drawable, ноль сети.
Свёрнуто
Swipix·5/7·сейчас⌄
День 5 засчитан 🔥
Ещё 2 дня — и чекпоинт твой.
setSubText("5/7") — счётчик виден, не раскрывая уведомление.
Раскрыто
Swipix·5/7·сейчас⌃
ЕЩЁ 2 ДНЯ ДО ЧЕКПОИНТА
День 5 засчитан
Серия идёт пятый день подряд.
123456★
ПРОДОЛЖИТЬ СЕРИЮ
Кнопка — из buttons, существующий рендер. Наше тело только над ней.
Нативные элементы
свёрнуто
setSubText + setLargeIcon
раскрыто
7 TextView, фон через setInt
сеть
только icon, 22 KB
высота тела
128 dp
// один новый ключ, стиль тот же, что у buttons"streak": "{\"view\":\"week\",\"pos\":5,\"len\":7,\"label\":\"ЕЩЁ 2 ДНЯ ДО ЧЕКПОИНТА\"}"
Онбординг — тот же вид с pos: 0: все ячейки пустые, первая в
обводке. Отдельного типа для него не нужно.
progress
Осталось поймать боксы
новый сценарий
День засчитывается после третьего пойманного бокса. Единственное место в механике, где у
пользователя есть незакрытое действие с понятным остатком — и единственный вид, который
меняется на месте, не создавая нового уведомления.
Свёрнуто
Swipix·2 из 3 боксов за сегодня·сейчас⌄
Остался 1 бокс
Поймай его — и день 6 засчитается.
setProgress(3, 2, false) — настоящий прогресс-бар в свёрнутом уведомлении, без кастомной вёрстки.
Раскрыто
Swipix·2 из 3 боксов за сегодня·сейчас⌃
2 ИЗ 3 БОКСОВ ЗА СЕГОДНЯ
Остался 1 бокс
Поймай его — и день 6 засчитается.
123456★
2 из 3 боксов за сегодня
ЛОВИТЬ БОКС
Нажмите: прилетает пуш со strk_update:"1" — то же уведомление меняется,
нового не появляется, звука нет. Отправок: 1, уведомлений в шторке: 1.
Нативные элементы
свёрнуто
setProgress(3, n, false)
раскрыто
ProgressBar + полоска
обновление
setOnlyAlertOnce, тот же id
высота тела
166 dp
"streak": "{\"view\":\"progress\",\"pos\":5,\"prog\":\"2/3\",\"label\":\"2 из 3 боксов за сегодня\"}",
"strk_update": "1"// тихая правка на месте
Два условия, без которых механика не работает
Правка на месте требует стабильного notificationId —
stableHash(collapse_id) из ТЗ по стопочкам. Без него обновление создаст
второе уведомление вместо правки первого; остальные четыре вида от этого не зависят.
И решение с нашей стороны: тихое обновление не должно считаться касанием в
touch-budget, иначе три шага прогресса съедят треть дневного капа пользователя.
timer
Серия сгорит сегодня
новый сценарий
Единственный вид с настоящей жизнью внутри уведомления: Chronometer тикает
сам, каждую секунду, без единого дополнительного пуша.
Свёрнуто
Swipix·3:58:07⌄
Серия сгорит сегодня
Поймай 3 бокса — день 6 останется за тобой.
setWhen + setUsesChronometer +
setChronometerCountDown — отсчёт живёт в строке времени хедера.
Кастомной вёрстки нет, работает с API 24.
Раскрыто
Swipix·3:58:07⌃
СЕРИЯ ПОД УГРОЗОЙ
Серия сгорит сегодня
Поймай 3 бокса — день 6 останется за тобой.
123456★
3:58:07до конца стрик-дня
СПАСТИ СЕРИЮЧЕРЕЗ ЧАС
Таймер на этой странице идёт по-настоящему — так же, как он пойдёт в шторке.
«Через час» — обычная кнопка с внутренним диплинком, отдельного поля не нужно.
Главная ловушка реализации
Chronometer живёт в timebase elapsedRealtime(), а не в
стенных часах. Передать epoch в setBase() — и таймер покажет полтора
месяца вместо четырёх часов.
base = SystemClock.elapsedRealtime()
+ (deadlineMs - System.currentTimeMillis())
"streak": "{\"view\":\"timer\",\"pos\":5,\"until\":\"utc_midnight\",\"label\":\"до конца стрик-дня\"}"
Дедлайн считает клиент, а не Liquid: пуш может уйти из очереди тихих часов до 16 ч
позже, и запечённый timestamp протухнет.
Проверить у бэкенда до запуска
В событиях стрика есть creditedUtcDate ⇒ день считается по UTC, поэтому
utc_midnight взят дефолтом. Если граница на самом деле локальная —
дедлайн будет врать до 12 часов, а неверный таймер хуже отсутствующего.
reward
Чекпоинт взят
Streak Reward Unlocked
Тот же сценарий, что в очереди 1, но сумма приходит числом и рисуется системой — не надо
генерировать отдельную картинку под каждый номинал награды.
Свёрнуто
Swipix·7/7·сейчас⌄
Чекпоинт 7 дней взят
Награда ждёт в приложении.
Раскрыто
Swipix·7/7·сейчас⌃
ЗА НЕДЕЛЬНУЮ СЕРИЮ
Чекпоинт 7 дней взят
Награда ждёт в приложении.
123456★
+10 USDTсумма из события, не из картинки
ЗАБРАТЬ
Требуется от CRM до запуска
Свойства streak_reward_value и streak_reward_currency
из Streak Reward Unlocked ещё не заведены. Сначала проверить, что
rewardJson в событии — объект, а не строка: если строка, путь
$.rewardJson.object.value не извлечётся и нужны плоские поля от
бэкенда.
Здесь вёрстка работает против стандартного «жаль, ты всё потерял»: полоска показывает,
что именно было набрано, крест стоит ровно на одном дне, а не на всей серии. Потеря
выглядит конкретной и потому восполнимой.
Свёрнуто
Swipix·0/7·сейчас⌄
Серия из 12 дней прервалась
Новая начинается с первого бокса сегодня.
Раскрыто
Swipix·0/7·сейчас⌃
ДНЕЙ ПОДРЯД ДО СБРОСА
Серия прервалась
Новая начинается с первого бокса сегодня.
12345×★
12дней подряд до сброса
НАЧАТЬ ЗАНОВО
Нативные элементы
ячейки
погашенные cell_empty + cell_break
метрика
приглушается в коде, setTextColor
сеть
только icon
высота тела
152 dp
"streak": "{\"view\":\"broken\",\"pos\":5,\"metric\":\"12\",\"label\":\"ДНЕЙ ПОДРЯД ДО СБРОСА\"}"
pos — последний засчитанный день цикла, metric — вся
потерянная серия (user.streak_prev_days).
§ 07
Контракт очереди 2 — один ключ
Ключ оформлен в том же стиле, что buttons: соседний ключ в
data, JSON-строка. Правило совместимости тоже то же самое, которое клиент
уже реализовал для кнопок — ключа нет, он пустой или JSON битый ⇒ уведомление
рендерится ровно как сегодня.
"streak": "{\"view\":\"timer\",\"pos\":5,\"len\":7,\"until\":\"utc_midnight\",\"label\":\"до конца стрик-дня\"}"
Поле
Обяз.
Тип / лимит
Смысл
view
да
week · progress · timer · reward · broken
Какой из пяти видов рендерить.
pos
да
целое 0…len
Сколько дней цикла засчитано. Из него выводятся состояния всех ячеек.
len
нет
целое 3…7, деф. 7
Длина полоски.
prog
для progress
"N/M", M ≤ 9
Боксы за сегодня. Рисуется и в свёрнутом виде.
until
для timer
utc_midnight · local_midnight · epoch-ms
Момент сгорания. Дату считает клиент.
metric
для reward / broken
≤ 12 симв.
Крупное число: сумма награды или потерянные дни.
label
нет
≤ 40 симв.
Подпись под баром, таймером или метрикой.
Плюс один плоский ключ strk_update: "1" — тихая правка висящего уведомления.
Семь ячеек в payload не передаются: два целых и enum описывают любое состояние серии.
§ 08
Бюджеты и версионный гейт
Payload — лимит FCM 4096 байт
Часть
байт
notification с экранированием
230
image
95
icon
100
buttons — две кнопки с градиентами
360
streak — максимальный
150
strk_update
18
df_message_id + hub_link
480
итого
≈ 1 433
запас
≈ 2 660
⚠️ Считать в байтах, не в символах: кириллица в UTF-8 — 2 байта на символ,
тайский и арабский — до 3. Худший случай (186 символов текста) — 558 байт вместо 186.
Запас выдерживает, но пересчитывать надо на самом тяжёлом языке, а не на английском.
Высота — бюджет платформы
Вид
бюджет
у нас
раскрытый контент (тело + кнопки)
256 dp
≤ 166 dp тело
свёрнутый (custom, Android 12+)
≈ 48 dp
не используем
Кнопкам остаётся ~90 dp. Инвариант: одновременно видима не более одной строки из
трёх — прогресс-бар, таймер, метрика. Плюс правило доступности: при системном шрифте
больше 130% полоска скрывается, информация остаётся в подписи.
Версия клиента — гейт обязателен
Версия
юзеров
1.15.0
12 133
1.13.1
9 063
1.16.0
9 020
1.14.0
7 002
всего с известной версией
74 517
Зонд app_version 17.08, последнее значение на юзера. 1.16.0 вышел 10.08 ⇒
релиз набирает ~12% базы за первую неделю. Без гейта 88% получат пуш без кнопок и
замер арма будет мусорным.
Ловушка сегмента
Строковое сравнение версий в Dittofeed даёт неверный порядок: "1.9.0"
больше "1.17.0". Сегмент собирать перечислением значений либо
синкать числовой трейт. Свойство app_version в DF живое, назначения свежие.
§ 09
ТЗ на разработку
Мост cdp-push-bridge — очередь 1, ~10 строк
Мост собирает исходящий Gorush-пуш из фиксированного набора полей и выбрасывает
незнакомые ключи — поэтому сегодня ни один ключ 1.17.0 до устройства не доезжает,
сколько бы мы их ни клали в шаблон. Нужен pass-through image,
icon, buttons как есть, под флагом.
Чтобы не приходить второй раз — сразу аллоу-лист: явный список
(image, icon, buttons, streak) плюс
всё с префиксом strk_. Ничего не валидируем и не пересобираем: у клиента
уже есть fail-soft, разбор на нашей стороне только добавит точку отказа.
Клиент — очередь 2
Реализовать
Разбор ключа streak — fail-soft, как у buttons.
Один лейаут notification_streak_expanded.xml, вид выбирается видимостью блоков; DecoratedCustomViewStyle + setCustomBigContentView. Кнопки остаются вашим существующим рендером под телом.
Полоска: 7 TextView с layout_weight=1, фон через setInt(id,"setBackgroundResource",r), шесть локальных <shape>.
Свёрнутый вид — стандартный шаблон плюс нативный стейт по виду (setProgress / отсчёт в хедере / setSubText).
Chronometer с пересчётом базы в elapsedRealtime(); на API < 24 строка скрывается.
Правка на месте по strk_update: setOnlyAlertOnce(true) + тот же notificationId.
При fontScale > 1.3 полоска скрывается.
Не просим
Кастомный свёрнутый вид, автопоказ раскрытого, позицию в шторке, анимацию, видео,
четвёртую кнопку, inline-reply, iOS. Heads-up-лейаут — опционально, вне минимального ТЗ.
Приёмка
#
Проверка
1
week: pos ячеек закрашено, pos+1 в обводке, последняя — звезда
2
progress: прогресс-бар виден без раскрытия
3
второй пуш со strk_update=1 меняет то же уведомление без звука, второго не появляется
4
timer: в свёрнутом хедере тикает отсчёт
5
timer: ±1 мин после 30 мин сна устройства
6
reward / broken: метрика и крест на нужной ячейке
7
кнопки из buttons работают как в 1.17.0 — тело их не задевает
8
пуш без ключа streak — бит-в-бит как в 1.17.0
9
битый JSON в streak — как п. 8, без крэша
10
светлая системная тема: текст читается
11
арабская локаль: полоска справа налево, текст не обрезан
12
системный шрифт 200%: полоска скрыта, тело не обрезано
Роутится ли https://app.swipix.io/<route> в кнопке нативно?
все кнопки
вопрос
Ключинский
Трекинг тапа по кнопке — поле hub или событие в PostHog
замер арма
вопрос
Клиент Android
Ключ streak + лейаут тела
всю очередь 2
предложение
Клиент Android
Стабильный notificationId (ТЗ по стопочкам)
только progress
не начато
Бэкенд
Граница стрик-дня: UTC или локальная?
timer
вопрос
Бэкенд
rewardJson — объект или строка?
reward
вопрос
CRM
Сегмент по app_version перечислением
обе очереди
наша работа
CRM
Пересборка шаблонов под 3 ключа, 33 языка в buttons[].text
очередь 1
наша работа
CRM
Свойства streak_boxes_today, streak_reward_value
progress, reward
наша работа
CRM
Журнеи STRK_Box_Progress_v1, STRK_At_Risk_v1
2 новых сценария
наша работа
CRM
Исключить тихие обновления из touch-budget
progress
наша работа
Осторожно с новыми журнеями
Streak Receipt Recorded — около 5 000 событий в сутки. Журней с входом по
нему нужно строить с гейтом «не чаще раза в день на пользователя», а
STRK_At_Risk_v1 — как Day Credited вчера → задержка → Wait-For
на сегодняшний credit, а не как сегмент. Сегодня, 17.08, активация журнея,
втянувшего 11,7 тыс. пользователей разом, задушила отправки всех остальных на час — и
откатить это нельзя: ни Pause, ни Archive очередь не дренируют. Размер входа меряем до
активации.
Порядок выката
Этап
Содержание
Условие выхода
0 — сейчас
Контракт, лейаут, Kotlin-референс, макеты, ТЗ мосту и клиенту
готово
1
Мост + пересборка двух флагманских STRK под image/icon/buttons + сегмент по версии
ответы Ключинского получены
2
Арм в CTRL_AB_STRK_50: замена одной ссылки templateId
правка Running-журнея без Pause — разрешена, но нужно подтверждение оператора
3
Очередь 2: клиент берёт ключ streak, три живых вида переезжают на нативное тело