Swipix · CRM · push-канал · 17.08.2026

Стрик-пуши: от картинки к нативной вёрстке

У стрика ровно пять состояний, и каждое — это число: сколько дней взято, сколько боксов осталось за сегодня, сколько часов до сгорания, какая награда, сколько дней потеряно. Сейчас эти числа запечены в JPEG на CDN. Здесь — что можно отправить уже на этой неделе на контракте, который клиент привёз 14.08, и что стоит попросить дальше.

Очередь 1 · ждёт только мост Очередь 2 · 1 ключ + 1 лейаут Типов вёрстки · 5 XML-лейаутов · 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.

{"message":{"token":"<FCM>","data":{
  "notification": "{\"title\":\"…\",\"description\":\"…\",\"link\":\"…\"}",
  "image":   "https://…/800x400.jpg",
  "icon":    "https://…/icon-192.png",
  "buttons": "[{\"text\":\"Открыть\",\"link\":\"…\",\"colors\":[\"#01E3EB\",\"#02A9AF\"]}]"
},"android":{"priority":"high"}}}

Поля кнопки: text (обязательное), link, colors (один цвет — заливка, два и больше — градиент), text_color, font_size. Максимум 3, лишние отбрасываются. Битый JSON в buttons пуш не ломает — придёт обычным. Кнопки видны только в развёрнутом виде.

§ 02

Две очереди

Очередь 1 — на этой неделеОчередь 2 — нативное тело
Что даёткартинка + иконка + до 3 тапабельных кнопокполоска дней, живой отсчёт, прогресс-бар, правка на месте
Клиентуже готово, 1.17.01 ключ + 1 лейаут
Мостпрокинуть 3 ключа, ~10 строк+ 2 ключа тем же аллоу-листом
Наша работапересобрать шаблоны, сегмент по версии+ 2 user-property, 2 журнея
Числа стрикапо-прежнему запечены в картинкурисует Android
Вес ассетов100–300 KB0 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.

"image":   "…/push/streak-ui/v2/reward.jpg",
"icon":    "…/push/streak-card/v1/reward-192.png",
"buttons": "[{\"text\":\"ЗАБРАТЬ\",\"link\":\"…/feed?adj_t=1zq7q5ba\",
             \"colors\":[\"#01E3EB\",\"#02A9AF\"],\"text_color\":\"#071324\"}]"

