diff --git a/PRIVACY.md b/PRIVACY.md
index b258f2e..cb3c9a2 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -1,10 +1,11 @@
# Privacy Policy
-Last updated: October 15, 2026.
+Last updated: October 19, 2026.
Message487 forwards selected notifications and new incoming SMS to a webhook you configure.
-Its core SMS feature transfers new incoming SMS from Android to your n8n workflow so you can
-view them on a computer.
+Notification events can include titles, text, image attachments and optional avatars or app icons.
+SMS events include text, sender and time. Your n8n workflow or another HTTPS receiver decides
+how to process or forward these events; the app does not depend on Telegram.
Both capture sources are off by default. Notifications require Android notification access and
selection of source apps. Optional automatic selection of newly detected launcher apps is off by default;
when enabled, it runs when you open or return to Message487 and preserves manual exclusions.
diff --git a/README.md b/README.md
index fbcbd20..e1c870d 100644
--- a/README.md
+++ b/README.md
@@ -10,8 +10,9 @@
Message487 connects selected Android notifications and incoming SMS to your n8n workflows.
-A custom webhook is also supported. Telegram forwarding is one possible workflow; the Android
-app does not depend on Telegram.
+Forward notification text and images, with optional sender avatars and app icons, or enable SMS
+forwarding separately. A custom HTTPS webhook is also supported. Telegram forwarding is one
+possible workflow; the Android app does not depend on Telegram.
**Status:** development preview with notification/SMS capture, a persistent private outbox,
background delivery, automatic retries and a delivery journal. Webhook requests require a Bearer token, stored encrypted on the device. Signed APK release automation is configured; see [Releases](docs/en/releases.md) and [release automation](docs/en/release-automation.md), and [release testing](docs/en/release-testing.md).
@@ -193,7 +194,9 @@ PR CI has no release signing credentials. It runs JVM/lint checks and instrument
on disposable API 26 and API 35 emulators. JUnit results appear in GitHub Checks; debug and
unsigned release APK links are kept in the PR description. See [testing](docs/en/testing.md).
For signed APK releases, see [Releases](docs/en/releases.md) and [release automation](docs/en/release-automation.md), and [release testing](docs/en/release-testing.md).
-Store graphics and their provenance are documented in [Branding](assets/branding/README.md).
+English and Russian store descriptions for F-Droid and Google Play share the canonical
+[Fastlane metadata](fastlane/metadata/android). Store graphics and their provenance are documented
+in [Branding](assets/branding/README.md).
See the [project context](docs/en/project-context.md) for remaining product decisions.
This project succeeds [sms487](https://github.com/andre487/sms487).
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
index 6388020..ae3397f 100644
--- a/app/src/main/res/drawable/ic_launcher_background.xml
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -1,15 +1,11 @@
-
+
-
-
-
-
-
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
index 09f63e5..2beb164 100644
--- a/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -1,16 +1,8 @@
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml
index e890330..a81c434 100644
--- a/app/src/main/res/drawable/ic_notification.xml
+++ b/app/src/main/res/drawable/ic_notification.xml
@@ -1,6 +1,3 @@
-
-
+
+
diff --git a/assets/branding/README.md b/assets/branding/README.md
index a33fd3d..7943add 100644
--- a/assets/branding/README.md
+++ b/assets/branding/README.md
@@ -20,30 +20,69 @@ Sizes: square/round icon 512×512, status icon 24×24, feature graphic 1024×500
The round and status PNGs retain transparency; the white status mark needs a contrasting background.
Android adaptive layers live in `app/src/main/res/drawable/ic_launcher_{background,foreground}.xml`.
-The adaptive foreground and round SVG use scale `0.65` about the center of their 1254-unit
-viewport for extra circular-mask padding. The square SVG retains scale `0.73`.
+The adaptive foreground follows the square SVG at scale `0.73` about the center of its
+1254-unit viewport. The separate round SVG retains scale `0.65` for extra circular-mask padding.
Both SVGs share the Android paths and gradient, cropped to the central 836×836 viewport.
-Keep these sources synchronized when changing the artwork.
+The SVG files remain separate editable sources; regenerate derived files after editing them.
The themed launcher and notification small icon share `ic_notification.xml`, the simplified
white monitor/phone silhouette without envelopes or gradient. Update notifications use this
small icon. The app UI retains its purple Material theme and standard Material icons.
-Export PNGs with the installed `rsvg-convert` utility:
+Generate all derived assets from the **separate** SVG sources:
```sh
-for name in icon icon-round status-icon feature; do
- rsvg-convert "assets/branding/$name.svg" -o "assets/branding/$name.png"
-done
-rsvg-convert assets/branding/icon.svg -o fastlane/metadata/android/en-US/images/icon.png
-rsvg-convert assets/branding/feature.svg -o /tmp/message487-feature.png
-magick /tmp/message487-feature.png -background white -alpha remove -alpha off -type TrueColor fastlane/metadata/android/en-US/images/featureGraphic.png
+python3 scripts/generate-branding.py
```
-For launcher PNGs use `rsvg-convert -w SIZE -h SIZE` with `icon.svg` or `icon-round.svg`.
-Sizes by density: mdpi 48, hdpi 72, xhdpi 96, xxhdpi 144, xxxhdpi 192.
+Requires `rsvg-convert` and ImageMagick's `magick`. Run from any directory.
+Use `--output-root /tmp/message487-branding` to inspect exports without overwriting repository files.
+
+| Source | Generated assets |
+| --- | --- |
+| `icon.svg` | Square branding PNG, opaque RGB store icon, square launcher PNGs, adaptive foreground/background XML |
+| `icon-round.svg` | Transparent round branding PNG, round launcher PNGs |
+| `status-icon.svg` | Transparent 24×24 PNG and `ic_notification.xml` |
+| `feature.svg` | Branding banner PNG and opaque RGB store feature graphic |
+
+Launcher PNG sizes: mdpi 48, hdpi 72, xhdpi 96, xxhdpi 144, xxxhdpi 192.
+The script preserves each source's layout, including the square/round scale difference.
+The Android vector exporter supports the existing path fill/stroke attributes, the square
+artwork's `translate(x y) scale(s)` transform, and its diagonal background gradient.
+Unsupported path features or gradient changes cause an error instead of silently losing details.
+Adaptive-icon and monochrome-inset XML files only reference these generated drawables and
+remain static. Samsung previews are optional and use the separate command below.
+
SVGs are self-contained: no external images, fonts, scripts or raster embeddings.
The feature graphic is promotional artwork, not an app screenshot.
+Preview the adaptive artwork with an approximate Samsung-style rounded-square mask:
+
+```sh
+python3 scripts/preview-samsung-icon.py --source assets/branding/icon.svg /tmp/message487-samsung.png
+# Edit the source SVG, then rerun the same command to regenerate the PNG.
+# Use an .svg output path instead to generate a masked vector preview.
+```
+
+Output defaults to 140×140 pixels, approximately the icon size in the supplied screenshot.
+Use `--size 512` for a larger preview.
+SVG output uses only Python's standard library; PNG output also needs `rsvg-convert`.
+Use `--source` to choose another SVG; its `viewBox` defines the mask bounds.
+By default the preview reuses `icon.svg`, which shares the adaptive foreground's scale and
+position. Keep it synchronized with the Android layers as described above. The mask
+approximates the supplied Samsung screenshot. Shadow radii (1/96 and 1/48 of icon size),
+alpha (41/255 and 26/255), and 0.96 artwork scale follow the published
+[SemAppIconSolution implementation](https://github.com/488315/samsung_framework/blob/master/framework/sources/android/app/SemAppIconSolution.java).
+SVG Gaussian blur is not identical to Android's Skia shadow rendering. The inner highlight
+and shade are a visual approximation, not a recovered Samsung bevel algorithm.
+Use `--bevel 0.3` to adjust edge strength (0–1), or `--flat` for the mask alone.
+Themes, night-mode tint and animations are not reproduced.
+
+For a device-rendered reference, [One UI Icon Extractor](https://github.com/Palmoe/OneUI-Icon-Extractor)
+uses Samsung's `semGetApplicationIconForIconTray` / `semGetActivityIconForIconTray` APIs
+on a Samsung device, retaining One UI effects. Compare its export from the target phone
+with this preview before claiming an exact match. This is a preview, not another Android
+launcher resource.
+
Earlier artwork originated from
[andre487/sms487](https://github.com/andre487/sms487/tree/d4aca0724c4d8c8cfcfe128c6df6cc93f64625f2/client/app/src/main)
under the [sms487 MIT license](sms487-LICENSE); the original license is retained.
diff --git a/docs/en/project-context.md b/docs/en/project-context.md
index edc9b89..e6f11b0 100644
--- a/docs/en/project-context.md
+++ b/docs/en/project-context.md
@@ -2,13 +2,14 @@
[English](../en/project-context.md) | [Русский](../ru/project-context.md)
-Updated September 8, 2026.
+Updated October 19, 2026.
## Confirmed by the user
- A new Android application replaces sms487 in a separate repository.
- Package ID: `life.andre.message487`.
-- Primary positioning is n8n integration; arbitrary webhooks must also be supported.
+- Primary positioning is forwarding selected Android notifications, images and new incoming SMS
+ to n8n workflows; arbitrary HTTPS webhooks are also supported.
- Project presentation, code style, privacy policy, documentation and CI follow
[AndroidMegaProxy](https://github.com/andre487/AndroidMegaProxy).
@@ -45,13 +46,21 @@ Compatibility with that protocol was not agreed as a requirement for the new app
## Capture and delivery
NotificationListenerService uses a package selection; SMS_RECEIVED uses RECEIVE_SMS and
-goAsync. Both sources default to off. Existing SMS history is not read. Duplicate SMS and notifications
-are suppressed by source package, exact original timestamp and text. The Sources switch is on
-by default and allows opting out. Group summaries, ongoing notifications
-and Message487's own notifications are excluded.
-
-Events are persisted in SQLite before delivery; request bodies use AES-GCM with Android
-Keystore. WorkManager retries transient failures; a periodic recovery task restores scheduling.
+goAsync. Both sources default to off, with no apps selected. Existing SMS history and existing
+notifications are not imported. Duplicate filters for SMS and notifications are independently
+on by default. Matching uses the source package, exact text, image checksum and original
+timestamps within a configurable inclusive time window (1 second by default). Titles and SMS
+senders are excluded from the key. Group summaries, ongoing notifications and Message487's
+own notifications are excluded.
+
+Notifications can include image attachments and optional avatars/app icons. JPEG/PNG, quality
+and output limits are configurable. See [README](../../README.md#delivery-and-data) for the
+payload contract, image handling and duplicate-filter details.
+
+Events are persisted before delivery. SQLite holds metadata and file references; request text
+and credentials are AES-GCM encrypted in private files with Android Keystore. Images are
+separate private files without additional app-level encryption.
+WorkManager retries transient failures; a periodic recovery task restores scheduling.
Event ID, contents, URL and acknowledgement mode are captured together. Changing the connection
does not redirect existing queued events. Invalid ACKs and permanent HTTP errors require manual
retry. Unconfirmed events are not deleted by age; confirmed payloads are removed while a bounded
@@ -61,8 +70,7 @@ attempts; a running request may complete. Backup and device transfer exclude app
Limitations: capture depends on Android, and the process may die before local persistence.
WorkManager does not promise immediate delivery. Sensitive-notification restrictions are not
bypassed. Physical erasure of SQLite pages is not guaranteed. Deduplication hashes persist until app data is cleared, independently of journal cleanup.
-The history starts with new captures after upgrading to database version 2. This does not replace
-server-side deduplication by event ID.
+This does not replace server-side deduplication by event ID.
## Product direction
@@ -79,8 +87,8 @@ viewing journal message contents are not implemented yet.
journal with contents hidden by default and manual retry.
- Webhook acceptance is distinct from delivery to Telegram or another downstream service.
Unconfirmed events are not silently removed by age.
-- Diagnostics exclude secrets and message contents. Encryption, backup and retention requirements
- must be decided before implementing storage.
+- Diagnostics exclude secrets and message contents. Current encryption, backup and retention
+ behavior is documented in the [privacy policy](../../PRIVACY.md).
n8n has test and production URLs; persistent integrations use a published workflow's URL.
`Immediately` acknowledges workflow startup, not completion of its actions. See the
diff --git a/docs/reviews/duplicates-2026-10-07.md b/docs/reviews/duplicates-2026-10-07.md
new file mode 100644
index 0000000..06e7711
--- /dev/null
+++ b/docs/reviews/duplicates-2026-10-07.md
@@ -0,0 +1,195 @@
+# Исследование дубликатов — 2026-10-07
+
+Ветка: `investigate/duplicate-delivery`, база `5558816` (`main`, PR #25).
+
+## Вывод
+
+Основной подтверждённый механизм: один webhook с неизменным `event_id` приходит
+повторно, а workflow каждый раз отправляет новое сообщение Telegram. Дедупликация
+захвата в Android не предназначена для подавления повторных попыток доставки уже
+созданного события. В рабочем workflow отсутствует идемпотентная обработка `event_id`.
+
+Дополнительно получена диагностика телефона: в сопоставленных примерах приложение
+записывает `NETWORK_ERROR` после первой попытки и повторяет доставку, пока не
+получит `ACCEPTED`. Точный вид сетевой ошибки не установлен: исключение в этой
+ветке WebhookClient не сохраняется. Успех узла Respond не доказывает получение
+ACK телефоном.
+
+## Метод и охват
+
+Прочитаны предоставленный экспорт `Message487 · receive.json`, код захвата,
+Outbox, DeliveryWorker, WebhookClient и история изменения дедупликации.
+Через разрешённый SSH доступ прочитаны `execution_entity` и `execution_data`
+workflow `GakWWqJ3jhCoNihC` в SQLite n8n 2.38.4. БД открыта с `sqlite3.OPEN_READONLY`,
+сохранённые данные декодированы штатной библиотекой n8n `flatted`.
+
+Проверены все 440 доступных сохранённых executions за период
+2026-09-23 09:07:57.878 — 2026-10-07 08:34:38.878 UTC. Это сохранённая история,
+а не гарантия полноты всех когда-либо принятых запросов.
+Тела событий и текст Telegram сравнивались на сервере по SHA-256;
+содержимое сообщений, отправители, токены и cookies в репозиторий не перенесены.
+Сервер, workflow и данные не изменялись; тестовые сообщения не отправлялись.
+
+## Повторная доставка одного события
+
+| Метрика | Результат |
+| --- | --- |
+| Сохранённые executions | 440, все `success` |
+| Уникальные `event_id` | 357 |
+| События с несколькими executions | 49: 48 notification, 1 SMS |
+| Дополнительные executions сверх первого | 83 |
+| Максимум executions одного события | 6 |
+| Тела webhook внутри каждой группы | Полностью одинаковые |
+| Отправки Telegram в каждом повторном execution | Одна, успешная |
+| Telegram `message_id` внутри каждой группы | Разные |
+| Текст Telegram внутри каждой группы | Одинаковый |
+
+Пример: [execution 893](https://n8n.jethelix.ru/workflow/GakWWqJ3jhCoNihC/executions/893)
+и [894](https://n8n.jethelix.ru/workflow/GakWWqJ3jhCoNihC/executions/894).
+Один `event_id`, одинаковое тело, `occurred_at = 2026-10-07T07:11:09.071Z`.
+
+| Execution | Начало, UTC | Длительность | Telegram message_id |
+| --- | --- | --- | --- |
+| 893 | 07:11:10.254 | 2258 мс | 40018 |
+| 894 | 07:11:51.088 | 2147 мс | 40019 |
+
+Ещё одно событие поступило в executions 873–876 четырежды. Первые три запроса
+начались 6 октября в 11:01:18.513, 11:01:48.463 и 11:02:18.513 UTC,
+четвёртый — в 11:21:03.643. Telegram создал сообщения 39998–40001.
+Все executions имеют `mode = webhook`, `retryOf = null`: это новые входящие
+HTTP-запросы, а не сохранённый n8n retry execution. User-Agent соответствует
+Android HttpURLConnection; один идентификатор устройства во всей выборке.
+Автоматический повтор, ручной повтор и повтор после восстановления очереди
+по одним серверным данным однозначно не различить.
+
+Запуски 896 и 897, совпадающие по отображаемой секунде, дубликатами этой категории
+не являются: различаются `event_id`, текст, заголовок и точное время события.
+Совпадение времени в списке executions само по себе не является свидетельством дубля.
+
+## Два близких захвата с разными ID
+
+Найдены также две пары одинакового текста из одного источника и устройства
+с разными ID и разницей `occurred_at` не более секунды:
+
+| Executions | Дата | Разница времени | Текст Telegram |
+| --- | --- | --- | --- |
+| 800 / 801 | 4 октября | 313 мс | Одинаковый |
+| 810 / 811 | 4 октября | 309 мс | Одинаковый |
+
+Обе пары — notifications `com.domonap.app`. Формат Telegram округляет время
+до секунд, поэтому различие миллисекунд пользователю не видно.
+До коммита `e0ae568` от 5 октября дедупликация требовала точного совпадения
+времени. Эти пары совместимы с прежней логикой; версия приложения и настройки
+на телефоне в тот момент по executions не определяются.
+В текущем коде при включённой дедупликации и окне 1 секунда такие события
+должны подавляться. Событий с разными ID и полностью одинаковыми устройством,
+источником, текстом и точным временем в выборке нет.
+
+## Диагностика телефона
+
+Прочитаны `diagnostic.log`, `environment.txt` и пустой `crash-latest.log` из
+предоставленного каталога `message487-diagnostics-0dbe63c5-b8aa-4ee4-b939-bdcb9cdcf462`.
+Архив указывает release 1.0.0 (versionCode 6), Android 16/API 36, ACK включён,
+приложение не на паузе. Лог содержит 4187 строк с 9 сентября по 7 октября;
+версия в environment описывает состояние при экспорте, а не каждый старый запуск.
+
+Для executions 893/894 последовательность телефона однозначна по времени:
+
+| Время телефона, UTC, 7 октября | Событие |
+| --- | --- |
+| 07:11:09.287605 | Единственный `EVENT_QUEUED` |
+| 07:11:09.304012 | `DELIVERY_STARTED`, первая попытка |
+| 07:11:20.750003 | `DELIVERY_FINISHED outcome=NETWORK_ERROR`, HTTP-кода нет |
+| 07:11:21.107714 | `DUPLICATE_SKIPPED`: повторный захват подавлен |
+| 07:11:50.944631 | `DELIVERY_STARTED`, повтор доставки |
+| 07:11:53.270662 | `DELIVERY_FINISHED outcome=ACCEPTED http=200` |
+
+Первая попытка телефона заняла около 11.446 секунды, хотя execution 893
+успешно завершился на сервере в 07:11:12.512. Повтор соответствует execution 894.
+Нового `EVENT_QUEUED`, ручного повтора, старта приложения или падения в этом
+интервале нет. Это подтверждает автоматический повтор после сетевой ошибки,
+при этом повторный захват уведомления отдельно был подавлен.
+
+Для executions 873–876 (6 октября) телефон записал:
+
+| Начало попытки, UTC | Конец попытки, UTC | Результат |
+| --- | --- | --- |
+| 11:01:18.332911 | 11:01:20.018022 | `NETWORK_ERROR` |
+| 11:01:48.362791 | 11:01:51.209457 | `NETWORK_ERROR` |
+| 11:02:18.362734 | 11:02:26.354947 | `NETWORK_ERROR` |
+| 11:21:03.431957 | 11:21:05.831900 | `ACCEPTED`, HTTP 200 |
+
+Таким образом, n8n отправил одно событие четырежды, пока телефон не зафиксировал
+успешный ACK. Это не ручные повторы: в соответствующем интервале нет `MANUAL_RETRY`.
+В журнале после 5 октября 16:00 UTC всего 10 результатов `NETWORK_ERROR`
+и 37 `ACCEPTED`; TIMEOUT и INVALID_ACK в этом интервале нет.
+
+ID события в диагностическом логе намеренно отсутствует, поэтому связь с n8n
+установлена по изолированным последовательностям времени, а не по ID из лога.
+При нескольких параллельных отправках такое сопоставление неоднозначно.
+
+## ACK и ограничения диагностики
+
+Рабочая цепочка: Webhook → Prepare Telegram text → Telegram → Respond.
+Respond настроен на JSON, HTTP 200 и
+`{ status: 'accepted', event_id: $('Webhook').first().json.body.event_id }`.
+Выражение использует исходный Webhook, а не результат Telegram. Ошибка ссылки
+на `$json.body` в проверенных конфигурациях отсутствует.
+
+WebhookClient использует таймауты подключения и чтения по 10 секунд.
+Outbox сохраняет исходные payload и `event_id` между попытками; TIMEOUT и
+NETWORK_ERROR ведут к RETRY. WorkManager использует уникальную работу на событие
+и экспоненциальную задержку с начальным значением 30 секунд.
+
+Из 49 первых executions повторявшихся событий только два длились не менее
+10 секунд; диапазон длительности 515–12756 мс. Поэтому объяснять все повторы
+медленным workflow нельзя. Полученный клиентский лог подтверждает сетевые ошибки
+в сопоставленных примерах, но не определяет их уровень: TCP, TLS, чтение ответа
+или другая IOException. В WebhookClient IOException преобразуется в NETWORK_ERROR
+без записи типа исключения и этапа запроса; безопасный стек для этой ошибки
+не попадает в DiagnosticLog. По этим данным нельзя выбрать конкретное исправление
+транспорта или обвинить Caddy, n8n либо мобильную сеть.
+
+Входной Caddy содержит только `reverse_proxy 127.0.0.1:5678`; access log
+не настроен. В его journal за интервал 07:10–07:13 UTC 7 октября записей нет.
+Сохранённый output Respond содержит входные данные Telegram, а не фактически
+полученный телефоном HTTP-ответ. Сам execution не позволяет проверить получение ACK.
+
+## Следующие действия
+
+1. Для защиты от доказанного механизма нужен постоянный учёт `event_id` на
+ принимающей стороне с уникальным ограничением БД. Уже обработанный ID должен
+ получить тот же успешный ACK без повторной отправки Telegram. Простая проверка
+ «найти, затем вставить» не защищает от одновременных запросов.
+2. Если ACK переносится перед Telegram, сначала необходимо надёжно сохранить
+ событие в очередь. Простой перенос Respond вперёд создаст риск потери сообщения
+ после удаления payload из очереди Android. Для больших сообщений нужен также
+ учёт отправленных частей. Сбой между успешной отправкой Telegram и фиксацией
+ результата всё ещё оставляет неоднозначность; Telegram sendMessage не даёт
+ гарантии exactly-once по нашему `event_id`.
+3. Клиентский архив уже подтвердил NETWORK_ERROR для проверенных повторов.
+ Для уточнения сетевой причины следующая минимальная доработка диагностики —
+ сохранять безопасный тип/стек IOException и фиксированный этап запроса,
+ без текстов исключений, URL, токенов и payload. Существующий safeStackTrace
+ позволяет не добавлять произвольные сообщения исключений. После успешного
+ повтора прежний outcome в записи очереди заменяется последним.
+4. Проверить установленную версию приложения и включённое окно дедупликации
+ уведомлений. Поведение двух пар от 4 октября не доказывает дефект текущей версии.
+
+В рамках исследования исправления Android и рабочего n8n не применялись.
+Увеличение таймаута или отключение повторов не устраняет отсутствие
+идемпотентности; отключение повторов создаст риск потери сообщений.
+
+## Локальная проверка
+
+```sh
+source scripts/java-toolchain.sh
+./gradlew :app:testDebugUnitTest \
+ --tests life.andre.message487.OutboxTest \
+ --tests life.andre.message487.WebhookClientTest
+```
+
+PASS: 21 существующий тест (13 Outbox, 8 WebhookClient), включая сохранение
+payload/идентичности при повторе, окно дедупликации, таймаут и валидацию ACK.
+Это проверка текущего локального кода, а не доказательство поведения установленного
+на телефоне APK или доставки HTTP-ответа через сеть.
diff --git a/docs/ru/project-context.md b/docs/ru/project-context.md
index f4240a9..2ed9e53 100644
--- a/docs/ru/project-context.md
+++ b/docs/ru/project-context.md
@@ -2,13 +2,14 @@
[English](../en/project-context.md) | [Русский](../ru/project-context.md)
-Обновлено 8 сентября 2026 года.
+Обновлено 19 октября 2026 года.
## Подтверждено пользователем
- Новое Android-приложение заменяет sms487 в отдельном репозитории.
- ID пакета: `life.andre.message487`.
-- Основное позиционирование — интеграция с n8n; произвольный webhook также должен поддерживаться.
+- Основное позиционирование — передача выбранных уведомлений Android, изображений и новых
+ входящих SMS в сценарии n8n; произвольный HTTPS-webhook также поддерживается.
- Оформление, стиль кода, политика конфиденциальности, документация и CI ориентируются на
[AndroidMegaProxy](https://github.com/andre487/AndroidMegaProxy).
@@ -44,12 +45,19 @@
## Захват и доставка
Реализованы NotificationListenerService с выбором пакетов и SMS_RECEIVED с RECEIVE_SMS и goAsync.
-Оба источника выключены по умолчанию. История SMS не читается. Дубли SMS и уведомлений
-подавляются по пакету приложения, точному исходному времени и тексту. Фильтр включён по умолчанию;
-его можно отключить на экране источников. Сводки групп, постоянные уведомления
-и собственные уведомления Message487 исключены.
-
-Событие сохраняется в SQLite до отправки; тело запроса зашифровано AES-GCM с Android Keystore.
+Оба источника выключены по умолчанию, приложения не выбраны. История SMS и существующие
+уведомления не импортируются. Фильтры дублей SMS и уведомлений включены независимо по умолчанию.
+Сравниваются пакет источника, точный текст, контрольная сумма изображений и исходные метки
+времени в настраиваемом включительном окне (по умолчанию 1 секунда). Заголовки и отправители SMS
+не входят в ключ. Сводки групп, постоянные уведомления и собственные уведомления Message487 исключены.
+
+Уведомления могут содержать вложенные изображения и необязательные аватары/иконки приложений.
+Настраиваются JPEG/PNG, качество и ограничения размера. Контракт событий, обработка изображений
+и подробности фильтра дублей описаны в [README](../../README.md#delivery-and-data).
+
+Событие сохраняется до отправки. SQLite хранит метаданные и ссылки на файлы; текст запроса
+и учётные данные зашифрованы AES-GCM с Android Keystore в приватных файлах. Изображения
+хранятся отдельно в приватных файлах без дополнительного шифрования приложения.
WorkManager отправляет с повторами при временных ошибках; отдельная периодическая задача
восстанавливает планирование сохранённых событий. event_id, содержимое, URL и режим подтверждения
фиксируются при захвате. Смена подключения не перенаправляет очередь. Неверный ACK и постоянные
@@ -61,8 +69,7 @@ HTTP-ошибки требуют ручного повтора. Неподтве
Ограничения: захват зависит от Android, процесс может завершиться до локального сохранения;
WorkManager не гарантирует немедленную доставку. Системные ограничения на чувствительные
уведомления не обходятся. Физическая очистка страниц SQLite не гарантируется. Хеши дедупликации
-сохраняются до очистки данных приложения независимо от удаления записей журнала. После обновления
-БД до версии 2 история начинается с новых событий. Это не заменяет серверную дедупликацию по event_id.
+сохраняются до очистки данных приложения независимо от удаления записей журнала. Это не заменяет серверную дедупликацию по event_id.
## Направление продукта
@@ -79,8 +86,8 @@ WorkManager не гарантирует немедленную доставку.
успешной отправкой. Журнал со скрытым по умолчанию содержимым и ручным повтором.
- Успех приёма webhook отделён от доставки в Telegram или другой конечный сервис.
Очередь не удаляет неподтверждённые события молча по возрасту.
-- Секреты и содержимое сообщений не попадают в диагностику. Требования к шифрованию,
- резервному копированию и срокам хранения определяются до реализации хранения.
+- Секреты и содержимое сообщений не попадают в диагностику. Текущее поведение шифрования,
+ резервного копирования и хранения описано в [политике конфиденциальности](../../PRIVACY.md).
У n8n есть test и production URL; постоянная интеграция использует URL опубликованного workflow.
Режим ответа `Immediately` подтверждает запуск workflow, а не завершение его действий.
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
index 590f305..187e7aa 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -1,23 +1,33 @@
-Read new SMS from your Android phone on your computer through your own n8n workflow. Message487 transfers incoming SMS text, sender and time to the HTTPS webhook you choose. This is one-way transfer of new messages: it does not import SMS history, send SMS or provide a two-way messaging inbox.
+Message487 connects selected Android notifications and new incoming SMS to your n8n workflows or another HTTPS webhook. Forward notification text and images, automate actions, or route messages to Telegram through your own workflow.
-HOW TO READ SMS ON YOUR COMPUTER
-1. Set up and publish an n8n webhook workflow with Bearer-token authentication.
-2. In Message487 Connection, save its HTTPS URL and token. In Sources, turn on Incoming SMS and grant the receive-SMS permission.
-3. Receive a new SMS on the phone. On your computer, open the workflow's Executions in n8n and view the received message text and sender. Compare the event ID with Message487 Journal.
-The Journal shows delivery status, not message contents. Notification access is not needed for SMS transfer. Without RECEIVE_SMS, Message487 cannot capture and transfer new incoming SMS; its separate notification feature can still be used.
+WHAT YOU CAN FORWARD
-You supply the n8n service or another compatible HTTPS receiver and token. Message487 does not include a hosted inbox or an n8n subscription. Another receiver does not require an n8n account.
+• Notifications from apps you select, including available titles, text and image attachments.
+• Optional sender avatars and source app icons. Choose JPEG or PNG, image quality and size limits.
+• New incoming SMS with text, sender and time. SMS forwarding is enabled separately and does not need notification access.
-OTHER FEATURES
+GET STARTED
-• Choose which applications can forward notifications, or select all visible launcher apps.
-• Authenticate requests with a Bearer token and optionally require an event acknowledgement.
-• Keep events in an encrypted local outbox and retry temporary delivery failures.
-• Pause forwarding, review delivery status, retry or delete queued events.
-• Identify your phone using a custom device code.
-• Use light or dark themes and English or Russian interfaces.
-• View rotating local diagnostics and optionally share a report by email.
+1. Prepare a published n8n webhook workflow or another HTTPS endpoint with Bearer-token authentication.
+2. In Connection, save the full webhook URL, token and your device code. Send a test event and check Journal and your receiver.
+3. In Sources, enable notification forwarding, grant Android notification access and select apps. You can select all visible launcher apps, add a package manually, or opt into selecting newly detected launcher apps automatically.
+4. If you also want SMS, enable Incoming SMS and grant the receive-SMS permission.
-Notification and SMS capture are off by default. Android notification access and SMS permission are requested for the corresponding features. Android may hide sensitive notification content. The app lists visible launcher apps rather than requesting access to the entire installed-app inventory.
+You supply the receiver and token. Message487 does not include a hosted inbox or an n8n subscription. A custom webhook does not require an n8n account. Telegram forwarding is configured in your workflow; the Android app does not depend on Telegram. Images are sent in the JSON event and need processing in the receiving workflow.
-You need your own HTTPS endpoint and token. The app has no advertising, analytics or automatic crash uploads. Your endpoint and downstream services receive forwarded message contents and apply their own retention policies. Retried requests can produce duplicates; configure deduplication on your server if needed.
+DELIVERY AND CONTROL
+
+• Save events in a private local outbox before sending and retry temporary delivery failures in the background.
+• Filter duplicate SMS and notifications independently, with a configurable time window.
+• Pause capture and queued delivery, check delivery status, and manually retry or delete events in Journal.
+• Identify your phone using a custom device code. Retries preserve the event ID so your receiver can deduplicate requests.
+• Use English or Russian interfaces and light or dark themes.
+• View local diagnostics and optionally share a report by email.
+
+Journal shows delivery metadata, not message contents or image previews. Webhook acceptance does not prove delivery to Telegram or another downstream service. Android battery and network restrictions can delay delivery, and lost responses can cause duplicate requests.
+
+PERMISSIONS AND PRIVACY
+
+Notification and SMS capture are off by default, with no apps selected. Each source needs its corresponding Android access or permission. Only new events are captured; existing notifications and SMS history are not imported. Message487 does not send SMS or reply to notifications. Android may hide sensitive notification content; only content exposed by the system can be forwarded.
+
+Queued message text and request credentials are encrypted on the device. Images are kept as private internal files without additional app-level encryption. Your endpoint and downstream services receive forwarded contents and apply their own retention policies. The app has no advertising, analytics or automatic crash uploads.
diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png
index d68e97d..1b64f8c 100644
Binary files a/fastlane/metadata/android/en-US/images/featureGraphic.png and b/fastlane/metadata/android/en-US/images/featureGraphic.png differ
diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png
index 7fc0b0b..48e4684 100644
Binary files a/fastlane/metadata/android/en-US/images/icon.png and b/fastlane/metadata/android/en-US/images/icon.png differ
diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt
index 964580a..ef14262 100644
--- a/fastlane/metadata/android/en-US/short_description.txt
+++ b/fastlane/metadata/android/en-US/short_description.txt
@@ -1 +1 @@
-Read phone SMS on your computer via n8n; forward selected notifications
+Forward Android notifications, images and SMS to n8n or your webhook
diff --git a/fastlane/metadata/android/ru-RU/full_description.txt b/fastlane/metadata/android/ru-RU/full_description.txt
index 5a4dc54..85f7861 100644
--- a/fastlane/metadata/android/ru-RU/full_description.txt
+++ b/fastlane/metadata/android/ru-RU/full_description.txt
@@ -1,23 +1,33 @@
-Читайте новые SMS своего Android-телефона на компьютере через собственный сценарий n8n. Message487 передаёт текст входящего SMS, отправителя и время на выбранный вами HTTPS-веб-хук. Это односторонняя передача новых сообщений: приложение не импортирует историю SMS, не отправляет SMS и не предоставляет двусторонний чат.
+Message487 передаёт выбранные уведомления Android и новые входящие SMS в ваши сценарии n8n или на другой HTTPS-веб-хук. Пересылайте текст и изображения из уведомлений, запускайте автоматизацию или направляйте сообщения в Telegram через собственный сценарий.
-КАК ПРОЧИТАТЬ SMS НА КОМПЬЮТЕРЕ
-1. Настройте и опубликуйте в n8n сценарий с веб-хуком и авторизацией по Bearer-токену.
-2. В разделе «Подключение» Message487 сохраните HTTPS-адрес и токен. В «Источниках» включите входящие SMS и предоставьте разрешение на их получение.
-3. Получите новое SMS на телефоне. На компьютере откройте Executions вашего сценария n8n и просмотрите полученные текст и отправителя. Сопоставьте идентификатор события с журналом Message487.
-Журнал показывает статус доставки, а не содержимое сообщения. Доступ к уведомлениям для передачи SMS не нужен. Без RECEIVE_SMS приложение не может получать и передавать новые входящие SMS; отдельная функция пересылки уведомлений остаётся доступна.
+ЧТО МОЖНО ПЕРЕСЫЛАТЬ
-Вы предоставляете сервис n8n либо другой совместимый HTTPS-получатель и токен. Message487 не включает готовый облачный почтовый ящик или подписку n8n. Для другого получателя аккаунт n8n не нужен.
+• Уведомления выбранных приложений: доступные заголовки, текст и вложенные изображения.
+• По желанию — аватары отправителей и иконки приложений. Настраивайте JPEG или PNG, качество и ограничения размера изображений.
+• Новые входящие SMS с текстом, отправителем и временем. Пересылка SMS включается отдельно и не требует доступа к уведомлениям.
-ДРУГИЕ ВОЗМОЖНОСТИ
+КАК НАЧАТЬ
-• Выберите приложения для пересылки уведомлений или все видимые приложения с иконкой запуска.
-• Используйте Bearer-токен и при необходимости подтверждение приёма события.
-• Храните события в зашифрованной локальной очереди с повторными попытками при временных сбоях.
-• Приостанавливайте пересылку, проверяйте статусы, повторяйте или удаляйте события.
-• Задайте собственное обозначение телефона.
-• Выберите светлую или тёмную тему, русский или английский интерфейс.
-• Просматривайте ротируемые локальные логи и по желанию отправляйте отчёт по почте.
+1. Подготовьте опубликованный сценарий n8n с веб-хуком или другой HTTPS-сервер с авторизацией по Bearer-токену.
+2. В «Подключении» сохраните полный адрес веб-хука, токен и код устройства. Отправьте тестовое событие и проверьте «Журнал» и принимающий сервер.
+3. В «Источниках» включите пересылку уведомлений, выдайте системный доступ и выберите приложения. Можно выбрать все видимые приложения с иконкой запуска, добавить пакет вручную или включить автоматический выбор новых обнаруженных приложений с иконкой запуска.
+4. Если нужны SMS, отдельно включите их приём и выдайте разрешение на получение SMS.
-Сбор уведомлений и SMS изначально выключен. Для соответствующих функций нужны доступ к уведомлениям и разрешение на получение SMS. Android может скрывать чувствительное содержимое уведомлений. Приложение показывает видимые приложения с иконкой запуска, не запрашивая доступ ко всему списку установленных пакетов.
+Вы предоставляете принимающий сервер и токен. Message487 не включает готовый облачный почтовый ящик или подписку n8n. Для собственного веб-хука аккаунт n8n не нужен. Пересылка в Telegram настраивается в вашем сценарии; Android-приложение не зависит от Telegram. Изображения передаются в JSON-событии и требуют обработки в принимающем сценарии.
-Нужны ваш HTTPS-сервер и токен. В приложении нет рекламы, аналитики и автоматической отправки отчётов о сбоях. Настроенный сервер и последующие сервисы получают содержимое сообщений и хранят его по своим правилам. Повторные попытки могут создавать дубликаты; при необходимости настройте дедупликацию на сервере.
+ДОСТАВКА И УПРАВЛЕНИЕ
+
+• События сохраняются в приватной локальной очереди до отправки; при временных сбоях приложение повторяет доставку в фоне.
+• Фильтры дублей SMS и уведомлений включаются независимо и используют настраиваемое временное окно.
+• Приостанавливайте сбор и отправку очереди, проверяйте статусы, повторяйте или удаляйте события в «Журнале».
+• Задайте собственный код устройства. При повторах сохраняется идентификатор события, по которому сервер может устранять дубли запросов.
+• Используйте русский или английский интерфейс, светлую или тёмную тему.
+• Просматривайте локальную диагностику и по желанию отправляйте отчёт по почте.
+
+Журнал показывает метаданные доставки, а не содержимое сообщений или изображения. Приём веб-хуком не доказывает доставку в Telegram или другой конечный сервис. Ограничения батареи и сети Android могут задерживать отправку, а потеря ответа сервера — приводить к повторным запросам.
+
+РАЗРЕШЕНИЯ И КОНФИДЕНЦИАЛЬНОСТЬ
+
+Сбор уведомлений и SMS изначально выключен, приложения не выбраны. Каждому источнику нужен соответствующий доступ или разрешение Android. Собираются только новые события: существующие уведомления и история SMS не импортируются. Message487 не отправляет SMS и не отвечает на уведомления. Android может скрывать чувствительное содержимое; пересылаются только доступные приложению данные.
+
+Текст сообщений и учётные данные запросов в очереди зашифрованы на устройстве. Изображения хранятся в приватных внутренних файлах без дополнительного шифрования приложения. Ваш сервер и последующие сервисы получают пересланное содержимое и хранят его по своим правилам. В приложении нет рекламы, аналитики и автоматической отправки отчётов о сбоях.
diff --git a/fastlane/metadata/android/ru-RU/short_description.txt b/fastlane/metadata/android/ru-RU/short_description.txt
index 8dcd5b2..75ba3a3 100644
--- a/fastlane/metadata/android/ru-RU/short_description.txt
+++ b/fastlane/metadata/android/ru-RU/short_description.txt
@@ -1 +1 @@
-Читайте SMS с телефона на компьютере через n8n. Пересылайте уведомления
+Пересылайте уведомления Android, изображения и SMS в n8n или свой веб-хук
diff --git a/scripts/generate-branding.py b/scripts/generate-branding.py
new file mode 100644
index 0000000..ad84d4e
--- /dev/null
+++ b/scripts/generate-branding.py
@@ -0,0 +1,212 @@
+"""Export the separate branding SVG sources to PNG and Android vector resources."""
+
+import argparse
+import re
+import shutil
+import subprocess
+import tempfile
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+BRANDING = ROOT / 'assets/branding'
+ANDROID = 'http://schemas.android.com/apk/res/android'
+AAPT = 'http://schemas.android.com/aapt'
+NS = {'s': 'http://www.w3.org/2000/svg'}
+
+
+def color(value):
+ if value == 'none':
+ return '#00000000'
+ if value == 'white':
+ return '#FFFFFFFF'
+ if re.fullmatch(r'#[0-9a-fA-F]{3}', value):
+ return '#FF' + ''.join(c * 2 for c in value[1:])
+ if re.fullmatch(r'#[0-9a-fA-F]{6}', value):
+ return '#FF' + value[1:]
+ raise ValueError(f'Unsupported SVG color: {value}')
+
+
+def vector(size, viewport):
+ return ET.Element(
+ 'vector',
+ {
+ f'{{{ANDROID}}}width': f'{size}dp',
+ f'{{{ANDROID}}}height': f'{size}dp',
+ f'{{{ANDROID}}}viewportWidth': str(viewport),
+ f'{{{ANDROID}}}viewportHeight': str(viewport),
+ },
+ )
+
+
+def paths(parent, elements):
+ attributes = {
+ 'd': 'pathData',
+ 'fill': 'fillColor',
+ 'fill-rule': 'fillType',
+ 'stroke': 'strokeColor',
+ 'stroke-width': 'strokeWidth',
+ 'stroke-linecap': 'strokeLineCap',
+ 'stroke-linejoin': 'strokeLineJoin',
+ }
+ # ponytail: handles our path-only artwork; reject new SVG features rather than silently dropping them.
+ for source in elements:
+ if (
+ source.tag != f"{{{NS['s']}}}path"
+ or source.attrib.keys() - attributes.keys()
+ ):
+ raise ValueError(
+ 'Android export supports only paths with fill/stroke attributes'
+ )
+ target = ET.SubElement(parent, 'path')
+ target.set(f'{{{ANDROID}}}fillColor', '#FF000000')
+ for key, value in source.attrib.items():
+ if key in ('fill', 'stroke'):
+ value = color(value)
+ elif key == 'fill-rule':
+ value = {'evenodd': 'evenOdd', 'nonzero': 'nonZero'}[value]
+ target.set(f'{{{ANDROID}}}{attributes[key]}', value)
+
+
+def android_vectors():
+ square_icon = ET.parse(BRANDING / 'icon.svg').getroot()
+ artwork = square_icon.find('s:g', NS)
+ transform = re.fullmatch(
+ r'translate\(([-\d.]+) ([-\d.]+)\) scale\(([-\d.]+)\)',
+ artwork.attrib['transform'],
+ )
+ if transform is None:
+ raise ValueError('Expected translate(x y) scale(s) on square icon artwork')
+ tx, ty, scale = transform.groups()
+ foreground = vector(108, 1254)
+ group = ET.SubElement(
+ foreground,
+ 'group',
+ {
+ f'{{{ANDROID}}}translateX': tx,
+ f'{{{ANDROID}}}translateY': ty,
+ f'{{{ANDROID}}}scaleX': scale,
+ f'{{{ANDROID}}}scaleY': scale,
+ },
+ )
+ paths(group, artwork)
+ background = vector(108, 108)
+ path = ET.SubElement(
+ background, 'path', {f'{{{ANDROID}}}pathData': 'M0,0H108V108H0Z'}
+ )
+ attr = ET.SubElement(path, f'{{{AAPT}}}attr', {'name': 'android:fillColor'})
+ gradient = ET.SubElement(
+ attr,
+ 'gradient',
+ {
+ f'{{{ANDROID}}}type': 'linear',
+ f'{{{ANDROID}}}startX': '0',
+ f'{{{ANDROID}}}startY': '0',
+ f'{{{ANDROID}}}endX': '108',
+ f'{{{ANDROID}}}endY': '108',
+ },
+ )
+ source_gradient = square_icon.find('s:defs/s:linearGradient', NS)
+ if source_gradient.attrib != {
+ 'id': 'background',
+ 'gradientUnits': 'userSpaceOnUse',
+ 'x1': '0',
+ 'y1': '0',
+ 'x2': '1254',
+ 'y2': '1254',
+ }:
+ raise ValueError(
+ 'Android background expects the existing diagonal linear gradient'
+ )
+ for stop in source_gradient:
+ ET.SubElement(
+ gradient,
+ 'item',
+ {
+ f'{{{ANDROID}}}offset': stop.get('offset', '0'),
+ f'{{{ANDROID}}}color': color(stop.attrib['stop-color']),
+ },
+ )
+ status = ET.parse(BRANDING / 'status-icon.svg').getroot()
+ if status.attrib['viewBox'] != '0 0 24 24':
+ raise ValueError('Status icon must retain its 24×24 viewBox')
+ notification = vector(24, 24)
+ paths(notification, status)
+ return {
+ 'ic_launcher_foreground.xml': foreground,
+ 'ic_launcher_background.xml': background,
+ 'ic_notification.xml': notification,
+ }
+
+
+def generate(output_root):
+ for tool in ('rsvg-convert', 'magick'):
+ if not shutil.which(tool):
+ raise RuntimeError(f'Required tool is missing: {tool}')
+ vectors = android_vectors()
+ ET.register_namespace('android', ANDROID)
+ ET.register_namespace('aapt', AAPT)
+
+ def render(source, destination, size=None, opaque=False):
+ destination = output_root / destination
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ with tempfile.TemporaryDirectory() as temp:
+ raster = Path(temp) / 'render.png'
+ command = ['rsvg-convert', str(BRANDING / source), '-o', str(raster)]
+ if size:
+ command += ['-w', str(size), '-h', str(size)]
+ subprocess.run(command, check=True)
+ if opaque:
+ subprocess.run(
+ [
+ 'magick',
+ str(raster),
+ '-background',
+ 'white',
+ '-alpha',
+ 'remove',
+ '-alpha',
+ 'off',
+ '-type',
+ 'TrueColor',
+ '-strip',
+ str(destination),
+ ],
+ check=True,
+ )
+ else:
+ shutil.copyfile(raster, destination)
+
+ for name in ('icon', 'icon-round', 'status-icon', 'feature'):
+ render(f'{name}.svg', f'assets/branding/{name}.png')
+ store = 'fastlane/metadata/android/en-US/images'
+ render('icon.svg', f'{store}/icon.png', opaque=True)
+ render('feature.svg', f'{store}/featureGraphic.png', opaque=True)
+ for density, size in [
+ ('mdpi', 48),
+ ('hdpi', 72),
+ ('xhdpi', 96),
+ ('xxhdpi', 144),
+ ('xxxhdpi', 192),
+ ]:
+ for source, name in [
+ ('icon.svg', 'ic_launcher'),
+ ('icon-round.svg', 'ic_launcher_round'),
+ ]:
+ render(source, f'app/src/main/res/mipmap-{density}/{name}.png', size)
+ for name, tree in vectors.items():
+ destination = output_root / 'app/src/main/res/drawable' / name
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ ET.indent(tree, space=' ')
+ destination.write_text(ET.tostring(tree, encoding='unicode') + '\n')
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ '--output-root',
+ type=Path,
+ default=ROOT,
+ help='Export root (default: repository)',
+ )
+ generate(parser.parse_args().output_root)
diff --git a/scripts/preview-samsung-icon.py b/scripts/preview-samsung-icon.py
new file mode 100644
index 0000000..0ce309f
--- /dev/null
+++ b/scripts/preview-samsung-icon.py
@@ -0,0 +1,147 @@
+"""Preview the adaptive artwork with an approximate Samsung-style mask."""
+
+import argparse
+import math
+import subprocess
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+SVG = 'http://www.w3.org/2000/svg'
+
+
+def preview_svg(source, bevel=0.18, effects=True):
+ tree = ET.parse(source)
+ root = tree.getroot()
+ x, y, width, height = map(float, root.attrib['viewBox'].split())
+ clip = None
+ for candidate in root.iter(f'{{{SVG}}}clipPath'):
+ # Illustrator renames IDs and references clips through CSS classes.
+ # Replace only a full-artboard circle; preserve unrelated internal clipping.
+ if (
+ len(candidate) != 1
+ or candidate[0].tag != f'{{{SVG}}}circle'
+ or candidate.get('clipPathUnits', 'userSpaceOnUse') != 'userSpaceOnUse'
+ or 'transform' in candidate.attrib
+ or 'transform' in candidate[0].attrib
+ ):
+ continue
+ circle = candidate[0]
+ cx, cy, radius = (float(circle.get(key, '0')) for key in ('cx', 'cy', 'r'))
+ if all(
+ math.isclose(actual, expected)
+ for actual, expected in (
+ (cx, x + width / 2),
+ (cy, y + height / 2),
+ (radius, width / 2),
+ (radius, height / 2),
+ )
+ ):
+ clip = candidate
+ clip.remove(circle)
+ break
+ if clip is None:
+ defs = ET.SubElement(root, f'{{{SVG}}}defs')
+ clip = ET.SubElement(defs, f'{{{SVG}}}clipPath', {'id': 'samsung-preview-mask'})
+ group = ET.Element(f'{{{SVG}}}g', {'clip-path': 'url(#samsung-preview-mask)'})
+ for child in list(root):
+ if child.tag != f'{{{SVG}}}defs':
+ root.remove(child)
+ group.append(child)
+ root.append(group)
+ # ponytail: approximate mask; use a device-extracted mask for pixel-exact comparison.
+ ET.SubElement(
+ clip,
+ f'{{{SVG}}}path',
+ {
+ 'transform': f'translate({x} {y}) scale({width / 100} {height / 100})',
+ 'd': 'M50 0 C88 0 100 12 100 50 C100 88 88 100 50 100 '
+ 'C12 100 0 88 0 50 C0 12 12 0 50 0Z',
+ },
+ )
+ if effects:
+ # Shadow proportions from SemAppIconSolution; SVG blur differs from Android Skia.
+ # ponytail: bevel is calibrated visually; use Samsung's on-device API for exact output.
+ defs = ET.SubElement(root, f'{{{SVG}}}defs')
+ defs.append(ET.fromstring(f'''
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ '''))
+ wrapper = ET.Element(
+ f'{{{SVG}}}g',
+ {
+ 'transform': f'translate({x + width*.02} {y + height*.02}) scale(.96) translate({-x} {-y})',
+ 'filter': 'url(#samsung-preview-effects)',
+ },
+ )
+ for child in list(root):
+ if child.tag != f'{{{SVG}}}defs':
+ root.remove(child)
+ wrapper.append(child)
+ root.append(wrapper)
+ ET.register_namespace('', SVG)
+ return ET.tostring(tree.getroot(), encoding='unicode')
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ '--source',
+ type=Path,
+ default=ROOT / 'assets/branding/icon.svg',
+ help='Editable source SVG (default: assets/branding/icon.svg)',
+ )
+ parser.add_argument('--flat', action='store_true', help='Disable shadow and bevel')
+ parser.add_argument(
+ '--bevel',
+ type=float,
+ default=0.18,
+ help='Edge strength, 0 to 1 (default: 0.18)',
+ )
+ parser.add_argument(
+ '--size', type=int, default=140, help='Output size in pixels (default: 140)'
+ )
+ parser.add_argument('output', type=Path, help='Output .svg or .png file')
+ args = parser.parse_args()
+ if args.output.suffix.lower() not in ('.svg', '.png'):
+ parser.error('output must end in .svg or .png')
+ if not 0 <= args.bevel <= 1:
+ parser.error('--bevel must be between 0 and 1')
+ if args.size <= 0:
+ parser.error('--size must be positive')
+ svg_root = ET.fromstring(
+ preview_svg(args.source, bevel=args.bevel, effects=not args.flat)
+ )
+ svg_root.set('width', str(args.size))
+ svg_root.set('height', str(args.size))
+ svg = ET.tostring(svg_root, encoding='unicode')
+ if args.output.suffix.lower() == '.svg':
+ args.output.write_text(svg + '\n')
+ else:
+ subprocess.run(
+ ['rsvg-convert', '-o', str(args.output)],
+ input=svg.encode(),
+ check=True,
+ )
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/tests/test_generate_branding.py b/scripts/tests/test_generate_branding.py
new file mode 100644
index 0000000..b3ff3fa
--- /dev/null
+++ b/scripts/tests/test_generate_branding.py
@@ -0,0 +1,81 @@
+"""Check vector conversion and the full export without modifying repository assets."""
+
+import runpy
+import shutil
+import struct
+import tempfile
+import unittest
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+GENERATOR = runpy.run_path(str(ROOT / 'scripts/generate-branding.py'))
+
+
+class BrandingExportTest(unittest.TestCase):
+ def test_android_paths_and_transform_follow_separate_sources(self):
+ ns = GENERATOR['NS']
+ android = GENERATOR['ANDROID']
+ vectors = GENERATOR['android_vectors']()
+ source = ET.parse(ROOT / 'assets/branding/icon.svg').find('s:g', ns)
+ group = vectors['ic_launcher_foreground.xml'].find('group')
+ self.assertEqual(group.get(f'{{{android}}}scaleX'), '0.73')
+ self.assertEqual(group.get(f'{{{android}}}translateX'), '169.29')
+ self.assertEqual(
+ [p.get(f'{{{android}}}pathData') for p in group],
+ [p.get('d') for p in source],
+ )
+ status = ET.parse(ROOT / 'assets/branding/status-icon.svg').getroot()
+ self.assertEqual(
+ vectors['ic_notification.xml'][0].get(f'{{{android}}}pathData'),
+ status[0].get('d'),
+ )
+ with self.assertRaises(ValueError):
+ GENERATOR['paths'](ET.Element('group'), [ET.Element('rect')])
+
+ @unittest.skipUnless(
+ shutil.which('rsvg-convert') and shutil.which('magick'), 'Export tools required'
+ )
+ def test_export_sizes_and_store_opacity(self):
+ with tempfile.TemporaryDirectory() as temp:
+ output = Path(temp)
+ GENERATOR['generate'](output)
+ pngs = list(output.rglob('*.png'))
+ self.assertEqual(len(pngs), 16)
+ for png in pngs:
+ data = png.read_bytes()
+ width, height = struct.unpack('>II', data[16:24])
+ if 'mipmap-' in str(png):
+ density = png.parent.name.removeprefix('mipmap-')
+ self.assertEqual(
+ (width, height),
+ (
+ dict(mdpi=48, hdpi=72, xhdpi=96, xxhdpi=144, xxxhdpi=192)[
+ density
+ ],
+ )
+ * 2,
+ )
+ elif png.name in ('feature.png', 'featureGraphic.png'):
+ self.assertEqual((width, height), (1024, 500))
+ elif png.name == 'status-icon.png':
+ self.assertEqual((width, height), (24, 24))
+ else:
+ self.assertEqual((width, height), (512, 512))
+ if 'fastlane' in str(png):
+ self.assertEqual(data[25], 2) # PNG RGB, without an alpha channel.
+ self.assertEqual(len(list(output.rglob('*.xml'))), 3)
+ first = {
+ p.relative_to(output): p.read_bytes()
+ for p in output.rglob('*')
+ if p.is_file()
+ }
+ GENERATOR['generate'](output)
+ self.assertEqual(
+ first,
+ {
+ p.relative_to(output): p.read_bytes()
+ for p in output.rglob('*')
+ if p.is_file()
+ },
+ )
diff --git a/scripts/tests/test_preview_samsung_icon.py b/scripts/tests/test_preview_samsung_icon.py
new file mode 100644
index 0000000..31bf96b
--- /dev/null
+++ b/scripts/tests/test_preview_samsung_icon.py
@@ -0,0 +1,79 @@
+"""Run with python3 -m unittest discover -s scripts/tests -p test_preview_samsung_icon.py."""
+
+import runpy
+import tempfile
+import unittest
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+
+class SamsungPreviewTest(unittest.TestCase):
+ def test_mask_preserves_adaptive_artwork(self):
+ root = Path(__file__).resolve().parents[2]
+ script = runpy.run_path(str(root / 'scripts/preview-samsung-icon.py'))
+ preview = ET.fromstring(
+ script['preview_svg'](
+ root / 'assets/branding/icon-round.svg', effects=False
+ )
+ )
+ source = ET.parse(root / 'assets/branding/icon-round.svg').getroot()
+ ns = {'s': 'http://www.w3.org/2000/svg'}
+ self.assertEqual(preview.attrib, source.attrib)
+ self.assertEqual(
+ ET.tostring(preview.find('s:g', ns)),
+ ET.tostring(source.find('s:g', ns)),
+ )
+ clip = preview.find('.//s:clipPath', ns)
+ self.assertEqual(clip.attrib['id'], 'circle')
+ self.assertEqual(len(clip), 1)
+ self.assertTrue(clip[0].tag.endswith('}path'))
+
+ def test_square_source_gets_mask_without_changing_artwork(self):
+ root = Path(__file__).resolve().parents[2]
+ script = runpy.run_path(str(root / 'scripts/preview-samsung-icon.py'))
+ source = ET.parse(root / 'assets/branding/icon.svg').getroot()
+ preview = ET.fromstring(
+ script['preview_svg'](root / 'assets/branding/icon.svg', effects=False)
+ )
+ ns = {'s': 'http://www.w3.org/2000/svg'}
+ group = preview.find('s:g', ns)
+ self.assertEqual(group.attrib['clip-path'], 'url(#samsung-preview-mask)')
+ original = [child for child in source if child.tag != '{' + ns['s'] + '}defs']
+ self.assertEqual(
+ [ET.tostring(c) for c in group], [ET.tostring(c) for c in original]
+ )
+
+ def test_effects_generate_valid_svg_with_adjustable_bevel(self):
+ root = Path(__file__).resolve().parents[2]
+ script = runpy.run_path(str(root / 'scripts/preview-samsung-icon.py'))
+ preview = ET.fromstring(
+ script['preview_svg'](root / 'assets/branding/icon-round.svg', bevel=0.3)
+ )
+ ns = {'s': 'http://www.w3.org/2000/svg'}
+ effect = preview.find('.//s:filter', ns)
+ self.assertEqual(effect.attrib['id'], 'samsung-preview-effects')
+ highlight = effect.find('s:feFlood[@flood-color="white"]', ns)
+ self.assertEqual(highlight.attrib['flood-opacity'], '0.3')
+ self.assertEqual(
+ preview.find('s:g', ns).attrib['filter'], 'url(#samsung-preview-effects)'
+ )
+
+ def test_illustrator_css_clip_is_replaced_without_losing_internal_clip(self):
+ root = Path(__file__).resolve().parents[2]
+ script = runpy.run_path(str(root / 'scripts/preview-samsung-icon.py'))
+ source = ''''''
+ with tempfile.TemporaryDirectory() as temp:
+ path = Path(temp) / 'Artboard 1.svg'
+ path.write_text(source)
+ preview = ET.fromstring(script['preview_svg'](path, effects=False))
+ ns = {'s': 'http://www.w3.org/2000/svg'}
+ self.assertIsNotNone(preview.find('.//s:clipPath[@id="clippath"]/s:path', ns))
+ self.assertIsNotNone(preview.find('.//s:clipPath[@id="internal"]/s:circle', ns))
+ self.assertEqual(len(preview.findall('.//s:clipPath', ns)), 2)
+ self.assertEqual(preview.find('s:g', ns).get('class'), 'cls-7')
+ self.assertIn('url(#clippath)', preview.find('.//s:style', ns).text)