From 55050f076aeb53f6fcbe913b65dd2ca4118bdded Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Wed, 9 Sep 2026 00:55:54 +0300 Subject: [PATCH 1/2] Add bilingual n8n and Telegram setup guides --- README.md | 13 +- docs/{ => en}/design.md | 2 + docs/{ => en}/diagnostics.md | 2 + docs/en/n8n-telegram.md | 195 ++++++++++++++++++++++++++++ docs/en/n8n-webhook.md | 199 ++++++++++++++++++++++++++++ docs/en/project-context.md | 120 +++++++++++++++++ docs/{ => en}/releases.md | 2 + docs/{ => en}/testing.md | 2 + docs/ru/design.md | 40 ++++++ docs/ru/diagnostics.md | 35 +++++ docs/ru/n8n-telegram.md | 199 ++++++++++++++++++++++++++++ docs/ru/n8n-webhook.md | 215 +++++++++++++++++++++++++++++++ docs/{ => ru}/project-context.md | 8 +- docs/ru/releases.md | 46 +++++++ docs/ru/testing.md | 43 +++++++ 15 files changed, 1113 insertions(+), 8 deletions(-) rename docs/{ => en}/design.md (97%) rename docs/{ => en}/diagnostics.md (96%) create mode 100644 docs/en/n8n-telegram.md create mode 100644 docs/en/n8n-webhook.md create mode 100644 docs/en/project-context.md rename docs/{ => en}/releases.md (97%) rename docs/{ => en}/testing.md (97%) create mode 100644 docs/ru/design.md create mode 100644 docs/ru/diagnostics.md create mode 100644 docs/ru/n8n-telegram.md create mode 100644 docs/ru/n8n-webhook.md rename docs/{ => ru}/project-context.md (94%) create mode 100644 docs/ru/releases.md create mode 100644 docs/ru/testing.md diff --git a/README.md b/README.md index 9932c2c..8b80802 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,13 @@ app does not depend on Telegram. **Status:** development preview with notification/SMS capture, a persistent encrypted outbox, background delivery, automatic retries and a delivery journal. Webhook authentication is not -implemented yet. Signed APK release automation is configured; see [Releases](docs/releases.md). +implemented yet. Signed APK release automation is configured; see [Releases](docs/en/releases.md). ## Getting started +Guides: [n8n webhook](docs/en/n8n-webhook.md) · [Telegram forwarding](docs/en/n8n-telegram.md). +На русском: [n8n webhook](docs/ru/n8n-webhook.md) · [Пересылка в Telegram](docs/ru/n8n-telegram.md). + 1. Save the full published webhook URL and a device code in **Connection**. Send a test event. 2. Check **Journal** and find the same event ID in n8n **Executions**. 3. In **Sources**, enable notification forwarding, grant notification access in Android settings, @@ -89,14 +92,14 @@ In VS Code, select **Run Message487 on Emulator** and **Run Without Debugging**, The debug app starts with the local n8n receive endpoint configured. Follow the capture checks in [DevServer/README.md](DevServer/README.md) using synthetic data only. Release builds require HTTPS. UI strings are supplied in English and Russian. The interface supports light/dark themes, -bottom navigation on phones and rail navigation on wider windows. See the [design notes](docs/design.md) +bottom navigation on phones and rail navigation on wider windows. See the [design notes](docs/en/design.md) for the visual conventions and references. Fastlane's `debug_artifact` lane builds only the debug APK. `checks` runs JVM/Robolectric tests, debug/release lint, and builds debug and unsigned release APKs under `app/build/outputs/apk/`. PR CI has no release signing credentials and does not require an emulator. -See the [project context](docs/project-context.md) for remaining product decisions. +See the [project context](docs/en/project-context.md) for remaining product decisions. This project succeeds [sms487](https://github.com/andre487/sms487). [AndroidMegaProxy](https://github.com/andre487/AndroidMegaProxy) is the reference for project conventions. @@ -108,6 +111,6 @@ Open the bug icon in the top bar to view or clear local diagnostic logs and prep `der-morgenstern@yandex.ru`. A ZIP contains rotating logs, the last crash and device/app information; message content and connection secrets are excluded. Sending requires action in your email app. After an unhandled crash the next launch offers to review the report. -See [diagnostic behavior and development checks](docs/diagnostics.md) and [privacy details](PRIVACY.md). +See [diagnostic behavior and development checks](docs/en/diagnostics.md) and [privacy details](PRIVACY.md). -Test categories, local commands, CI jobs and device-only limitations: [Testing](docs/testing.md). +Test categories, local commands, CI jobs and device-only limitations: [Testing](docs/en/testing.md). diff --git a/docs/design.md b/docs/en/design.md similarity index 97% rename from docs/design.md rename to docs/en/design.md index 1d78815..caef775 100644 --- a/docs/design.md +++ b/docs/en/design.md @@ -1,5 +1,7 @@ # Interface design +[English](../en/design.md) | [Русский](../ru/design.md) + Message487 uses a restrained Material 3 interface with the standard purple baseline palette from Material 3. Both light and dark schemes come directly from the library without color overrides or wallpaper-derived dynamic colors. The visual reference is MegaProxy: prominent operational status, diff --git a/docs/diagnostics.md b/docs/en/diagnostics.md similarity index 96% rename from docs/diagnostics.md rename to docs/en/diagnostics.md index 7c4ef66..90768dd 100644 --- a/docs/diagnostics.md +++ b/docs/en/diagnostics.md @@ -1,5 +1,7 @@ # Diagnostics +[English](../en/diagnostics.md) | [Русский](../ru/diagnostics.md) + The top-bar bug icon opens diagnostics. Refresh reads a bounded log preview; Prepare email creates an immutable ZIP attachment using FileProvider and temporary read-only access. Email apps are preferred, with the Android share sheet as fallback. No message is sent by Message487 itself. diff --git a/docs/en/n8n-telegram.md b/docs/en/n8n-telegram.md new file mode 100644 index 0000000..981a60a --- /dev/null +++ b/docs/en/n8n-telegram.md @@ -0,0 +1,195 @@ +# Forward Message487 notifications to Telegram + +[English](../en/n8n-telegram.md) | [Русский](../ru/n8n-telegram.md) + +First configure the [n8n webhook](n8n-webhook.md) and confirm a test event. That guide +also links to official documentation, n8n Cloud and self-hosted installation options. +Keep the bot token in **n8n Credentials**; it is not needed in Android. Forwarded +messages are available to the selected Telegram chat and may remain in n8n history. + +## 1. Create a bot and credential + +1. Open the official [@BotFather](https://t.me/BotFather) in Telegram. +2. Send `/newbot`, then choose a name and username. +3. Save the issued token in a new **Telegram** credential in n8n, in **Access Token**. + Do not put it in node text, the webhook URL or exported workflow files. +4. Open a private chat with the new bot and press **Start** or send `/start` so it + can send you messages. + +References: [creating bots](https://core.telegram.org/bots/features#botfather) and +[n8n Telegram credentials](https://docs.n8n.io/integrations/builtin/credentials/telegram/). + +## 2. Find the chat ID + +For a new bot that is not connected to a Telegram Trigger, save this code locally as +`telegram-chat-id.py` and run `python3 telegram-chat-id.py`. It prompts for the token +without echoing it and reads updates without sending messages. Send `/start` to the +bot from your Telegram account before running it. + +```python +import getpass +import json +import urllib.error +import urllib.request + +bot_token = getpass.getpass('Telegram bot token: ').strip() +request = urllib.request.Request( + f'https://api.telegram.org/bot{bot_token}/getUpdates', + data=b'{"timeout":0,"limit":100}', + headers={'Content-Type': 'application/json'}, +) +try: + with urllib.request.urlopen(request, timeout=10) as response: + result = json.load(response) +except (urllib.error.URLError, TimeoutError): + raise SystemExit('Could not read updates; check token, network and existing webhook') from None + +chats = {} +for update in result.get('result', []): + message = update.get('message') or update.get('channel_post') or {} + chat = message.get('chat') or {} + if 'id' in chat: + chats[chat['id']] = chat.get('type', 'unknown') +for chat_id, chat_type in chats.items(): + print(f'chat_id={chat_id} type={chat_type}') +if not chats: + print('No chats found. Send /start to the bot and run again.') +``` + +Copy the desired ID into the Telegram node. For a group, add the bot and send +`/start@your_bot_username`, then read the group ID from updates. Preserve any minus +sign. For a public channel, you can use `@channelusername`; add the bot as an +administrator with permission to post messages. + +`getUpdates` cannot be used while a Telegram webhook is installed. If the bot is +already connected to Telegram Trigger, read `message.chat.id` from its execution +instead. Do not delete a working bot's webhook just to obtain an ID. This forwarding +workflow does not need Telegram Trigger: its incoming trigger is Webhook. +See [getUpdates](https://core.telegram.org/bots/api#getupdates). + +## 3. Build the forwarding chain + +```mermaid +flowchart LR + W[Webhook: POST] --> C[Code: Prepare Telegram text] + C --> T[Telegram: Send Message] + T --> R[Respond to Webhook: ACK] +``` + +Keep **Webhook → Respond → Using 'Respond to Webhook' Node**. If you imported +`receive.json`, remove the direct Webhook → Respond connection and insert Code +and Telegram before Respond. Do not leave another branch acknowledging early. + +Add a **Code** node named `Prepare Telegram text`, select **JavaScript** and +**Run Once for All Items**, and paste: + +```javascript +const event = $('Webhook').first().json.body; +if (!event || event.schema_version !== 1 || + typeof event.event_id !== 'string' || !event.event_id || + !['test', 'notification', 'sms'].includes(event.message_type) || + typeof event.text !== 'string') { + throw new Error('Invalid Message487 event'); +} + +const text = [ + `Device: ${event.device_code || event.device_id || '—'}`, + `Source: ${event.source_name || event.source || '—'}`, + `Type: ${event.message_type}`, + event.title ? `Title: ${event.title}` : '', + event.sender ? `Sender: ${event.sender}` : '', + event.text, +].filter(line => line !== '').join('\n'); + +const chunks = []; +let chunk = ''; +for (const character of text) { + if (chunk.length + character.length > 3500) { + chunks.push(chunk); + chunk = ''; + } + chunk += character; +} +if (chunk) chunks.push(chunk); +const escapeHtml = value => value.replace(/[&<>]/g, character => ({ + '&': '&', '<': '<', '>': '>', +})[character]); +return chunks.map((part, index) => ({ + json: { + telegram_text: escapeHtml(chunks.length > 1 + ? `[${index + 1}/${chunks.length}]\n${part}` : part), + }, +})); +``` + +This preserves the complete text by splitting long events into several messages. +It does not split emoji UTF-16 pairs and escapes `<`, `>` and `&` for HTML. +The chunk size leaves room for part numbers. Telegram accepts up to 4096 characters +after entity parsing; see [sendMessage](https://core.telegram.org/bots/api#sendmessage). + +Configure the **Telegram** node: + +| Field | Value | +| --- | --- | +| Credential | Your Telegram credential | +| Resource | `Message` | +| Operation | `Send Message` | +| Chat ID | A fixed ID for your chat, group or channel | +| Text, Expression mode | `{{ $json.telegram_text }}` | +| Additional Fields → Parse Mode | `HTML` | +| Append n8n Attribution | Off | +| Disable WebPage Preview | On if previews are not wanted | + +Set Chat ID yourself rather than taking it from the incoming payload. The node +processes each chunk returned by Code. Keep **On Error → Stop Workflow** so a +Telegram error does not turn into a successful ACK. See the official +[Telegram operation reference](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.telegram/message-operations/#send-message). + +Set **Respond to Webhook** to JSON and HTTP 200. In Response Body's Expression mode, +reference the original request rather than the Telegram result: + +```javascript +{{ { status: 'accepted', event_id: $('Webhook').first().json.body.event_id } }} +``` + +After Telegram, `$json` contains Telegram's response, so `$json.body.event_id` is +incorrect. If you imported `receive.json`, replace **both Response Body and Response +Code**: both original expressions read `$json.body`. Code already performs basic +validation before sending in this chain. Publish the workflow again. + +## 4. Verify delivery + +1. Enable n8n confirmation in Message487 and send a test event. +2. Confirm that Telegram receives a message with type `test`. +3. Check the n8n execution, successful Telegram operation and client ACK. +4. Enable notifications, grant access and select a source app. +5. Create a new notification and match it to the execution using `event_id`. + +The chain also accepts SMS and tests. To forward only notifications, add an **If** +node after Webhook: `{{ $json.body.message_type }}` equals `notification`. Route true +to Code → Telegram → Respond and false to a separate Respond with the same ACK. +Filtered events must still be acknowledged or they remain in the client's queue. + +Do not select Telegram as a notification source on the phone receiving your bot's +messages: that can create a Telegram → Message487 → n8n → Telegram loop. Excluding +Telegram from selected apps is the simplest way to prevent it. + +## Errors and retries + +| Error | Check | +| --- | --- | +| `chat not found` | Chat ID, private chat started, bot added to group/channel | +| `bot was blocked` / HTTP 403 | Unblock the bot or restore its permissions | +| `can't parse entities` | HTML mode and the supplied escaping; do not use Markdown for this template | +| HTTP 429 | Telegram limits; reduce frequency and honor `retry_after` | +| Message arrives but the client retries | ACK event ID and time until webhook response | + +If sending all chunks exceeds the client's timeout, persist the event to a durable +queue first, acknowledge receipt and send to Telegram in a separate workflow. +Do not add a long wait before ACK. + +This simple example does not deduplicate. If Telegram accepted a message but ACK +was lost, retrying creates a duplicate; a failed chunk may cause earlier chunks to +repeat too. For more resilient processing, persist `event_id` and per-part progress. +Even that cannot guarantee exactly-once delivery if a crash occurs between Telegram's +response and recording the result; account for this in your retry design. diff --git a/docs/en/n8n-webhook.md b/docs/en/n8n-webhook.md new file mode 100644 index 0000000..49c5142 --- /dev/null +++ b/docs/en/n8n-webhook.md @@ -0,0 +1,199 @@ +# Connect Message487 to n8n + +[English](../en/n8n-webhook.md) | [Русский](../ru/n8n-webhook.md) + +This guide targets Message487 0.0.1. You need the n8n editor and an HTTPS endpoint +reachable from your phone. For a local Android emulator, use the debug build and +[DevServer](../../DevServer/README.md). Release APKs reject HTTP endpoints. + +## Where to run n8n + +- [Official n8n documentation](https://docs.n8n.io/) covers nodes, expressions and workflows. +- [Get started with n8n Cloud](https://docs.n8n.io/deploy/use-n8n-cloud/start-your-free-trial) to use a managed instance. +- [Cloud or self-hosting](https://docs.n8n.io/choose-how-to-use-n8n) compares deployment options. +- [Self-hosted installation options](https://docs.n8n.io/deploy/host-n8n/install-options). +- Official [Docker Compose installation guide](https://docs.n8n.io/deploy/host-n8n/install-options/install-using-docker-compose). + +For Cloud, use your workspace's HTTPS Production URL. For self-hosting, configure +HTTPS, persistent storage and backups. DevServer is for local development; do not +expose it publicly with its bundled test credentials. + +## 1. Create a receiving workflow + +Import [receive.json](../../DevServer/workflows/receive.json) using **Import from File** +in the editor menu. It validates basic fields and acknowledges the incoming `event_id`. +Import a separate workflow rather than replacing an existing production workflow. + +```mermaid +flowchart LR + W[Webhook: POST] --> R[Respond to Webhook: JSON ACK] +``` + +To configure the same two nodes manually, set **Webhook** as follows: + +| Field | Value | +| --- | --- | +| HTTP Method | `POST` | +| Path | A unique path such as `message487/receive-` | +| Authentication | `None` for the current client version | +| Respond | `Using 'Respond to Webhook' Node` | + +Generate a random suffix with `openssl rand -hex 16`; replace the entire placeholder, +including angle brackets. Message487 currently sends no authentication headers, +Basic Auth or JWT. The n8n editor login does not automatically protect webhooks. +Keep the full URL private; a random path is not a substitute for authentication. +For personal messages, restrict endpoint access where possible, for example using +a private network reachable from the phone. Enabling Header/Basic/JWT authentication +without client support will reject delivery. + +Set **Respond to Webhook → Respond With → JSON**, **Response Code → 200**, and use +this **Expression** in **Response Body**: + +```javascript +{{ { status: 'accepted', event_id: $('Webhook').first().json.body.event_id } }} +``` + +`Webhook` is the trigger node's name; adjust the expression if you rename it. +This minimal manual example does not validate requests. The imported `receive.json` +also validates `schema_version`, `event_id`, `message_type` and `text`, returning +HTTP 400 for invalid input. + +The client expects an object, not an array, JSON-encoded string or HTML: + +```json +{"status":"accepted","event_id":"the-same-event_id-as-the-request"} +``` + +The status must be exactly `accepted` and the ID must match. The default n8n response +“Workflow got started” is not an ACK for this client. See the official +[Webhook](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/) and +[Respond to Webhook](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.respondtowebhook/) documentation. + +## 2. Publish and copy the URL + +Select **Publish** (or enable **Active** in older n8n versions), then copy the +Webhook node's **Production URL**, for example: + +```text +https://n8n.example.org/webhook/message487/receive- +``` + +The `/webhook-test/` **Test URL** is for temporary listening with **Listen for test event**. +Production requests use a published workflow and appear under **Executions**. +Publish again after editing the workflow. + +If a reverse proxy causes n8n to display an internal URL, configure `WEBHOOK_URL`, +`N8N_PROXY_HOPS` and forwarded headers using the official +[reverse-proxy guide](https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/configuration-examples/configure-webhook-urls-with-reverse-proxy). +Use the final HTTPS URL with a trusted certificate: Message487 does not follow +HTTP redirects or disable TLS verification. + +## 3. Configure the app + +1. Open **Connection** and paste the complete Production URL. +2. Set a recognizable **Device code**, such as `personal-phone`. +3. Keep **n8n confirmation** enabled. +4. Save and send a test event. +5. Open the event in **Journal** and check its acknowledgement and HTTP 200. + +After the test succeeds, enable your desired **Sources**. Notifications require +Android notification access and selected apps; SMS requires receive-SMS permission. +Enabling SMS and selecting your SMS app can produce two events for one message. + +The bundled emulator endpoint is `http://10.0.2.2:5678/webhook/message487/receive`, +which requires a debug build. On a physical phone, `localhost` means the phone +itself, and `10.0.2.2` is not your computer's address. + +## 4. Inspect received data + +Open **Executions → execution → Webhook → Output → body** in n8n. Match its +`event_id` with the app journal. Enable saving successful execution data if needed; +DevServer already does this. Execution history contains complete messages, so +configure access and retention accordingly. + +Example notification body: + +```json +{ + "schema_version": 1, + "event_id": "5d8ee1a5-9360-4b0e-8412-942b2e1c99ab", + "device_id": "43b55766-95b6-40b8-8f4f-c0240f547914", + "device_code": "personal-phone", + "message_type": "notification", + "occurred_at": "2026-09-09T09:00:00Z", + "source": "org.example.chat", + "source_name": "Example Chat", + "title": "Test notification", + "text": "Connection check" +} +``` + +| Field | Meaning | +| --- | --- | +| `event_id` | Event ID, preserved across delivery retries | +| `device_id` | Installation ID | +| `device_code` | Editable device label | +| `message_type` | `test`, `notification` or `sms` | +| `occurred_at` | Event timestamp in ISO 8601 format | +| `source` / `source_name` | Source package / app name; SMS uses source `android` | +| `title` | Notification title; absent for SMS and tests | +| `sender` | SMS sender; absent for notifications and tests | +| `text` | Event text | + +Directly after Webhook use `{{ $json.body.text }}` or `{{ $json.body.device_code }}`. +If intermediate nodes replace the data, reference the original node explicitly: +`{{ $('Webhook').first().json.body.text }}`. + +## 5. Add message processing + +For a complete example, see [forwarding to Telegram](n8n-telegram.md). + +The bundled receiver only acknowledges requests; it does not send to Telegram, +store a separate durable queue or deduplicate. Put your actions before Respond to +Webhook, acknowledging only after the operation you consider acceptance succeeds. +For slow processing, persist the event to a durable queue/database first, ACK it, +and perform downstream work separately. + +The client uses 10-second connection and read timeouts. A lost response can cause +an already processed request to be retried. Use `event_id` as a unique storage key; +already accepted duplicates must receive the same successful ACK. A separate +“check then insert” without a unique constraint does not prevent concurrent duplicates. + +Acknowledging before processing removes the event body from the client's queue +once confirmed. A later n8n failure will not trigger a phone retry. Successful +n8n execution and successful client acknowledgement are different outcomes. + +## Test without a phone + +Use your actual Production URL and a new ID for each test. This request contains +synthetic data only: + +```sh +curl --fail-with-body --max-time 10 \ + -X POST 'https://n8n.example.org/webhook/message487/YOUR-PATH' \ + -H 'Content-Type: application/json' \ + --data '{"schema_version":1,"event_id":"manual-check-001","device_id":"manual-test","device_code":"test-phone","message_type":"test","occurred_at":"2026-09-09T09:00:00Z","source":"life.andre.message487","source_name":"Message487","text":"Synthetic connection test"}' +``` + +Expect HTTP 200 and `{"status":"accepted","event_id":"manual-check-001"}`. +This checks the server contract; also test the Android connection button and a new +notification or SMS to exercise actual app delivery. + +## Troubleshooting + +| Symptom | Check | +| --- | --- | +| HTTP 404 | Production URL, publication, POST method and path | +| HTTP 401/403 | Authentication and proxy/n8n access restrictions | +| HTTP 301/302 | Supply the final URL; redirects are not followed | +| `INVALID_ACK` with HTTP 200 | JSON object, `status: accepted`, matching ID and Webhook response mode | +| Timeout/network error | Phone connectivity, TLS, firewall and time until response | +| No execution in the editor | Executions tab and successful execution retention | +| Tests arrive, messages do not | Sources, Android permissions, selected apps and pause state | + +Network failures, timeouts, HTTP 408/425/429 and 5xx retry automatically. Invalid ACKs +and other HTTP errors require intervention and manual retry from the journal. +Changing the URL only affects new events; queued events retain their old destination. +Send a new test after fixing configuration and delete old records separately if needed. +The top-bar bug icon opens [diagnostics](diagnostics.md), which records delivery +outcomes without message text. diff --git a/docs/en/project-context.md b/docs/en/project-context.md new file mode 100644 index 0000000..f15c3dc --- /dev/null +++ b/docs/en/project-context.md @@ -0,0 +1,120 @@ +# Message487 context + +[English](../en/project-context.md) | [Русский](../ru/project-context.md) + +Updated September 8, 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. +- Project presentation, code style, privacy policy, documentation and CI follow + [AndroidMegaProxy](https://github.com/andre487/AndroidMegaProxy). + +## Original system + +[sms487](https://github.com/andre487/sms487) forwards SMS and notifications through a Go API +and SQS to a separate Telegram bot. The bot's code was not reviewed. The running system +has not been migrated. + +## Initial implementation + +The user requested a Docker Compose DevServer with n8n test workflows and an Android +connection-testing client. The client saves the endpoint, sends a synthetic event, +checks acknowledgement with a matching `event_id` and displays the result. A generic +webhook mode accepts HTTP 2xx. The ACK format is our example contract, not the default +response of every n8n workflow. + +Fastlane handles builds/checks. The UI uses Kotlin/Compose with English and Russian +resources. Notification/SMS capture, a persistent queue and retries were added later. +Webhook authentication remains unimplemented. `DevServer/README.md` describes the local +server and its limitations. + +## Risks found in the old sms487 client + +The prior discussion and static Android-code review identified asynchronous delivery +outliving a Worker, an SMS receiver without `goAsync()`, a loss window before persistence, +and no stable event ID. An HTTP success callback marks a batch sent before validating +the response. Logs contain a message prefix. These were static findings, not reproduced +on a device. + +The old client appends `/add-sms` to its server address and uses a proprietary batch format. +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. Unchanged notification +updates are suppressed; changed content creates an event. 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. +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 +metadata history remains. The journal hides message contents. Pause stops capture and new +attempts; a running request may complete. Backup and device transfer exclude application data. + +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. SMS deduplication uses sender, +time, text and installation; it does not replace server-side deduplication. + +## Product direction + +These items describe the direction; showing the last successful delivery on the overview and +viewing journal message contents are not implemented yet. + +- n8n connection with a sample workflow and synthetic test; alternatively a full custom webhook + URL. Shared transport sends JSON over HTTPS. +- App selection and separate SMS enablement, with contextual permission requests. Filtering + happens on the phone before persistence and delivery. +- A local queue persisted before network calls, stable retry IDs and installation identity + rather than the phone model. +- Overview with connection/permission/queue status, global pause and last successful delivery; + 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. + +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 +[Webhook documentation](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/). + +## Open decisions + +1. Physical-device checks for power saving, reboot and permission restrictions. +2. ACK after workflow execution versus durable server-side queue persistence. HTTP success + alone does not establish durable storage or downstream delivery. +3. Webhook authentication and future contract changes. README describes the current contract + and queue; limits for the unconfirmed queue require a separate decision. +4. Distribution and required device checks. The initial minimum SDK is configured in Gradle; + its suitability for future capture behavior still needs verification. +5. Whether to migrate the existing Telegram scenario and retain SQS. n8n does not require + removing the existing queue; PostgreSQL is one replacement discussed. + +Lost responses can produce duplicates. Deduplication must use a stable event ID; exactly-once +was not agreed and must not be promised. + +## AndroidMegaProxy references + +Its main branch was reviewed at `c8190e97b705a2c4578d278c40a690e97c5d5f27`. + +- Kotlin, Compose Material 3, system light/dark themes, English and Russian resources. +- JDK 21, Gradle Kotlin DSL and Fastlane through Bundler for builds/checks. +- PR/main CI: JVM tests, Android lint and builds, without a mandatory hosted emulator. +- PR APK artifacts without release keys; signing and releases are separate. +- Public privacy documentation describes actual application behavior. + +VPN, Go/JNI, DNS diagnostics and MegaProxy's publication specifics are not Message487 +requirements. Its privacy policy cannot simply be copied: Message487 sends event contents +to the chosen recipient, and n8n/downstream services have their own retention policies. + +Local rotating diagnostics, next-launch crash prompts and manual ZIP reports are implemented; +see [diagnostics](diagnostics.md). Recipient: der-morgenstern@yandex.ru. + +[Test categories and CI commands](testing.md) include Compose tests on Robolectric without +an emulator. [Signed APK releases](releases.md) use environment-based signing as in MegaProxy. diff --git a/docs/releases.md b/docs/en/releases.md similarity index 97% rename from docs/releases.md rename to docs/en/releases.md index f835dee..54e5d05 100644 --- a/docs/releases.md +++ b/docs/en/releases.md @@ -1,5 +1,7 @@ # Signed APK releases +[English](../en/releases.md) | [Русский](../ru/releases.md) + Run `bundle exec fastlane android release_artifacts` with JDK 21 and Android SDK 36. The lane runs Android JVM/Compose tests, debug/release lint and a signed release build, then checks the certificate, package ID, version and non-debuggable flag. Outputs are `dist/release/message487-.apk`, diff --git a/docs/testing.md b/docs/en/testing.md similarity index 97% rename from docs/testing.md rename to docs/en/testing.md index 82f51e1..209093f 100644 --- a/docs/testing.md +++ b/docs/en/testing.md @@ -1,5 +1,7 @@ # Tests and CI +[English](../en/testing.md) | [Русский](../ru/testing.md) + Run the same suites locally and in GitHub Actions: | Suite | Command | Coverage | diff --git a/docs/ru/design.md b/docs/ru/design.md new file mode 100644 index 0000000..ceecae9 --- /dev/null +++ b/docs/ru/design.md @@ -0,0 +1,40 @@ +# Дизайн интерфейса + +[English](../en/design.md) | [Русский](../ru/design.md) + +Message487 использует Material 3 со стандартной базовой фиолетовой палитрой. +Светлая и тёмная схемы берутся из библиотеки без изменения цветов и динамических +цветов обоев. Ориентир — MegaProxy: заметный статус работы, сгруппированные настройки, +скруглённые поверхности, читаемая типографика и ограниченная ширина содержимого. + +Главный экран показывает готовность захвата и проблемы, требующие внимания. +Этот статус не доказывает доступность сервера: её проверяют тестовым событием и журналом. +На основных экранах вне главного есть кнопка возврата к главному. На узких окнах используется +нижняя навигация, на широких — боковая панель. Черновики подключения переживают переходы +между разделами. Длинные списки приложений и событий отображаются лениво. + +Журнал выделяет источник, время и состояние доставки. Нажатие открывает ID с возможностью +копирования, HTTP-ответ, попытки, повтор и удаление. Текст сообщения скрыт. Выбор источников +поддерживает поиск, фильтр выбранных и массовый выбор всего доступного списка. +Ручной ввод пакета вынесен в отдельный диалог. Справка объясняет очередь, повторы +и системные ограничения. + +Область нажатия должна быть не меньше 48 dp. Передавайте статус текстом и значками, +а не только цветом. Используйте смысловые роли цветов темы, масштабируемую типографику, +системные отступы и прокрутку; не фиксируйте высоту текста. Проверяйте обе локали, +тёмную тему, крупный текст и узкие/широкие окна на эмуляторе. + +## Источники + +- [Google: Themes](https://developer.android.com/design/ui/mobile/guides/styles/themes) — базовая палитра Material. +- [Google: Layout basics](https://developer.android.com/design/ui/mobile/guides/layout-and-content/layout-basics) — группировка, отступы и доступность действий. +- [Google: Accessibility](https://developer.android.com/design/ui/mobile/guides/foundations/accessibility) — контраст, масштабирование и области нажатия. +- [Material 3: Navigation bar](https://m3.material.io/components/navigation-bar/guidelines) — основные разделы. +- [Google Design: Expressive design research](https://design.google/library/expressive-material-design-google-research) — выделение значимой информации. +- [Nielsen Norman Group: Visual hierarchy](https://www.nngroup.com/articles/visual-hierarchy-ux-definition/) — масштаб, контраст и группировка. + +## Проверка + +Редизайн проверен на эмуляторе API 35 на английском и русском, в светлой и тёмной +темах, с крупным текстом в узком окне и с боковой навигацией в широком. Android Lint, +JVM-тесты и обе APK-сборки запускаются через существующий Fastlane lane `checks`. diff --git a/docs/ru/diagnostics.md b/docs/ru/diagnostics.md new file mode 100644 index 0000000..fe44052 --- /dev/null +++ b/docs/ru/diagnostics.md @@ -0,0 +1,35 @@ +# Диагностика + +[English](../en/diagnostics.md) | [Русский](../ru/diagnostics.md) + +Значок жука в верхней панели открывает диагностику. Обновление читает ограниченный +фрагмент лога; подготовка письма создаёт отдельный ZIP и передаёт его через FileProvider +с временным доступом только на чтение. Предпочтение отдаётся почтовым приложениям, +запасной вариант — системное меню отправки. Message487 самостоятельно не отправляет +письмо. Очистка удаляет логи, последний креш и кеш архивов, но не очередь сообщений. + +В приватном каталоге `files/logs` хранятся три файла по 256 КиБ и `crash-latest.log` +размером до 256 КиБ. Запись выполняется одним фоновым потоком через очередь на 256 +записей; ротация и экспорт используют общую блокировку. При переполнении новые записи +пропускаются; их количество фиксируется при следующей успешной записи. Ошибки ввода-вывода +не приводят к крешу и отображаются на экране диагностики. Предпросмотр содержит последние +48 КиБ и последний креш. В `cache/feedback` остаётся не более трёх ZIP с уникальными именами. + +Обработчик необработанных исключений синхронно записывает креш с `fsync`, сохраняет +признак для диалога следующего запуска и передаёт управление прежнему обработчику Android. +Отдельный файл креша сохраняется при обычной ротации. Обрабатываются необработанные +Java/Kotlin-исключения, но не ANR, нативные креши, принудительная остановка или завершение +без исключения. Нехватка места и тяжёлые сбои процесса могут помешать записи. +Тексты исключений и имена потоков исключены; глубина причин, подавленные исключения и +число кадров ограничены. Имена символов приложения и строки исходников сохраняются в release. + +Логи используют фиксированный набор событий и типизированные либо разрешённые значения +метаданных. Не добавляйте тела сообщений, отправителей, пакеты, URL, ID событий/установок, +коды устройств или произвольные тексты исключений. В архив входят версии приложения, +Android, сведения об устройстве, архитектура и флаги настроек без секретов. + +Команда `bundle exec fastlane android checks` проверяет ротацию и перезапуск, сохранение +креша, исключение секретов, ошибки хранилища, передачу управления прежнему обработчику, +хранение архивов и разрешения вложения. Для ручной проверки на debug-эмуляторе запустите +приложение, выполните `adb shell am crash life.andre.message487` и откройте его снова. +Проверьте диалог и ZIP. Остановитесь на редакторе письма, если не собираетесь отправлять отчёт. diff --git a/docs/ru/n8n-telegram.md b/docs/ru/n8n-telegram.md new file mode 100644 index 0000000..a172376 --- /dev/null +++ b/docs/ru/n8n-telegram.md @@ -0,0 +1,199 @@ +# Пересылка уведомлений из Message487 в Telegram + +[English](../en/n8n-telegram.md) | [Русский](../ru/n8n-telegram.md) + +Сначала настройте [приём webhook в n8n](n8n-webhook.md) и проверьте тестовое событие. +Там же есть ссылки на официальную документацию, n8n Cloud и самостоятельную установку. +Telegram-токен хранится в **Credentials n8n**; в Android-приложение его вводить не нужно. +Сообщения будут доступны выбранному Telegram-чату и могут сохраняться в истории n8n. + +## 1. Создайте бота и credential + +1. В Telegram откройте официального [@BotFather](https://t.me/BotFather). +2. Отправьте `/newbot` и задайте имя и username бота. +3. Сохраните выданный токен в новом credential типа **Telegram** в n8n, поле + **Access Token**. Не вставляйте токен в текст узла, webhook URL или экспорт workflow. +4. Откройте личный чат с новым ботом и нажмите **Start** либо отправьте `/start`. + Это нужно, чтобы бот мог писать вам. + +Справка: [создание бота](https://core.telegram.org/bots/features#botfather), +[Telegram credentials в n8n](https://docs.n8n.io/integrations/builtin/credentials/telegram/). + +## 2. Узнайте chat_id + +Для нового бота, к которому ещё не подключён Telegram Trigger, сохраните следующий +код в локальный файл `telegram-chat-id.py` и запустите `python3 telegram-chat-id.py`. +Он запросит токен скрытым вводом и прочитает входящие обновления, не отправляя сообщений. +Перед запуском отправьте боту `/start` со своего Telegram-аккаунта. + +```python +import getpass +import json +import urllib.error +import urllib.request + +bot_token = getpass.getpass('Telegram bot token: ').strip() +request = urllib.request.Request( + f'https://api.telegram.org/bot{bot_token}/getUpdates', + data=b'{"timeout":0,"limit":100}', + headers={'Content-Type': 'application/json'}, +) +try: + with urllib.request.urlopen(request, timeout=10) as response: + result = json.load(response) +except (urllib.error.URLError, TimeoutError): + raise SystemExit('Could not read updates; check token, network and existing webhook') from None + +chats = {} +for update in result.get('result', []): + message = update.get('message') or update.get('channel_post') or {} + chat = message.get('chat') or {} + if 'id' in chat: + chats[chat['id']] = chat.get('type', 'unknown') +for chat_id, chat_type in chats.items(): + print(f'chat_id={chat_id} type={chat_type}') +if not chats: + print('No chats found. Send /start to the bot and run again.') +``` + +Скопируйте ID нужного чата в настройку узла Telegram. Для группы добавьте бота в +группу, отправьте `/start@имя_бота` и возьмите ID группы из обновлений; сохраняйте +знак минус, если он есть. Для публичного канала можно использовать `@username` +канала, добавив бота администратором с правом публикации сообщений. + +`getUpdates` несовместим с уже установленным webhook Telegram. Если этот бот +используется в Telegram Trigger, возьмите `message.chat.id` из входных данных +его execution. Не удаляйте webhook работающего бота ради получения ID. +Для отправки из Message487 **Telegram Trigger не нужен**: входной триггер — Webhook. +Официальная справка: [getUpdates](https://core.telegram.org/bots/api#getupdates). + +## 3. Соберите цепочку отправки + +```mermaid +flowchart LR + W[Webhook: POST] --> C[Code: Prepare Telegram text] + C --> T[Telegram: Send Message] + T --> R[Respond to Webhook: ACK] +``` + +В Webhook оставьте **Respond → Using 'Respond to Webhook' Node**. Если вы +импортировали `receive.json`, удалите прямое соединение Webhook → Respond и +вставьте Code и Telegram перед Respond. Не оставляйте параллельный путь, который +подтвердит событие раньше отправки. + +Добавьте узел **Code**, имя `Prepare Telegram text`, язык **JavaScript**, режим +**Run Once for All Items**, и вставьте: + +```javascript +const event = $('Webhook').first().json.body; +if (!event || event.schema_version !== 1 || + typeof event.event_id !== 'string' || !event.event_id || + !['test', 'notification', 'sms'].includes(event.message_type) || + typeof event.text !== 'string') { + throw new Error('Invalid Message487 event'); +} + +const text = [ + `Устройство: ${event.device_code || event.device_id || '—'}`, + `Источник: ${event.source_name || event.source || '—'}`, + `Тип: ${event.message_type}`, + event.title ? `Заголовок: ${event.title}` : '', + event.sender ? `Отправитель: ${event.sender}` : '', + event.text, +].filter(line => line !== '').join('\n'); + +const chunks = []; +let chunk = ''; +for (const character of text) { + if (chunk.length + character.length > 3500) { + chunks.push(chunk); + chunk = ''; + } + chunk += character; +} +if (chunk) chunks.push(chunk); +const escapeHtml = value => value.replace(/[&<>]/g, character => ({ + '&': '&', '<': '<', '>': '>', +})[character]); +return chunks.map((part, index) => ({ + json: { + telegram_text: escapeHtml(chunks.length > 1 + ? `[${index + 1}/${chunks.length}]\n${part}` : part), + }, +})); +``` + +Код сохраняет полный текст, разделяя длинное событие на несколько сообщений. +Эмодзи не разрезаются внутри UTF-16-пары; `<`, `>` и `&` экранируются для HTML. +Запас по длине оставлен под номер части. Telegram допускает до 4096 символов +после разбора entities; см. [sendMessage](https://core.telegram.org/bots/api#sendmessage). + +Настройте узел **Telegram**: + +| Поле | Значение | +| --- | --- | +| Credential | Созданный Telegram credential | +| Resource | `Message` | +| Operation | `Send Message` | +| Chat ID | Постоянный ID вашего чата, группы или канала | +| Text, режим Expression | `{{ $json.telegram_text }}` | +| Additional Fields → Parse Mode | `HTML` | +| Append n8n Attribution | Выключить | +| Disable WebPage Preview | Включить, если не нужны превью ссылок | + +Chat ID задайте сами, не берите его из входящего запроса. Узел обработает каждую +часть, которую вернул Code. Оставьте **On Error → Stop Workflow**, чтобы ошибка +Telegram не превратилась в успешное подтверждение. Настройки операции описаны +в [официальной документации узла Telegram](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.telegram/message-operations/#send-message). + +В **Respond to Webhook** выберите JSON и HTTP 200. В режиме Expression для +Response Body используйте исходный запрос, а не результат узла Telegram: + +```javascript +{{ { status: 'accepted', event_id: $('Webhook').first().json.body.event_id } }} +``` + +После узла Telegram `$json` содержит ответ Telegram, поэтому `$json.body.event_id` +здесь не подходит. Если импортировали `receive.json`, замените **и Response Body, +и Response Code**: старые выражения в обоих полях обращаются к `$json.body`. +Базовая валидация в этой цепочке уже выполнена в Code до отправки. + +Опубликуйте workflow повторно. + +## 4. Проверьте доставку + +1. В Message487 включите подтверждение n8n и отправьте тестовое событие. +2. Убедитесь, что в Telegram пришло сообщение с типом `test`. +3. В n8n проверьте execution и успешное выполнение Telegram, а в приложении — ACK. +4. Включите уведомления, выдайте доступ и выберите нужное приложение-источник. +5. Создайте новое уведомление и сопоставьте его с execution по `event_id`. + +Эта цепочка принимает также SMS и тесты. Если нужны только уведомления, добавьте +после Webhook узел **If** с условием `{{ $json.body.message_type }}` равным +`notification`. Ветку true направьте в Code → Telegram → Respond, а false — +в отдельный Respond с тем же ACK: отфильтрованное событие должно быть подтверждено, +иначе оно останется в очереди клиента. + +Не выбирайте Telegram источником уведомлений в Message487, когда этот же телефон +получает сообщения вашего бота: это может создать цикл «Telegram → Message487 → +n8n → Telegram». Проще исключить Telegram целиком из выбранных приложений. + +## Ошибки и повторы + +| Ошибка | Что проверить | +| --- | --- | +| `chat not found` | Chat ID; начат ли личный диалог; добавлен ли бот в группу/канал | +| `bot was blocked` / HTTP 403 | Разблокируйте бота или восстановите его права | +| `can't parse entities` | Parse Mode HTML и приведённое экранирование; не используйте Markdown для этого шаблона | +| HTTP 429 | Ограничение Telegram; уменьшите частоту, учитывайте `retry_after` | +| Сообщение пришло, но клиент повторяет запрос | Правильный `event_id` в ACK и время ответа webhook | + +Если отправка всех частей не успевает до таймаута клиента, сначала сохраняйте +событие в надёжную очередь, подтверждайте приём и выполняйте Telegram-отправку +отдельным workflow. Не добавляйте длинное ожидание перед ACK. + +В простом примере нет дедупликации. Если Telegram принял сообщение, а ACK потерялся, +повтор создаст дубль; при ошибке одной из частей могут повториться и предыдущие. +Для устойчивой обработки храните `event_id` и прогресс отправки частей в БД. +Даже это не гарантирует ровно одну отправку при сбое между ответом Telegram и +фиксацией результата — учитывайте этот случай в своей схеме повторов. diff --git a/docs/ru/n8n-webhook.md b/docs/ru/n8n-webhook.md new file mode 100644 index 0000000..01a25a6 --- /dev/null +++ b/docs/ru/n8n-webhook.md @@ -0,0 +1,215 @@ +# Как подключить Message487 к n8n + +[English](../en/n8n-webhook.md) | [Русский](../ru/n8n-webhook.md) + +Инструкция для Message487 0.0.1. Нужен доступ к редактору n8n и HTTPS-адрес, +доступный с телефона. Для локального Android-эмулятора используйте debug-сборку и +[готовый DevServer](../../DevServer/README.md). Релизный APK не принимает HTTP-адреса. + +## Где запустить n8n + +- [Официальная документация n8n](https://docs.n8n.io/) — узлы, выражения и работа с workflow. +- [n8n Cloud: начало работы](https://docs.n8n.io/deploy/use-n8n-cloud/start-your-free-trial) — облачная версия без установки сервера. +- [Облако или собственный сервер](https://docs.n8n.io/choose-how-to-use-n8n) — сравнение вариантов запуска. +- [Самостоятельная установка](https://docs.n8n.io/deploy/host-n8n/install-options) — варианты развёртывания. +- [Установка с Docker Compose](https://docs.n8n.io/deploy/host-n8n/install-options/install-using-docker-compose) — официальная инструкция. + +Для n8n Cloud используйте HTTPS Production URL своего workspace. Для собственного +сервера настройте доступ по HTTPS, хранение данных и резервные копии. +[DevServer](../../DevServer/README.md) предназначен для локальной разработки, +а не для публикации в интернете с его тестовыми учётными данными. + +## 1. Создайте принимающий workflow + +Быстрее всего импортировать [receive.json](../../DevServer/workflows/receive.json) +через **Import from File** в меню редактора n8n. Это готовый пример приёмника с +проверкой базовых полей и подтверждением `event_id`. Импортируйте его как отдельный +workflow; не заменяйте им рабочий сценарий с вашими действиями. + +В импортированном workflow уже соединены два узла: + +```mermaid +flowchart LR + W[Webhook: POST] --> R[Respond to Webhook: JSON ACK] +``` + +Для ручной настройки создайте те же узлы. В узле **Webhook** установите: + +| Поле | Значение | +| --- | --- | +| HTTP Method | `POST` | +| Path | Уникальный путь, например `message487/receive-<случайная-строка>` | +| Authentication | `None` для текущей версии клиента | +| Respond | `Using 'Respond to Webhook' Node` | + +Случайную часть пути можно сгенерировать командой `openssl rand -hex 16`. +Не вставляйте угловые скобки из примера в настоящий URL. + +Message487 пока не отправляет заголовки авторизации, Basic Auth или JWT. Пароль +входа в редактор n8n не защищает webhook автоматически. Не распространяйте полный +URL; случайный путь не заменяет полноценную авторизацию. Для передачи личных +сообщений ограничьте доступ к endpoint доступным вам способом, например частной +сетью с доступом телефона. Настройка узла с Header/Basic/JWT Auth без поддержки +со стороны клиента приведёт к отказу доставки. + +В узле **Respond to Webhook** выберите **Respond With → JSON**, добавьте +**Response Code → 200**, а **Response Body** переключите в режим **Expression**: + +```javascript +{{ { status: 'accepted', event_id: $('Webhook').first().json.body.event_id } }} +``` + +Здесь `Webhook` — имя первого узла; при переименовании исправьте ссылку в выражении. +Этот минимальный ручной пример подтверждает запрос без валидации. Импортированный +`receive.json` дополнительно проверяет `schema_version`, `event_id`, `message_type` +и `text` и возвращает HTTP 400 для некорректного тела. + +Клиент ждёт JSON-объект следующего вида, а не массив, строку с JSON или HTML: + +```json +{"status":"accepted","event_id":"тот-же-event_id-что-в-запросе"} +``` + +`status` должен быть ровно `accepted`, а `event_id` — совпадать с запросом. +Обычный ответ n8n «Workflow got started» не подходит для режима подтверждения. +Настройки узлов описаны в документации [Webhook](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/) +и [Respond to Webhook](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.respondtowebhook/). + +## 2. Опубликуйте workflow и скопируйте URL + +Нажмите **Publish** (в более старых версиях n8n — включите **Active**). В узле +Webhook скопируйте **Production URL**, например: + +```text +https://n8n.example.org/webhook/message487/receive-<случайная-строка> +``` + +Не используйте постоянным адресом **Test URL** с `/webhook-test/`: он предназначен +для временного прослушивания через **Listen for test event**. Production URL +работает для опубликованного workflow, а его вызовы смотрят во вкладке **Executions**. +После изменения workflow опубликуйте изменения повторно. + +Если n8n стоит за reverse proxy и показывает внутренний адрес, настройте внешний +`WEBHOOK_URL`, число доверенных proxy в `N8N_PROXY_HOPS` и forwarded-заголовки по +[инструкции n8n для reverse proxy](https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/configuration-examples/configure-webhook-urls-with-reverse-proxy). +Телефон должен обращаться сразу к конечному HTTPS URL с доверенным сертификатом: +Message487 не следует HTTP-перенаправлениям и не отключает проверку TLS. + +## 3. Настройте приложение + +1. Откройте **Подключение / Connection**. +2. Вставьте полный Production URL в поле веб-хука. +3. Задайте понятный **Код устройства / Device code**, например `personal-phone`. +4. Оставьте включённым **Подтверждение n8n / n8n confirmation**. +5. Нажмите кнопку сохранения и отправки тестового события. +6. В **Журнале / Journal** откройте событие и проверьте подтверждение и HTTP 200. + +После успешного теста включите нужные источники в **Источниках / Sources**: +для уведомлений выдайте системный доступ и выберите приложения; для SMS выдайте +разрешение на получение SMS. Если выбраны и SMS, и уведомления SMS-приложения, +одно входящее SMS может породить два разных события. + +Для эмулятора готовый DevServer использует +`http://10.0.2.2:5678/webhook/message487/receive`. Такой URL допустим только в +debug-сборке. На физическом телефоне `localhost` обозначает сам телефон, а +`10.0.2.2` не является адресом вашего компьютера. + +## 4. Посмотрите полученные данные + +В n8n откройте **Executions → нужный запуск → Webhook → Output → body**. Найдите +`event_id` из журнала приложения. Для сохранения успешных запусков включите +соответствующую настройку сохранения execution data; в DevServer она уже включена. +История n8n содержит полные сообщения, поэтому настройте её срок хранения и доступ. + +Пример тела уведомления: + +```json +{ + "schema_version": 1, + "event_id": "5d8ee1a5-9360-4b0e-8412-942b2e1c99ab", + "device_id": "43b55766-95b6-40b8-8f4f-c0240f547914", + "device_code": "personal-phone", + "message_type": "notification", + "occurred_at": "2026-09-09T09:00:00Z", + "source": "org.example.chat", + "source_name": "Example Chat", + "title": "Тестовое уведомление", + "text": "Проверка подключения" +} +``` + +| Поле | Значение | +| --- | --- | +| `event_id` | ID события; сохраняется при повторах доставки | +| `device_id` | ID установки приложения | +| `device_code` | Редактируемая метка устройства | +| `message_type` | `test`, `notification` или `sms` | +| `occurred_at` | Время события в формате ISO 8601 | +| `source` / `source_name` | Пакет источника / имя приложения; для SMS источник `android` | +| `title` | Заголовок уведомления; отсутствует у SMS и теста | +| `sender` | Отправитель SMS; отсутствует у уведомления и теста | +| `text` | Текст события | + +В узле сразу после Webhook доступны выражения `{{ $json.body.text }}` и +`{{ $json.body.device_code }}`. Если промежуточный узел меняет данные, обращайтесь +к исходному событию явно: `{{ $('Webhook').first().json.body.text }}`. + +## 5. Добавьте обработку сообщения + +Готовый пошаговый сценарий: [пересылка уведомлений в Telegram](n8n-telegram.md). + + +Готовый приёмник только подтверждает входящий запрос: он не отправляет сообщение +в Telegram, не сохраняет его в отдельную очередь и не удаляет дубли. + +Добавьте нужные действия перед Respond to Webhook и отправляйте ACK после +успешного завершения того действия, которое хотите считать приёмом сообщения. +Для долгой обработки сначала надёжно сохраните событие в собственной очереди или +БД, затем верните ACK, а последующие действия выполняйте отдельно. + +Клиент использует 10-секундные таймауты подключения и чтения. Если ответ потерян, +он может повторить уже обработанный запрос. Используйте `event_id` как уникальный +ключ в хранилище; уже принятый дубль должен получить тот же успешный ACK. +Отдельные операции «проверить наличие → добавить» без уникального ограничения не +защищают от двух одновременных запросов. + +Если отправить ACK **до** обработки, приложение удалит тело события из своей +очереди после подтверждения. Последующая ошибка в n8n не вызовет повтор на телефоне. +Статус успешного execution в n8n и успешное подтверждение клиенту — разные вещи. + +## Проверка без телефона + +Следующий запрос содержит только синтетические данные. Подставьте свой Production +URL и при каждом новом тесте используйте новый `event_id`: + +```sh +curl --fail-with-body --max-time 10 \ + -X POST 'https://n8n.example.org/webhook/message487/ВАШ-ПУТЬ' \ + -H 'Content-Type: application/json' \ + --data '{"schema_version":1,"event_id":"manual-check-001","device_id":"manual-test","device_code":"test-phone","message_type":"test","occurred_at":"2026-09-09T09:00:00Z","source":"life.andre.message487","source_name":"Message487","text":"Synthetic connection test"}' +``` + +Ожидаемый ответ: `{"status":"accepted","event_id":"manual-check-001"}` с HTTP 200. +Это проверяет серверный контракт; реальную доставку из Android проверьте кнопкой +тестового события и затем новым уведомлением или SMS. + +## Если доставка не работает + +| Симптом | Что проверить | +| --- | --- | +| HTTP 404 | Production URL, публикацию workflow, метод POST и путь | +| HTTP 401/403 | Настройки авторизации и ограничения доступа на proxy/n8n | +| HTTP 301/302 | Укажите конечный URL: клиент не следует redirect | +| `INVALID_ACK` при HTTP 200 | JSON-объект, `status: accepted`, точное совпадение `event_id`, режим ответа Webhook | +| Таймаут / сетевая ошибка | Доступность адреса с телефона, TLS, firewall, время до ответа | +| Нет execution в редакторе | Откройте вкладку Executions, проверьте сохранение успешных запусков | +| Тест проходит, а сообщений нет | Источники, разрешения Android, выбор приложений и паузу пересылки | + +Сетевые ошибки, таймауты, HTTP 408/425/429 и 5xx приводят к автоматическим повторам. +Неверный ACK и прочие HTTP-ошибки требуют вмешательства и ручного повтора из журнала. +Изменение URL в настройках влияет только на **новые** события: уже поставленные +в очередь сохраняют прежний адрес. После исправления подключения отправьте новый +тест; старые записи при необходимости удалите отдельно. + +Для разбора ошибок откройте значок диагностики в верхней панели приложения. +[Диагностический лог](diagnostics.md) показывает исход доставки без текста сообщений. diff --git a/docs/project-context.md b/docs/ru/project-context.md similarity index 94% rename from docs/project-context.md rename to docs/ru/project-context.md index 8a99a00..349c285 100644 --- a/docs/project-context.md +++ b/docs/ru/project-context.md @@ -1,5 +1,7 @@ # Контекст Message487 +[English](../en/project-context.md) | [Русский](../ru/project-context.md) + Обновлено 8 сентября 2026 года. ## Подтверждено пользователем @@ -111,8 +113,8 @@ VPN, Go/JNI, DNS-диагностика и детали публикации Meg Политику конфиденциальности нельзя копировать дословно: Message487 передаёт содержимое событий выбранному получателю, а n8n и последующие сервисы имеют собственные правила хранения. -Local rotating diagnostics, a next-launch crash prompt and manual ZIP email reports are implemented; see [diagnostics](diagnostics.md). Recipient: der-morgenstern@yandex.ru. +Реализованы локальные логи с ротацией, диалог после креша и ручная отправка ZIP-отчёта; см. [диагностику](diagnostics.md). Адрес: der-morgenstern@yandex.ru. -CI test categories and commands are documented in [testing](testing.md); Compose UI tests run on Robolectric without an emulator. +Наборы тестов и команды CI описаны в [тестировании](testing.md); Compose UI-тесты выполняются на Robolectric без эмулятора. -Signed APK releases use environment-based signing as in MegaProxy; see [releases](releases.md). +Подписанные APK используют параметры окружения, как в MegaProxy; см. [релизы](releases.md). diff --git a/docs/ru/releases.md b/docs/ru/releases.md new file mode 100644 index 0000000..cb4b5e4 --- /dev/null +++ b/docs/ru/releases.md @@ -0,0 +1,46 @@ +# Выпуск подписанного APK + +[English](../en/releases.md) | [Русский](../ru/releases.md) + +Запустите `bundle exec fastlane android release_artifacts` с JDK 21 и Android SDK 36. +Lane выполняет JVM/Compose-тесты, debug/release lint и подписанную release-сборку, +затем проверяет сертификат, package ID, версию и отсутствие debug-флага. Результаты: +`dist/release/message487-.apk`, файл R8 `mapping.txt` и `SHA256SUMS`. +При разборе крешей используйте mapping от конкретного APK. + +Подпись следует контракту окружения MegaProxy с префиксом `MESSAGE487_`: + +| Переменная | Источник / значение по умолчанию | +| --- | --- | +| `MESSAGE487_KEYSTORE_PATH` | Локально `~/AndroidApkKey`; в CI — восстановленный временный файл | +| `MESSAGE487_KEY_PASSWORD_FILE` | Локально `~/.my-tokens/android-key-password`; в CI — временный файл | +| `MESSAGE487_KEYSTORE_PASSWORD` | Экспортируется release-скриптом из файла пароля | +| `MESSAGE487_KEY_ALIAS` | Локально `key0`; в CI — секрет `ANDROID_KEY_ALIAS` | +| `MESSAGE487_KEY_PASSWORD` | Содержимое файла пароля, если не задан отдельно | +| `MESSAGE487_EXPECTED_CERT_SHA256` | Ожидаемый публичный отпечаток сертификата, закреплённый в скрипте | +| `MESSAGE487_RELEASE_DIR` | `dist/release` | + +Gradle читает только четыре signing-переменные: путь, пароль хранилища, alias и пароль +ключа. Частичная конфигурация приводит к ошибке. PR-lane `checks` отвергает signing-параметры +и проверяет неподписанный release APK. Пароли не передаются аргументами командной строки; +их нельзя печатать, коммитить или передавать через Gradle `-P`. + +Имена GitHub Secrets совпадают с MegaProxy: `ANDROID_SIGNING_KEY_BASE64`, +`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. +Workflow восстанавливает ключ и пароль с приватными правами и удаляет временные файлы +даже при сбое. Job сборки имеет только чтение репозитория; GitHub Release может записывать +лишь отдельный job публикации. PR-workflow не использует signing-секреты. + +## Проверка и публикация + +- Тег `release-check/*` запускает сборку и проверку подписанного APK в GitHub Actions + без публикации Release. APK, mapping, контрольные суммы и отчёты доступны как артефакты. +- После слияния workflow в основную ветку ручной запуск также только собирает артефакты. +- Для публикации увеличьте `versionCode`, задайте нужный `versionName` в `app/build.gradle.kts` + и слейте проверенный PR после обязательных проверок. Отправьте тег `v`. + Workflow требует наличия коммита в `main` и совпадения версии с тегом. Проверенные APK, + mapping и контрольные суммы публикуются в GitHub Releases. Повторный запуск не перезаписывает + существующий Release. + +Первая настроенная версия — `0.0.1`, `versionCode = 1`. Для последующих версий увеличивайте +`versionCode`, чтобы Android мог обновить приложение. Сохраняйте тот же ключ подписи. diff --git a/docs/ru/testing.md b/docs/ru/testing.md new file mode 100644 index 0000000..08e9ead --- /dev/null +++ b/docs/ru/testing.md @@ -0,0 +1,43 @@ +# Тесты и CI + +[English](../en/testing.md) | [Русский](../ru/testing.md) + +Локально и в GitHub Actions используются одинаковые команды: + +| Набор | Команда | Что проверяется | +| --- | --- | --- | +| Android | `bundle exec fastlane android checks` | JVM-логика, HTTP через MockWebServer, БД/настройки/provider через Robolectric, Compose UI, локализация, манифест, контраст обеих тем, debug/release lint и сборки, отсутствие release-подписи | +| Python | `PYTHON=.venv/bin/python bundle exec fastlane android python_checks` | HTTP-клиент тестового сервера на локальном HTTP-стенде, форматирование закреплёнными Black/isort | +| n8n | `bundle exec fastlane android server_tests` | Приём test/notification/SMS, валидация, HTTP-ошибка, неверный ACK и таймаут на живом сервере | + +Для Python создайте `.venv` командой `python3 -m venv .venv` и установите +`requirements-dev.txt`. Для n8n сначала выполните +`docker compose -f DevServer/compose.yaml up -d --wait --wait-timeout 300`. +Серверные проверки используют синтетические данные и сохраняют их в истории execution. + +`.github/workflows/ci.yml` запускает все три job на PR, push в main и вручную. +XML/HTML-отчёты Android-тестов и lint загружаются и при ошибках. Успешный Android-job +также публикует debug APK и неподписанный release APK. Подпись release в этих проверках +не используется. Локальный успех не подтверждает результат GitHub для неотправленных изменений. + +## Сравнение с MegaProxy + +Совпадают применимые категории: JVM-логика, Android-интеграция, Compose через Robolectric, +контракты ресурсов/безопасности/UI, Python и форматирование, lint и сборки. Message487 +дополнительно проверяет живой Docker/n8n. Go race-тесты и необязательный fuzz-lane +MegaProxy относятся к его Go/JNI-коду; здесь такого кода нет. Его Python-тесты истории +CI относятся к скриптам, которых в Message487 нет. + +Compose-тесты используют настоящую навигацию, экраны, ViewModel и настройки с тестовым +Application без запуска Worker и установки глобального обработчика крешей. Явная +фабрика ViewModel привязывает каждый тест к своему Application; перед проверками +дожидаются фоновых операций. Покрыты переходы и возврат, валидация и сохранение подключения, +массовый выбор приложений, очистка диагностики. + +Эти тесты не подтверждают системную доставку SMS/уведомлений, работу разрешений, +планирование WorkManager, настоящий Android Keystore, смерть процесса или почтовый клиент. +Для этого нужны устройства/эмулятор; сценарий креша описан в [диагностике](diagnostics.md). +Как и MegaProxy, обязательный hosted CI не зависит от нестабильной доступности KVM. + +Подписанная release-сборка отдельно запускает Android-тесты и lint с signing-переменными. +PR-проверки отвергают эти переменные и остаются неподписанными. См. [релизы](releases.md). From 93f1b6625d36b911815325a585142c54fa408073 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Wed, 9 Sep 2026 10:03:05 +0300 Subject: [PATCH 2/2] Require Bearer authentication for webhook delivery --- DevServer/README.md | 8 +-- DevServer/credentials/header-auth.json | 11 +++++ DevServer/import.sh | 1 + DevServer/start.sh | 2 +- DevServer/tests/smoke.py | 28 +++++++++-- DevServer/tests/test_smoke.py | 14 +++++- DevServer/workflows/error.json | 11 ++++- DevServer/workflows/invalid-ack.json | 11 ++++- DevServer/workflows/receive.json | 11 ++++- DevServer/workflows/slow.json | 11 ++++- PRIVACY.md | 12 +++-- README.md | 3 +- .../life/andre/message487/ConnectionScreen.kt | 6 +++ .../andre/message487/ConnectionViewModel.kt | 12 +++-- .../life/andre/message487/DeliveryWorker.kt | 2 +- .../andre/message487/ForwardingSettings.kt | 23 +++++++-- .../life/andre/message487/MessageGraph.kt | 3 +- .../main/java/life/andre/message487/Outbox.kt | 10 ++-- .../life/andre/message487/WebhookClient.kt | 11 +++-- app/src/main/res/values-ru/strings.xml | 5 +- app/src/main/res/values/strings.xml | 5 +- .../life/andre/message487/ForwardingTest.kt | 2 +- .../java/life/andre/message487/OutboxTest.kt | 14 +++++- .../andre/message487/ScreenInteractionTest.kt | 15 ++++++ .../andre/message487/SettingsStoreTest.kt | 49 +++++++++++++++++++ .../andre/message487/WebhookClientTest.kt | 28 ++++++++--- .../diagnostics/FeedbackEmailTest.kt | 2 +- docs/en/n8n-webhook.md | 34 +++++++------ docs/en/project-context.md | 4 +- docs/ru/n8n-webhook.md | 36 ++++++++------ docs/ru/project-context.md | 4 +- 31 files changed, 303 insertions(+), 85 deletions(-) create mode 100644 DevServer/credentials/header-auth.json create mode 100644 app/src/test/java/life/andre/message487/SettingsStoreTest.kt diff --git a/DevServer/README.md b/DevServer/README.md index a7e23e9..ee5b775 100644 --- a/DevServer/README.md +++ b/DevServer/README.md @@ -13,7 +13,8 @@ Open the editor at . Local development login: - Password: `Message487-Local-Only` These are public test credentials. This configuration is for synthetic local development data. -The webhook endpoints do not require authentication. The published port is bound to host loopback. +All webhook endpoints require `Authorization: Bearer `. The public local token is +the `data.value` in [header-auth.json](credentials/header-auth.json), without the `Bearer ` prefix. The published port is bound to host loopback. The editor account and its bcrypt password hash are provisioned through n8n environment variables. The image version, execution retention, and runtime settings live in [compose.yaml](compose.yaml). @@ -24,7 +25,7 @@ returns an HTTP error; its n8n execution itself may still be marked successful. ## Android connection The standard Android Emulator reaches the host through `10.0.2.2`. The debug app starts with the -receive endpoint configured. Its **Save and send test event** button submits synthetic data and +receive endpoint configured. Enter the local token in **Webhook token** before saving. Its **Save and send test event** button submits synthetic data and shows the event ID and confirmation result in **Journal**. Find the same ID in the workflow's execution input. In the Webhook output, `body.device_id` is the installation UUID and `body.device_code` is the editable device label. Both are preserved in execution history; older events may lack the label. @@ -78,7 +79,8 @@ docker compose -f DevServer/compose.yaml stop docker compose -f DevServer/compose.yaml up -d --wait ``` -Bootstrap runs once per data volume. Restarts preserve editor changes. To explicitly replace the +The authorization upgrade reimports the four fixtures and their credential once on existing +volumes; subsequent bootstrap runs once per data volume. Restarts preserve editor changes. To explicitly replace the bundled workflows with the checked-in versions, stop n8n before importing: ```sh diff --git a/DevServer/credentials/header-auth.json b/DevServer/credentials/header-auth.json new file mode 100644 index 0000000..167eee4 --- /dev/null +++ b/DevServer/credentials/header-auth.json @@ -0,0 +1,11 @@ +[ + { + "id": "message487-header-auth", + "name": "Message487 local webhook", + "type": "httpHeaderAuth", + "data": { + "name": "Authorization", + "value": "Bearer message487-local-test-only" + } + } +] diff --git a/DevServer/import.sh b/DevServer/import.sh index 7f7a6e5..53f8d54 100644 --- a/DevServer/import.sh +++ b/DevServer/import.sh @@ -1,6 +1,7 @@ #!/bin/sh set -eu +n8n import:credentials --input=/bootstrap/credentials/header-auth.json n8n import:workflow --separate --input=/bootstrap/workflows for workflow in receive error slow invalid-ack; do n8n publish:workflow --id="message487-$workflow" diff --git a/DevServer/start.sh b/DevServer/start.sh index 3c836aa..8c9d139 100644 --- a/DevServer/start.sh +++ b/DevServer/start.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu -marker=/home/node/.n8n/message487-bootstrap-v1 +marker=/home/node/.n8n/message487-bootstrap-v2-auth if [ ! -f "$marker" ]; then /bin/sh /bootstrap/import.sh touch "$marker" diff --git a/DevServer/tests/smoke.py b/DevServer/tests/smoke.py index 234ebd9..d7943ff 100644 --- a/DevServer/tests/smoke.py +++ b/DevServer/tests/smoke.py @@ -3,20 +3,36 @@ import urllib.error import urllib.request import uuid +from pathlib import Path +DEV_TOKEN = json.loads( + (Path(__file__).resolve().parents[1] / 'credentials/header-auth.json').read_text() +)[0]['data']['value'].removeprefix('Bearer ') -def post(scenario, payload, timeout=5, base_url='http://127.0.0.1:5678'): + +def post( + scenario, payload, timeout=5, base_url='http://127.0.0.1:5678', token=DEV_TOKEN +): + headers = {'Content-Type': 'application/json'} + if token is not None: + headers['Authorization'] = f'Bearer {token}' request = urllib.request.Request( f'{base_url}/webhook/message487/{scenario}', data=json.dumps(payload).encode(), - headers={'Content-Type': 'application/json'}, + headers=headers, ) opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) try: with opener.open(request, timeout=timeout) as response: return response.status, json.load(response) except urllib.error.HTTPError as error: - return error.code, json.load(error) + with error: + body = error.read().decode() + try: + body = json.loads(body) + except json.JSONDecodeError: + pass + return error.code, body def main(): @@ -30,6 +46,10 @@ def main(): 'message_type': 'test', 'text': 'Synthetic smoke test', } + for scenario in ('receive', 'error', 'slow', 'invalid-ack'): + for token in (None, '', 'wrong-token'): + code, _ = post(scenario, event, token=token) + assert code in (401, 403), (scenario, code) for message_type in ('test', 'notification', 'sms'): event.update(message_type=message_type, event_id=str(uuid.uuid4())) if message_type == 'notification': @@ -55,7 +75,7 @@ def main(): else: raise AssertionError('Slow endpoint did not time out') print( - 'Passed: test/notification/SMS receive, validation, HTTP error, invalid ACK, timeout' + 'Passed: mandatory authorization on all endpoints, test/notification/SMS receive, validation, HTTP error, invalid ACK, timeout' ) diff --git a/DevServer/tests/test_smoke.py b/DevServer/tests/test_smoke.py index be1dc9f..33fea48 100644 --- a/DevServer/tests/test_smoke.py +++ b/DevServer/tests/test_smoke.py @@ -9,7 +9,12 @@ class Handler(http.server.BaseHTTPRequestHandler): def do_POST(self): payload = json.loads(self.rfile.read(int(self.headers['Content-Length']))) - self.server.received = (self.path, self.headers['Content-Type'], payload) + self.server.received = ( + self.path, + self.headers['Content-Type'], + self.headers.get('Authorization'), + payload, + ) self.send_response(500 if self.path.endswith('/error') else 200) self.end_headers() if self.path.endswith('/malformed'): @@ -42,7 +47,12 @@ def test_posts_unicode_json_and_returns_ack(self): smoke.post('receive', event, base_url=self.base_url), ) self.assertEqual( - ('/webhook/message487/receive', 'application/json', event), + ( + '/webhook/message487/receive', + 'application/json', + f'Bearer {smoke.DEV_TOKEN}', + event, + ), self.server.received, ) diff --git a/DevServer/workflows/error.json b/DevServer/workflows/error.json index f361831..44d0aba 100644 --- a/DevServer/workflows/error.json +++ b/DevServer/workflows/error.json @@ -8,7 +8,8 @@ "httpMethod": "POST", "path": "message487/error", "responseMode": "responseNode", - "options": {} + "options": {}, + "authentication": "headerAuth" }, "id": "webhook", "name": "Webhook", @@ -18,7 +19,13 @@ 0, 0 ], - "webhookId": "message487-error" + "webhookId": "message487-error", + "credentials": { + "httpHeaderAuth": { + "id": "message487-header-auth", + "name": "Message487 local webhook" + } + } }, { "parameters": { diff --git a/DevServer/workflows/invalid-ack.json b/DevServer/workflows/invalid-ack.json index 5182ab2..3c307e9 100644 --- a/DevServer/workflows/invalid-ack.json +++ b/DevServer/workflows/invalid-ack.json @@ -8,7 +8,8 @@ "httpMethod": "POST", "path": "message487/invalid-ack", "responseMode": "responseNode", - "options": {} + "options": {}, + "authentication": "headerAuth" }, "id": "webhook", "name": "Webhook", @@ -18,7 +19,13 @@ 0, 0 ], - "webhookId": "message487-invalid-ack" + "webhookId": "message487-invalid-ack", + "credentials": { + "httpHeaderAuth": { + "id": "message487-header-auth", + "name": "Message487 local webhook" + } + } }, { "parameters": { diff --git a/DevServer/workflows/receive.json b/DevServer/workflows/receive.json index d482dec..556e311 100644 --- a/DevServer/workflows/receive.json +++ b/DevServer/workflows/receive.json @@ -8,7 +8,8 @@ "httpMethod": "POST", "path": "message487/receive", "responseMode": "responseNode", - "options": {} + "options": {}, + "authentication": "headerAuth" }, "id": "webhook", "name": "Webhook", @@ -18,7 +19,13 @@ 0, 0 ], - "webhookId": "message487-receive" + "webhookId": "message487-receive", + "credentials": { + "httpHeaderAuth": { + "id": "message487-header-auth", + "name": "Message487 local webhook" + } + } }, { "parameters": { diff --git a/DevServer/workflows/slow.json b/DevServer/workflows/slow.json index 5f644a8..aac8313 100644 --- a/DevServer/workflows/slow.json +++ b/DevServer/workflows/slow.json @@ -8,7 +8,8 @@ "httpMethod": "POST", "path": "message487/slow", "responseMode": "responseNode", - "options": {} + "options": {}, + "authentication": "headerAuth" }, "id": "webhook", "name": "Webhook", @@ -18,7 +19,13 @@ 0, 0 ], - "webhookId": "message487-slow" + "webhookId": "message487-slow", + "credentials": { + "httpHeaderAuth": { + "id": "message487-header-auth", + "name": "Message487 local webhook" + } + } }, { "parameters": { diff --git a/PRIVACY.md b/PRIVACY.md index de90974..125255a 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy Policy -Last updated: September 8, 2026. +Last updated: September 9, 2026. Message487 forwards selected notifications and new incoming SMS to a webhook you configure. Both capture sources are off by default. Notifications require Android notification access and @@ -14,9 +14,12 @@ selected package names, pause state and a random installation ID in private pref preferences do not have additional application-level encryption. A webhook URL may itself contain a secret, so treat it as sensitive. Android cloud backup and device transfer are disabled for app data. +The webhook Bearer token is encrypted with AES-GCM and an Android Keystore key before +being saved in preferences. It is sent in the Authorization header to your configured endpoint. + Captured event bodies, including message text, notification titles and SMS senders, are stored in a private SQLite outbox encrypted with AES-GCM and a key held in Android Keystore. Each queued -request includes its original destination and confirmation mode. Delivery metadata (event ID, +request includes its original destination, encrypted authentication token and confirmation mode. Delivery metadata (event ID, source display name, type, timestamps, state, attempt count and HTTP result) is stored without additional application-level encryption. Notification duplicate detection stores hashes of keys and contents; these hashes are not a substitute for encryption against guesses of known content. @@ -31,7 +34,7 @@ Message bodies and server response bodies are not written to diagnostic logs by The app records local diagnostic events: startup, listener connectivity, capture and delivery outcomes, HTTP status codes, queue counts and failures. Logs exclude message and response bodies, -notification titles, SMS senders, source packages, webhook URLs, device codes and installation IDs. +notification titles, SMS senders, source packages, webhook URLs, authentication tokens, device codes and installation IDs. Exception messages are omitted; limited stack traces contain exception classes and code locations. Unhandled Java/Kotlin exceptions are saved synchronously and a report prompt appears on next launch. @@ -58,7 +61,8 @@ The endpoint operator can also see connection metadata such as your IP address. any downstream services process data under their own policies. An n8n instance may retain complete event bodies in its execution history; the bundled development server does so. Retries can produce more than one server-side copy of an event. Changing the webhook affects new events; existing -queued events keep their previous destination. +queued events keep their previous destination and token. Server execution history may also +include request headers; restrict access and retention. The app lists visible launcher apps locally to let you select sources and resolve display names. It does not upload an installed-app inventory or request visibility of all installed packages. diff --git a/README.md b/README.md index 8b80802..3fa7a0a 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,7 @@ A custom webhook is also supported. Telegram forwarding is one possible workflow app does not depend on Telegram. **Status:** development preview with notification/SMS capture, a persistent encrypted outbox, -background delivery, automatic retries and a delivery journal. Webhook authentication is not -implemented yet. Signed APK release automation is configured; see [Releases](docs/en/releases.md). +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). ## Getting started diff --git a/app/src/main/java/life/andre/message487/ConnectionScreen.kt b/app/src/main/java/life/andre/message487/ConnectionScreen.kt index 78f1a82..d55c040 100644 --- a/app/src/main/java/life/andre/message487/ConnectionScreen.kt +++ b/app/src/main/java/life/andre/message487/ConnectionScreen.kt @@ -30,6 +30,12 @@ internal fun ConnectionScreen(state: ConnectionState, model: ConnectionViewModel isError = state.invalidUrl, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), shape = MaterialTheme.shapes.small, supportingText = { Text(stringResource(if (state.invalidUrl) R.string.invalid_url else R.string.url_hint)) }) + OutlinedTextField(value = state.authToken, onValueChange = model::setAuthToken, + label = { Text(stringResource(R.string.auth_token)) }, modifier = Modifier.fillMaxWidth(), + enabled = !state.busy, singleLine = true, isError = state.invalidToken, + visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + supportingText = { Text(stringResource(if (state.invalidToken) R.string.invalid_token else R.string.token_hint)) }) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { Icon(Icons.Outlined.VerifiedUser, null, tint = MaterialTheme.colorScheme.primary) diff --git a/app/src/main/java/life/andre/message487/ConnectionViewModel.kt b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt index cc6901d..d1a7cef 100644 --- a/app/src/main/java/life/andre/message487/ConnectionViewModel.kt +++ b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt @@ -26,7 +26,11 @@ data class ConnectionState( val invalidUrl: Boolean = false, val invalidDeviceCode: Boolean = false, val notice: Int? = null, -) + val authToken: String = "", + val invalidToken: Boolean = false, +) { + override fun toString(): String = "ConnectionState(redacted)" +} data class QueueSnapshot(val entries: List = emptyList(), val pending: Int = 0) data class PermissionState(val notifications: Boolean = false, val sms: Boolean = false) @@ -36,7 +40,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati val settings = graph.settings.state val listenerConnected = ListenerState.connected private val mutableState = MutableStateFlow(settings.value.let { - ConnectionState(it.url, it.deviceCode, it.requireAck) + ConnectionState(it.url, it.deviceCode, it.requireAck, authToken = it.authToken) }) val state = mutableState.asStateFlow() private val mutableQueue = MutableStateFlow(QueueSnapshot()) @@ -82,6 +86,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati ) } + fun setAuthToken(token: String) { mutableState.value = state.value.copy(authToken = token, invalidToken = false, notice = null) } fun setUrl(url: String) { mutableState.value = state.value.copy(url = url, invalidUrl = false, notice = null) } fun setDeviceCode(code: String) { mutableState.value = state.value.copy(deviceCode = code, invalidDeviceCode = false, notice = null) } fun setRequireAck(value: Boolean) { mutableState.value = state.value.copy(requireAck = value, notice = null) } @@ -92,8 +97,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati val code = draft.deviceCode.trim() if (code.isBlank()) { mutableState.value = draft.copy(invalidDeviceCode = true); return } if (!validWebhookUrl(url, BuildConfig.DEBUG)) { mutableState.value = draft.copy(invalidUrl = true); return } + if (!validAuthToken(draft.authToken)) { mutableState.value = draft.copy(invalidToken = true); return } action(if (sendTest) R.string.test_queued else R.string.saved) { - graph.settings.update { it.copy(url = url, deviceCode = code, requireAck = draft.requireAck) } + graph.settings.update { it.copy(url = url, deviceCode = code, requireAck = draft.requireAck, authToken = draft.authToken) } if (sendTest) graph.enqueueTest() } } diff --git a/app/src/main/java/life/andre/message487/DeliveryWorker.kt b/app/src/main/java/life/andre/message487/DeliveryWorker.kt index 029a410..11f2625 100644 --- a/app/src/main/java/life/andre/message487/DeliveryWorker.kt +++ b/app/src/main/java/life/andre/message487/DeliveryWorker.kt @@ -54,7 +54,7 @@ class DeliveryWorker(context: Context, parameters: WorkerParameters) : Coroutine graph.diagnostics.record(DiagnosticEvent.DELIVERY_STARTED) val request = attempt.request val result = if (validWebhookUrl(request.url, BuildConfig.DEBUG)) { - WebhookClient().sendJson(request.url, id, request.json, request.requireAck) + WebhookClient().sendJson(request.url, id, request.json, request.requireAck, request.authToken) } else DeliveryResult(id, DeliveryStatus.HTTP_ERROR) graph.diagnostics.record(DiagnosticEvent.DELIVERY_FINISHED, outcome = result.status.name, http = result.httpCode) val state = graph.outbox.finish(id, attempt.token, result) diff --git a/app/src/main/java/life/andre/message487/ForwardingSettings.kt b/app/src/main/java/life/andre/message487/ForwardingSettings.kt index a083629..e791480 100644 --- a/app/src/main/java/life/andre/message487/ForwardingSettings.kt +++ b/app/src/main/java/life/andre/message487/ForwardingSettings.kt @@ -16,19 +16,30 @@ data class ForwardingSettings( val paused: Boolean = false, val packages: Set = emptySet(), val captureFailed: Boolean = false, + val authToken: String = "", ) { - fun ready(): Boolean = deviceCode.isNotBlank() && validWebhookUrl(url, BuildConfig.DEBUG) + override fun toString(): String = "ForwardingSettings(redacted)" + + fun ready(): Boolean = deviceCode.isNotBlank() && validWebhookUrl(url, BuildConfig.DEBUG) && validAuthToken(authToken) fun acceptsPackage(packageName: String, ownPackage: String): Boolean = !paused && notifications && packageName != ownPackage && packageName in packages && ready() } -class SettingsStore(context: Context) { +class SettingsStore( + context: Context, + private val cipher: PayloadCipher = (context.applicationContext as? MessageApplication)?.payloadCipher ?: KeystorePayloadCipher(), +) { private val diagnostics = (context.applicationContext as? MessageApplication)?.diagnostics private val preferences = context.getSharedPreferences("connection", Context.MODE_PRIVATE) private val mutableState = MutableStateFlow(read()) val state = mutableState.asStateFlow() private fun read() = ForwardingSettings( + authToken = runCatching { + preferences.getString("auth_token_encrypted", null)?.let { + cipher.decrypt(android.util.Base64.decode(it, android.util.Base64.NO_WRAP)) + }.orEmpty() + }.getOrDefault(""), url = preferences.getString("url", BuildConfig.DEFAULT_WEBHOOK_URL).orEmpty(), deviceId = preferences.getString("device_id", "").orEmpty(), deviceCode = preferences.getString("device_code", "android-device").orEmpty(), @@ -45,7 +56,13 @@ class SettingsStore(context: Context) { val next = transform(mutableState.value).let { if (it.deviceId.isBlank()) it.copy(deviceId = UUID.randomUUID().toString()) else it } - if (!preferences.edit() + val editor = preferences.edit() + if (next.authToken != mutableState.value.authToken) { + require(validAuthToken(next.authToken)) + editor.putString("auth_token_encrypted", android.util.Base64.encodeToString( + cipher.encrypt(next.authToken), android.util.Base64.NO_WRAP)) + } + if (!editor .putString("url", next.url).putString("device_id", next.deviceId) .putString("device_code", next.deviceCode).putBoolean("require_ack", next.requireAck) .putBoolean("notifications", next.notifications).putBoolean("sms", next.sms) diff --git a/app/src/main/java/life/andre/message487/MessageGraph.kt b/app/src/main/java/life/andre/message487/MessageGraph.kt index 4f01cae..be56dfd 100644 --- a/app/src/main/java/life/andre/message487/MessageGraph.kt +++ b/app/src/main/java/life/andre/message487/MessageGraph.kt @@ -12,6 +12,7 @@ import life.andre.message487.diagnostics.DiagnosticLog import life.andre.message487.diagnostics.DiagnosticEvent open class MessageApplication : Application() { + internal open val payloadCipher: PayloadCipher by lazy { KeystorePayloadCipher() } internal val diagnostics by lazy { DiagnosticLog(File(filesDir, "logs")) } internal val crashHandler by lazy { CrashHandler(this, diagnostics) } val graph by lazy { MessageGraph(this) } @@ -27,7 +28,7 @@ open class MessageApplication : Application() { class MessageGraph internal constructor(private val context: Application) { internal val diagnostics get() = (context as MessageApplication).diagnostics val settings = SettingsStore(context) - val outbox = Outbox(context, KeystorePayloadCipher()) + val outbox = Outbox(context, (context as MessageApplication).payloadCipher) val scheduler by lazy { DeliveryScheduler(context) } val captureExecutor = Executors.newSingleThreadExecutor() private val sources = AppSourceResolver(context.packageManager) diff --git a/app/src/main/java/life/andre/message487/Outbox.kt b/app/src/main/java/life/andre/message487/Outbox.kt index 237fb54..0fc1eef 100644 --- a/app/src/main/java/life/andre/message487/Outbox.kt +++ b/app/src/main/java/life/andre/message487/Outbox.kt @@ -25,7 +25,9 @@ data class QueueEntry( val delivered: Boolean get() = state == QueueState.ACCEPTED || state == QueueState.HTTP_SUCCESS } -data class QueuedRequest(val url: String, val requireAck: Boolean, val json: String) +data class QueuedRequest(val url: String, val requireAck: Boolean, val json: String, val authToken: String) { + override fun toString(): String = "QueuedRequest(redacted)" +} data class Attempt(val token: String, val entry: QueueEntry, val request: QueuedRequest) fun deliveryQueueState(result: DeliveryResult): QueueState = when (result.status) { @@ -69,7 +71,7 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe db.rawQuery("SELECT id FROM events WHERE id = ?", arrayOf(event.eventId)).use { if (it.moveToFirst()) return false } - val envelope = JSONObject().put("url", settings.url).put("require_ack", settings.requireAck) + val envelope = JSONObject().put("url", settings.url).put("require_ack", settings.requireAck).put("auth_token", settings.authToken) .put("event", JSONObject(event.toJson())).toString() db.insertOrThrow("events", null, ContentValues().apply { put("id", event.eventId) @@ -122,7 +124,9 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe val entry = it.entry() if (entry.delivered || entry.state == QueueState.BLOCKED) return null val envelope = JSONObject(cipher.decrypt(it.getBlob(it.getColumnIndexOrThrow("payload")))) - entry to QueuedRequest(envelope.getString("url"), envelope.getBoolean("require_ack"), envelope.getJSONObject("event").toString()) + val authToken = envelope.getString("auth_token") + require(validAuthToken(authToken)) { "Missing queued authentication token" } + entry to QueuedRequest(envelope.getString("url"), envelope.getBoolean("require_ack"), envelope.getJSONObject("event").toString(), authToken) } val token = UUID.randomUUID().toString() db.execSQL("UPDATE events SET state = 'SENDING', attempts = attempts + 1, attempt_token = ? WHERE id = ?", arrayOf(token, id)) diff --git a/app/src/main/java/life/andre/message487/WebhookClient.kt b/app/src/main/java/life/andre/message487/WebhookClient.kt index eb7a907..493d3c8 100644 --- a/app/src/main/java/life/andre/message487/WebhookClient.kt +++ b/app/src/main/java/life/andre/message487/WebhookClient.kt @@ -53,11 +53,15 @@ fun validWebhookUrl(value: String, allowLocalHttp: Boolean): Boolean = runCatchi (uri.scheme == "https" || (allowLocalHttp && local && uri.scheme == "http")) }.getOrDefault(false) +fun validAuthToken(value: String): Boolean = value.length in 1..4096 && + value.matches(Regex("[A-Za-z0-9._~+/-]+=*")) + class WebhookClient(private val timeoutMs: Int = 10_000) { - fun send(url: String, event: MessageEvent, requireAck: Boolean): DeliveryResult = - sendJson(url, event.eventId, event.toJson(), requireAck) + fun send(url: String, event: MessageEvent, requireAck: Boolean, authToken: String): DeliveryResult = + sendJson(url, event.eventId, event.toJson(), requireAck, authToken) - fun sendJson(url: String, eventId: String, json: String, requireAck: Boolean): DeliveryResult { + fun sendJson(url: String, eventId: String, json: String, requireAck: Boolean, authToken: String): DeliveryResult { + require(validAuthToken(authToken)) { "Invalid authentication token" } val start = System.nanoTime() var connection: HttpURLConnection? = null var httpCode: Int? = null @@ -71,6 +75,7 @@ class WebhookClient(private val timeoutMs: Int = 10_000) { doOutput = true setRequestProperty("Content-Type", "application/json; charset=utf-8") setRequestProperty("Accept", "application/json") + setRequestProperty("Authorization", "Bearer $authToken") } val payload = json.toByteArray(StandardCharsets.UTF_8) connection.setFixedLengthStreamingMode(payload.size) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 4cf59a1..0b5387d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -36,7 +36,7 @@ Скрыть Не удалось выполнить операцию. Проверьте место на устройстве и настройки. Не удалось открыть настройки Android. - События в очереди сохраняют адрес получателя и режим подтверждения на момент приёма. + События в очереди сохраняют адрес получателя, токен и режим подтверждения на момент приёма. Включённые источники передают текст, имя источника и время на сохранённый webhook. SMS также содержит отправителя. По умолчанию оба источника выключены. Уведомления Только выбранные приложения. Постоянные уведомления и сводки групп пропускаются. @@ -117,4 +117,7 @@ Приложение аварийно завершилось Локальный отчёт о креше поможет разобраться в проблеме. Можно просмотреть его и подготовить письмо разработчику. Ничего не отправляется автоматически. Посмотреть отчёт + Токен веб-хука + Введите токен без префикса Bearer. Хранится на устройстве в зашифрованном виде. + Введите токен без пробелов и префикса Bearer. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fc94e8e..37ac61a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,7 +36,7 @@ Dismiss Could not complete the local operation. Check storage and settings. Could not open Android settings. - Queued events keep the recipient and confirmation mode used when they were captured. + Queued events keep the recipient, token and confirmation mode used when they were captured. Enabled sources send message text, source names and timestamps to your saved webhook. SMS also includes the sender. Both sources are off initially. Notifications Selected apps only. Ongoing notifications and group summaries are skipped. @@ -117,4 +117,7 @@ The app stopped unexpectedly A local crash report may help diagnose the problem. You can review it and prepare an email to the developer. Nothing is sent automatically. Review report + Webhook token + Enter the token without the Bearer prefix. Stored encrypted on this device. + Enter a token without spaces or the Bearer prefix. diff --git a/app/src/test/java/life/andre/message487/ForwardingTest.kt b/app/src/test/java/life/andre/message487/ForwardingTest.kt index c681d5b..cf56b81 100644 --- a/app/src/test/java/life/andre/message487/ForwardingTest.kt +++ b/app/src/test/java/life/andre/message487/ForwardingTest.kt @@ -6,7 +6,7 @@ import org.junit.Test class ForwardingTest { @Test fun `capture requires opt in and selected package and excludes self`() { - val settings = ForwardingSettings(url = "https://example.com/receive", packages = setOf("example.chat", "self")) + val settings = ForwardingSettings(authToken = "test-token", url = "https://example.com/receive", packages = setOf("example.chat", "self")) assertFalse(settings.acceptsPackage("example.chat", "self")) val enabled = settings.copy(notifications = true) assertTrue(enabled.acceptsPackage("example.chat", "self")) diff --git a/app/src/test/java/life/andre/message487/OutboxTest.kt b/app/src/test/java/life/andre/message487/OutboxTest.kt index 639ccd8..336fb11 100644 --- a/app/src/test/java/life/andre/message487/OutboxTest.kt +++ b/app/src/test/java/life/andre/message487/OutboxTest.kt @@ -21,7 +21,7 @@ class OutboxTest { override fun decrypt(value: ByteArray) = String(value).reversed() } private lateinit var outbox: Outbox - private val settings = ForwardingSettings(url = "https://original.example/receive", deviceId = "device") + private val settings = ForwardingSettings(authToken = "test-token", url = "https://original.example/receive", deviceId = "device") private fun event(id: String = java.util.UUID.randomUUID().toString()) = MessageEvent( "device", "phone", AppSource("example.chat", "Chat"), eventId = id, messageType = "notification", title = "Заголовок", text = "Text\nСообщение", @@ -41,6 +41,8 @@ class OutboxTest { outbox = Outbox(context, codec) val first = outbox.beginAttempt(event.eventId)!! assertEquals(settings.url, first.request.url) + assertEquals(settings.authToken, first.request.authToken) + assertFalse(first.request.toString().contains(settings.authToken)) assertTrue(first.request.requireAck) assertEquals(event.text, JSONObject(first.request.json).getString("text")) outbox.finish(event.eventId, first.token, DeliveryResult(event.eventId, DeliveryStatus.TIMEOUT)) @@ -50,6 +52,16 @@ class OutboxTest { assertEquals(2, outbox.entries().single().attempts) } + @Test fun `legacy payload without authentication cannot start delivery`() { + val event = event() + outbox.enqueue(event, settings) + val envelope = JSONObject().put("url", settings.url).put("require_ack", true) + .put("event", JSONObject(event.toJson())) + val values = android.content.ContentValues().apply { put("payload", codec.encrypt(envelope.toString())) } + outbox.writableDatabase.update("events", values, "id = ?", arrayOf(event.eventId)) + assertThrows(org.json.JSONException::class.java) { outbox.beginAttempt(event.eventId) } + } + @Test fun `success removes payload but retains journal and rejects stale completion`() { val event = event() outbox.enqueue(event, settings) diff --git a/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt index 8b9d13e..7e94406 100644 --- a/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt +++ b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt @@ -15,6 +15,10 @@ import org.robolectric.annotation.LooperMode // UI tests retain real stores/screens but do not install a process crash handler or start workers. class UiTestApplication : MessageApplication() { + override val payloadCipher = object : PayloadCipher { + override fun encrypt(value: String) = value.reversed().toByteArray() + override fun decrypt(value: ByteArray) = String(value).reversed() + } override fun onCreate() { val intent = android.content.Intent(android.content.Intent.ACTION_MAIN).addCategory(android.content.Intent.CATEGORY_LAUNCHER) for (pkg in listOf("example.alpha", "example.beta", packageName)) { @@ -70,11 +74,21 @@ class ScreenInteractionTest { @Test fun `invalid URL is rejected then valid connection persists across store recreation`() { node(R.string.connection_nav).performClick() node(R.string.webhook_url).performTextReplacement("not a URL") + compose.onNode(hasScrollToIndexAction()).performScrollToNode(hasText(application.getString(R.string.save))) node(R.string.save).performScrollTo().performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) { it() } + compose.onNode(hasScrollToIndexAction()).performScrollToNode(hasText(application.getString(R.string.invalid_url))) node(R.string.invalid_url).performScrollTo().assertIsDisplayed() assertNotEquals("not a URL", application.graph.settings.state.value.url) node(R.string.webhook_url).performTextReplacement("https://example.test/webhook") + compose.onNode(hasScrollToIndexAction()).performScrollToNode(hasText(application.getString(R.string.save))) + node(R.string.save).performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) { it() } + compose.onNode(hasScrollToIndexAction()).performScrollToNode(hasText(application.getString(R.string.invalid_token))) + node(R.string.invalid_token).assertIsDisplayed() + assertEquals("", application.graph.settings.state.value.authToken) + node(R.string.auth_token).performScrollTo().performTextReplacement("test-token") + compose.onNode(hasScrollToIndexAction()).performScrollToNode(hasText(application.getString(R.string.device_code))) node(R.string.device_code).performScrollTo().performTextReplacement("test-phone") + compose.onNode(hasScrollToIndexAction()).performScrollToNode(hasText(application.getString(R.string.save))) node(R.string.save).performScrollTo().performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) { it() } compose.waitForIdle() assertSame(application.graph.settings.state, model.settings) @@ -87,6 +101,7 @@ class ScreenInteractionTest { val restored = SettingsStore(application).state.value assertEquals("https://example.test/webhook", restored.url) assertEquals("test-phone", restored.deviceCode) + assertEquals("test-token", restored.authToken) assertTrue(restored.deviceId.isNotBlank()) } diff --git a/app/src/test/java/life/andre/message487/SettingsStoreTest.kt b/app/src/test/java/life/andre/message487/SettingsStoreTest.kt new file mode 100644 index 0000000..da2506d --- /dev/null +++ b/app/src/test/java/life/andre/message487/SettingsStoreTest.kt @@ -0,0 +1,49 @@ +package life.andre.message487 + +import android.app.Application +import android.content.Context +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [35], manifest = Config.NONE) +class SettingsStoreTest { + private val context get() = RuntimeEnvironment.getApplication() + private val cipher = object : PayloadCipher { + override fun encrypt(value: String) = value.reversed().toByteArray() + override fun decrypt(value: ByteArray) = String(value).reversed() + } + + @Test fun `token survives restart through injected encryption and is redacted`() { + val store = SettingsStore(context, cipher) + store.update { it.copy(authToken = "private-token", url = "https://example.test/hook") } + assertEquals("private-token", SettingsStore(context, cipher).state.value.authToken) + val prefs = context.getSharedPreferences("connection", Context.MODE_PRIVATE) + assertFalse(prefs.all.toString().contains("private-token")) + assertFalse(store.state.value.toString().contains("private-token")) + assertFalse(ConnectionState(authToken = "private-token").toString().contains("private-token")) + assertTrue(store.state.value.ready()) + assertFalse(store.state.value.copy(authToken = "").ready()) + } + + @Test fun `unreadable token disables capture and unrelated updates preserve ciphertext`() { + SettingsStore(context, cipher).update { it.copy(authToken = "private-token") } + val prefs = context.getSharedPreferences("connection", Context.MODE_PRIVATE) + val encrypted = prefs.getString("auth_token_encrypted", null) + val broken = object : PayloadCipher { + override fun encrypt(value: String): ByteArray = error("Key unavailable") + override fun decrypt(value: ByteArray): String = error("Key unavailable") + } + val store = SettingsStore(context, broken) + assertFalse(store.state.value.ready()) + store.update { it.copy(paused = true) } + assertEquals(encrypted, prefs.getString("auth_token_encrypted", null)) + assertThrows(IllegalStateException::class.java) { store.update { it.copy(authToken = "replacement") } } + assertEquals("", store.state.value.authToken) + assertEquals(encrypted, prefs.getString("auth_token_encrypted", null)) + } +} diff --git a/app/src/test/java/life/andre/message487/WebhookClientTest.kt b/app/src/test/java/life/andre/message487/WebhookClientTest.kt index 5db77d9..f86892b 100644 --- a/app/src/test/java/life/andre/message487/WebhookClientTest.kt +++ b/app/src/test/java/life/andre/message487/WebhookClientTest.kt @@ -41,12 +41,14 @@ class WebhookClientTest { fun `HTTP transport sends event and validates confirmation`() = withServer { server -> val event = MessageEvent(deviceId = "installation", deviceCode = "test-device", source = source) server.enqueue(MockResponse().setBody("""{"status":"accepted","event_id":"${event.eventId}"}""")) - val result = WebhookClient().send(server.url("/receive").toString(), event, true) + val result = WebhookClient().send(server.url("/receive").toString(), event, true, "test-token") assertEquals(DeliveryStatus.ACCEPTED, result.status) assertEquals(200, result.httpCode) val request = server.takeRequest(1, TimeUnit.SECONDS)!! assertEquals("POST", request.method) + assertEquals("Bearer test-token", request.getHeader("Authorization")) val body = JSONObject(request.body.readUtf8()) + assertFalse(body.toString().contains("test-token")) assertEquals(event.eventId, body.getString("event_id")) assertEquals("installation", body.getString("device_id")) assertEquals("test-device", body.getString("device_code")) @@ -60,26 +62,36 @@ class WebhookClientTest { val client = WebhookClient() val url = server.url("/receive").toString() server.enqueue(MockResponse().setBody("{}")) - assertEquals(DeliveryStatus.INVALID_ACK, client.send(url, MessageEvent("d", "test-device", source), true).status) + assertEquals(DeliveryStatus.INVALID_ACK, client.send(url, MessageEvent("d", "test-device", source), true, "test-token").status) server.enqueue(MockResponse().setResponseCode(204)) - assertEquals(DeliveryStatus.HTTP_SUCCESS, client.send(url, MessageEvent("d", "test-device", source), false).status) - for (code in listOf(302, 500)) { + assertEquals(DeliveryStatus.HTTP_SUCCESS, client.send(url, MessageEvent("d", "test-device", source), false, "test-token").status) + for (code in listOf(302, 401, 403, 500)) { server.enqueue(MockResponse().setResponseCode(code).addHeader("Location", url)) - assertEquals(DeliveryStatus.HTTP_ERROR, client.send(url, MessageEvent("d", "test-device", source), true).status) + assertEquals(DeliveryStatus.HTTP_ERROR, client.send(url, MessageEvent("d", "test-device", source), true, "test-token").status) } - assertEquals(4, server.requestCount) + assertEquals(6, server.requestCount) } @Test fun `oversized ACK is rejected`() = withServer { server -> server.enqueue(MockResponse().setBody(" ".repeat(70_000))) - assertEquals(DeliveryStatus.INVALID_ACK, WebhookClient().send(server.url("/").toString(), MessageEvent("d", "test-device", source), true).status) + assertEquals(DeliveryStatus.INVALID_ACK, WebhookClient().send(server.url("/").toString(), MessageEvent("d", "test-device", source), true, "test-token").status) } @Test fun `slow server produces timeout`() = withServer { server -> server.enqueue(MockResponse().setBody("{}").setBodyDelay(300, TimeUnit.MILLISECONDS)) - assertEquals(DeliveryStatus.TIMEOUT, WebhookClient(50).send(server.url("/").toString(), MessageEvent("d", "test-device", source), true).status) + assertEquals(DeliveryStatus.TIMEOUT, WebhookClient(50).send(server.url("/").toString(), MessageEvent("d", "test-device", source), true, "test-token").status) + } + + @Test + fun `invalid token cannot start a request`() = withServer { server -> + for (token in listOf("", "Bearer token", "token\r\nX-Injected: yes")) { + org.junit.Assert.assertThrows(IllegalArgumentException::class.java) { + WebhookClient().send(server.url("/").toString(), MessageEvent("d", "test", source), true, token) + } + } + assertEquals(0, server.requestCount) } private fun withServer(block: (MockWebServer) -> Unit) { diff --git a/app/src/test/java/life/andre/message487/diagnostics/FeedbackEmailTest.kt b/app/src/test/java/life/andre/message487/diagnostics/FeedbackEmailTest.kt index 7cde688..1e353e8 100644 --- a/app/src/test/java/life/andre/message487/diagnostics/FeedbackEmailTest.kt +++ b/app/src/test/java/life/andre/message487/diagnostics/FeedbackEmailTest.kt @@ -47,7 +47,7 @@ class FeedbackEmailTest { DiagnosticLog(temporary.newFolder()).use { log -> val chooser = FeedbackEmail.createIntent(context, log, ForwardingSettings( url = "https://secret.example/token", deviceId = "secret-id", deviceCode = "secret-code", - packages = setOf("secret.package"))) + authToken = "secret-token", packages = setOf("secret.package"))) @Suppress("DEPRECATION") val send = chooser.getParcelableExtra(Intent.EXTRA_INTENT)!! assertArrayEquals(arrayOf("der-morgenstern@yandex.ru"), send.getStringArrayExtra(Intent.EXTRA_EMAIL)) diff --git a/docs/en/n8n-webhook.md b/docs/en/n8n-webhook.md index 49c5142..1171e1b 100644 --- a/docs/en/n8n-webhook.md +++ b/docs/en/n8n-webhook.md @@ -2,7 +2,7 @@ [English](../en/n8n-webhook.md) | [Русский](../ru/n8n-webhook.md) -This guide targets Message487 0.0.1. You need the n8n editor and an HTTPS endpoint +This guide targets the development version with Bearer authentication (not release 0.0.1). You need the n8n editor and an HTTPS endpoint reachable from your phone. For a local Android emulator, use the debug build and [DevServer](../../DevServer/README.md). Release APKs reject HTTP endpoints. @@ -35,16 +35,14 @@ To configure the same two nodes manually, set **Webhook** as follows: | --- | --- | | HTTP Method | `POST` | | Path | A unique path such as `message487/receive-` | -| Authentication | `None` for the current client version | +| Authentication | `Header Auth` | | Respond | `Using 'Respond to Webhook' Node` | -Generate a random suffix with `openssl rand -hex 16`; replace the entire placeholder, -including angle brackets. Message487 currently sends no authentication headers, -Basic Auth or JWT. The n8n editor login does not automatically protect webhooks. -Keep the full URL private; a random path is not a substitute for authentication. -For personal messages, restrict endpoint access where possible, for example using -a private network reachable from the phone. Enabling Header/Basic/JWT authentication -without client support will reject delivery. +Create a **Header Auth** credential: **Name** = `Authorization`, **Value** = `Bearer `. +Generate a private token with `openssl rand -hex 32` and replace ``. Select this +credential in Webhook, including after importing the fixture; do not use the bundled public +DevServer credential in production. The editor login is separate from webhook authentication. +See [official webhook credentials](https://docs.n8n.io/integrations/builtin/credentials/webhook/). Set **Respond to Webhook → Respond With → JSON**, **Response Code → 200**, and use this **Expression** in **Response Body**: @@ -91,10 +89,11 @@ HTTP redirects or disable TLS verification. ## 3. Configure the app 1. Open **Connection** and paste the complete Production URL. -2. Set a recognizable **Device code**, such as `personal-phone`. -3. Keep **n8n confirmation** enabled. -4. Save and send a test event. -5. Open the event in **Journal** and check its acknowledgement and HTTP 200. +2. Enter the same token in **Webhook token**, without `Bearer `. +3. Set a recognizable **Device code**, such as `personal-phone`. +4. Keep **n8n confirmation** enabled. +5. Save and send a test event. +6. Open the event in **Journal** and check its acknowledgement and HTTP 200. After the test succeeds, enable your desired **Sources**. Notifications require Android notification access and selected apps; SMS requires receive-SMS permission. @@ -109,7 +108,7 @@ itself, and `10.0.2.2` is not your computer's address. Open **Executions → execution → Webhook → Output → body** in n8n. Match its `event_id` with the app journal. Enable saving successful execution data if needed; DevServer already does this. Execution history contains complete messages, so -configure access and retention accordingly. +configure access and retention accordingly. Request headers may contain the token too. Example notification body: @@ -168,10 +167,13 @@ n8n execution and successful client acknowledgement are different outcomes. Use your actual Production URL and a new ID for each test. This request contains synthetic data only: +Set `WEBHOOK_TOKEN` in your shell to the same private token before running the command. + ```sh curl --fail-with-body --max-time 10 \ -X POST 'https://n8n.example.org/webhook/message487/YOUR-PATH' \ -H 'Content-Type: application/json' \ + -H "Authorization: Bearer ${WEBHOOK_TOKEN:?Set WEBHOOK_TOKEN}" \ --data '{"schema_version":1,"event_id":"manual-check-001","device_id":"manual-test","device_code":"test-phone","message_type":"test","occurred_at":"2026-09-09T09:00:00Z","source":"life.andre.message487","source_name":"Message487","text":"Synthetic connection test"}' ``` @@ -193,7 +195,9 @@ notification or SMS to exercise actual app delivery. Network failures, timeouts, HTTP 408/425/429 and 5xx retry automatically. Invalid ACKs and other HTTP errors require intervention and manual retry from the journal. -Changing the URL only affects new events; queued events retain their old destination. +Changing the URL or token only affects new events; queued events retain both original values. +Old queued events created without authentication are blocked and cannot be sent anonymously. +After upgrading from 0.0.1, enter the token and send a new test; delete obsolete blocked events. Send a new test after fixing configuration and delete old records separately if needed. The top-bar bug icon opens [diagnostics](diagnostics.md), which records delivery outcomes without message text. diff --git a/docs/en/project-context.md b/docs/en/project-context.md index f15c3dc..e6072ae 100644 --- a/docs/en/project-context.md +++ b/docs/en/project-context.md @@ -28,7 +28,7 @@ response of every n8n workflow. Fastlane handles builds/checks. The UI uses Kotlin/Compose with English and Russian resources. Notification/SMS capture, a persistent queue and retries were added later. -Webhook authentication remains unimplemented. `DevServer/README.md` describes the local +Webhook authentication requires a Bearer token encrypted on the device. `DevServer/README.md` describes the local server and its limitations. ## Risks found in the old sms487 client @@ -89,7 +89,7 @@ n8n has test and production URLs; persistent integrations use a published workfl 1. Physical-device checks for power saving, reboot and permission restrictions. 2. ACK after workflow execution versus durable server-side queue persistence. HTTP success alone does not establish durable storage or downstream delivery. -3. Webhook authentication and future contract changes. README describes the current contract +3. Future authentication methods and contract changes. README describes the current contract and queue; limits for the unconfirmed queue require a separate decision. 4. Distribution and required device checks. The initial minimum SDK is configured in Gradle; its suitability for future capture behavior still needs verification. diff --git a/docs/ru/n8n-webhook.md b/docs/ru/n8n-webhook.md index 01a25a6..a5ef0b8 100644 --- a/docs/ru/n8n-webhook.md +++ b/docs/ru/n8n-webhook.md @@ -2,7 +2,7 @@ [English](../en/n8n-webhook.md) | [Русский](../ru/n8n-webhook.md) -Инструкция для Message487 0.0.1. Нужен доступ к редактору n8n и HTTPS-адрес, +Инструкция для разрабатываемой версии с Bearer-авторизацией (не для релиза 0.0.1). Нужен доступ к редактору n8n и HTTPS-адрес, доступный с телефона. Для локального Android-эмулятора используйте debug-сборку и [готовый DevServer](../../DevServer/README.md). Релизный APK не принимает HTTP-адреса. @@ -39,18 +39,14 @@ flowchart LR | --- | --- | | HTTP Method | `POST` | | Path | Уникальный путь, например `message487/receive-<случайная-строка>` | -| Authentication | `None` для текущей версии клиента | +| Authentication | `Header Auth` | | Respond | `Using 'Respond to Webhook' Node` | -Случайную часть пути можно сгенерировать командой `openssl rand -hex 16`. -Не вставляйте угловые скобки из примера в настоящий URL. - -Message487 пока не отправляет заголовки авторизации, Basic Auth или JWT. Пароль -входа в редактор n8n не защищает webhook автоматически. Не распространяйте полный -URL; случайный путь не заменяет полноценную авторизацию. Для передачи личных -сообщений ограничьте доступ к endpoint доступным вам способом, например частной -сетью с доступом телефона. Настройка узла с Header/Basic/JWT Auth без поддержки -со стороны клиента приведёт к отказу доставки. +Создайте credential **Header Auth**: **Name** = `Authorization`, **Value** = `Bearer <токен>`. +Сгенерируйте собственный токен командой `openssl rand -hex 32`, замените `<токен>` +и выберите credential в Webhook, в том числе после импорта файла. Публичный тестовый +credential DevServer нельзя использовать на рабочем сервере. Пароль редактора n8n +не заменяет авторизацию веб-хука. См. [официальную документацию](https://docs.n8n.io/integrations/builtin/credentials/webhook/). В узле **Respond to Webhook** выберите **Respond With → JSON**, добавьте **Response Code → 200**, а **Response Body** переключите в режим **Expression**: @@ -99,10 +95,11 @@ Message487 не следует HTTP-перенаправлениям и не о 1. Откройте **Подключение / Connection**. 2. Вставьте полный Production URL в поле веб-хука. -3. Задайте понятный **Код устройства / Device code**, например `personal-phone`. -4. Оставьте включённым **Подтверждение n8n / n8n confirmation**. -5. Нажмите кнопку сохранения и отправки тестового события. -6. В **Журнале / Journal** откройте событие и проверьте подтверждение и HTTP 200. +3. Введите тот же токен в **Токен веб-хука**, без `Bearer `. +4. Задайте понятный **Код устройства / Device code**, например `personal-phone`. +5. Оставьте включённым **Подтверждение n8n / n8n confirmation**. +6. Нажмите кнопку сохранения и отправки тестового события. +7. В **Журнале / Journal** откройте событие и проверьте подтверждение и HTTP 200. После успешного теста включите нужные источники в **Источниках / Sources**: для уведомлений выдайте системный доступ и выберите приложения; для SMS выдайте @@ -182,10 +179,13 @@ debug-сборке. На физическом телефоне `localhost` об Следующий запрос содержит только синтетические данные. Подставьте свой Production URL и при каждом новом тесте используйте новый `event_id`: +Перед запуском задайте в оболочке переменную `WEBHOOK_TOKEN` с тем же секретным токеном. + ```sh curl --fail-with-body --max-time 10 \ -X POST 'https://n8n.example.org/webhook/message487/ВАШ-ПУТЬ' \ -H 'Content-Type: application/json' \ + -H "Authorization: Bearer ${WEBHOOK_TOKEN:?Set WEBHOOK_TOKEN}" \ --data '{"schema_version":1,"event_id":"manual-check-001","device_id":"manual-test","device_code":"test-phone","message_type":"test","occurred_at":"2026-09-09T09:00:00Z","source":"life.andre.message487","source_name":"Message487","text":"Synthetic connection test"}' ``` @@ -213,3 +213,9 @@ curl --fail-with-body --max-time 10 \ Для разбора ошибок откройте значок диагностики в верхней панели приложения. [Диагностический лог](diagnostics.md) показывает исход доставки без текста сообщений. + +Токен хранится на устройстве зашифрованным. Смена URL или токена применяется только +к новым событиям: очередь сохраняет исходные значения. Старые события без токена +блокируются. После обновления с 0.0.1 введите токен, отправьте новый тест и удалите +ненужные заблокированные события. История n8n может сохранять заголовки с токеном; +ограничьте доступ и срок хранения. diff --git a/docs/ru/project-context.md b/docs/ru/project-context.md index 349c285..9ade22c 100644 --- a/docs/ru/project-context.md +++ b/docs/ru/project-context.md @@ -27,7 +27,7 @@ Сборка и проверки выполняются через Fastlane, интерфейс — Kotlin/Compose с английскими и русскими ресурсами. Далее реализованы захват уведомлений/SMS, постоянная очередь и повторы. -Авторизация webhook пока не реализована. +Авторизация webhook требует Bearer-токен, зашифрованный на устройстве. `DevServer/README.md` описывает запуск и ограничения локального стенда. ## Риски старого клиента sms487 @@ -89,7 +89,7 @@ WorkManager не гарантирует немедленную доставку. 1. Проверка энергосбережения, перезагрузки и ограничений разрешений на физических устройствах. 2. Подтверждение приёма: после выполнения workflow либо после устойчивой записи в серверную очередь. Простой HTTP-успех не доказывает надёжное сохранение или конечную доставку. -3. Авторизация webhook и дальнейшее развитие контракта. Текущий формат и поведение очереди +3. Новые методы авторизации webhook и развитие контракта. Текущий формат и поведение очереди описаны в README; лимиты неподтверждённой очереди требуют отдельного решения. 4. Распространение приложения и необходимые проверки на устройстве. Минимальная версия первого каркаса задана в Gradle; её пригодность для будущего захвата событий ещё предстоит проверить.