Ограничение, которое надо держать в голове: числа остаются в картинке. Сумма награды разная по тирам (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.

ВозможностьAPIAPIГде используем
Прогресс в стандартном шаблонеsetProgress(max, n, false)14progressвиден в свёрнутом виде
Обратный отсчёт в хедереsetWhen + setUsesChronometer + setChronometerCountDown24timer — свёрнутый вид
Живой виджет отсчётаChronometer в RemoteViews24timer — раскрытый вид
Кастомное телоsetCustomBigContentView + DecoratedCustomViewStyle21все пять видов
Смена фона элементаsetInt(id,"setBackgroundResource",r)21шесть состояний ячейки дня
Подпись в шапкеsetSubText21«5/7» в свёрнутом виде
Правка на месте, без звукаsetOnlyAlertOnce + тот же notificationId211/3 → 2/3 → 3/3 одним уведомлением
Чего Android не умеет — не просим

Автоматически показать уведомление раскрытым: setFullScreenIntent с Android 14 доступен только звонилкам и будильникам, для остального — блок в Play. Позицию в шторке задаёт ОС. Виджеты вне whitelist RemoteViews не инфлейтятся вообще. Кастомный свёрнутый вид получает ~48dp на Android 12+ и первым ломается на OEM-шкурках — поэтому свёрнутый оставляем стандартным.

§ 05

Сценарии стрика → пять типов

Сценариев мало, и это хорошо: пять видов покрывают весь жизненный цикл серии, а с точки зрения клиента это один XML-лейаут, у которого переключается видимость блоков.

ТипСценарий и триггерШаблонСтатус
weekДень засчитан · Streak Day CreditedPUSH_Strk_DayDoneживой
weekОнбординг · сегмент, pos = 0PUSH_Strk_Onboard_P1…P3живой
progressN из 3 боксов сегодня · Streak Receipt Recorded + задержкаPUSH_Strk_BoxProgressновый
timerСерия сгорит сегодня · Day Credited вчера → Wait-ForPUSH_Strk_AtRiskновый
rewardЧекпоинт взят · Streak Reward UnlockedPUSH_Strk_RewardUnlockedживой
brokenСерия прервалась · Streak Progress ResetPUSH_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 засчитан
Серия идёт пятый день подряд.
12 34 56
ПРОДОЛЖИТЬ СЕРИЮ

Кнопка — из 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 засчитается.
12 34 56
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"   // тихая правка на месте
Два условия, без которых механика не работает

Правка на месте требует стабильного notificationIdstableHash(collapse_id) из ТЗ по стопочкам. Без него обновление создаст второе уведомление вместо правки первого; остальные четыре вида от этого не зависят. И решение с нашей стороны: тихое обновление не должно считаться касанием в touch-budget, иначе три шага прогресса съедят треть дневного капа пользователя.

timer

Серия сгорит сегодня

новый сценарий

Единственный вид с настоящей жизнью внутри уведомления: Chronometer тикает сам, каждую секунду, без единого дополнительного пуша.

Свёрнуто
Swipix ·3:58:07
Серия сгорит сегодня
Поймай 3 бокса — день 6 останется за тобой.

setWhen + setUsesChronometer + setChronometerCountDown — отсчёт живёт в строке времени хедера. Кастомной вёрстки нет, работает с API 24.

Раскрыто
Swipix ·3:58:07
СЕРИЯ ПОД УГРОЗОЙ
Серия сгорит сегодня
Поймай 3 бокса — день 6 останется за тобой.
12 34 56
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 дней взят
Награда ждёт в приложении.
12 34 56
+10 USDT сумма из события, не из картинки
ЗАБРАТЬ

Требуется от CRM до запуска

Свойства streak_reward_value и streak_reward_currency из Streak Reward Unlocked ещё не заведены. Сначала проверить, что rewardJson в событии — объект, а не строка: если строка, путь $.rewardJson.object.value не извлечётся и нужны плоские поля от бэкенда.

"streak": "{\"view\":\"reward\",\"pos\":7,
            \"metric\":\"+10 USDT\",
            \"label\":\"ЗА НЕДЕЛЬНУЮ СЕРИЮ\"}"
broken

Серия прервалась

Streak Progress Reset

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

Свёрнуто
Swipix ·0/7·сейчас
Серия из 12 дней прервалась
Новая начинается с первого бокса сегодня.
Раскрыто
Swipix ·0/7·сейчас
ДНЕЙ ПОДРЯД ДО СБРОСА
Серия прервалась
Новая начинается с первого бокса сегодня.
12 34 5×
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для timerutc_midnight · local_midnight · epoch-msМомент сгорания. Дату считает клиент.
metricдля reward / broken≤ 12 симв.Крупное число: сумма награды или потерянные дни.
labelнет≤ 40 симв.Подпись под баром, таймером или метрикой.

Плюс один плоский ключ strk_update: "1" — тихая правка висящего уведомления. Семь ячеек в payload не передаются: два целых и enum описывают любое состояние серии.

§ 08

Бюджеты и версионный гейт

Payload — лимит FCM 4096 байт

Частьбайт
notification с экранированием230
image95
icon100
buttons — две кнопки с градиентами360
streak — максимальный150
strk_update18
df_message_id + hub_link480
итого≈ 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.012 133
1.13.19 063
1.16.09 020
1.14.07 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

Реализовать

  1. Разбор ключа streak — fail-soft, как у buttons.
  2. Один лейаут notification_streak_expanded.xml, вид выбирается видимостью блоков; DecoratedCustomViewStyle + setCustomBigContentView. Кнопки остаются вашим существующим рендером под телом.
  3. Полоска: 7 TextView с layout_weight=1, фон через setInt(id,"setBackgroundResource",r), шесть локальных <shape>.
  4. Свёрнутый вид — стандартный шаблон плюс нативный стейт по виду (setProgress / отсчёт в хедере / setSubText).
  5. Chronometer с пересчётом базы в elapsedRealtime(); на API < 24 строка скрывается.
  6. Правка на месте по strk_update: setOnlyAlertOnce(true) + тот же notificationId.
  7. При fontScale > 1.3 полоска скрывается.

Не просим

Кастомный свёрнутый вид, автопоказ раскрытого, позицию в шторке, анимацию, видео, четвёртую кнопку, inline-reply, iOS. Heads-up-лейаут — опционально, вне минимального ТЗ.

Приёмка

#Проверка
1week: pos ячеек закрашено, pos+1 в обводке, последняя — звезда
2progress: прогресс-бар виден без раскрытия
3второй пуш со strk_update=1 меняет то же уведомление без звука, второго не появляется
4timer: в свёрнутом хедере тикает отсчёт
5timer: ±1 мин после 30 мин сна устройства
6reward / broken: метрика и крест на нужной ячейке
7кнопки из buttons работают как в 1.17.0 — тело их не задевает
8пуш без ключа streak — бит-в-бит как в 1.17.0
9битый JSON в streak — как п. 8, без крэша
10светлая системная тема: текст читается
11арабская локаль: полоска справа налево, текст не обрезан
12системный шрифт 200%: полоска скрыта, тело не обрезано
13матрица: Android 8 / 10 / 12 / 14 / 15 × One UI, HyperOS, EMUI, «чистый»
§ 10

Что нужно от кого

ВладелецЗадачаБлокируетСтатус
Мост (Илья)Проброс image/icon/buttonsвсю очередь 1не начато
КлючинскийРоутится ли 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_valueprogress, rewardнаша работа
CRMЖурнеи STRK_Box_Progress_v1, STRK_At_Risk_v12 новых сценариянаша работа
CRMИсключить тихие обновления из touch-budgetprogressнаша работа
Осторожно с новыми журнеями

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, три живых вида переезжают на нативное телоприёмка § 09 пройдена
4Новые свойства и журнеи, виды progress и timerответы бэкенда, размер входа измерен