From 0ae476f7d1e389e2d98a0a8c215245c0f5c12af0 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 8 Sep 2026 21:10:12 +0300 Subject: [PATCH 1/6] Add basic structure --- .github/workflows/ci.yml | 61 ++++ .gitignore | 5 + DevServer/README.md | 74 +++++ DevServer/compose.yaml | 44 +++ DevServer/import.sh | 7 + DevServer/start.sh | 10 + DevServer/tests/smoke.py | 53 ++++ DevServer/workflows/error.json | 60 ++++ DevServer/workflows/invalid-ack.json | 60 ++++ DevServer/workflows/receive.json | 60 ++++ DevServer/workflows/slow.json | 86 ++++++ Gemfile | 3 + Gemfile.lock | 263 ++++++++++++++++++ PRIVACY.md | 43 +++ README.md | 74 ++++- app/build.gradle.kts | 55 ++++ .../debug/res/xml/network_security_config.xml | 9 + app/src/main/AndroidManifest.xml | 26 ++ .../java/life/andre/message487/AppSource.kt | 25 ++ .../andre/message487/ConnectionViewModel.kt | 88 ++++++ .../life/andre/message487/MainActivity.kt | 130 +++++++++ .../life/andre/message487/WebhookClient.kt | 108 +++++++ .../res/drawable/ic_launcher_foreground.xml | 5 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 4 + app/src/main/res/values-ru/strings.xml | 32 +++ app/src/main/res/values/colors.xml | 3 + app/src/main/res/values/strings.xml | 32 +++ app/src/main/res/values/styles.xml | 6 + .../main/res/xml/data_extraction_rules.xml | 19 ++ .../main/res/xml/network_security_config.xml | 4 + .../andre/message487/WebhookClientTest.kt | 91 ++++++ build.gradle.kts | 5 + docs/project-context.md | 89 ++++++ fastlane/Fastfile | 26 ++ fastlane/README.md | 48 ++++ gradle.properties | 3 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 +++++++++++++++++ gradlew.bat | 94 +++++++ scripts/emulator.sh | 36 +++ settings.gradle.kts | 16 ++ 42 files changed, 2113 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 DevServer/README.md create mode 100644 DevServer/compose.yaml create mode 100644 DevServer/import.sh create mode 100644 DevServer/start.sh create mode 100644 DevServer/tests/smoke.py create mode 100644 DevServer/workflows/error.json create mode 100644 DevServer/workflows/invalid-ack.json create mode 100644 DevServer/workflows/receive.json create mode 100644 DevServer/workflows/slow.json create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 PRIVACY.md create mode 100644 app/build.gradle.kts create mode 100644 app/src/debug/res/xml/network_security_config.xml create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/life/andre/message487/AppSource.kt create mode 100644 app/src/main/java/life/andre/message487/ConnectionViewModel.kt create mode 100644 app/src/main/java/life/andre/message487/MainActivity.kt create mode 100644 app/src/main/java/life/andre/message487/WebhookClient.kt create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/values-ru/strings.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 app/src/test/java/life/andre/message487/WebhookClientTest.kt create mode 100644 build.gradle.kts create mode 100644 docs/project-context.md create mode 100644 fastlane/Fastfile create mode 100644 fastlane/README.md create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100755 scripts/emulator.sh create mode 100644 settings.gradle.kts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b375ee9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + android: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + - uses: gradle/actions/setup-gradle@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.10" + bundler-cache: true + - name: Install Android SDK components + run: sdkmanager "platforms;android-36" "build-tools;36.0.0" + - run: bundle exec fastlane android checks + - uses: actions/upload-artifact@v7 + id: apk + with: + name: message487-apks + path: app/build/outputs/apk/**/*.apk + if-no-files-found: error + retention-days: 14 + - name: Link APK artifacts + env: + ARTIFACT_URL: ${{ steps.apk.outputs.artifact-url }} + run: echo "[Download debug and unsigned release APKs]($ARTIFACT_URL)" >> "$GITHUB_STEP_SUMMARY" + + dev-server: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - run: docker compose -f DevServer/compose.yaml up -d --wait --wait-timeout 300 + - run: python3 DevServer/tests/smoke.py + - name: Server logs + if: failure() + run: docker compose -f DevServer/compose.yaml logs --tail 150 + - name: Stop server + if: always() + run: docker compose -f DevServer/compose.yaml down diff --git a/.gitignore b/.gitignore index e5cbb64..88a377f 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,8 @@ google-services.json # Android Profiling *.hprof +.bundle/ +.kotlin/ +.DS_Store +fastlane/report.xml +DevServer/.env diff --git a/DevServer/README.md b/DevServer/README.md new file mode 100644 index 0000000..8a1d87e --- /dev/null +++ b/DevServer/README.md @@ -0,0 +1,74 @@ +# Message487 development server + +Run from the repository root with Docker Compose installed and a Docker engine running: + +```sh +docker compose -f DevServer/compose.yaml up -d --wait +python3 DevServer/tests/smoke.py +``` + +Open the editor at . Local development login: + +- Email: `developer@message487.test` +- 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. +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). +The named volume stores the database and n8n-generated encryption key. Successes and failures are +visible in **Executions**, including submitted event bodies. The `error` workflow deliberately +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 +shows the event ID and confirmation result. 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. +`body.source` contains the package identifier and `body.source_name` the application's display name. +Use **n8n confirmation** for this server; generic webhook mode only checks the HTTP status. + +| Endpoint suffix under `/webhook/message487/` | Expected result | +| --- | --- | +| `receive` | Accepted event ID, or HTTP 400 for invalid input | +| `error` | HTTP 500 | +| `slow` | Response delayed beyond the client's timeout | +| `invalid-ack` | HTTP 200 with a mismatched event ID | + +These are published webhooks, so **Listen for test event** is unnecessary. They confirm a test +execution only; there is no durable delivery queue, deduplication, or Telegram integration here. +The JSON fixtures define the accepted test contract. The client preserves no automatic retry +queue at this stage. + +## Lifecycle + +```sh +docker compose -f DevServer/compose.yaml logs -f n8n +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 +bundled workflows with the checked-in versions, stop n8n before importing: + +```sh +docker compose -f DevServer/compose.yaml stop n8n +docker compose -f DevServer/compose.yaml run --rm --no-deps --entrypoint /bin/sh n8n /bootstrap/import.sh +docker compose -f DevServer/compose.yaml up -d --wait +``` + +This overwrites the four fixture workflow IDs; other workflows remain. To discard **all** local +workflows, execution history and settings, delete this Compose project's volume explicitly: + +```sh +docker compose -f DevServer/compose.yaml down -v +``` + +## References + +- [n8n Server CLI](https://docs.n8n.io/deploy/host-n8n/configure-n8n/use-the-command-line) +- [Owner provisioning](https://docs.n8n.io/deploy/host-n8n/configure-n8n/manage-settings-using-environment-variables) +- [Emulator networking](https://developer.android.com/studio/run/emulator-networking-address) diff --git a/DevServer/compose.yaml b/DevServer/compose.yaml new file mode 100644 index 0000000..7324436 --- /dev/null +++ b/DevServer/compose.yaml @@ -0,0 +1,44 @@ +name: message487-dev + +services: + n8n: + image: docker.n8n.io/n8nio/n8n:2.38.1 + ports: + - "127.0.0.1:5678:5678" + entrypoint: ["tini", "--", "/bin/sh", "/bootstrap/start.sh"] + environment: + N8N_HOST: localhost + N8N_PORT: "5678" + N8N_PROTOCOL: http + N8N_EDITOR_BASE_URL: http://localhost:5678 + N8N_WEBHOOK_URL: http://10.0.2.2:5678/ + N8N_SECURE_COOKIE: "false" + N8N_DIAGNOSTICS_ENABLED: "false" + N8N_VERSION_NOTIFICATIONS_ENABLED: "false" + N8N_TEMPLATES_ENABLED: "false" + N8N_PERSONALIZATION_ENABLED: "false" + N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true" + N8N_INSTANCE_OWNER_MANAGED_BY_ENV: "true" + N8N_INSTANCE_OWNER_EMAIL: developer@message487.test + N8N_INSTANCE_OWNER_FIRST_NAME: Message487 + N8N_INSTANCE_OWNER_LAST_NAME: Developer + N8N_INSTANCE_OWNER_PASSWORD_HASH: '$$2b$$05$$ZY9ZLe5jdVHCw9KXegI.yef0wjCGyZtCIxIPsai4rEFl6yS.4ozbC' + EXECUTIONS_DATA_SAVE_ON_ERROR: all + EXECUTIONS_DATA_SAVE_ON_SUCCESS: all + EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS: "true" + EXECUTIONS_DATA_PRUNE: "true" + EXECUTIONS_DATA_MAX_AGE: "168" + GENERIC_TIMEZONE: UTC + TZ: UTC + volumes: + - n8n-data:/home/node/.n8n + - ./:/bootstrap:ro + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:5678/healthz/readiness').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 5s + timeout: 5s + retries: 60 + start_period: 30s + +volumes: + n8n-data: diff --git a/DevServer/import.sh b/DevServer/import.sh new file mode 100644 index 0000000..7f7a6e5 --- /dev/null +++ b/DevServer/import.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +n8n import:workflow --separate --input=/bootstrap/workflows +for workflow in receive error slow invalid-ack; do + n8n publish:workflow --id="message487-$workflow" +done diff --git a/DevServer/start.sh b/DevServer/start.sh new file mode 100644 index 0000000..3c836aa --- /dev/null +++ b/DevServer/start.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +marker=/home/node/.n8n/message487-bootstrap-v1 +if [ ! -f "$marker" ]; then + /bin/sh /bootstrap/import.sh + touch "$marker" +fi + +exec n8n start diff --git a/DevServer/tests/smoke.py b/DevServer/tests/smoke.py new file mode 100644 index 0000000..d1623a2 --- /dev/null +++ b/DevServer/tests/smoke.py @@ -0,0 +1,53 @@ +import json +import socket +import urllib.error +import urllib.request +import uuid + + +def post(scenario, payload, timeout=5): + request = urllib.request.Request( + f'http://127.0.0.1:5678/webhook/message487/{scenario}', + data=json.dumps(payload).encode(), + headers={'Content-Type': 'application/json'}, + ) + 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) + + +def main(): + event = { + 'schema_version': 1, + 'event_id': str(uuid.uuid4()), + 'device_id': 'dev-server-smoke-test', + 'device_code': 'smoke-test', + 'source': 'life.andre.message487', + 'source_name': 'Message487', + 'message_type': 'test', + 'text': 'Synthetic smoke test', + } + code, body = post('receive', event) + assert code == 200 and body == { + 'status': 'accepted', 'event_id': event['event_id'] + }, (code, body) + code, body = post('receive', {}) + assert code == 400 and body['status'] == 'rejected', (code, body) + code, body = post('error', event) + assert code == 500, (code, body) + code, body = post('invalid-ack', event) + assert code == 200 and body['event_id'] != event['event_id'], (code, body) + try: + post('slow', event, timeout=1) + except (TimeoutError, socket.timeout): + pass + else: + raise AssertionError('Slow endpoint did not time out') + print('Passed: receive, validation, HTTP error, invalid ACK, timeout') + + +if __name__ == '__main__': + main() diff --git a/DevServer/workflows/error.json b/DevServer/workflows/error.json new file mode 100644 index 0000000..f361831 --- /dev/null +++ b/DevServer/workflows/error.json @@ -0,0 +1,60 @@ +{ + "id": "message487-error", + "name": "Message487 \u00b7 error", + "active": false, + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "message487/error", + "responseMode": "responseNode", + "options": {} + }, + "id": "webhook", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 0, + 0 + ], + "webhookId": "message487-error" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "{\"status\":\"error\"}", + "options": { + "responseCode": 500 + } + }, + "id": "response", + "name": "Respond", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.4, + "position": [ + 440, + 0 + ] + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Respond", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": true + } +} diff --git a/DevServer/workflows/invalid-ack.json b/DevServer/workflows/invalid-ack.json new file mode 100644 index 0000000..5182ab2 --- /dev/null +++ b/DevServer/workflows/invalid-ack.json @@ -0,0 +1,60 @@ +{ + "id": "message487-invalid-ack", + "name": "Message487 \u00b7 invalid-ack", + "active": false, + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "message487/invalid-ack", + "responseMode": "responseNode", + "options": {} + }, + "id": "webhook", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 0, + 0 + ], + "webhookId": "message487-invalid-ack" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "{\"status\":\"accepted\",\"event_id\":\"wrong-event-id\"}", + "options": { + "responseCode": 200 + } + }, + "id": "response", + "name": "Respond", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.4, + "position": [ + 440, + 0 + ] + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Respond", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": true + } +} diff --git a/DevServer/workflows/receive.json b/DevServer/workflows/receive.json new file mode 100644 index 0000000..d482dec --- /dev/null +++ b/DevServer/workflows/receive.json @@ -0,0 +1,60 @@ +{ + "id": "message487-receive", + "name": "Message487 \u00b7 receive", + "active": false, + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "message487/receive", + "responseMode": "responseNode", + "options": {} + }, + "id": "webhook", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 0, + 0 + ], + "webhookId": "message487-receive" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ { status: (typeof $json.body.event_id === 'string' && $json.body.event_id.length > 0 && $json.body.schema_version === 1 && ['test', 'notification', 'sms'].includes($json.body.message_type) && typeof $json.body.text === 'string') ? 'accepted' : 'rejected', event_id: $json.body.event_id ?? null } }}", + "options": { + "responseCode": "={{ (typeof $json.body.event_id === 'string' && $json.body.event_id.length > 0 && $json.body.schema_version === 1 && ['test', 'notification', 'sms'].includes($json.body.message_type) && typeof $json.body.text === 'string') ? 200 : 400 }}" + } + }, + "id": "response", + "name": "Respond", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.4, + "position": [ + 440, + 0 + ] + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Respond", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": true + } +} diff --git a/DevServer/workflows/slow.json b/DevServer/workflows/slow.json new file mode 100644 index 0000000..5f644a8 --- /dev/null +++ b/DevServer/workflows/slow.json @@ -0,0 +1,86 @@ +{ + "id": "message487-slow", + "name": "Message487 \u00b7 slow", + "active": false, + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "message487/slow", + "responseMode": "responseNode", + "options": {} + }, + "id": "webhook", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 0, + 0 + ], + "webhookId": "message487-slow" + }, + { + "parameters": { + "amount": 20, + "unit": "seconds" + }, + "id": "wait", + "name": "Delay", + "type": "n8n-nodes-base.wait", + "typeVersion": 1.1, + "position": [ + 220, + 0 + ], + "webhookId": "message487-delay" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ { status: (typeof $json.body.event_id === 'string' && $json.body.event_id.length > 0 && $json.body.schema_version === 1 && ['test', 'notification', 'sms'].includes($json.body.message_type) && typeof $json.body.text === 'string') ? 'accepted' : 'rejected', event_id: $json.body.event_id ?? null } }}", + "options": { + "responseCode": "={{ (typeof $json.body.event_id === 'string' && $json.body.event_id.length > 0 && $json.body.schema_version === 1 && ['test', 'notification', 'sms'].includes($json.body.message_type) && typeof $json.body.text === 'string') ? 200 : 400 }}" + } + }, + "id": "response", + "name": "Respond", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.4, + "position": [ + 440, + 0 + ] + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Delay", + "type": "main", + "index": 0 + } + ] + ] + }, + "Delay": { + "main": [ + [ + { + "node": "Respond", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": true + } +} diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..2b65e2b --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "fastlane", "2.238.0" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..bc01cfd --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,263 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1283.0) + aws-sdk-core (3.254.1) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.130.0) + aws-sdk-core (~> 3, >= 3.254.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.229.0) + aws-sdk-core (~> 3, >= 3.254.1) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.2) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + csv (3.3.6) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20260902) + dotenv (2.8.1) + emoji_regex (3.2.3) + erb (6.0.7) + excon (1.7.1) + logger + faraday (2.14.3) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (3.4.4) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) + fastimage (2.4.1) + fastlane (2.238.0) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) + addressable (>= 2.9.0, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.197) + babosa (>= 1.0.3, < 2.0.0) + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 2.4.0, < 5.0.0) + colored (~> 1.2) + commander (~> 4.6) + csv (~> 3.3) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 2.0.0) + faraday (~> 2.7) + faraday-cookie_jar (~> 0.0.8) + faraday-follow_redirects (~> 0.3) + faraday-multipart (~> 1.0) + faraday-retry (~> 2.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.1.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.3.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + irb (>= 1.8) + json (< 3.0.0) + jwt (>= 2.10.3, < 4) + logger (>= 1.6, < 2.0) + mini_magick (>= 4.9.4, < 5.0.0) + multi_json (~> 1.12) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3) + naturally (~> 2.2) + nkf (~> 0.2) + optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 4) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.1.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.107.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (1.2.5) + addressable (~> 2.9) + faraday (~> 2.13) + faraday-follow_redirects (~> 0.3) + googleauth (~> 1.14) + mini_mime (~> 1.1) + multi_json (~> 1.11) + representable (~> 3.0) + retriable (>= 3.1, < 5.0) + google-apis-iamcredentials_v1 (0.28.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.18.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.66.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.9.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (2.2.2) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.7.0) + google-cloud-storage (1.62.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) + google-cloud-core (~> 1.6) + googleauth (~> 1.9) + mini_mime (~> 1.0) + google-logging-utils (0.2.0) + googleauth (1.17.4) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) + os (>= 0.9, < 2.0) + pstore (~> 0.1) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + io-console (0.9.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jmespath (1.6.2) + json (2.21.2) + jwt (3.2.0) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.21.1) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + net-http (0.9.1) + uri (>= 0.11.1) + nkf (0.3.0) + optparse (0.8.1) + os (1.1.4) + ostruct (0.6.3) + plist (3.7.2) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + pstore (0.2.1) + public_suffix (7.0.5) + rake (13.4.2) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + reline (0.7.0) + io-console (~> 0.5) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (4.2.0) + rexml (3.4.4) + rouge (3.28.0) + rubyzip (2.4.1) + security (0.1.5) + signet (0.22.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + simctl (1.6.10) + CFPropertyList + naturally + terminal-notifier (2.0.0) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) + trailblazer-option (0.1.2) + tsort (0.2.0) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + word_wrap (1.0.0) + xcodeproj (1.28.1) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + base64 + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + nkf + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + arm64-darwin-25 + ruby + x86_64-linux + x86_64-linux-musl + +DEPENDENCIES + fastlane (= 2.238.0) + +BUNDLED WITH + 2.6.9 diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..2fcaf32 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,43 @@ +# Privacy Policy + +Last updated: September 8, 2026. + +This policy describes the current Message487 development preview. It sends synthetic connection +test events to a webhook you configure. It does not yet read SMS or other applications' notifications. + +## On your device + +The app stores your webhook URL, editable device code, confirmation preference, and a randomly generated installation +ID in its private preferences. These preferences do not have additional application-level encryption. +Android cloud backup and device transfer are disabled for app data. + +Recent test results contain event IDs, HTTP status codes, request durations and outcome labels. +They are held in memory and disappear when the app process ends. Message bodies and server +response bodies are not written to diagnostic logs by the app. + +## Network requests + +When you press **Save and send test event**, your selected endpoint receives a synthetic message, +event ID, installation ID, your device code, timestamp, schema version, event type, the source app's +package identifier and its display name. +Its operator can also see connection metadata such as your IP address. The endpoint and any +downstream services process requests under their own policies. An n8n instance may retain event +bodies in its execution history; the bundled development server does so. + +The app declares visibility of apps with launcher activities so it can look up a source +application's display name by package identifier. It does not request access to all installed +packages or collect or upload an inventory of apps. If a source name is unavailable, its package +identifier is used instead. The current test sender looks up Message487's own name. + +Release builds require HTTPS. Debug builds also permit HTTP to the Android emulator host and +localhost for development. The app contains no advertising, analytics SDKs or automatic crash +reporting, and does not automatically send data to the developer. + +## Deletion + +Clearing app data or uninstalling removes local preferences and resets the installation identity. +It does not delete records on your webhook server. Delete n8n execution history and downstream +copies separately using the controls of those services. + +Project information and contact: [Message487 on GitHub](https://github.com/andre487/AndroidMessage487). +Do not post personal messages or credentials in public issues. diff --git a/README.md b/README.md index edb8fda..f3033aa 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,72 @@ -# AndroidMessage487 -N8N client for notification resending +# Message487 + +Message487 is an open-source Android app being developed to connect selected notifications and +SMS to your n8n workflows. A custom webhook will also be supported for other integrations. + +**Status:** development preview. The client saves a webhook connection and sends synthetic test +events. Notification/SMS capture, persistent delivery queues, automatic retries and webhook +authentication are not implemented yet. + +The intended setup starts with n8n: connect a workflow, select event sources, grant the required +permissions, and send a test event. You choose which events leave your phone and where they go. +Telegram forwarding is one possible workflow; the Android app does not depend on Telegram. + +## Local development + +Start the [development server](DevServer/README.md), which provisions n8n and published test workflows: + +```sh +docker compose -f DevServer/compose.yaml up -d --wait +python3 DevServer/tests/smoke.py +``` + +Build with JDK 21, Ruby/Bundler and the Android SDK. Use `ANDROID_HOME` or an untracked +`local.properties` file to point Gradle to your SDK. SDK and library versions are in the Gradle +build files; Ruby dependencies are pinned by `Gemfile.lock`. + +```sh +bundle install +bundle exec fastlane android checks +scripts/emulator.sh +``` + +Once the emulator has booted, use another terminal: + +```sh +bundle exec fastlane android install +adb shell am start -n life.andre.message487/.MainActivity +``` + +The debug app is preconfigured for the local n8n receive endpoint. Press **Save and send test event**, +then find the displayed event ID in n8n **Executions**. Strict confirmation requires JSON with +`status: "accepted"` and the matching `event_id`; this is our fixture contract, not a built-in n8n +response. Turn off **n8n confirmation** for a generic webhook that acknowledges with HTTP 2xx. + +Fastlane's `debug_artifact` lane builds only the debug APK. `checks` runs JVM 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. + +UI strings are supplied in English and Russian. Connection settings survive app restarts; +recent test results remain in memory only. + +`device_id` identifies the installation; `device_code` is an editable label sent alongside it. +Changing the device code preserves the installation ID. A timeout does not prove the server +failed to receive the event. See [PRIVACY.md](PRIVACY.md) for the current data handling. + +`source` contains the source package name and `source_name` its display name from Android's +PackageManager. Test events use Message487 itself. If a label cannot be resolved, the package +name is used as the display name. Labels may change with the app version or device language; +use `source` for matching rules. + +The manifest uses `` for apps with launcher activities, in addition to packages Android +makes visible automatically. Sources outside this visibility scope fall back to their package +name. No application inventory is collected or sent, and `QUERY_ALL_PACKAGES` is not requested. +See [Android package visibility](https://developer.android.com/training/package-visibility/declaring). + +See the [project context](docs/project-context.md) for the product direction and remaining decisions. + +This project succeeds [sms487](https://github.com/andre487/sms487). +[AndroidMegaProxy](https://github.com/andre487/AndroidMegaProxy) is the reference for project +conventions, UI, documentation, build tooling, and CI. + +Licensed under the [MIT License](LICENSE). diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..55145bc --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "life.andre.message487" + compileSdk = 36 + buildToolsVersion = "36.0.0" + defaultConfig { + applicationId = "life.andre.message487" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0-dev" + } + buildTypes { + getByName("debug") { + buildConfigField("String", "DEFAULT_WEBHOOK_URL", "\"http://10.0.2.2:5678/webhook/message487/receive\"") + } + getByName("release") { + buildConfigField("String", "DEFAULT_WEBHOOK_URL", "\"\"") + isMinifyEnabled = true + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + } + buildFeatures { + compose = true + buildConfig = true + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + lint { abortOnError = true } +} + +kotlin { + jvmToolchain(21) + compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } +} + +dependencies { + implementation(platform("androidx.compose:compose-bom:2025.01.01")) + implementation("androidx.activity:activity-compose:1.10.0") + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.compose.material3:material3") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20250107") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") +} diff --git a/app/src/debug/res/xml/network_security_config.xml b/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 0000000..920ef3d --- /dev/null +++ b/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + 10.0.2.2 + 127.0.0.1 + localhost + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..882fd9d --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/life/andre/message487/AppSource.kt b/app/src/main/java/life/andre/message487/AppSource.kt new file mode 100644 index 0000000..668d6fb --- /dev/null +++ b/app/src/main/java/life/andre/message487/AppSource.kt @@ -0,0 +1,25 @@ +package life.andre.message487 + +import android.content.pm.PackageManager +import android.os.Build + +data class AppSource(val packageName: String, val name: String) + +class AppSourceResolver(private val packageManager: PackageManager) { + fun resolve(packageName: String): AppSource { + val name = try { + val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + packageManager.getApplicationInfo(packageName, 0) + } + packageManager.getApplicationLabel(info).toString().takeIf { it.isNotBlank() } ?: packageName + } catch (_: PackageManager.NameNotFoundException) { + packageName + } catch (_: SecurityException) { + packageName + } + return AppSource(packageName, name) + } +} diff --git a/app/src/main/java/life/andre/message487/ConnectionViewModel.kt b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt new file mode 100644 index 0000000..a48783e --- /dev/null +++ b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt @@ -0,0 +1,88 @@ +package life.andre.message487 + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.UUID + +data class ConnectionState( + val url: String = "", + val deviceCode: String = "android-device", + val requireAck: Boolean = true, + val busy: Boolean = false, + val invalidUrl: Boolean = false, + val invalidDeviceCode: Boolean = false, + val saveFailed: Boolean = false, + val saved: Boolean = false, + val results: List = emptyList(), +) + +class ConnectionViewModel(application: Application) : AndroidViewModel(application) { + private val preferences = application.getSharedPreferences("connection", 0) + private val mutableState = MutableStateFlow( + ConnectionState( + url = preferences.getString("url", BuildConfig.DEFAULT_WEBHOOK_URL).orEmpty(), + deviceCode = preferences.getString("device_code", null) ?: ConnectionState().deviceCode, + requireAck = preferences.getBoolean("require_ack", true), + ) + ) + val state = mutableState.asStateFlow() + + fun setUrl(url: String) { + if (!state.value.busy) mutableState.value = state.value.copy(url = url, invalidUrl = false, saved = false) + } + + fun setRequireAck(value: Boolean) { + if (!state.value.busy) mutableState.value = state.value.copy(requireAck = value, saved = false) + } + + fun setDeviceCode(value: String) { + if (!state.value.busy) { + mutableState.value = state.value.copy(deviceCode = value, invalidDeviceCode = false, saved = false) + } + } + + fun save(sendTest: Boolean) { + val current = state.value + if (current.busy) return + val url = current.url.trim() + val deviceCode = current.deviceCode.trim() + if (deviceCode.isEmpty()) { + mutableState.value = current.copy(invalidDeviceCode = true) + return + } + if (!validWebhookUrl(url, BuildConfig.DEBUG)) { + mutableState.value = current.copy(invalidUrl = true) + return + } + mutableState.value = current.copy(url = url, deviceCode = deviceCode, busy = true, saveFailed = false, saved = false) + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { + val deviceId = preferences.getString("device_id", null) ?: UUID.randomUUID().toString() + val committed = preferences.edit() + .putString("url", url) + .putBoolean("require_ack", current.requireAck) + .putString("device_id", deviceId) + .putString("device_code", deviceCode) + .commit() + if (!committed) false to null + else true to if (sendTest) { + val application = getApplication() + val source = AppSourceResolver(application.packageManager).resolve(application.packageName) + WebhookClient().send(url, TestEvent(deviceId, deviceCode, source), current.requireAck) + } else null + } + mutableState.value = state.value.copy( + busy = false, + saved = result.first, + saveFailed = !result.first, + results = (listOfNotNull(result.second) + state.value.results).take(20), + ) + } + } +} diff --git a/app/src/main/java/life/andre/message487/MainActivity.kt b/app/src/main/java/life/andre/message487/MainActivity.kt new file mode 100644 index 0000000..af9c549 --- /dev/null +++ b/app/src/main/java/life/andre/message487/MainActivity.kt @@ -0,0 +1,130 @@ +package life.andre.message487 + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + ConnectionScreen() + } + } + } +} + +@Composable +private fun ConnectionScreen(model: ConnectionViewModel = viewModel()) { + val state by model.state.collectAsStateWithLifecycle() + Scaffold { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge) + Text(stringResource(R.string.tagline), style = MaterialTheme.typography.titleMedium) + Card { + Text(stringResource(R.string.development_status), Modifier.padding(16.dp)) + } + Text(stringResource(R.string.connection), style = MaterialTheme.typography.titleLarge) + OutlinedTextField( + value = state.deviceCode, + onValueChange = model::setDeviceCode, + label = { Text(stringResource(R.string.device_code)) }, + modifier = Modifier.fillMaxWidth(), + enabled = !state.busy, + singleLine = true, + isError = state.invalidDeviceCode, + supportingText = { + Text(stringResource(if (state.invalidDeviceCode) R.string.invalid_device_code else R.string.device_code_hint)) + }, + ) + OutlinedTextField( + value = state.url, + onValueChange = model::setUrl, + label = { Text(stringResource(R.string.webhook_url)) }, + modifier = Modifier.fillMaxWidth(), + enabled = !state.busy, + isError = state.invalidUrl, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + supportingText = { + Text(stringResource(if (state.invalidUrl) R.string.invalid_url else R.string.url_hint)) + }, + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.n8n_mode), style = MaterialTheme.typography.titleMedium) + Text(stringResource(if (state.requireAck) R.string.n8n_hint else R.string.raw_hint)) + } + Switch(checked = state.requireAck, onCheckedChange = model::setRequireAck, enabled = !state.busy) + } + OutlinedButton(onClick = { model.save(false) }, enabled = !state.busy, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.save)) + } + Button(onClick = { model.save(true) }, enabled = !state.busy, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.send_test)) + } + if (state.busy) { + LinearProgressIndicator(Modifier.fillMaxWidth()) + Text(stringResource(R.string.sending)) + } + if (state.saved) Text(stringResource(R.string.saved)) + if (state.saveFailed) Text(stringResource(R.string.save_failed), color = MaterialTheme.colorScheme.error) + Text(stringResource(R.string.journal), style = MaterialTheme.typography.titleLarge) + if (state.results.isEmpty()) Text(stringResource(R.string.no_events)) + state.results.forEach { result -> + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(result.status.label()), style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.event_id, result.eventId), style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.duration, result.durationMs)) + result.httpCode?.let { Text(stringResource(R.string.http_code, it)) } + } + } + } + Text(stringResource(R.string.delivery_note), style = MaterialTheme.typography.bodySmall) + } + } +} + +private fun DeliveryStatus.label(): Int = when (this) { + DeliveryStatus.ACCEPTED -> R.string.accepted + DeliveryStatus.HTTP_SUCCESS -> R.string.http_success + DeliveryStatus.HTTP_ERROR -> R.string.http_error + DeliveryStatus.INVALID_ACK -> R.string.invalid_ack + DeliveryStatus.TIMEOUT -> R.string.timeout + DeliveryStatus.NETWORK_ERROR -> R.string.network_error +} diff --git a/app/src/main/java/life/andre/message487/WebhookClient.kt b/app/src/main/java/life/andre/message487/WebhookClient.kt new file mode 100644 index 0000000..0ec0fe3 --- /dev/null +++ b/app/src/main/java/life/andre/message487/WebhookClient.kt @@ -0,0 +1,108 @@ +package life.andre.message487 + +import org.json.JSONObject +import java.io.IOException +import java.io.ByteArrayOutputStream +import java.net.HttpURLConnection +import java.net.SocketTimeoutException +import java.net.URI +import java.nio.charset.StandardCharsets +import java.time.Instant +import java.util.UUID + +enum class DeliveryStatus { ACCEPTED, HTTP_SUCCESS, HTTP_ERROR, INVALID_ACK, TIMEOUT, NETWORK_ERROR } + +data class DeliveryResult( + val eventId: String, + val status: DeliveryStatus, + val httpCode: Int? = null, + val durationMs: Long = 0, +) + +data class TestEvent( + val deviceId: String, + val deviceCode: String, + val source: AppSource, + val eventId: String = UUID.randomUUID().toString(), + val occurredAt: String = Instant.now().toString(), +) { + fun toJson(): String = JSONObject() + .put("schema_version", 1) + .put("event_id", eventId) + .put("device_id", deviceId) + .put("device_code", deviceCode) + .put("message_type", "test") + .put("occurred_at", occurredAt) + .put("source", source.packageName) + .put("source_name", source.name) + .put("text", "Message487 connection test") + .toString() +} + +fun validWebhookUrl(value: String, allowLocalHttp: Boolean): Boolean = runCatching { + val uri = URI(value) + val local = uri.host?.lowercase() in setOf("10.0.2.2", "127.0.0.1", "localhost") + uri.host != null && uri.rawUserInfo == null && uri.rawFragment == null && + (uri.port == -1 || uri.port in 1..65535) && + (uri.scheme == "https" || (allowLocalHttp && local && uri.scheme == "http")) +}.getOrDefault(false) + +class WebhookClient(private val timeoutMs: Int = 10_000) { + fun send(url: String, event: TestEvent, requireAck: Boolean): DeliveryResult { + val start = System.nanoTime() + var connection: HttpURLConnection? = null + var httpCode: Int? = null + val status = try { + connection = URI(url).toURL().openConnection() as HttpURLConnection + connection.apply { + requestMethod = "POST" + instanceFollowRedirects = false + connectTimeout = timeoutMs + readTimeout = timeoutMs + doOutput = true + setRequestProperty("Content-Type", "application/json; charset=utf-8") + setRequestProperty("Accept", "application/json") + } + val payload = event.toJson().toByteArray(StandardCharsets.UTF_8) + connection.setFixedLengthStreamingMode(payload.size) + connection.outputStream.use { it.write(payload) } + httpCode = connection.responseCode + when { + httpCode !in 200..299 -> DeliveryStatus.HTTP_ERROR + !requireAck -> DeliveryStatus.HTTP_SUCCESS + else -> { + val bytes = connection.inputStream.use { input -> + val output = ByteArrayOutputStream() + val buffer = ByteArray(4096) + while (output.size() <= MAX_ACK_BYTES) { + val count = input.read(buffer, 0, minOf(buffer.size, MAX_ACK_BYTES + 1 - output.size())) + if (count == -1) break + output.write(buffer, 0, count) + } + output.toByteArray() + } + if (bytes.size > MAX_ACK_BYTES) DeliveryStatus.INVALID_ACK + else validateAck(String(bytes, StandardCharsets.UTF_8), event.eventId) + } + } + } catch (_: SocketTimeoutException) { + DeliveryStatus.TIMEOUT + } catch (_: IOException) { + DeliveryStatus.NETWORK_ERROR + } finally { + connection?.disconnect() + } + return DeliveryResult(event.eventId, status, httpCode, (System.nanoTime() - start) / 1_000_000) + } + + companion object { + private const val MAX_ACK_BYTES = 65_536 + + fun validateAck(body: String, eventId: String): DeliveryStatus = runCatching { + val json = JSONObject(body) + if (json.opt("status") == "accepted" && json.opt("event_id") == eventId) { + DeliveryStatus.ACCEPTED + } else DeliveryStatus.INVALID_ACK + }.getOrDefault(DeliveryStatus.INVALID_ACK) + } +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..d1ea502 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..fffffba --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..8a04a4a --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,32 @@ + + Message487 + Подключите Android к n8n + Версия для разработки: только тестовые события. Пересылка уведомлений и SMS пока не реализована. + Подключение + Код устройства + Метка в каждом событии, например personal-phone. + Укажите код устройства. + Адрес webhook + Укажите полный адрес опубликованного workflow. + Укажите HTTPS-адрес без встроенных учётных данных и фрагмента. В debug-сборке также разрешён HTTP к хосту эмулятора и localhost. + Подтверждение n8n + Требовать ответ accepted с идентификатором отправленного события. + Произвольный webhook: любой ответ HTTP 2xx означает успех HTTP-запроса. + Сохранить подключение + Сохранить и отправить тест + Ожидаем ответ webhook… + Подключение сохранено + Не удалось сохранить настройки подключения. Событие не отправлено. + Последние тесты + В этой сессии тестов ещё не было + ID события: %1$s + Длительность запроса: %1$d мс + HTTP %1$d + Принято webhook + HTTP-запрос выполнен успешно + Webhook вернул ошибку HTTP + Подтверждение отсутствует или неверно + Истекло время ожидания ответа + Не удалось завершить подключение + Ответ webhook не подтверждает доставку в Telegram. Тест содержит искусственный текст, ID установки и указанный код устройства. Автоматических повторов нет; последние результаты хранятся в памяти. + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..d477f75 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,3 @@ + + #315DA8 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..43184f8 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,32 @@ + + Message487 + Connect your Android to n8n + Development preview: test events only. Notification and SMS forwarding is not implemented yet. + Connection + Device code + A label sent with each event, for example personal-phone. + Enter a device code. + Webhook URL + Enter the full URL from your published workflow. + Enter an HTTPS URL without embedded credentials or a fragment. Debug builds also allow HTTP to the emulator host or localhost. + n8n confirmation + Require an accepted response matching the event ID. + Custom webhook: any HTTP 2xx response counts as HTTP success. + Save connection + Save and send test event + Waiting for the webhook response… + Connection saved + Could not save connection settings. No event was sent. + Recent tests + No tests in this session + Event ID: %1$s + Request duration: %1$d ms + HTTP %1$d + Accepted by webhook + HTTP request succeeded + Webhook returned an HTTP error + Missing or invalid confirmation + Response timed out + Could not complete the connection + A webhook response does not confirm delivery to Telegram. Test events contain synthetic text, an installation ID and your device code. Tests are not retried automatically; recent results are kept in memory. + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..5dddbaf --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..0f87131 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..6115950 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/test/java/life/andre/message487/WebhookClientTest.kt b/app/src/test/java/life/andre/message487/WebhookClientTest.kt new file mode 100644 index 0000000..70c6cd1 --- /dev/null +++ b/app/src/test/java/life/andre/message487/WebhookClientTest.kt @@ -0,0 +1,91 @@ +package life.andre.message487 + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.TimeUnit + +class WebhookClientTest { + private val source = AppSource("example.source", "Source app") + @Test + fun `release rejects cleartext and embedded credentials`() { + assertTrue(validWebhookUrl("https://example.com/webhook/test", false)) + assertFalse(validWebhookUrl("http://10.0.2.2:5678/test", false)) + assertFalse(validWebhookUrl("https://user:password@example.com/test", true)) + assertFalse(validWebhookUrl("https://example.com/test#fragment", true)) + assertFalse(validWebhookUrl("https://example.com:99999/test", true)) + assertFalse(validWebhookUrl("file:///tmp/test", true)) + } + + @Test + fun `debug permits only local HTTP hosts`() { + assertTrue(validWebhookUrl("http://10.0.2.2:5678/test", true)) + assertTrue(validWebhookUrl("http://127.0.0.1:5678/test", true)) + assertFalse(validWebhookUrl("http://example.com/test", true)) + assertFalse(validWebhookUrl("http://10.0.2.2.example.com/test", true)) + } + + @Test + fun `ACK must match status and event id`() { + assertEquals(DeliveryStatus.ACCEPTED, WebhookClient.validateAck("""{"status":"accepted","event_id":"a"}""", "a")) + for (body in listOf("", "OK", "{}", """{"status":"accepted","event_id":"b"}""", """{"status":"rejected","event_id":"a"}""")) { + assertEquals(DeliveryStatus.INVALID_ACK, WebhookClient.validateAck(body, "a")) + } + } + + @Test + fun `HTTP transport sends event and validates confirmation`() = withServer { server -> + val event = TestEvent(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) + assertEquals(DeliveryStatus.ACCEPTED, result.status) + assertEquals(200, result.httpCode) + val request = server.takeRequest(1, TimeUnit.SECONDS)!! + assertEquals("POST", request.method) + val body = JSONObject(request.body.readUtf8()) + assertEquals(event.eventId, body.getString("event_id")) + assertEquals("installation", body.getString("device_id")) + assertEquals("test-device", body.getString("device_code")) + assertEquals("example.source", body.getString("source")) + assertEquals("Source app", body.getString("source_name")) + assertEquals("test", body.getString("message_type")) + } + + @Test + fun `errors redirects and invalid ACK never count as accepted`() = withServer { server -> + val client = WebhookClient() + val url = server.url("/receive").toString() + server.enqueue(MockResponse().setBody("{}")) + assertEquals(DeliveryStatus.INVALID_ACK, client.send(url, TestEvent("d", "test-device", source), true).status) + server.enqueue(MockResponse().setResponseCode(204)) + assertEquals(DeliveryStatus.HTTP_SUCCESS, client.send(url, TestEvent("d", "test-device", source), false).status) + for (code in listOf(302, 500)) { + server.enqueue(MockResponse().setResponseCode(code).addHeader("Location", url)) + assertEquals(DeliveryStatus.HTTP_ERROR, client.send(url, TestEvent("d", "test-device", source), true).status) + } + assertEquals(4, 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(), TestEvent("d", "test-device", source), true).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(), TestEvent("d", "test-device", source), true).status) + } + + private fun withServer(block: (MockWebServer) -> Unit) { + MockWebServer().use { server -> + server.start() + block(server) + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..022c030 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.9.1" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false +} diff --git a/docs/project-context.md b/docs/project-context.md new file mode 100644 index 0000000..f12e980 --- /dev/null +++ b/docs/project-context.md @@ -0,0 +1,89 @@ +# Контекст Message487 + +Обновлено 8 сентября 2026 года. + +## Подтверждено пользователем + +- Новое Android-приложение заменяет sms487 в отдельном репозитории. +- ID пакета: `life.andre.message487`. +- Основное позиционирование — интеграция с n8n; произвольный webhook также должен поддерживаться. +- Оформление, стиль кода, политика конфиденциальности, документация и CI ориентируются на + [AndroidMegaProxy](https://github.com/andre487/AndroidMegaProxy). + +## Исходная система + +[sms487](https://github.com/andre487/sms487) передаёт SMS и уведомления через Go API и SQS +в отдельного Telegram-бота. Код бота не изучен. Миграция рабочей системы не выполнялась. + +## Первый этап реализации + +По просьбе пользователя добавлены `DevServer` с Docker Compose и тестовыми workflow n8n, +а также Android-клиент для проверки подключения. Клиент сохраняет адрес, отправляет искусственное +событие, проверяет подтверждение с совпадающим `event_id` и показывает результат запроса. +Есть альтернативный режим произвольного webhook с успехом по HTTP 2xx. Формат подтверждения +является контрактом наших примеров, а не стандартным ответом любого workflow n8n. + +Сборка и проверки выполняются через Fastlane, интерфейс — Kotlin/Compose с английскими и русскими +ресурсами. Захват уведомлений/SMS, надёжная очередь, повторы и авторизация ещё не реализованы. +`DevServer/README.md` описывает запуск и ограничения локального стенда. + +## Риски старого клиента sms487 + +В предыдущем обсуждении и при чтении Android-кода выявлены риски: асинхронная отправка +завершается за пределами жизненного цикла Worker, SMS receiver не использует `goAsync()`, +между получением и сохранением есть окно потери события, стабильного идентификатора события нет. +Успешный HTTP callback помечает пачку отправленной до проверки ответа. В журнал попадает +начало содержимого сообщения. Эти выводы получены статически, без воспроизведения на устройстве. + +Старый клиент добавляет `/add-sms` к адресу сервера и использует собственный формат пачек. +Совместимость с этим протоколом не согласована как требование к новому приложению. + +## Предложение для первого продукта + +Следующие пункты — основа для обсуждения, а не реализованные возможности или утверждённый протокол. + +- Подключение к n8n с готовым примером workflow и тестовым событием. Альтернативная настройка — + полный URL произвольного webhook. Общий транспорт отправляет JSON по HTTPS. +- Выбор приложений и отдельное включение SMS; разрешения запрашиваются в контексте выбранного + источника. Фильтрация выполняется на телефоне до сохранения и отправки. +- Локальная очередь с сохранением до сетевого запроса, стабильным `event_id` при повторах и + идентификатором установки вместо модели телефона. +- Главный экран со статусом подключения, разрешений и очереди, общей паузой и последней + успешной отправкой. Журнал со скрытым по умолчанию содержимым и ручным повтором. +- Успех приёма webhook отделён от доставки в Telegram или другой конечный сервис. + Очередь не удаляет неподтверждённые события молча по возрасту. +- Секреты и содержимое сообщений не попадают в диагностику. Требования к шифрованию, + резервному копированию и срокам хранения определяются до реализации хранения. + +У n8n есть test и production URL; постоянная интеграция использует URL опубликованного workflow. +Режим ответа `Immediately` подтверждает запуск workflow, а не завершение его действий. +См. [документацию Webhook](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/). + +## Что предстоит решить + +1. Следующий этап захвата событий: уведомления сначала или уведомления и SMS одновременно. +2. Подтверждение приёма: после выполнения workflow либо после устойчивой записи в серверную + очередь. Простой HTTP-успех не доказывает надёжное сохранение или конечную доставку. +3. Формат события и ответа, авторизация, таймауты, повторы, обработка обновлений уведомлений, + ограничения очереди и поведение при смене получателя. +4. Распространение приложения и необходимые проверки на устройстве. Минимальная версия первого + каркаса задана в Gradle; её пригодность для будущего захвата событий ещё предстоит проверить. +5. Нужна ли миграция текущего Telegram-сценария и остаётся ли SQS. n8n не требует автоматически + отказываться от существующей очереди; PostgreSQL — один из обсуждавшихся вариантов замены. + +Повторы после потери ответа могут создавать дубли. Дедупликация должна опираться на стабильный +идентификатор события; гарантия exactly-once не согласована и не должна обещаться. + +## Ориентиры из AndroidMegaProxy + +Просмотрен `main` на коммите `c8190e97b705a2c4578d278c40a690e97c5d5f27`. + +- Kotlin, Compose Material 3, системная светлая/тёмная тема, английские и русские ресурсы. +- JDK 21, Gradle Kotlin DSL, Fastlane через Bundler как общий вход для сборки и проверок. +- CI для PR и main: JVM-тесты, Android lint и сборка; без обязательного эмулятора на GitHub. +- APK-артефакты PR без ключей релизной подписи; выпуск и подпись отдельно от PR CI. +- Публичная политика конфиденциальности описывает фактическое поведение приложения. + +VPN, Go/JNI, DNS-диагностика и детали публикации MegaProxy не являются требованиями Message487. +Политику конфиденциальности нельзя копировать дословно: Message487 передаёт содержимое событий +выбранному получателю, а n8n и последующие сервисы имеют собственные правила хранения. diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..388d7ee --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,26 @@ +default_platform(:android) +opt_out_usage +ensure_bundle_exec + +project_root = File.expand_path("..", __dir__) + +platform :android do + desc "Run JVM tests, Android lint, and build debug and unsigned release APKs" + lane :checks do + gradle( + tasks: %w[testDebugUnitTest lintDebug lintRelease assembleDebug assembleRelease], + flags: "--no-daemon", + project_dir: project_root + ) + end + + desc "Build a debug APK" + lane :debug_artifact do + gradle(task: "assembleDebug", project_dir: project_root) + end + + desc "Install the debug APK on the connected emulator or device" + lane :install do + gradle(task: "installDebug", project_dir: project_root) + end +end diff --git a/fastlane/README.md b/fastlane/README.md new file mode 100644 index 0000000..fd3974d --- /dev/null +++ b/fastlane/README.md @@ -0,0 +1,48 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +## Android + +### android checks + +```sh +[bundle exec] fastlane android checks +``` + +Run JVM tests, Android lint, and build debug and unsigned release APKs + +### android debug_artifact + +```sh +[bundle exec] fastlane android debug_artifact +``` + +Build a debug APK + +### android install + +```sh +[bundle exec] fastlane android install +``` + +Install the debug APK on the connected emulator or device + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..e696167 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/scripts/emulator.sh b/scripts/emulator.sh new file mode 100755 index 0000000..d7aabbf --- /dev/null +++ b/scripts/emulator.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -euo pipefail + +sdk_dir="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" +if [[ -z "$sdk_dir" && "$(uname -s)" == Darwin ]]; then + sdk_dir="$HOME/Library/Android/sdk" +fi +if [[ -z "$sdk_dir" || ! -x "$sdk_dir/emulator/emulator" ]]; then + echo "Set ANDROID_HOME to an Android SDK with the emulator installed." >&2 + exit 1 +fi + +avd_name=Message487_API_35 +while read -r serial state; do + if [[ "$serial" == emulator-* && "$state" == device ]]; then + current_avd=$("$sdk_dir/platform-tools/adb" -s "$serial" emu avd name | head -n 1 | tr -d '\r') + if [[ "$current_avd" == "$avd_name" ]]; then + echo "$avd_name is already running on $serial." + exit 0 + fi + fi +done < <("$sdk_dir/platform-tools/adb" devices) + +case "$(uname -m)" in + arm64|aarch64) abi=arm64-v8a ;; + *) abi=x86_64 ;; +esac +system_image="system-images;android-35;google_apis;$abi" + +if ! "$sdk_dir/emulator/emulator" -list-avds | rg -qx "$avd_name"; then + "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" "$system_image" + printf 'no\n' | "$sdk_dir/cmdline-tools/latest/bin/avdmanager" create avd \ + --name "$avd_name" --package "$system_image" --device pixel_7 +fi + +exec "$sdk_dir/emulator/emulator" -avd "$avd_name" -no-snapshot -no-boot-anim -gpu auto diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..3496723 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "Message487" +include(":app") From 743b8901923b4e6b3d6a1bbd673cd20ae5c6cd7f Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 8 Sep 2026 22:25:46 +0300 Subject: [PATCH 2/6] Add notification and SMS forwarding with emulator launcher --- .vscode/launch.json | 12 + .vscode/tasks.json | 13 + DevServer/README.md | 33 ++- DevServer/tests/smoke.py | 17 +- PRIVACY.md | 62 ++-- README.md | 118 +++++--- app/build.gradle.kts | 8 + app/src/main/AndroidManifest.xml | 19 ++ .../andre/message487/ConnectionViewModel.kt | 156 ++++++---- .../life/andre/message487/DeliveryWorker.kt | 78 +++++ .../andre/message487/ForwardingSettings.kt | 57 ++++ .../life/andre/message487/MainActivity.kt | 275 ++++++++++++------ .../life/andre/message487/MessageGraph.kt | 85 ++++++ .../message487/NotificationCaptureService.kt | 63 ++++ .../main/java/life/andre/message487/Outbox.kt | 184 ++++++++++++ .../life/andre/message487/PayloadCipher.kt | 43 +++ .../andre/message487/SmsCaptureReceiver.kt | 29 ++ .../life/andre/message487/WebhookClient.kt | 23 +- app/src/main/res/values-ru/strings.xml | 52 +++- app/src/main/res/values/strings.xml | 52 +++- .../life/andre/message487/ForwardingTest.kt | 43 +++ .../java/life/andre/message487/OutboxTest.kt | 118 ++++++++ .../andre/message487/WebhookClientTest.kt | 12 +- docs/project-context.md | 35 ++- scripts/emulator.sh | 89 ++++-- scripts/run-without-debugging.sh | 34 +++ 26 files changed, 1442 insertions(+), 268 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 app/src/main/java/life/andre/message487/DeliveryWorker.kt create mode 100644 app/src/main/java/life/andre/message487/ForwardingSettings.kt create mode 100644 app/src/main/java/life/andre/message487/MessageGraph.kt create mode 100644 app/src/main/java/life/andre/message487/NotificationCaptureService.kt create mode 100644 app/src/main/java/life/andre/message487/Outbox.kt create mode 100644 app/src/main/java/life/andre/message487/PayloadCipher.kt create mode 100644 app/src/main/java/life/andre/message487/SmsCaptureReceiver.kt create mode 100644 app/src/test/java/life/andre/message487/ForwardingTest.kt create mode 100644 app/src/test/java/life/andre/message487/OutboxTest.kt create mode 100755 scripts/run-without-debugging.sh diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..6d36c0d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node-terminal", + "name": "Run Message487 on Emulator", + "request": "launch", + "command": "\"${workspaceFolder}/scripts/run-without-debugging.sh\"", + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..ade0d6f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,13 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Android: Run on emulator", + "type": "process", + "command": "${workspaceFolder}/scripts/run-without-debugging.sh", + "options": { "cwd": "${workspaceFolder}" }, + "problemMatcher": [], + "presentation": { "reveal": "always", "panel": "dedicated" } + } + ] +} diff --git a/DevServer/README.md b/DevServer/README.md index 8a1d87e..75ffd29 100644 --- a/DevServer/README.md +++ b/DevServer/README.md @@ -25,7 +25,7 @@ returns an HTTP error; its n8n execution itself may still be marked successful. 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 -shows the event ID and confirmation result. Find the same ID in the workflow's execution input. +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. `body.source` contains the package identifier and `body.source_name` the application's display name. @@ -40,8 +40,35 @@ Use **n8n confirmation** for this server; generic webhook mode only checks the H These are published webhooks, so **Listen for test event** is unnecessary. They confirm a test execution only; there is no durable delivery queue, deduplication, or Telegram integration here. -The JSON fixtures define the accepted test contract. The client preserves no automatic retry -queue at this stage. +The JSON fixtures define the accepted event contract. The Android client keeps a persistent +retry queue; the server does not deduplicate repeated requests. + +## Capture checks on an emulator + +Use synthetic data only. In the app, save the receive connection, enable SMS and notifications +in **Sources**, grant the requested permissions, and add `com.android.shell` to selected packages. +Then generate real Android events: + +```sh +adb emu sms send +15551234567 'Message487 synthetic SMS' +adb shell 'cmd notification post -t "Message487 test" message487-test "Synthetic notification"' +``` + +Open **Journal** and match each accepted event ID with n8n **Executions → Webhook → Output → body**. +The SMS event includes `sender`; the notification includes `title`. Repeat the notification command +with identical content: it should create no new event. Change its text: a new event should appear. +A long SMS exceeding one segment should arrive as one event with its complete text. + +For recovery testing, stop this Compose server, generate an event, and check that it remains queued +or waiting for retry. Restart the server and wait for Android background scheduling. The same event +ID should become accepted. The app must retain the event across process restarts. **Retry now** can +request another attempt after a transient failure; pause prevents delivery until resumed. + +The `error` endpoint exercises automatic retries. `invalid-ack` requires a manual retry; changing +the saved URL will not redirect an already queued event. Remove unwanted test records in the journal. +Disable sources or pause before generating events that should not be forwarded, and verify there +is no new journal record. Keep your SMS app unselected to avoid forwarding both its notification +and the SMS broadcast during this check. ## Lifecycle diff --git a/DevServer/tests/smoke.py b/DevServer/tests/smoke.py index d1623a2..bd302e8 100644 --- a/DevServer/tests/smoke.py +++ b/DevServer/tests/smoke.py @@ -30,10 +30,17 @@ def main(): 'message_type': 'test', 'text': 'Synthetic smoke test', } - code, body = post('receive', event) - assert code == 200 and body == { - 'status': 'accepted', 'event_id': event['event_id'] - }, (code, body) + for message_type in ('test', 'notification', 'sms'): + event.update(message_type=message_type, event_id=str(uuid.uuid4())) + if message_type == 'notification': + event['title'] = 'Synthetic notification' + if message_type == 'sms': + event.pop('title', None) + event['sender'] = '+15551234567' + code, body = post('receive', event) + assert code == 200 and body == { + 'status': 'accepted', 'event_id': event['event_id'] + }, (code, body) code, body = post('receive', {}) assert code == 400 and body['status'] == 'rejected', (code, body) code, body = post('error', event) @@ -46,7 +53,7 @@ def main(): pass else: raise AssertionError('Slow endpoint did not time out') - print('Passed: receive, validation, HTTP error, invalid ACK, timeout') + print('Passed: test/notification/SMS receive, validation, HTTP error, invalid ACK, timeout') if __name__ == '__main__': diff --git a/PRIVACY.md b/PRIVACY.md index 2fcaf32..13a1fa5 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -2,42 +2,60 @@ Last updated: September 8, 2026. -This policy describes the current Message487 development preview. It sends synthetic connection -test events to a webhook you configure. It does not yet read SMS or other applications' notifications. +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 +an explicit selection of source apps. SMS forwarding requires the receive-SMS permission. +The app does not read existing SMS history, send SMS, or reply to notifications. ## On your device -The app stores your webhook URL, editable device code, confirmation preference, and a randomly generated installation -ID in its private preferences. These preferences do not have additional application-level encryption. -Android cloud backup and device transfer are disabled for app data. +The app stores your webhook URL, editable device code, confirmation preference, enabled sources, +selected package names, pause state and a random installation ID in private preferences. These +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. -Recent test results contain event IDs, HTTP status codes, request durations and outcome labels. -They are held in memory and disappear when the app process ends. Message bodies and server -response bodies are not written to diagnostic logs by the app. +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, +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. + +The journal shows delivery metadata and does not display message contents. Confirmed events have +their encrypted payload removed from the active database record; only recent delivery metadata is +retained. Undelivered payloads are not automatically deleted by age. Database deletion is logical +and does not guarantee forensic erasure of previously allocated storage. +Message bodies and server response bodies are not written to diagnostic logs by the app. ## Network requests -When you press **Save and send test event**, your selected endpoint receives a synthetic message, -event ID, installation ID, your device code, timestamp, schema version, event type, the source app's -package identifier and its display name. -Its operator can also see connection metadata such as your IP address. The endpoint and any -downstream services process requests under their own policies. An n8n instance may retain event -bodies in its execution history; the bundled development server does so. +Your selected endpoint receives event text, an event ID, installation ID, device code, timestamp, +schema version, event type, source package identifier and source display name. Notifications add +a title; SMS add the sender address. Manual connection tests send synthetic content through the +same queue. Android may redact notification content before giving it to the app. + +The endpoint operator can also see connection metadata such as your IP address. The endpoint and +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. -The app declares visibility of apps with launcher activities so it can look up a source -application's display name by package identifier. It does not request access to all installed -packages or collect or upload an inventory of apps. If a source name is unavailable, its package -identifier is used instead. The current test sender looks up Message487's own name. +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. +If a source name is unavailable, its package identifier is used instead. Release builds require HTTPS. Debug builds also permit HTTP to the Android emulator host and localhost for development. The app contains no advertising, analytics SDKs or automatic crash reporting, and does not automatically send data to the developer. -## Deletion +## Controls and deletion -Clearing app data or uninstalling removes local preferences and resets the installation identity. -It does not delete records on your webhook server. Delete n8n execution history and downstream -copies separately using the controls of those services. +Disable a source to stop capturing its new events. Pause forwarding to also pause queued delivery; +a request already running may finish. Neither action erases previously queued events. Delete +individual records from the journal to remove them from the active database and stop future +attempts. Clearing app data or uninstalling removes local app data and resets the installation +identity. These actions do not remove copies already received by the webhook or downstream services. +Delete n8n execution history and downstream copies separately using those services' controls. Project information and contact: [Message487 on GitHub](https://github.com/andre487/AndroidMessage487). Do not post personal messages or credentials in public issues. diff --git a/README.md b/README.md index f3033aa..3f29682 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,62 @@ # Message487 -Message487 is an open-source Android app being developed to connect selected notifications and -SMS to your n8n workflows. A custom webhook will also be supported for other integrations. - -**Status:** development preview. The client saves a webhook connection and sends synthetic test -events. Notification/SMS capture, persistent delivery queues, automatic retries and webhook -authentication are not implemented yet. - -The intended setup starts with n8n: connect a workflow, select event sources, grant the required -permissions, and send a test event. You choose which events leave your phone and where they go. -Telegram forwarding is one possible workflow; the Android app does not depend on Telegram. +Message487 connects selected Android notifications and incoming SMS to your n8n workflows. +A custom webhook is also supported. Telegram forwarding is one possible workflow; the Android +app does not depend on Telegram. + +**Status:** development preview with notification/SMS capture, a persistent encrypted outbox, +background delivery, automatic retries and a delivery journal. Webhook authentication and +production release signing are not implemented yet. + +## Getting started + +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, + and select applications. Add a package manually if it has no launcher icon. +4. Enable SMS forwarding separately and grant SMS permission. Only new incoming SMS are read; + existing history is not imported. Multipart SMS are combined into one event. + +Both capture sources are off by default, and no applications are selected. Message487 excludes +its own notifications, ongoing notifications and group summaries. An unchanged update of the +same notification is suppressed; changed content creates another event. Existing notifications +are not replayed when access is enabled. Avoid selecting applications that receive the forwarded +messages, or the workflow can create a feedback loop. Selecting your SMS app as a notification +source may forward the same message as both SMS and a notification. + +**Pause forwarding** stops capture and queued delivery. A request already running may finish. +Events arriving while capture is disabled or paused are not saved for later. Background delivery +uses Android WorkManager: Doze, battery restrictions and network availability can delay it. +After a force-stop, open the app again. Android may hide sensitive notification content; Message487 +forwards only the content the system exposes. + +## Delivery and data + +The n8n confirmation mode requires JSON with `status: "accepted"` and the matching `event_id`. +This is our fixture contract, not a built-in n8n response. Turn off **n8n confirmation** for a +custom webhook that acknowledges with HTTP 2xx. Neither response proves delivery to a downstream +service such as Telegram. + +Events are saved locally before transmission. Retries preserve the event ID and body. Timeouts, +connection failures and transient HTTP errors retry with backoff; other HTTP failures and invalid +confirmations need a manual retry from **Journal** after fixing the server. A lost response can +cause duplicate delivery: downstream workflows should deduplicate using `event_id`. + +Queued events retain the URL and confirmation mode from capture time. Changing the connection +does not reroute them. Undelivered events remain until confirmed or explicitly deleted. The +journal persists across restarts and hides message contents. Confirmed payloads are removed; +only a bounded recent history of delivery metadata remains. See [PRIVACY.md](PRIVACY.md). + +The JSON event carries `schema_version`, `event_id`, `device_id`, `device_code`, `message_type`, +`occurred_at`, `source`, `source_name` and `text`. Notifications also carry `title`; SMS carry +`sender`. `device_id` is a random installation identity; `device_code` is your editable label. +`source` is the originating package, with SMS using `android` for the system SMS broadcast. +`source_name` is its PackageManager display label, falling back to the package when unavailable. +Labels can change with language or app version; use `source` for matching rules. + +The manifest declares launcher-app visibility rather than `QUERY_ALL_PACKAGES`. The app requests +notification listener access and `RECEIVE_SMS` only for the features you enable. It does not request +SMS history access or become the default SMS app. ## Local development @@ -20,53 +67,32 @@ docker compose -f DevServer/compose.yaml up -d --wait python3 DevServer/tests/smoke.py ``` -Build with JDK 21, Ruby/Bundler and the Android SDK. Use `ANDROID_HOME` or an untracked +Build with JDK 21, Ruby/Bundler and the Android SDK; the emulator launcher also uses Python 3. +Use `ANDROID_HOME` or an untracked `local.properties` file to point Gradle to your SDK. SDK and library versions are in the Gradle build files; Ruby dependencies are pinned by `Gemfile.lock`. ```sh bundle install bundle exec fastlane android checks -scripts/emulator.sh -``` - -Once the emulator has booted, use another terminal: - -```sh -bundle exec fastlane android install -adb shell am start -n life.andre.message487/.MainActivity +scripts/run-without-debugging.sh ``` -The debug app is preconfigured for the local n8n receive endpoint. Press **Save and send test event**, -then find the displayed event ID in n8n **Executions**. Strict confirmation requires JSON with -`status: "accepted"` and the matching `event_id`; this is our fixture contract, not a built-in n8n -response. Turn off **n8n confirmation** for a generic webhook that acknowledges with HTTP 2xx. - -Fastlane's `debug_artifact` lane builds only the debug APK. `checks` runs JVM 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. - -UI strings are supplied in English and Russian. Connection settings survive app restarts; -recent test results remain in memory only. - -`device_id` identifies the installation; `device_code` is an editable label sent alongside it. -Changing the device code preserves the installation ID. A timeout does not prove the server -failed to receive the event. See [PRIVACY.md](PRIVACY.md) for the current data handling. - -`source` contains the source package name and `source_name` its display name from Android's -PackageManager. Test events use Message487 itself. If a label cannot be resolved, the package -name is used as the display name. Labels may change with the app version or device language; -use `source` for matching rules. +The run script starts the development emulator if necessary, waits for Android, builds and installs +the debug APK, then launches the app without waiting for a debugger. Existing app data is preserved. +In VS Code, select **Run Message487 on Emulator** and **Run Without Debugging**, or run the task +**Android: Run on emulator**. To start only the emulator, use `scripts/emulator.sh`. -The manifest uses `` for apps with launcher activities, in addition to packages Android -makes visible automatically. Sources outside this visibility scope fall back to their package -name. No application inventory is collected or sent, and `QUERY_ALL_PACKAGES` is not requested. -See [Android package visibility](https://developer.android.com/training/package-visibility/declaring). +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. -See the [project context](docs/project-context.md) for the product direction and remaining decisions. +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. This project succeeds [sms487](https://github.com/andre487/sms487). -[AndroidMegaProxy](https://github.com/andre487/AndroidMegaProxy) is the reference for project -conventions, UI, documentation, build tooling, and CI. +[AndroidMegaProxy](https://github.com/andre487/AndroidMegaProxy) is the reference for project conventions. Licensed under the [MIT License](LICENSE). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 55145bc..e50ca37 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -35,6 +35,12 @@ android { targetCompatibility = JavaVersion.VERSION_17 } lint { abortOnError = true } + testOptions { + unitTests.isIncludeAndroidResources = true + unitTests.all { + it.systemProperty("robolectric.dependency.repo.url", "https://repo.maven.apache.org/maven2") + } + } } kotlin { @@ -49,6 +55,8 @@ dependencies { implementation("androidx.compose.material3:material3") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("androidx.work:work-runtime-ktx:2.11.2") + testImplementation("org.robolectric:robolectric:4.16") testImplementation("junit:junit:4.13.2") testImplementation("org.json:json:20250107") testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 882fd9d..c318f83 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + @@ -8,6 +11,7 @@ + + + + + + + + + + diff --git a/app/src/main/java/life/andre/message487/ConnectionViewModel.kt b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt index a48783e..d8116f1 100644 --- a/app/src/main/java/life/andre/message487/ConnectionViewModel.kt +++ b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt @@ -1,6 +1,13 @@ package life.andre.message487 +import android.Manifest import android.app.Application +import android.content.ComponentName +import android.content.Intent +import android.content.pm.PackageManager +import android.service.notification.NotificationListenerService +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers @@ -8,81 +15,122 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.util.UUID + data class ConnectionState( val url: String = "", - val deviceCode: String = "android-device", + val deviceCode: String = "", val requireAck: Boolean = true, val busy: Boolean = false, val invalidUrl: Boolean = false, val invalidDeviceCode: Boolean = false, - val saveFailed: Boolean = false, - val saved: Boolean = false, - val results: List = emptyList(), + val notice: Int? = null, ) +data class QueueSnapshot(val entries: List = emptyList(), val pending: Int = 0) +data class PermissionState(val notifications: Boolean = false, val sms: Boolean = false) + class ConnectionViewModel(application: Application) : AndroidViewModel(application) { - private val preferences = application.getSharedPreferences("connection", 0) - private val mutableState = MutableStateFlow( - ConnectionState( - url = preferences.getString("url", BuildConfig.DEFAULT_WEBHOOK_URL).orEmpty(), - deviceCode = preferences.getString("device_code", null) ?: ConnectionState().deviceCode, - requireAck = preferences.getBoolean("require_ack", true), - ) - ) + private val graph = MessageGraph.get(application) + val settings = graph.settings.state + val listenerConnected = ListenerState.connected + private val mutableState = MutableStateFlow(settings.value.let { + ConnectionState(it.url, it.deviceCode, it.requireAck) + }) val state = mutableState.asStateFlow() + private val mutableQueue = MutableStateFlow(QueueSnapshot()) + val queue = mutableQueue.asStateFlow() + private val mutablePermissions = MutableStateFlow(PermissionState()) + val permissions = mutablePermissions.asStateFlow() + private val mutableApps = MutableStateFlow>(emptyList()) + val apps = mutableApps.asStateFlow() - fun setUrl(url: String) { - if (!state.value.busy) mutableState.value = state.value.copy(url = url, invalidUrl = false, saved = false) + init { + refreshPermissions() + viewModelScope.launch { + graph.outbox.revision.collect { + try { + mutableQueue.value = withContext(Dispatchers.IO) { + QueueSnapshot(graph.outbox.entries(), graph.outbox.pendingCount()) + } + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (_: Exception) { + mutableState.value = state.value.copy(notice = R.string.local_error) + } + } + } + viewModelScope.launch { + mutableApps.value = withContext(Dispatchers.IO) { + val pm = application.packageManager + val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + @Suppress("DEPRECATION") + val packages = pm.queryIntentActivities(intent, 0).map { it.activityInfo.packageName } + packages.distinct().filter { it != application.packageName } + .map { AppSourceResolver(pm).resolve(it) }.sortedBy { it.name.lowercase() } + } + } } - fun setRequireAck(value: Boolean) { - if (!state.value.busy) mutableState.value = state.value.copy(requireAck = value, saved = false) + fun refreshPermissions() { + val app = getApplication() + mutablePermissions.value = PermissionState( + notifications = app.packageName in NotificationManagerCompat.getEnabledListenerPackages(app), + sms = ContextCompat.checkSelfPermission(app, Manifest.permission.RECEIVE_SMS) == PackageManager.PERMISSION_GRANTED, + ) } - fun setDeviceCode(value: String) { - if (!state.value.busy) { - mutableState.value = state.value.copy(deviceCode = value, invalidDeviceCode = false, saved = false) - } - } + 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) } fun save(sendTest: Boolean) { - val current = state.value - if (current.busy) return - val url = current.url.trim() - val deviceCode = current.deviceCode.trim() - if (deviceCode.isEmpty()) { - mutableState.value = current.copy(invalidDeviceCode = true) - return - } - if (!validWebhookUrl(url, BuildConfig.DEBUG)) { - mutableState.value = current.copy(invalidUrl = true) - return + val draft = state.value + val url = draft.url.trim() + 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 } + action(if (sendTest) R.string.test_queued else R.string.saved) { + graph.settings.update { it.copy(url = url, deviceCode = code, requireAck = draft.requireAck) } + if (sendTest) graph.enqueueTest() } - mutableState.value = current.copy(url = url, deviceCode = deviceCode, busy = true, saveFailed = false, saved = false) + } + + fun sendTest() = action(R.string.test_queued) { graph.enqueueTest() } + fun notifications(enabled: Boolean) = action { graph.settings.update { it.copy(notifications = enabled) } } + fun sms(enabled: Boolean) = action { graph.settings.update { it.copy(sms = enabled) } } + fun selectPackage(packageName: String, selected: Boolean) = action { + require(packageName.matches(Regex("[A-Za-z0-9_]+(\\.[A-Za-z0-9_]+)*"))) + require(packageName != getApplication().packageName) + graph.settings.update { it.copy(packages = if (selected) it.packages + packageName else it.packages - packageName) } + } + fun pause(paused: Boolean) = action { + graph.settings.update { it.copy(paused = paused) } + if (!paused) graph.recover() + } + fun retry(id: String) = action { + if (graph.outbox.retry(id)) graph.scheduler.schedule(id, replace = true) + } + fun delete(id: String) = action { graph.outbox.delete(id) } + fun clearError() = action { graph.settings.update { it.copy(captureFailed = false) } } + fun rebind() { + NotificationListenerService.requestRebind(ComponentName(getApplication(), NotificationCaptureService::class.java)) + } + fun showSettingsError() { mutableState.value = state.value.copy(notice = R.string.settings_unavailable) } + + private fun action(notice: Int? = null, block: () -> Unit) { + if (state.value.busy) return + mutableState.value = state.value.copy(busy = true, notice = null) viewModelScope.launch { - val result = withContext(Dispatchers.IO) { - val deviceId = preferences.getString("device_id", null) ?: UUID.randomUUID().toString() - val committed = preferences.edit() - .putString("url", url) - .putBoolean("require_ack", current.requireAck) - .putString("device_id", deviceId) - .putString("device_code", deviceCode) - .commit() - if (!committed) false to null - else true to if (sendTest) { - val application = getApplication() - val source = AppSourceResolver(application.packageManager).resolve(application.packageName) - WebhookClient().send(url, TestEvent(deviceId, deviceCode, source), current.requireAck) - } else null + val result = try { + withContext(Dispatchers.IO) { block() } + notice + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (_: Exception) { + R.string.local_error } - mutableState.value = state.value.copy( - busy = false, - saved = result.first, - saveFailed = !result.first, - results = (listOfNotNull(result.second) + state.value.results).take(20), - ) + mutableState.value = state.value.copy(busy = false, notice = result) } } } diff --git a/app/src/main/java/life/andre/message487/DeliveryWorker.kt b/app/src/main/java/life/andre/message487/DeliveryWorker.kt new file mode 100644 index 0000000..d19decc --- /dev/null +++ b/app/src/main/java/life/andre/message487/DeliveryWorker.kt @@ -0,0 +1,78 @@ +package life.andre.message487 + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.TimeUnit + +class DeliveryScheduler(context: Context) { + private val manager = WorkManager.getInstance(context) + + fun schedule(id: String, replace: Boolean = false) { + val work = OneTimeWorkRequestBuilder() + .setInputData(workDataOf("event_id" to id)) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .build() + manager.enqueueUniqueWork("event-$id", if (replace) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP, work) + } + + fun startRecovery() { + manager.enqueueUniquePeriodicWork("outbox-recovery", ExistingPeriodicWorkPolicy.KEEP, + PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build()) + } +} + +class DeliveryWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) { + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + val graph = MessageGraph.get(applicationContext) + val id = inputData.getString("event_id") ?: return@withContext Result.failure() + try { + if (graph.settings.state.value.paused) return@withContext Result.retry() + val attempt = try { + graph.outbox.beginAttempt(id) + } catch (databaseError: android.database.SQLException) { + throw databaseError + } catch (_: Exception) { + graph.outbox.blockUnreadable(id) + return@withContext Result.success() + } ?: return@withContext Result.success() + val request = attempt.request + val result = if (validWebhookUrl(request.url, BuildConfig.DEBUG)) { + WebhookClient().sendJson(request.url, id, request.json, request.requireAck) + } else DeliveryResult(id, DeliveryStatus.HTTP_ERROR) + val state = graph.outbox.finish(id, attempt.token, result) + if (state == QueueState.RETRY) Result.retry() else Result.success() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + graph.captureFailed() + Result.retry() + } + } +} + +class RecoveryWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) { + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + try { + MessageGraph.get(applicationContext).recover() + Result.success() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + Result.retry() + } + } +} diff --git a/app/src/main/java/life/andre/message487/ForwardingSettings.kt b/app/src/main/java/life/andre/message487/ForwardingSettings.kt new file mode 100644 index 0000000..f5d0c4b --- /dev/null +++ b/app/src/main/java/life/andre/message487/ForwardingSettings.kt @@ -0,0 +1,57 @@ +package life.andre.message487 + +import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.IOException +import java.util.UUID + +data class ForwardingSettings( + val url: String = BuildConfig.DEFAULT_WEBHOOK_URL, + val deviceId: String = "", + val deviceCode: String = "android-device", + val requireAck: Boolean = true, + val notifications: Boolean = false, + val sms: Boolean = false, + val paused: Boolean = false, + val packages: Set = emptySet(), + val captureFailed: Boolean = false, +) { + fun ready(): Boolean = deviceCode.isNotBlank() && validWebhookUrl(url, BuildConfig.DEBUG) + fun acceptsPackage(packageName: String, ownPackage: String): Boolean = + !paused && notifications && packageName != ownPackage && packageName in packages && ready() +} + +class SettingsStore(context: Context) { + private val preferences = context.getSharedPreferences("connection", Context.MODE_PRIVATE) + private val mutableState = MutableStateFlow(read()) + val state = mutableState.asStateFlow() + + private fun read() = ForwardingSettings( + url = preferences.getString("url", BuildConfig.DEFAULT_WEBHOOK_URL).orEmpty(), + deviceId = preferences.getString("device_id", "").orEmpty(), + deviceCode = preferences.getString("device_code", "android-device").orEmpty(), + requireAck = preferences.getBoolean("require_ack", true), + notifications = preferences.getBoolean("notifications", false), + sms = preferences.getBoolean("sms", false), + paused = preferences.getBoolean("paused", false), + packages = preferences.getStringSet("packages", emptySet()).orEmpty().toSet(), + captureFailed = preferences.getBoolean("capture_failed", false), + ) + + @Synchronized + fun update(transform: (ForwardingSettings) -> ForwardingSettings): ForwardingSettings { + val next = transform(mutableState.value).let { + if (it.deviceId.isBlank()) it.copy(deviceId = UUID.randomUUID().toString()) else it + } + if (!preferences.edit() + .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) + .putBoolean("paused", next.paused).putStringSet("packages", next.packages) + .putBoolean("capture_failed", next.captureFailed).commit() + ) throw IOException("Could not save settings") + mutableState.value = next + return next + } +} diff --git a/app/src/main/java/life/andre/message487/MainActivity.kt b/app/src/main/java/life/andre/message487/MainActivity.kt index af9c549..ce87dea 100644 --- a/app/src/main/java/life/andre/message487/MainActivity.kt +++ b/app/src/main/java/life/andre/message487/MainActivity.kt @@ -1,38 +1,36 @@ package life.andre.message487 +import android.Manifest +import android.content.Intent import android.os.Bundle +import android.provider.Settings import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import java.text.DateFormat +import java.util.Date class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -40,91 +38,192 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { - ConnectionScreen() + MessageScreen() } } } } @Composable -private fun ConnectionScreen(model: ConnectionViewModel = viewModel()) { +private fun MessageScreen(model: ConnectionViewModel = viewModel()) { val state by model.state.collectAsStateWithLifecycle() + val settings by model.settings.collectAsStateWithLifecycle() + val permissions by model.permissions.collectAsStateWithLifecycle() + val connected by model.listenerConnected.collectAsStateWithLifecycle() + val queue by model.queue.collectAsStateWithLifecycle() + val apps by model.apps.collectAsStateWithLifecycle() + var tab by rememberSaveable { mutableIntStateOf(0) } + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) model.refreshPermissions() } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } Scaffold { padding -> - Column( - modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge) - Text(stringResource(R.string.tagline), style = MaterialTheme.typography.titleMedium) - Card { - Text(stringResource(R.string.development_status), Modifier.padding(16.dp)) - } - Text(stringResource(R.string.connection), style = MaterialTheme.typography.titleLarge) - OutlinedTextField( - value = state.deviceCode, - onValueChange = model::setDeviceCode, - label = { Text(stringResource(R.string.device_code)) }, - modifier = Modifier.fillMaxWidth(), - enabled = !state.busy, - singleLine = true, - isError = state.invalidDeviceCode, - supportingText = { - Text(stringResource(if (state.invalidDeviceCode) R.string.invalid_device_code else R.string.device_code_hint)) - }, - ) - OutlinedTextField( - value = state.url, - onValueChange = model::setUrl, - label = { Text(stringResource(R.string.webhook_url)) }, - modifier = Modifier.fillMaxWidth(), - enabled = !state.busy, - isError = state.invalidUrl, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - supportingText = { - Text(stringResource(if (state.invalidUrl) R.string.invalid_url else R.string.url_hint)) - }, - ) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Column(Modifier.weight(1f)) { - Text(stringResource(R.string.n8n_mode), style = MaterialTheme.typography.titleMedium) - Text(stringResource(if (state.requireAck) R.string.n8n_hint else R.string.raw_hint)) + Column(Modifier.fillMaxSize().padding(padding)) { + Text(stringResource(R.string.app_name), Modifier.padding(16.dp), style = MaterialTheme.typography.headlineMedium) + TabRow(selectedTabIndex = tab) { + listOf(R.string.status_tab, R.string.connection, R.string.sources_tab, R.string.journal).forEachIndexed { index, title -> + Tab(selected = tab == index, onClick = { tab = index }, text = { Text(stringResource(title)) }) } - Switch(checked = state.requireAck, onCheckedChange = model::setRequireAck, enabled = !state.busy) - } - OutlinedButton(onClick = { model.save(false) }, enabled = !state.busy, modifier = Modifier.fillMaxWidth()) { - Text(stringResource(R.string.save)) } - Button(onClick = { model.save(true) }, enabled = !state.busy, modifier = Modifier.fillMaxWidth()) { - Text(stringResource(R.string.send_test)) + if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + state.notice?.let { Text(stringResource(it), Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) } + key(tab) { + Column(Modifier.fillMaxWidth().weight(1f).verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp)) { + when (tab) { + 0 -> { + Text(stringResource(R.string.tagline), style = MaterialTheme.typography.titleLarge) + SettingSwitch(R.string.pause, R.string.pause_hint, settings.paused, !state.busy, model::pause) + Text(stringResource(R.string.queue_count, queue.pending), style = MaterialTheme.typography.titleLarge) + Text(stringResource(if (settings.ready()) R.string.connection_ready else R.string.configure_connection)) + Text(stringResource(if (permissions.notifications && connected) R.string.listener_connected else R.string.listener_disconnected)) + Text(stringResource(if (permissions.sms) R.string.sms_granted else R.string.sms_not_granted)) + if (settings.captureFailed) { + Text(stringResource(R.string.capture_error), color = MaterialTheme.colorScheme.error) + OutlinedButton(onClick = model::clearError, enabled = !state.busy) { Text(stringResource(R.string.dismiss)) } + } + Button(onClick = model::sendTest, enabled = !state.busy && settings.ready() && !settings.paused) { + Text(stringResource(R.string.test_saved_connection)) + } + Text(stringResource(R.string.delivery_note)) + } + 1 -> ConnectionContent(state, model) + 2 -> SourcesContent(settings, permissions, connected, apps, state.busy, model) + 3 -> JournalContent(queue, state.busy, model) + } + } } - if (state.busy) { - LinearProgressIndicator(Modifier.fillMaxWidth()) - Text(stringResource(R.string.sending)) + } + } +} + +@Composable +private fun ConnectionContent(state: ConnectionState, model: ConnectionViewModel) { + OutlinedTextField(value = state.deviceCode, onValueChange = model::setDeviceCode, + label = { Text(stringResource(R.string.device_code)) }, modifier = Modifier.fillMaxWidth(), + enabled = !state.busy, singleLine = true, isError = state.invalidDeviceCode, + supportingText = { Text(stringResource(if (state.invalidDeviceCode) R.string.invalid_device_code else R.string.device_code_hint)) }) + OutlinedTextField(value = state.url, onValueChange = model::setUrl, + label = { Text(stringResource(R.string.webhook_url)) }, modifier = Modifier.fillMaxWidth(), enabled = !state.busy, + isError = state.invalidUrl, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + supportingText = { Text(stringResource(if (state.invalidUrl) R.string.invalid_url else R.string.url_hint)) }) + SettingSwitch(R.string.n8n_mode, if (state.requireAck) R.string.n8n_hint else R.string.raw_hint, + state.requireAck, !state.busy, model::setRequireAck) + Text(stringResource(R.string.destination_note)) + OutlinedButton(onClick = { model.save(false) }, enabled = !state.busy, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.save)) } + Button(onClick = { model.save(true) }, enabled = !state.busy, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.send_test)) } +} + +@Composable +private fun SourcesContent(settings: ForwardingSettings, permissions: PermissionState, connected: Boolean, + apps: List, busy: Boolean, model: ConnectionViewModel) { + val context = LocalContext.current + var manualPackage by remember { mutableStateOf("") } + val smsPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + model.refreshPermissions() + model.sms(granted) + } + fun openNotificationSettings() { + try { context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) } + catch (_: android.content.ActivityNotFoundException) { model.showSettingsError() } + } + Text(stringResource(R.string.sources_disclosure)) + SettingSwitch(R.string.notifications_enabled, R.string.notifications_hint, settings.notifications, !busy) { + model.notifications(it) + if (it && !permissions.notifications) openNotificationSettings() + } + OutlinedButton(onClick = { openNotificationSettings() }) { Text(stringResource(R.string.notification_access)) } + if (permissions.notifications && !connected) { + OutlinedButton(onClick = model::rebind) { Text(stringResource(R.string.reconnect_listener)) } + } + SettingSwitch(R.string.sms_enabled, R.string.sms_hint, settings.sms, !busy) { + if (it && !permissions.sms) smsPermission.launch(Manifest.permission.RECEIVE_SMS) else model.sms(it) + } + if (settings.sms && !permissions.sms) Text(stringResource(R.string.sms_not_granted), color = MaterialTheme.colorScheme.error) + Text(stringResource(R.string.selected_apps, settings.packages.size), style = MaterialTheme.typography.titleLarge) + Text(stringResource(R.string.app_selection_hint)) + val displayedApps = (apps + settings.packages.filter { pkg -> apps.none { it.packageName == pkg } }.map { AppSource(it, it) }) + .sortedBy { it.name.lowercase() } + displayedApps.forEach { app -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Checkbox(checked = app.packageName in settings.packages, + onCheckedChange = { model.selectPackage(app.packageName, it) }, enabled = !busy) + Column(Modifier.weight(1f).padding(top = 8.dp)) { + Text(app.name) + Text(app.packageName, style = MaterialTheme.typography.bodySmall) } - if (state.saved) Text(stringResource(R.string.saved)) - if (state.saveFailed) Text(stringResource(R.string.save_failed), color = MaterialTheme.colorScheme.error) - Text(stringResource(R.string.journal), style = MaterialTheme.typography.titleLarge) - if (state.results.isEmpty()) Text(stringResource(R.string.no_events)) - state.results.forEach { result -> - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(stringResource(result.status.label()), style = MaterialTheme.typography.titleMedium) - Text(stringResource(R.string.event_id, result.eventId), style = MaterialTheme.typography.bodySmall) - Text(stringResource(R.string.duration, result.durationMs)) - result.httpCode?.let { Text(stringResource(R.string.http_code, it)) } - } + } + } + OutlinedTextField(value = manualPackage, onValueChange = { manualPackage = it }, + label = { Text(stringResource(R.string.package_name)) }, modifier = Modifier.fillMaxWidth(), singleLine = true) + OutlinedButton(onClick = { model.selectPackage(manualPackage.trim(), true) }, enabled = !busy && manualPackage.isNotBlank()) { + Text(stringResource(R.string.add_package)) + } +} + +@Composable +private fun SettingSwitch(title: Int, description: Int, checked: Boolean, enabled: Boolean, onChange: (Boolean) -> Unit) { + val label = stringResource(title) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f)) { + Text(stringResource(title), style = MaterialTheme.typography.titleMedium) + Text(stringResource(description), style = MaterialTheme.typography.bodySmall) + } + Switch(checked = checked, onCheckedChange = onChange, enabled = enabled, + modifier = Modifier.semantics { contentDescription = label }) + } +} + +@Composable +private fun JournalContent(queue: QueueSnapshot, busy: Boolean, model: ConnectionViewModel) { + var deleteTarget by remember { mutableStateOf(null) } + Text(stringResource(R.string.journal_hint)) + if (queue.entries.isEmpty()) Text(stringResource(R.string.no_events)) + queue.entries.forEach { entry -> + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(entry.sourceName, style = MaterialTheme.typography.titleMedium) + Text(stringResource(when (entry.type) { "sms" -> R.string.sms_type; "notification" -> R.string.notification_type; else -> R.string.test_type })) + Text(DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM).format(Date(entry.createdAt))) + Text(stringResource(entry.state.label())) + entry.outcome?.let { outcome -> + if (!entry.delivered) Text(stringResource(outcomeLabel(outcome))) + } + Text(stringResource(R.string.event_id, entry.id), style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.attempts, entry.attempts)) + entry.httpCode?.let { Text(stringResource(R.string.http_code, it)) } + if (!entry.delivered && entry.state != QueueState.SENDING) { + OutlinedButton(onClick = { model.retry(entry.id) }, enabled = !busy) { Text(stringResource(R.string.retry)) } + } + if (entry.state != QueueState.SENDING) { + TextButton(onClick = { deleteTarget = entry }, enabled = !busy) { Text(stringResource(R.string.delete_event)) } } } - Text(stringResource(R.string.delivery_note), style = MaterialTheme.typography.bodySmall) } } + deleteTarget?.let { entry -> + AlertDialog(onDismissRequest = { deleteTarget = null }, title = { Text(stringResource(R.string.delete_event)) }, + text = { Text(stringResource(R.string.delete_confirmation)) }, + confirmButton = { TextButton(onClick = { model.delete(entry.id); deleteTarget = null }) { Text(stringResource(R.string.delete_event)) } }, + dismissButton = { TextButton(onClick = { deleteTarget = null }) { Text(stringResource(R.string.cancel)) } }) + } +} + +private fun QueueState.label(): Int = when (this) { + QueueState.PENDING -> R.string.pending + QueueState.SENDING -> R.string.sending + QueueState.RETRY -> R.string.retry_wait + QueueState.BLOCKED -> R.string.blocked + QueueState.ACCEPTED -> R.string.accepted + QueueState.HTTP_SUCCESS -> R.string.http_success } -private fun DeliveryStatus.label(): Int = when (this) { - DeliveryStatus.ACCEPTED -> R.string.accepted - DeliveryStatus.HTTP_SUCCESS -> R.string.http_success - DeliveryStatus.HTTP_ERROR -> R.string.http_error - DeliveryStatus.INVALID_ACK -> R.string.invalid_ack - DeliveryStatus.TIMEOUT -> R.string.timeout - DeliveryStatus.NETWORK_ERROR -> R.string.network_error +private fun outcomeLabel(outcome: String): Int = when (outcome) { + DeliveryStatus.HTTP_ERROR.name -> R.string.http_error + DeliveryStatus.INVALID_ACK.name -> R.string.invalid_ack + DeliveryStatus.TIMEOUT.name -> R.string.timeout + DeliveryStatus.NETWORK_ERROR.name -> R.string.network_error + else -> R.string.local_error } diff --git a/app/src/main/java/life/andre/message487/MessageGraph.kt b/app/src/main/java/life/andre/message487/MessageGraph.kt new file mode 100644 index 0000000..f0e5a52 --- /dev/null +++ b/app/src/main/java/life/andre/message487/MessageGraph.kt @@ -0,0 +1,85 @@ +package life.andre.message487 + +import android.app.Application +import android.content.Context +import java.security.MessageDigest +import java.time.Instant +import java.util.UUID +import java.util.concurrent.Executors + +class MessageApplication : Application() { + val graph by lazy { MessageGraph(this) } + + override fun onCreate() { + super.onCreate() + graph.start() + } +} + +class MessageGraph internal constructor(private val context: Application) { + val settings = SettingsStore(context) + val outbox = Outbox(context, KeystorePayloadCipher()) + val scheduler by lazy { DeliveryScheduler(context) } + val captureExecutor = Executors.newSingleThreadExecutor() + private val sources = AppSourceResolver(context.packageManager) + + fun start() { + captureExecutor.execute { + try { + settings.update { it } + scheduler.startRecovery() + recover() + } catch (_: Exception) { captureFailed() } + } + } + + fun recover() { + if (!settings.state.value.paused) outbox.pendingIds().forEach { scheduler.schedule(it) } + } + + fun enqueueTest() { + val config = settings.state.value + require(config.ready()) + val event = MessageEvent(config.deviceId, config.deviceCode, sources.resolve(context.packageName)) + enqueue(event, config) + } + + fun captureNotification(notification: CapturedNotification) { + val config = settings.state.value + if (!config.acceptsPackage(notification.packageName, context.packageName)) return + val event = MessageEvent( + config.deviceId, config.deviceCode, sources.resolve(notification.packageName), + occurredAt = Instant.ofEpochMilli(notification.postedAt).toString(), + messageType = "notification", text = notification.text, title = notification.title, + ) + enqueue(event, config, digest(notification.key), digest(notification.title + "\u0000" + notification.text)) + } + + fun captureSms(sender: String, text: String, timestamp: Long) { + val config = settings.state.value + if (config.paused || !config.sms || !config.ready()) return + val identity = "${config.deviceId}\u0000$sender\u0000$timestamp\u0000$text" + val event = MessageEvent( + config.deviceId, config.deviceCode, sources.resolve("android"), + eventId = UUID.nameUUIDFromBytes(identity.toByteArray(Charsets.UTF_8)).toString(), + occurredAt = Instant.ofEpochMilli(timestamp).toString(), + messageType = "sms", text = text, sender = sender, + ) + enqueue(event, config) + } + + private fun enqueue(event: MessageEvent, config: ForwardingSettings, key: String? = null, fingerprint: String? = null) { + if (outbox.enqueue(event, config, key, fingerprint)) scheduler.schedule(event.eventId) + } + + fun captureFailed() { + try { settings.update { it.copy(captureFailed = true) } } catch (_: Exception) { } + } + + companion object { + fun get(context: Context): MessageGraph = (context.applicationContext as MessageApplication).graph + } +} + +fun digest(value: String): String = MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) } diff --git a/app/src/main/java/life/andre/message487/NotificationCaptureService.kt b/app/src/main/java/life/andre/message487/NotificationCaptureService.kt new file mode 100644 index 0000000..51e0703 --- /dev/null +++ b/app/src/main/java/life/andre/message487/NotificationCaptureService.kt @@ -0,0 +1,63 @@ +package life.andre.message487 + +import android.app.Notification +import android.service.notification.NotificationListenerService +import android.service.notification.StatusBarNotification +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class CapturedNotification( + val key: String, + val packageName: String, + val title: String, + val text: String, + val postedAt: Long, +) + +object ListenerState { + internal val mutableConnected = MutableStateFlow(false) + val connected = mutableConnected.asStateFlow() +} + +class NotificationCaptureService : NotificationListenerService() { + override fun onListenerConnected() { + ListenerState.mutableConnected.value = true + val graph = MessageGraph.get(this) + graph.captureExecutor.execute { + try { graph.recover() } catch (_: Exception) { graph.captureFailed() } + } + } + + override fun onListenerDisconnected() { ListenerState.mutableConnected.value = false } + + override fun onDestroy() { + ListenerState.mutableConnected.value = false + super.onDestroy() + } + + override fun onNotificationPosted(sbn: StatusBarNotification) { + val graph = MessageGraph.get(this) + if (!graph.settings.state.value.acceptsPackage(sbn.packageName, packageName)) return + if (!sbn.isClearable || sbn.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0) return + try { + val extras = sbn.notification.extras + val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty() + val text = extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString() + ?: extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() + ?: extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES)?.joinToString("\n") + ?: "" + if (title.isBlank() && text.isBlank()) return + val captured = CapturedNotification(sbn.key, sbn.packageName, title, text, sbn.postTime) + graph.captureExecutor.execute { + try { graph.captureNotification(captured) } catch (_: Exception) { graph.captureFailed() } + } + } catch (_: Exception) { graph.captureFailed() } + } + + override fun onNotificationRemoved(sbn: StatusBarNotification) { + val graph = MessageGraph.get(this) + graph.captureExecutor.execute { + try { graph.outbox.forgetNotification(digest(sbn.key)) } catch (_: Exception) { graph.captureFailed() } + } + } +} diff --git a/app/src/main/java/life/andre/message487/Outbox.kt b/app/src/main/java/life/andre/message487/Outbox.kt new file mode 100644 index 0000000..237fb54 --- /dev/null +++ b/app/src/main/java/life/andre/message487/Outbox.kt @@ -0,0 +1,184 @@ +package life.andre.message487 + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONObject +import java.util.UUID + +enum class QueueState { PENDING, SENDING, RETRY, BLOCKED, ACCEPTED, HTTP_SUCCESS } + +data class QueueEntry( + val id: String, + val type: String, + val sourceName: String, + val createdAt: Long, + val state: QueueState, + val attempts: Int, + val outcome: String?, + val httpCode: Int?, +) { + 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 Attempt(val token: String, val entry: QueueEntry, val request: QueuedRequest) + +fun deliveryQueueState(result: DeliveryResult): QueueState = when (result.status) { + DeliveryStatus.ACCEPTED -> QueueState.ACCEPTED + DeliveryStatus.HTTP_SUCCESS -> QueueState.HTTP_SUCCESS + DeliveryStatus.NETWORK_ERROR, DeliveryStatus.TIMEOUT -> QueueState.RETRY + DeliveryStatus.INVALID_ACK -> QueueState.BLOCKED + DeliveryStatus.HTTP_ERROR -> if (result.httpCode in listOf(408, 425, 429) || result.httpCode in 500..599) { + QueueState.RETRY + } else QueueState.BLOCKED +} + +class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHelper(context, "outbox.db", null, 1) { + private val mutableRevision = MutableStateFlow(0L) + val revision = mutableRevision.asStateFlow() + + override fun onCreate(db: SQLiteDatabase) { + db.execSQL("""CREATE TABLE events ( + id TEXT PRIMARY KEY, type TEXT NOT NULL, source_name TEXT NOT NULL, + created_at INTEGER NOT NULL, state TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, + outcome TEXT, http_code INTEGER, payload BLOB, attempt_token TEXT + )""") + db.execSQL("CREATE INDEX events_state ON events(state)") + db.execSQL("CREATE TABLE notifications (notification_key TEXT PRIMARY KEY, fingerprint TEXT NOT NULL)") + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + error("Missing forward migration from $oldVersion to $newVersion") + } + + @Synchronized + fun enqueue(event: MessageEvent, settings: ForwardingSettings, notificationKey: String? = null, fingerprint: String? = null): Boolean { + val db = writableDatabase + db.beginTransaction() + try { + if (notificationKey != null) { + db.rawQuery("SELECT fingerprint FROM notifications WHERE notification_key = ?", arrayOf(notificationKey)).use { + if (it.moveToFirst() && it.getString(0) == fingerprint) return false + } + } + 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) + .put("event", JSONObject(event.toJson())).toString() + db.insertOrThrow("events", null, ContentValues().apply { + put("id", event.eventId) + put("type", event.messageType) + put("source_name", event.source.name) + put("created_at", System.currentTimeMillis()) + put("state", QueueState.PENDING.name) + put("payload", cipher.encrypt(envelope)) + }) + if (notificationKey != null) { + db.insertWithOnConflict("notifications", null, ContentValues().apply { + put("notification_key", notificationKey) + put("fingerprint", requireNotNull(fingerprint)) + }, SQLiteDatabase.CONFLICT_REPLACE) + db.execSQL("DELETE FROM notifications WHERE rowid NOT IN (SELECT rowid FROM notifications ORDER BY rowid DESC LIMIT 2000)") + } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + changed() + return true + } + + @Synchronized + fun forgetNotification(key: String) { + writableDatabase.delete("notifications", "notification_key = ?", arrayOf(key)) + } + + @Synchronized + fun pendingIds(): List = readableDatabase.rawQuery( + "SELECT id FROM events WHERE state IN ('PENDING', 'SENDING', 'RETRY') ORDER BY created_at", null + ).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.getString(0)) } } + + @Synchronized + fun entries(): List = readableDatabase.rawQuery( + "SELECT * FROM events ORDER BY CASE WHEN state IN ('ACCEPTED', 'HTTP_SUCCESS') THEN 1 ELSE 0 END, created_at DESC LIMIT 200", null + ).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.entry()) } } + + @Synchronized + fun pendingCount(): Int = readableDatabase.rawQuery( + "SELECT COUNT(*) FROM events WHERE state NOT IN ('ACCEPTED', 'HTTP_SUCCESS')", null + ).use { it.moveToFirst(); it.getInt(0) } + + @Synchronized + fun beginAttempt(id: String): Attempt? { + val db = writableDatabase + val (entry, request) = db.rawQuery("SELECT * FROM events WHERE id = ?", arrayOf(id)).use { + if (!it.moveToFirst()) return null + 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 token = UUID.randomUUID().toString() + db.execSQL("UPDATE events SET state = 'SENDING', attempts = attempts + 1, attempt_token = ? WHERE id = ?", arrayOf(token, id)) + changed() + return Attempt(token, entry, request) + } + + @Synchronized + fun finish(id: String, token: String, result: DeliveryResult): QueueState { + val state = deliveryQueueState(result) + val values = ContentValues().apply { + put("state", state.name) + put("outcome", result.status.name) + result.httpCode?.let { put("http_code", it) } ?: putNull("http_code") + putNull("attempt_token") + if (state == QueueState.ACCEPTED || state == QueueState.HTTP_SUCCESS) putNull("payload") + } + writableDatabase.update("events", values, "id = ? AND attempt_token = ?", arrayOf(id, token)) + writableDatabase.execSQL("""DELETE FROM events WHERE state IN ('ACCEPTED', 'HTTP_SUCCESS') AND id NOT IN + (SELECT id FROM events WHERE state IN ('ACCEPTED', 'HTTP_SUCCESS') ORDER BY created_at DESC LIMIT 100)""") + changed() + return state + } + + @Synchronized + fun blockUnreadable(id: String) { + writableDatabase.execSQL("UPDATE events SET state = 'BLOCKED', outcome = 'LOCAL_ERROR', attempt_token = NULL WHERE id = ? AND payload IS NOT NULL", arrayOf(id)) + changed() + } + + @Synchronized + fun retry(id: String): Boolean { + val changed = writableDatabase.update("events", ContentValues().apply { + put("state", QueueState.PENDING.name) + putNull("outcome") + }, "id = ? AND state IN ('BLOCKED', 'RETRY', 'PENDING')", arrayOf(id)) > 0 + if (changed) changed() + return changed + } + + @Synchronized + fun delete(id: String) { + writableDatabase.delete("events", "id = ? AND state != 'SENDING'", arrayOf(id)) + changed() + } + + private fun changed() { mutableRevision.value += 1 } + + private fun Cursor.entry() = QueueEntry( + id = getString(getColumnIndexOrThrow("id")), + type = getString(getColumnIndexOrThrow("type")), + sourceName = getString(getColumnIndexOrThrow("source_name")), + createdAt = getLong(getColumnIndexOrThrow("created_at")), + state = QueueState.valueOf(getString(getColumnIndexOrThrow("state"))), + attempts = getInt(getColumnIndexOrThrow("attempts")), + outcome = getString(getColumnIndexOrThrow("outcome")), + httpCode = getColumnIndexOrThrow("http_code").let { if (isNull(it)) null else getInt(it) }, + ) +} diff --git a/app/src/main/java/life/andre/message487/PayloadCipher.kt b/app/src/main/java/life/andre/message487/PayloadCipher.kt new file mode 100644 index 0000000..d22ce5b --- /dev/null +++ b/app/src/main/java/life/andre/message487/PayloadCipher.kt @@ -0,0 +1,43 @@ +package life.andre.message487 + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +interface PayloadCipher { + fun encrypt(value: String): ByteArray + fun decrypt(value: ByteArray): String +} + +class KeystorePayloadCipher : PayloadCipher { + @Synchronized + private fun key(): SecretKey { + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (store.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { + init(KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build()) + }.generateKey() + } + + override fun encrypt(value: String): ByteArray { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key()) + return cipher.iv + cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + } + + override fun decrypt(value: ByteArray): String { + require(value.size >= 28) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, value.copyOfRange(0, 12))) + return String(cipher.doFinal(value, 12, value.size - 12), Charsets.UTF_8) + } + + companion object { private const val KEY_ALIAS = "message487-outbox-v1" } +} diff --git a/app/src/main/java/life/andre/message487/SmsCaptureReceiver.kt b/app/src/main/java/life/andre/message487/SmsCaptureReceiver.kt new file mode 100644 index 0000000..9f58675 --- /dev/null +++ b/app/src/main/java/life/andre/message487/SmsCaptureReceiver.kt @@ -0,0 +1,29 @@ +package life.andre.message487 + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.provider.Telephony + +class SmsCaptureReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return + val graph = MessageGraph.get(context) + val config = graph.settings.state.value + if (!config.sms || config.paused || !config.ready()) return + val pending = goAsync() + graph.captureExecutor.execute { + try { + val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent).orEmpty() + if (messages.isNotEmpty()) { + val sender = messages.first().originatingAddress.orEmpty() + graph.captureSms(sender, messages.joinToString("") { it.messageBody.orEmpty() }, messages.first().timestampMillis) + } + } catch (_: Exception) { + graph.captureFailed() + } finally { + pending.finish() + } + } + } +} diff --git a/app/src/main/java/life/andre/message487/WebhookClient.kt b/app/src/main/java/life/andre/message487/WebhookClient.kt index 0ec0fe3..eb7a907 100644 --- a/app/src/main/java/life/andre/message487/WebhookClient.kt +++ b/app/src/main/java/life/andre/message487/WebhookClient.kt @@ -19,23 +19,29 @@ data class DeliveryResult( val durationMs: Long = 0, ) -data class TestEvent( +data class MessageEvent( val deviceId: String, val deviceCode: String, val source: AppSource, val eventId: String = UUID.randomUUID().toString(), val occurredAt: String = Instant.now().toString(), + val messageType: String = "test", + val text: String = "Message487 connection test", + val title: String? = null, + val sender: String? = null, ) { fun toJson(): String = JSONObject() .put("schema_version", 1) .put("event_id", eventId) .put("device_id", deviceId) .put("device_code", deviceCode) - .put("message_type", "test") + .put("message_type", messageType) .put("occurred_at", occurredAt) .put("source", source.packageName) .put("source_name", source.name) - .put("text", "Message487 connection test") + .put("text", text) + .put("title", title) + .put("sender", sender) .toString() } @@ -48,7 +54,10 @@ fun validWebhookUrl(value: String, allowLocalHttp: Boolean): Boolean = runCatchi }.getOrDefault(false) class WebhookClient(private val timeoutMs: Int = 10_000) { - fun send(url: String, event: TestEvent, requireAck: Boolean): DeliveryResult { + fun send(url: String, event: MessageEvent, requireAck: Boolean): DeliveryResult = + sendJson(url, event.eventId, event.toJson(), requireAck) + + fun sendJson(url: String, eventId: String, json: String, requireAck: Boolean): DeliveryResult { val start = System.nanoTime() var connection: HttpURLConnection? = null var httpCode: Int? = null @@ -63,7 +72,7 @@ class WebhookClient(private val timeoutMs: Int = 10_000) { setRequestProperty("Content-Type", "application/json; charset=utf-8") setRequestProperty("Accept", "application/json") } - val payload = event.toJson().toByteArray(StandardCharsets.UTF_8) + val payload = json.toByteArray(StandardCharsets.UTF_8) connection.setFixedLengthStreamingMode(payload.size) connection.outputStream.use { it.write(payload) } httpCode = connection.responseCode @@ -82,7 +91,7 @@ class WebhookClient(private val timeoutMs: Int = 10_000) { output.toByteArray() } if (bytes.size > MAX_ACK_BYTES) DeliveryStatus.INVALID_ACK - else validateAck(String(bytes, StandardCharsets.UTF_8), event.eventId) + else validateAck(String(bytes, StandardCharsets.UTF_8), eventId) } } } catch (_: SocketTimeoutException) { @@ -92,7 +101,7 @@ class WebhookClient(private val timeoutMs: Int = 10_000) { } finally { connection?.disconnect() } - return DeliveryResult(event.eventId, status, httpCode, (System.nanoTime() - start) / 1_000_000) + return DeliveryResult(eventId, status, httpCode, (System.nanoTime() - start) / 1_000_000) } companion object { diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 8a04a4a..ee56d7f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,7 +1,6 @@ Message487 Подключите Android к n8n - Версия для разработки: только тестовые события. Пересылка уведомлений и SMS пока не реализована. Подключение Код устройства Метка в каждом событии, например personal-phone. @@ -14,13 +13,11 @@ Произвольный webhook: любой ответ HTTP 2xx означает успех HTTP-запроса. Сохранить подключение Сохранить и отправить тест - Ожидаем ответ webhook… + Отправляется Подключение сохранено - Не удалось сохранить настройки подключения. Событие не отправлено. - Последние тесты - В этой сессии тестов ещё не было + Журнал + Событий пока нет ID события: %1$s - Длительность запроса: %1$d мс HTTP %1$d Принято webhook HTTP-запрос выполнен успешно @@ -28,5 +25,46 @@ Подтверждение отсутствует или неверно Истекло время ожидания ответа Не удалось завершить подключение - Ответ webhook не подтверждает доставку в Telegram. Тест содержит искусственный текст, ID установки и указанный код устройства. Автоматических повторов нет; последние результаты хранятся в памяти. + Android может задерживать фоновую работу и скрывать чувствительное содержимое уведомлений. Повторы используют тот же ID события; потеря ответа может привести к дублю. Подтверждение webhook не означает доставку в Telegram. + Статус + Источники + Приостановить пересылку + Приостановить приём и отправку очереди. Уже начавшийся запрос может завершиться. + Ожидают подтверждения: %1$d + Подключение настроено + Сохраните адрес webhook в разделе подключения перед включением приёма. + Слушатель уведомлений подключён + Слушатель уведомлений не подключён + Разрешение на SMS выдано + Нет разрешения на SMS. Разрешите доступ в настройках приложения. + Не удалось сохранить или запланировать некоторые события. Проверьте свободное место и журнал. + Скрыть + Не удалось выполнить операцию. Проверьте место на устройстве и настройки. + Не удалось открыть настройки Android. + События в очереди сохраняют адрес получателя и режим подтверждения на момент приёма. + Включённые источники передают текст, имя источника и время на сохранённый webhook. SMS также содержит отправителя. По умолчанию оба источника выключены. + Пересылать уведомления + Только выбранные приложения. Постоянные уведомления и сводки групп пропускаются. + Настройки доступа к уведомлениям + Переподключить слушатель + Пересылать входящие SMS + Принимать новые SMS, включая составные сообщения. История SMS не читается. + Выбрано приложений: %1$d + По умолчанию ничего не выбрано. Пакет без значка запуска можно добавить вручную. Не выбирайте приложения, в которые возвращаются пересланные сообщения. + Имя пакета + Добавить пакет + Содержимое сообщений скрыто. Неподтверждённые события хранятся до подтверждения или явного удаления. В журнале до 200 записей. + В очереди + Ожидает автоматического повтора + Требует внимания — повторите после устранения причины + Попыток отправки: %1$d + Повторить сейчас + Удалить событие + Удалить локальную запись и прекратить попытки доставки? Копии, уже полученные сервером, останутся. + Отмена + Уведомление + SMS + Тест + Тестовое событие сохранено в очередь. Результат появится в журнале. + Отправить тест на сохранённый адрес diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 43184f8..7ed7b46 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,6 @@ Message487 Connect your Android to n8n - Development preview: test events only. Notification and SMS forwarding is not implemented yet. Connection Device code A label sent with each event, for example personal-phone. @@ -14,13 +13,11 @@ Custom webhook: any HTTP 2xx response counts as HTTP success. Save connection Save and send test event - Waiting for the webhook response… + Sending Connection saved - Could not save connection settings. No event was sent. - Recent tests - No tests in this session + Journal + No captured events yet Event ID: %1$s - Request duration: %1$d ms HTTP %1$d Accepted by webhook HTTP request succeeded @@ -28,5 +25,46 @@ Missing or invalid confirmation Response timed out Could not complete the connection - A webhook response does not confirm delivery to Telegram. Test events contain synthetic text, an installation ID and your device code. Tests are not retried automatically; recent results are kept in memory. + Android may delay background work or hide sensitive notification content. Retries reuse the same event ID; a lost response can still cause duplicate delivery. A webhook confirmation does not confirm delivery to Telegram. + Status + Sources + Pause forwarding + Pause capture and queued delivery. A request already in progress may finish. + Waiting for confirmation: %1$d + Connection is configured + Save a valid webhook in Connection before enabling capture. + Notification listener is connected + Notification listener is not connected + SMS permission granted + SMS permission is missing. Enable SMS access in the app permissions. + Some events could not be saved or scheduled. Check available storage and the journal. + 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. + Enabled sources send message text, source names and timestamps to your saved webhook. SMS also includes the sender. Both sources are off initially. + Forward notifications + Only selected applications. Ongoing notifications and group summaries are skipped. + Notification access settings + Reconnect listener + Forward incoming SMS + Receive new SMS, including multipart messages. Existing SMS history is not read. + Selected applications: %1$d + No applications are selected by default. Add a package manually if it has no launcher icon. Avoid selecting apps that receive your forwarded messages. + Package name + Add package + Message contents are hidden. Pending events remain until confirmed or explicitly deleted. The journal shows up to 200 entries. + Queued + Waiting for automatic retry + Needs attention — retry manually after fixing the cause + Send attempts: %1$d + Retry now + Delete event + Delete this local record and stop future delivery attempts? This cannot remove copies already received by the server. + Cancel + Notification + SMS + Test + Test event saved to the queue. Check the journal for its result. + Send test to saved connection diff --git a/app/src/test/java/life/andre/message487/ForwardingTest.kt b/app/src/test/java/life/andre/message487/ForwardingTest.kt new file mode 100644 index 0000000..c681d5b --- /dev/null +++ b/app/src/test/java/life/andre/message487/ForwardingTest.kt @@ -0,0 +1,43 @@ +package life.andre.message487 + +import org.json.JSONObject +import org.junit.Assert.* +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")) + assertFalse(settings.acceptsPackage("example.chat", "self")) + val enabled = settings.copy(notifications = true) + assertTrue(enabled.acceptsPackage("example.chat", "self")) + assertFalse(enabled.acceptsPackage("other.chat", "self")) + assertFalse(enabled.acceptsPackage("self", "self")) + assertFalse(enabled.copy(paused = true).acceptsPackage("example.chat", "self")) + assertFalse(enabled.copy(url = "").acceptsPackage("example.chat", "self")) + assertFalse(enabled.copy(deviceCode = " ").acceptsPackage("example.chat", "self")) + assertFalse(settings.sms) + } + + @Test fun `only transient transport outcomes retry automatically`() { + for (status in listOf(DeliveryStatus.TIMEOUT, DeliveryStatus.NETWORK_ERROR)) { + assertEquals(QueueState.RETRY, deliveryQueueState(DeliveryResult("event", status))) + } + for (code in listOf(408, 425, 429, 500, 503, 599)) { + assertEquals(QueueState.RETRY, deliveryQueueState(DeliveryResult("event", DeliveryStatus.HTTP_ERROR, code))) + } + for (code in listOf(301, 400, 401, 403, 404, 422)) { + assertEquals(QueueState.BLOCKED, deliveryQueueState(DeliveryResult("event", DeliveryStatus.HTTP_ERROR, code))) + } + assertEquals(QueueState.BLOCKED, deliveryQueueState(DeliveryResult("event", DeliveryStatus.INVALID_ACK, 200))) + } + + @Test fun `sms serializes sender and Unicode without notification title`() { + val event = MessageEvent("device", "phone", AppSource("android", "Android System"), + messageType = "sms", sender = "+15551234567", text = "Привет\nSecond part") + val json = JSONObject(event.toJson()) + assertEquals("sms", json.getString("message_type")) + assertEquals(event.sender, json.getString("sender")) + assertEquals(event.text, json.getString("text")) + assertFalse(json.has("title")) + } +} diff --git a/app/src/test/java/life/andre/message487/OutboxTest.kt b/app/src/test/java/life/andre/message487/OutboxTest.kt new file mode 100644 index 0000000..639ccd8 --- /dev/null +++ b/app/src/test/java/life/andre/message487/OutboxTest.kt @@ -0,0 +1,118 @@ +package life.andre.message487 + +import android.app.Application +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +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 OutboxTest { + private val context get() = RuntimeEnvironment.getApplication() + // Database tests inject a codec; Android Keystore is verified on the emulator. + private val codec = object : PayloadCipher { + override fun encrypt(value: String) = value.reversed().toByteArray() + 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 fun event(id: String = java.util.UUID.randomUUID().toString()) = MessageEvent( + "device", "phone", AppSource("example.chat", "Chat"), eventId = id, + messageType = "notification", title = "Заголовок", text = "Text\nСообщение", + ) + + @Before fun open() { + context.deleteDatabase("outbox.db") + outbox = Outbox(context, codec) + } + + @After fun close() { outbox.close(); context.deleteDatabase("outbox.db") } + + @Test fun `restart preserves payload destination and identity across retries`() { + val event = event() + assertTrue(outbox.enqueue(event, settings)) + outbox.close() + outbox = Outbox(context, codec) + val first = outbox.beginAttempt(event.eventId)!! + assertEquals(settings.url, first.request.url) + assertTrue(first.request.requireAck) + assertEquals(event.text, JSONObject(first.request.json).getString("text")) + outbox.finish(event.eventId, first.token, DeliveryResult(event.eventId, DeliveryStatus.TIMEOUT)) + val second = outbox.beginAttempt(event.eventId)!! + assertEquals(first.request, second.request) + assertNotEquals(first.token, second.token) + assertEquals(2, outbox.entries().single().attempts) + } + + @Test fun `success removes payload but retains journal and rejects stale completion`() { + val event = event() + outbox.enqueue(event, settings) + val abandoned = outbox.beginAttempt(event.eventId)!! + val active = outbox.beginAttempt(event.eventId)!! + outbox.finish(event.eventId, abandoned.token, DeliveryResult(event.eventId, DeliveryStatus.ACCEPTED)) + assertEquals(QueueState.SENDING, outbox.entries().single().state) + outbox.finish(event.eventId, active.token, DeliveryResult(event.eventId, DeliveryStatus.ACCEPTED, 200)) + assertEquals(0, outbox.pendingCount()) + assertEquals(QueueState.ACCEPTED, outbox.entries().single().state) + assertNull(outbox.beginAttempt(event.eventId)) + outbox.readableDatabase.rawQuery("SELECT payload FROM events", null).use { + assertTrue(it.moveToFirst()); assertTrue(it.isNull(0)) + } + } + + @Test fun `notification duplicates survive restart while changed or reposted content is captured`() { + assertTrue(outbox.enqueue(event(), settings, "key", "content")) + outbox.close() + outbox = Outbox(context, codec) + assertFalse(outbox.enqueue(event(), settings, "key", "content")) + assertTrue(outbox.enqueue(event(), settings, "key", "changed")) + outbox.forgetNotification("key") + assertTrue(outbox.enqueue(event(), settings, "key", "changed")) + assertEquals(3, outbox.pendingCount()) + } + + @Test fun `invalid acknowledgement blocks automatic delivery until manual retry`() { + val event = event() + outbox.enqueue(event, settings) + val attempt = outbox.beginAttempt(event.eventId)!! + outbox.finish(event.eventId, attempt.token, DeliveryResult(event.eventId, DeliveryStatus.INVALID_ACK, 200)) + assertTrue(outbox.pendingIds().isEmpty()) + assertEquals(1, outbox.pendingCount()) + assertNull(outbox.beginAttempt(event.eventId)) + assertTrue(outbox.retry(event.eventId)) + assertEquals(attempt.request, outbox.beginAttempt(event.eventId)!!.request) + } + + @Test fun `cleanup never removes unsent events`() { + val waiting = event("waiting") + outbox.enqueue(waiting, settings) + repeat(105) { + val event = event() + outbox.enqueue(event, settings) + val attempt = outbox.beginAttempt(event.eventId)!! + outbox.finish(event.eventId, attempt.token, DeliveryResult(event.eventId, DeliveryStatus.HTTP_SUCCESS, 204)) + } + assertEquals(listOf(waiting.eventId), outbox.pendingIds()) + assertEquals(101, outbox.entries().size) + assertEquals(waiting.eventId, outbox.entries().first().id) + assertFalse(outbox.enqueue(waiting, settings)) + } + + @Test fun `failed persistence does not advance notification deduplication`() { + val broken = Outbox(context, object : PayloadCipher { + override fun encrypt(value: String): ByteArray = throw java.io.IOException("Storage failure") + override fun decrypt(value: ByteArray): String = error("Unused") + }) + broken.use { + assertThrows(java.io.IOException::class.java) { it.enqueue(event(), settings, "key", "content") } + } + assertTrue(outbox.enqueue(event(), settings, "key", "content")) + assertEquals(1, outbox.pendingCount()) + } +} diff --git a/app/src/test/java/life/andre/message487/WebhookClientTest.kt b/app/src/test/java/life/andre/message487/WebhookClientTest.kt index 70c6cd1..5db77d9 100644 --- a/app/src/test/java/life/andre/message487/WebhookClientTest.kt +++ b/app/src/test/java/life/andre/message487/WebhookClientTest.kt @@ -39,7 +39,7 @@ class WebhookClientTest { @Test fun `HTTP transport sends event and validates confirmation`() = withServer { server -> - val event = TestEvent(deviceId = "installation", deviceCode = "test-device", source = source) + 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) assertEquals(DeliveryStatus.ACCEPTED, result.status) @@ -60,12 +60,12 @@ class WebhookClientTest { val client = WebhookClient() val url = server.url("/receive").toString() server.enqueue(MockResponse().setBody("{}")) - assertEquals(DeliveryStatus.INVALID_ACK, client.send(url, TestEvent("d", "test-device", source), true).status) + assertEquals(DeliveryStatus.INVALID_ACK, client.send(url, MessageEvent("d", "test-device", source), true).status) server.enqueue(MockResponse().setResponseCode(204)) - assertEquals(DeliveryStatus.HTTP_SUCCESS, client.send(url, TestEvent("d", "test-device", source), false).status) + assertEquals(DeliveryStatus.HTTP_SUCCESS, client.send(url, MessageEvent("d", "test-device", source), false).status) for (code in listOf(302, 500)) { server.enqueue(MockResponse().setResponseCode(code).addHeader("Location", url)) - assertEquals(DeliveryStatus.HTTP_ERROR, client.send(url, TestEvent("d", "test-device", source), true).status) + assertEquals(DeliveryStatus.HTTP_ERROR, client.send(url, MessageEvent("d", "test-device", source), true).status) } assertEquals(4, server.requestCount) } @@ -73,13 +73,13 @@ class WebhookClientTest { @Test fun `oversized ACK is rejected`() = withServer { server -> server.enqueue(MockResponse().setBody(" ".repeat(70_000))) - assertEquals(DeliveryStatus.INVALID_ACK, WebhookClient().send(server.url("/").toString(), TestEvent("d", "test-device", source), true).status) + assertEquals(DeliveryStatus.INVALID_ACK, WebhookClient().send(server.url("/").toString(), MessageEvent("d", "test-device", source), true).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(), TestEvent("d", "test-device", source), true).status) + assertEquals(DeliveryStatus.TIMEOUT, WebhookClient(50).send(server.url("/").toString(), MessageEvent("d", "test-device", source), true).status) } private fun withServer(block: (MockWebServer) -> Unit) { diff --git a/docs/project-context.md b/docs/project-context.md index f12e980..32fb327 100644 --- a/docs/project-context.md +++ b/docs/project-context.md @@ -24,7 +24,8 @@ является контрактом наших примеров, а не стандартным ответом любого workflow n8n. Сборка и проверки выполняются через Fastlane, интерфейс — Kotlin/Compose с английскими и русскими -ресурсами. Захват уведомлений/SMS, надёжная очередь, повторы и авторизация ещё не реализованы. +ресурсами. Далее реализованы захват уведомлений/SMS, постоянная очередь и повторы. +Авторизация webhook пока не реализована. `DevServer/README.md` описывает запуск и ограничения локального стенда. ## Риски старого клиента sms487 @@ -38,9 +39,31 @@ Старый клиент добавляет `/add-sms` к адресу сервера и использует собственный формат пачек. Совместимость с этим протоколом не согласована как требование к новому приложению. -## Предложение для первого продукта +## Захват и доставка -Следующие пункты — основа для обсуждения, а не реализованные возможности или утверждённый протокол. +Реализованы NotificationListenerService с выбором пакетов и SMS_RECEIVED с RECEIVE_SMS и goAsync. +Оба источника выключены по умолчанию. История SMS не читается. Неизменившиеся обновления +уведомления подавляются; изменившиеся создают новое событие. Сводки групп, постоянные уведомления +и собственные уведомления Message487 исключены. + +Событие сохраняется в SQLite до отправки; тело запроса зашифровано AES-GCM с Android Keystore. +WorkManager отправляет с повторами при временных ошибках; отдельная периодическая задача +восстанавливает планирование сохранённых событий. event_id, содержимое, URL и режим подтверждения +фиксируются при захвате. Смена подключения не перенаправляет очередь. Неверный ACK и постоянные +HTTP-ошибки требуют ручного повтора. Неподтверждённые события не очищаются по возрасту; +подтверждённое содержимое удаляется, остаётся ограниченная история метаданных. Журнал не показывает +текст сообщения. Общая пауза останавливает захват и новые попытки отправки; выполняющийся запрос +может завершиться. Данные исключены из резервного копирования и переноса устройства. + +Ограничения: захват зависит от Android, процесс может завершиться до локального сохранения; +WorkManager не гарантирует немедленную доставку. Системные ограничения на чувствительные +уведомления не обходятся. Физическая очистка страниц SQLite не гарантируется. Дедупликация SMS +основана на отправителе, времени, тексте и установке; она не заменяет серверную дедупликацию. + +## Направление продукта + +Следующие пункты описывают направление; последняя успешная отправка на главном экране и просмотр +содержимого журнала пока не реализованы. - Подключение к n8n с готовым примером workflow и тестовым событием. Альтернативная настройка — полный URL произвольного webhook. Общий транспорт отправляет JSON по HTTPS. @@ -61,11 +84,11 @@ ## Что предстоит решить -1. Следующий этап захвата событий: уведомления сначала или уведомления и SMS одновременно. +1. Проверка энергосбережения, перезагрузки и ограничений разрешений на физических устройствах. 2. Подтверждение приёма: после выполнения workflow либо после устойчивой записи в серверную очередь. Простой HTTP-успех не доказывает надёжное сохранение или конечную доставку. -3. Формат события и ответа, авторизация, таймауты, повторы, обработка обновлений уведомлений, - ограничения очереди и поведение при смене получателя. +3. Авторизация webhook и дальнейшее развитие контракта. Текущий формат и поведение очереди + описаны в README; лимиты неподтверждённой очереди требуют отдельного решения. 4. Распространение приложения и необходимые проверки на устройстве. Минимальная версия первого каркаса задана в Gradle; её пригодность для будущего захвата событий ещё предстоит проверить. 5. Нужна ли миграция текущего Telegram-сценария и остаётся ли SQS. n8n не требует автоматически diff --git a/scripts/emulator.sh b/scripts/emulator.sh index d7aabbf..dd46176 100755 --- a/scripts/emulator.sh +++ b/scripts/emulator.sh @@ -1,6 +1,18 @@ #!/bin/bash set -euo pipefail +background=false +case "${1:-}" in + --background) background=true ;; + --help|-h) + echo "Usage: $0 [--background]" + echo "Create and launch the development emulator." + echo "With --background, wait for Android to boot and print its ADB serial." + exit 0 ;; + "") ;; + *) echo "Unknown argument: $1" >&2; exit 1 ;; +esac + sdk_dir="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" if [[ -z "$sdk_dir" && "$(uname -s)" == Darwin ]]; then sdk_dir="$HOME/Library/Android/sdk" @@ -11,26 +23,69 @@ if [[ -z "$sdk_dir" || ! -x "$sdk_dir/emulator/emulator" ]]; then fi avd_name=Message487_API_35 -while read -r serial state; do - if [[ "$serial" == emulator-* && "$state" == device ]]; then - current_avd=$("$sdk_dir/platform-tools/adb" -s "$serial" emu avd name | head -n 1 | tr -d '\r') - if [[ "$current_avd" == "$avd_name" ]]; then - echo "$avd_name is already running on $serial." - exit 0 +adb="$sdk_dir/platform-tools/adb" +find_serial() { + local serial state current_avd + while read -r serial state; do + if [[ "$serial" == emulator-* && "$state" == device ]]; then + current_avd=$("$adb" -s "$serial" emu avd name | head -n 1 | tr -d '\r') + if [[ "$current_avd" == "$avd_name" ]]; then + echo "$serial" + return + fi fi + done < <("$adb" devices) +} + +serial=$(find_serial) +if [[ -n "$serial" ]]; then + echo "$avd_name is already running on $serial." >&2 + if [[ "$background" == false ]]; then exit 0; fi +else + + case "$(uname -m)" in + arm64|aarch64) abi=arm64-v8a ;; + *) abi=x86_64 ;; + esac + system_image="system-images;android-35;google_apis;$abi" + + if ! "$sdk_dir/emulator/emulator" -list-avds | rg -qx "$avd_name"; then + "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" "$system_image" >&2 + printf 'no\n' | "$sdk_dir/cmdline-tools/latest/bin/avdmanager" create avd \ + --name "$avd_name" --package "$system_image" --device pixel_7 >&2 fi -done < <("$sdk_dir/platform-tools/adb" devices) -case "$(uname -m)" in - arm64|aarch64) abi=arm64-v8a ;; - *) abi=x86_64 ;; -esac -system_image="system-images;android-35;google_apis;$abi" + # Emulator 37.1.11 can hang in netsimd on macOS; virtual networking still works without radio simulation. + emulator_options=(-avd "$avd_name" -no-snapshot -no-boot-anim -gpu auto + -feature -WiFiPacketStream -feature -Uwb -feature -Nfc + -netsim-args "--no-test-beacons --no-cli-ui --no-web-ui") + if [[ "$background" == false ]]; then + exec "$sdk_dir/emulator/emulator" "${emulator_options[@]}" + fi + emulator_log="${TMPDIR:-/tmp}/message487-emulator.log" + echo "Starting $avd_name. Emulator log: $emulator_log" >&2 + # A separate session keeps the emulator alive after the task terminal exits. + python3 - "$sdk_dir/emulator/emulator" "$emulator_log" "${emulator_options[@]}" <<'PY' +import subprocess as sp +import sys -if ! "$sdk_dir/emulator/emulator" -list-avds | rg -qx "$avd_name"; then - "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" "$system_image" - printf 'no\n' | "$sdk_dir/cmdline-tools/latest/bin/avdmanager" create avd \ - --name "$avd_name" --package "$system_image" --device pixel_7 +with open(sys.argv[2], 'w') as log: + sp.Popen([sys.argv[1], *sys.argv[3:]], stdin=sp.DEVNULL, + stdout=log, stderr=sp.STDOUT, start_new_session=True) +PY fi -exec "$sdk_dir/emulator/emulator" -avd "$avd_name" -no-snapshot -no-boot-anim -gpu auto +echo "Waiting for Android to finish booting." >&2 +for ((attempt = 0; attempt < 180; attempt++)); do + if [[ -z "$serial" ]]; then serial=$(find_serial); fi + if [[ -n "$serial" ]]; then + boot_completed=$("$adb" -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true) + if [[ "$boot_completed" == 1 ]]; then + echo "$serial" + exit 0 + fi + fi + sleep 1 +done +echo "Android emulator did not finish booting. Check ${emulator_log:-the emulator window}." >&2 +exit 1 diff --git a/scripts/run-without-debugging.sh b/scripts/run-without-debugging.sh new file mode 100755 index 0000000..a15a20f --- /dev/null +++ b/scripts/run-without-debugging.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ "${1:-}" == --help || "${1:-}" == -h ]]; then + echo "Usage: $0" + echo "Start the development emulator, build and install the debug APK, and launch without a debugger." + exit 0 +fi +if [[ $# -ne 0 ]]; then + echo "Unknown argument: $1" >&2 + exit 1 +fi + +if [[ -f "$HOME/.zshrc.extra" ]]; then + # VS Code launched from Finder may not inherit the interactive shell environment. + source "$HOME/.zshrc.extra" +fi + +sdk_dir="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" +if [[ -z "$sdk_dir" && "$(uname -s)" == Darwin ]]; then + sdk_dir="$HOME/Library/Android/sdk" +fi +export ANDROID_HOME="$sdk_dir" +serial=$("$project_dir/scripts/emulator.sh" --background) +adb="$sdk_dir/platform-tools/adb" + +cd "$project_dir" +bundle exec fastlane android debug_artifact +"$adb" -s "$serial" install -r "$project_dir/app/build/outputs/apk/debug/app-debug.apk" +"$adb" -s "$serial" shell am clear-debug-app +"$adb" -s "$serial" shell am force-stop life.andre.message487 +"$adb" -s "$serial" shell am start -W -n life.andre.message487/.MainActivity From af8f789efb62ac276ccfd605d6e2baacd2b9facb Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 8 Sep 2026 23:27:15 +0300 Subject: [PATCH 3/6] Redesign UI with Material 3 and bulk app selection --- README.md | 9 +- app/build.gradle.kts | 1 + .../life/andre/message487/ConnectionScreen.kt | 66 +++++ .../andre/message487/ConnectionViewModel.kt | 5 + .../life/andre/message487/JournalScreen.kt | 125 +++++++++ .../life/andre/message487/MainActivity.kt | 250 +++++------------- .../andre/message487/MessageComponents.kt | 55 ++++ .../life/andre/message487/MessageTheme.kt | 22 ++ .../life/andre/message487/OverviewScreen.kt | 148 +++++++++++ .../life/andre/message487/SourcesScreen.kt | 177 +++++++++++++ app/src/main/res/values-ru/strings.xml | 63 ++++- app/src/main/res/values/strings.xml | 65 +++-- docs/design.md | 37 +++ 13 files changed, 814 insertions(+), 209 deletions(-) create mode 100644 app/src/main/java/life/andre/message487/ConnectionScreen.kt create mode 100644 app/src/main/java/life/andre/message487/JournalScreen.kt create mode 100644 app/src/main/java/life/andre/message487/MessageComponents.kt create mode 100644 app/src/main/java/life/andre/message487/MessageTheme.kt create mode 100644 app/src/main/java/life/andre/message487/OverviewScreen.kt create mode 100644 app/src/main/java/life/andre/message487/SourcesScreen.kt create mode 100644 docs/design.md diff --git a/README.md b/README.md index 3f29682..56e41d1 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ production release signing are not implemented yet. 2. Check **Journal** and find the same event ID in n8n **Executions**. 3. In **Sources**, enable notification forwarding, grant notification access in Android settings, and select applications. Add a package manually if it has no launcher icon. + **Select all applications** selects the entire available list, regardless of the search filter; + manually added selections are preserved. Newly installed apps must be selected separately. 4. Enable SMS forwarding separately and grant SMS permission. Only new incoming SMS are read; existing history is not imported. Multipart SMS are combined into one event. @@ -44,7 +46,8 @@ cause duplicate delivery: downstream workflows should deduplicate using `event_i Queued events retain the URL and confirmation mode from capture time. Changing the connection does not reroute them. Undelivered events remain until confirmed or explicitly deleted. The -journal persists across restarts and hides message contents. Confirmed payloads are removed; +journal persists across restarts and hides message contents. Tap an event for its ID, HTTP result, +retry and deletion controls. Confirmed payloads are removed; only a bounded recent history of delivery metadata remains. See [PRIVACY.md](PRIVACY.md). The JSON event carries `schema_version`, `event_id`, `device_id`, `device_code`, `message_type`, @@ -85,7 +88,9 @@ 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. +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) +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/`. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e50ca37..aa9e3c8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { implementation("androidx.activity:activity-compose:1.10.0") implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") implementation("androidx.work:work-runtime-ktx:2.11.2") diff --git a/app/src/main/java/life/andre/message487/ConnectionScreen.kt b/app/src/main/java/life/andre/message487/ConnectionScreen.kt new file mode 100644 index 0000000..78f1a82 --- /dev/null +++ b/app/src/main/java/life/andre/message487/ConnectionScreen.kt @@ -0,0 +1,66 @@ +package life.andre.message487 + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp + +@Composable +internal fun ConnectionScreen(state: ConnectionState, model: ConnectionViewModel) { + LazyColumn(contentPadding = PaddingValues(20.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) { + item { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.connection_heading), style = MaterialTheme.typography.headlineSmall) + SupportingText(stringResource(R.string.connection_description)) + } + } + item { + Panel { + SectionTitle(stringResource(R.string.recipient)) + OutlinedTextField(value = state.url, onValueChange = model::setUrl, + label = { Text(stringResource(R.string.webhook_url)) }, modifier = Modifier.fillMaxWidth(), enabled = !state.busy, + 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)) }) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Icon(Icons.Outlined.VerifiedUser, null, tint = MaterialTheme.colorScheme.primary) + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.n8n_mode), style = MaterialTheme.typography.titleSmall) + SupportingText(stringResource(if (state.requireAck) R.string.n8n_hint else R.string.raw_hint)) + } + Switch(checked = state.requireAck, onCheckedChange = model::setRequireAck, enabled = !state.busy, + modifier = Modifier.switchLabel(stringResource(R.string.n8n_mode))) + } + } + } + item { + Panel { + SectionTitle(stringResource(R.string.this_device)) + OutlinedTextField(value = state.deviceCode, onValueChange = model::setDeviceCode, + label = { Text(stringResource(R.string.device_code)) }, modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.small, + enabled = !state.busy, singleLine = true, isError = state.invalidDeviceCode, + supportingText = { Text(stringResource(if (state.invalidDeviceCode) R.string.invalid_device_code else R.string.device_code_hint)) }) + } + } + item { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { model.save(false) }, enabled = !state.busy, modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp)) { + Text(stringResource(R.string.save)) + } + OutlinedButton(onClick = { model.save(true) }, enabled = !state.busy, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { + Text(stringResource(R.string.send_test)) + } + } + } + item { SupportingText(stringResource(R.string.destination_note)) } + } +} diff --git a/app/src/main/java/life/andre/message487/ConnectionViewModel.kt b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt index d8116f1..77bcdac 100644 --- a/app/src/main/java/life/andre/message487/ConnectionViewModel.kt +++ b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt @@ -99,6 +99,11 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati fun sendTest() = action(R.string.test_queued) { graph.enqueueTest() } fun notifications(enabled: Boolean) = action { graph.settings.update { it.copy(notifications = enabled) } } fun sms(enabled: Boolean) = action { graph.settings.update { it.copy(sms = enabled) } } + fun selectAllPackages() = action { + val packages = apps.value.map { it.packageName }.toSet() - getApplication().packageName + graph.settings.update { it.copy(packages = it.packages + packages) } + } + fun clearPackageSelection() = action { graph.settings.update { it.copy(packages = emptySet()) } } fun selectPackage(packageName: String, selected: Boolean) = action { require(packageName.matches(Regex("[A-Za-z0-9_]+(\\.[A-Za-z0-9_]+)*"))) require(packageName != getApplication().packageName) diff --git a/app/src/main/java/life/andre/message487/JournalScreen.kt b/app/src/main/java/life/andre/message487/JournalScreen.kt new file mode 100644 index 0000000..d680972 --- /dev/null +++ b/app/src/main/java/life/andre/message487/JournalScreen.kt @@ -0,0 +1,125 @@ +package life.andre.message487 + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import java.text.DateFormat +import java.util.Date + +@Composable +internal fun JournalScreen(queue: QueueSnapshot, busy: Boolean, model: ConnectionViewModel) { + var waitingOnly by rememberSaveable { mutableStateOf(false) } + var selectedId by rememberSaveable { mutableStateOf(null) } + var deleteId by rememberSaveable { mutableStateOf(null) } + val visible = queue.entries.filter { !waitingOnly || !it.delivered } + val selected = queue.entries.firstOrNull { it.id == selectedId } + LazyColumn(contentPadding = PaddingValues(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + item { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Icon(Icons.Outlined.Lock, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + SupportingText(stringResource(R.string.journal_privacy)) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = !waitingOnly, onClick = { waitingOnly = false }, label = { Text(stringResource(R.string.all_events)) }) + FilterChip(selected = waitingOnly, onClick = { waitingOnly = true }, label = { Text(stringResource(R.string.waiting_filter, queue.pending)) }) + } + } + if (visible.isEmpty()) item { + Panel { + IconTile(if (waitingOnly) Icons.Outlined.DoneAll else Icons.Outlined.Inbox) + Text(stringResource(if (waitingOnly) R.string.queue_clear else R.string.no_events), style = MaterialTheme.typography.titleLarge) + SupportingText(stringResource(if (waitingOnly) R.string.queue_clear_hint else R.string.empty_journal_hint)) + } + } + items(visible, key = { it.id }) { entry -> + EventRow(entry) { selectedId = entry.id } + } + } + if (selected != null) { + AlertDialog(onDismissRequest = { selectedId = null }, + title = { Text(selected.sourceName) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(14.dp)) { + StatusLabel(selected) + Text(DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(selected.createdAt))) + SelectionContainer { Text(stringResource(R.string.event_id, selected.id), style = MaterialTheme.typography.bodySmall) } + Text(stringResource(R.string.attempts, selected.attempts)) + selected.httpCode?.let { Text(stringResource(R.string.http_code, it)) } + selected.outcome?.let { if (!selected.delivered) SupportingText(stringResource(outcomeLabel(it))) } + if (!selected.delivered && selected.state != QueueState.SENDING) { + Button(onClick = { model.retry(selected.id) }, enabled = !busy, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.retry)) } + } + if (selected.state != QueueState.SENDING) { + TextButton(onClick = { deleteId = selected.id }, enabled = !busy) { + Text(stringResource(R.string.delete_event), color = MaterialTheme.colorScheme.error) + } + } + } + }, confirmButton = { TextButton(onClick = { selectedId = null }) { Text(stringResource(R.string.close)) } }) + } + if (deleteId != null) AlertDialog(onDismissRequest = { deleteId = null }, + title = { Text(stringResource(R.string.delete_event)) }, text = { Text(stringResource(R.string.delete_confirmation)) }, + confirmButton = { TextButton(onClick = { deleteId?.let(model::delete); deleteId = null; selectedId = null }, enabled = !busy) { + Text(stringResource(R.string.delete_event), color = MaterialTheme.colorScheme.error) + } }, dismissButton = { TextButton(onClick = { deleteId = null }) { Text(stringResource(R.string.cancel)) } }) +} + +@Composable +private fun EventRow(entry: QueueEntry, onClick: () -> Unit) { + Surface(onClick = onClick, shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceContainerLowest) { + Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + IconTile(when (entry.type) { "sms" -> Icons.Outlined.Sms; "notification" -> Icons.Outlined.Notifications; else -> Icons.Outlined.Science }) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) { + Text(entry.sourceName, style = MaterialTheme.typography.titleSmall) + Text(stringResource(when (entry.type) { "sms" -> R.string.sms_type; "notification" -> R.string.notification_type; else -> R.string.test_type }) + + " · " + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(entry.createdAt)), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + StatusLabel(entry) + } + Icon(Icons.Outlined.ChevronRight, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +private fun StatusLabel(entry: QueueEntry) { + val color = when { + entry.delivered -> MaterialTheme.colorScheme.primary + entry.state == QueueState.BLOCKED -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(when { entry.delivered -> Icons.Outlined.CheckCircle; entry.state == QueueState.BLOCKED -> Icons.Outlined.ErrorOutline; else -> Icons.Outlined.Schedule }, + null, Modifier.size(14.dp), tint = color) + Text(stringResource(entry.state.label()), style = MaterialTheme.typography.labelMedium, color = color) + } +} + +private fun QueueState.label(): Int = when (this) { + QueueState.PENDING -> R.string.pending + QueueState.SENDING -> R.string.sending + QueueState.RETRY -> R.string.retry_wait + QueueState.BLOCKED -> R.string.needs_attention + QueueState.ACCEPTED -> R.string.accepted + QueueState.HTTP_SUCCESS -> R.string.http_success +} + +private fun outcomeLabel(outcome: String): Int = when (outcome) { + DeliveryStatus.HTTP_ERROR.name -> R.string.http_error + DeliveryStatus.INVALID_ACK.name -> R.string.invalid_ack + DeliveryStatus.TIMEOUT.name -> R.string.timeout + DeliveryStatus.NETWORK_ERROR.name -> R.string.network_error + else -> R.string.local_error +} diff --git a/app/src/main/java/life/andre/message487/MainActivity.kt b/app/src/main/java/life/andre/message487/MainActivity.kt index ce87dea..f93fa0c 100644 --- a/app/src/main/java/life/andre/message487/MainActivity.kt +++ b/app/src/main/java/life/andre/message487/MainActivity.kt @@ -1,49 +1,44 @@ package life.andre.message487 -import android.Manifest -import android.content.Intent import android.os.Bundle -import android.provider.Settings import androidx.activity.ComponentActivity -import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import java.text.DateFormat -import java.util.Date class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - setContent { - MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { - MessageScreen() - } - } + setContent { MessageTheme { MessageScreen() } } } } +private enum class Destination(val label: Int, val icon: ImageVector) { + OVERVIEW(R.string.overview, Icons.Outlined.Dashboard), + SOURCES(R.string.sources_tab, Icons.Outlined.Notifications), + JOURNAL(R.string.journal, Icons.Outlined.History), + CONNECTION(R.string.connection_nav, Icons.Outlined.Tune), +} + +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun MessageScreen(model: ConnectionViewModel = viewModel()) { val state by model.state.collectAsStateWithLifecycle() @@ -52,178 +47,77 @@ private fun MessageScreen(model: ConnectionViewModel = viewModel()) { val connected by model.listenerConnected.collectAsStateWithLifecycle() val queue by model.queue.collectAsStateWithLifecycle() val apps by model.apps.collectAsStateWithLifecycle() - var tab by rememberSaveable { mutableIntStateOf(0) } + var destination by rememberSaveable { mutableStateOf(Destination.OVERVIEW) } + var help by rememberSaveable { mutableStateOf(false) } + val snackbar = remember { SnackbarHostState() } + val noticeText = state.notice?.let { stringResource(it) } + LaunchedEffect(noticeText) { noticeText?.let { snackbar.showSnackbar(it) } } + BackHandler(destination != Destination.OVERVIEW) { destination = Destination.OVERVIEW } val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(lifecycle) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) model.refreshPermissions() } lifecycle.addObserver(observer) onDispose { lifecycle.removeObserver(observer) } } - Scaffold { padding -> - Column(Modifier.fillMaxSize().padding(padding)) { - Text(stringResource(R.string.app_name), Modifier.padding(16.dp), style = MaterialTheme.typography.headlineMedium) - TabRow(selectedTabIndex = tab) { - listOf(R.string.status_tab, R.string.connection, R.string.sources_tab, R.string.journal).forEachIndexed { index, title -> - Tab(selected = tab == index, onClick = { tab = index }, text = { Text(stringResource(title)) }) + BoxWithConstraints { + val wide = maxWidth >= 600.dp + Row(Modifier.fillMaxSize()) { + if (wide) { + NavigationRail(Modifier.fillMaxHeight(), containerColor = MaterialTheme.colorScheme.surfaceContainerLow) { + Spacer(Modifier.height(24.dp)) + Destination.entries.forEach { item -> + NavigationRailItem(selected = destination == item, onClick = { destination = item }, + icon = { Icon(item.icon, null) }, label = { Text(stringResource(item.label)) }) + } } } - if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) - state.notice?.let { Text(stringResource(it), Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) } - key(tab) { - Column(Modifier.fillMaxWidth().weight(1f).verticalScroll(rememberScrollState()).padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp)) { - when (tab) { - 0 -> { - Text(stringResource(R.string.tagline), style = MaterialTheme.typography.titleLarge) - SettingSwitch(R.string.pause, R.string.pause_hint, settings.paused, !state.busy, model::pause) - Text(stringResource(R.string.queue_count, queue.pending), style = MaterialTheme.typography.titleLarge) - Text(stringResource(if (settings.ready()) R.string.connection_ready else R.string.configure_connection)) - Text(stringResource(if (permissions.notifications && connected) R.string.listener_connected else R.string.listener_disconnected)) - Text(stringResource(if (permissions.sms) R.string.sms_granted else R.string.sms_not_granted)) - if (settings.captureFailed) { - Text(stringResource(R.string.capture_error), color = MaterialTheme.colorScheme.error) - OutlinedButton(onClick = model::clearError, enabled = !state.busy) { Text(stringResource(R.string.dismiss)) } + Scaffold( + modifier = Modifier.weight(1f).imePadding(), + topBar = { + TopAppBar(title = { + Text(stringResource(if (destination == Destination.OVERVIEW) R.string.app_name + else if (destination == Destination.CONNECTION) R.string.connection else destination.label), + style = MaterialTheme.typography.titleLarge) + }, navigationIcon = { + if (destination != Destination.OVERVIEW) { + IconButton(onClick = { destination = Destination.OVERVIEW }) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, stringResource(R.string.back)) } - Button(onClick = model::sendTest, enabled = !state.busy && settings.ready() && !settings.paused) { - Text(stringResource(R.string.test_saved_connection)) + } + }, actions = { + IconButton(onClick = { help = true }) { Icon(Icons.Outlined.HelpOutline, stringResource(R.string.help)) } + }) + }, + bottomBar = { + if (!wide) NavigationBar(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) { + Destination.entries.forEach { item -> + NavigationBarItem(selected = destination == item, onClick = { destination = item }, + icon = { Icon(item.icon, null) }, label = { Text(stringResource(item.label)) }) + } + } + }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.TopCenter) { + if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + key(destination) { + Box(Modifier.widthIn(max = 720.dp).fillMaxSize()) { + when (destination) { + Destination.OVERVIEW -> OverviewScreen(settings, permissions, connected, queue, state.busy, model, + onConnection = { destination = Destination.CONNECTION }, + onSources = { destination = Destination.SOURCES }, onJournal = { destination = Destination.JOURNAL }) + Destination.SOURCES -> SourcesScreen(settings, permissions, connected, apps, state.busy, model) + Destination.JOURNAL -> JournalScreen(queue, state.busy, model) + Destination.CONNECTION -> ConnectionScreen(state, model) } - Text(stringResource(R.string.delivery_note)) } - 1 -> ConnectionContent(state, model) - 2 -> SourcesContent(settings, permissions, connected, apps, state.busy, model) - 3 -> JournalContent(queue, state.busy, model) } } } } } -} - -@Composable -private fun ConnectionContent(state: ConnectionState, model: ConnectionViewModel) { - OutlinedTextField(value = state.deviceCode, onValueChange = model::setDeviceCode, - label = { Text(stringResource(R.string.device_code)) }, modifier = Modifier.fillMaxWidth(), - enabled = !state.busy, singleLine = true, isError = state.invalidDeviceCode, - supportingText = { Text(stringResource(if (state.invalidDeviceCode) R.string.invalid_device_code else R.string.device_code_hint)) }) - OutlinedTextField(value = state.url, onValueChange = model::setUrl, - label = { Text(stringResource(R.string.webhook_url)) }, modifier = Modifier.fillMaxWidth(), enabled = !state.busy, - isError = state.invalidUrl, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - supportingText = { Text(stringResource(if (state.invalidUrl) R.string.invalid_url else R.string.url_hint)) }) - SettingSwitch(R.string.n8n_mode, if (state.requireAck) R.string.n8n_hint else R.string.raw_hint, - state.requireAck, !state.busy, model::setRequireAck) - Text(stringResource(R.string.destination_note)) - OutlinedButton(onClick = { model.save(false) }, enabled = !state.busy, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.save)) } - Button(onClick = { model.save(true) }, enabled = !state.busy, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.send_test)) } -} - -@Composable -private fun SourcesContent(settings: ForwardingSettings, permissions: PermissionState, connected: Boolean, - apps: List, busy: Boolean, model: ConnectionViewModel) { - val context = LocalContext.current - var manualPackage by remember { mutableStateOf("") } - val smsPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - model.refreshPermissions() - model.sms(granted) - } - fun openNotificationSettings() { - try { context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) } - catch (_: android.content.ActivityNotFoundException) { model.showSettingsError() } - } - Text(stringResource(R.string.sources_disclosure)) - SettingSwitch(R.string.notifications_enabled, R.string.notifications_hint, settings.notifications, !busy) { - model.notifications(it) - if (it && !permissions.notifications) openNotificationSettings() - } - OutlinedButton(onClick = { openNotificationSettings() }) { Text(stringResource(R.string.notification_access)) } - if (permissions.notifications && !connected) { - OutlinedButton(onClick = model::rebind) { Text(stringResource(R.string.reconnect_listener)) } - } - SettingSwitch(R.string.sms_enabled, R.string.sms_hint, settings.sms, !busy) { - if (it && !permissions.sms) smsPermission.launch(Manifest.permission.RECEIVE_SMS) else model.sms(it) - } - if (settings.sms && !permissions.sms) Text(stringResource(R.string.sms_not_granted), color = MaterialTheme.colorScheme.error) - Text(stringResource(R.string.selected_apps, settings.packages.size), style = MaterialTheme.typography.titleLarge) - Text(stringResource(R.string.app_selection_hint)) - val displayedApps = (apps + settings.packages.filter { pkg -> apps.none { it.packageName == pkg } }.map { AppSource(it, it) }) - .sortedBy { it.name.lowercase() } - displayedApps.forEach { app -> - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Checkbox(checked = app.packageName in settings.packages, - onCheckedChange = { model.selectPackage(app.packageName, it) }, enabled = !busy) - Column(Modifier.weight(1f).padding(top = 8.dp)) { - Text(app.name) - Text(app.packageName, style = MaterialTheme.typography.bodySmall) - } - } - } - OutlinedTextField(value = manualPackage, onValueChange = { manualPackage = it }, - label = { Text(stringResource(R.string.package_name)) }, modifier = Modifier.fillMaxWidth(), singleLine = true) - OutlinedButton(onClick = { model.selectPackage(manualPackage.trim(), true) }, enabled = !busy && manualPackage.isNotBlank()) { - Text(stringResource(R.string.add_package)) - } -} - -@Composable -private fun SettingSwitch(title: Int, description: Int, checked: Boolean, enabled: Boolean, onChange: (Boolean) -> Unit) { - val label = stringResource(title) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Column(Modifier.weight(1f)) { - Text(stringResource(title), style = MaterialTheme.typography.titleMedium) - Text(stringResource(description), style = MaterialTheme.typography.bodySmall) - } - Switch(checked = checked, onCheckedChange = onChange, enabled = enabled, - modifier = Modifier.semantics { contentDescription = label }) - } -} - -@Composable -private fun JournalContent(queue: QueueSnapshot, busy: Boolean, model: ConnectionViewModel) { - var deleteTarget by remember { mutableStateOf(null) } - Text(stringResource(R.string.journal_hint)) - if (queue.entries.isEmpty()) Text(stringResource(R.string.no_events)) - queue.entries.forEach { entry -> - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(entry.sourceName, style = MaterialTheme.typography.titleMedium) - Text(stringResource(when (entry.type) { "sms" -> R.string.sms_type; "notification" -> R.string.notification_type; else -> R.string.test_type })) - Text(DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM).format(Date(entry.createdAt))) - Text(stringResource(entry.state.label())) - entry.outcome?.let { outcome -> - if (!entry.delivered) Text(stringResource(outcomeLabel(outcome))) - } - Text(stringResource(R.string.event_id, entry.id), style = MaterialTheme.typography.bodySmall) - Text(stringResource(R.string.attempts, entry.attempts)) - entry.httpCode?.let { Text(stringResource(R.string.http_code, it)) } - if (!entry.delivered && entry.state != QueueState.SENDING) { - OutlinedButton(onClick = { model.retry(entry.id) }, enabled = !busy) { Text(stringResource(R.string.retry)) } - } - if (entry.state != QueueState.SENDING) { - TextButton(onClick = { deleteTarget = entry }, enabled = !busy) { Text(stringResource(R.string.delete_event)) } - } - } - } - } - deleteTarget?.let { entry -> - AlertDialog(onDismissRequest = { deleteTarget = null }, title = { Text(stringResource(R.string.delete_event)) }, - text = { Text(stringResource(R.string.delete_confirmation)) }, - confirmButton = { TextButton(onClick = { model.delete(entry.id); deleteTarget = null }) { Text(stringResource(R.string.delete_event)) } }, - dismissButton = { TextButton(onClick = { deleteTarget = null }) { Text(stringResource(R.string.cancel)) } }) - } -} - -private fun QueueState.label(): Int = when (this) { - QueueState.PENDING -> R.string.pending - QueueState.SENDING -> R.string.sending - QueueState.RETRY -> R.string.retry_wait - QueueState.BLOCKED -> R.string.blocked - QueueState.ACCEPTED -> R.string.accepted - QueueState.HTTP_SUCCESS -> R.string.http_success -} - -private fun outcomeLabel(outcome: String): Int = when (outcome) { - DeliveryStatus.HTTP_ERROR.name -> R.string.http_error - DeliveryStatus.INVALID_ACK.name -> R.string.invalid_ack - DeliveryStatus.TIMEOUT.name -> R.string.timeout - DeliveryStatus.NETWORK_ERROR.name -> R.string.network_error - else -> R.string.local_error + if (help) AlertDialog(onDismissRequest = { help = false }, + icon = { Icon(Icons.Outlined.PrivacyTip, null) }, title = { Text(stringResource(R.string.delivery_help)) }, + text = { HelpContent() }, + confirmButton = { TextButton(onClick = { help = false }) { Text(stringResource(R.string.close)) } }) } diff --git a/app/src/main/java/life/andre/message487/MessageComponents.kt b/app/src/main/java/life/andre/message487/MessageComponents.kt new file mode 100644 index 0000000..dad396a --- /dev/null +++ b/app/src/main/java/life/andre/message487/MessageComponents.kt @@ -0,0 +1,55 @@ +package life.andre.message487 + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp + +@Composable +internal fun SectionTitle(title: String, action: String? = null, onAction: () -> Unit = {}) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(title, Modifier.weight(1f).semantics { heading() }, style = MaterialTheme.typography.titleMedium) + if (action != null) TextButton(onClick = onAction) { Text(action) } + } +} + +@Composable +internal fun Panel(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { + Surface(modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLowest) { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), content = content) + } +} + +@Composable +internal fun IconTile(icon: ImageVector, modifier: Modifier = Modifier) { + Surface(modifier.size(44.dp), shape = CircleShape, color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer) { + Box(contentAlignment = Alignment.Center) { Icon(icon, null, Modifier.size(22.dp)) } + } +} + +@Composable +internal fun SupportingText(text: String, modifier: Modifier = Modifier) { + Text(text, modifier, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) +} + +@Composable +internal fun HelpContent() { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text(stringResource(R.string.delivery_note)) + Text(stringResource(R.string.pause_hint)) + Text(stringResource(R.string.destination_note)) + Text(stringResource(R.string.journal_hint)) + Text(stringResource(R.string.app_selection_hint)) + } +} diff --git a/app/src/main/java/life/andre/message487/MessageTheme.kt b/app/src/main/java/life/andre/message487/MessageTheme.kt new file mode 100644 index 0000000..9b001e0 --- /dev/null +++ b/app/src/main/java/life/andre/message487/MessageTheme.kt @@ -0,0 +1,22 @@ +package life.andre.message487 + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp + +private val LightColors = lightColorScheme() +private val DarkColors = darkColorScheme() + +@Composable +fun MessageTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = if (isSystemInDarkTheme()) DarkColors else LightColors, + shapes = Shapes(small = RoundedCornerShape(12.dp), medium = RoundedCornerShape(20.dp), large = RoundedCornerShape(28.dp)), + content = content, + ) +} diff --git a/app/src/main/java/life/andre/message487/OverviewScreen.kt b/app/src/main/java/life/andre/message487/OverviewScreen.kt new file mode 100644 index 0000000..107395f --- /dev/null +++ b/app/src/main/java/life/andre/message487/OverviewScreen.kt @@ -0,0 +1,148 @@ +package life.andre.message487 + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowForward +import androidx.compose.material.icons.automirrored.outlined.Send +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import java.net.URI + +@Composable +internal fun OverviewScreen(settings: ForwardingSettings, permissions: PermissionState, connected: Boolean, + queue: QueueSnapshot, busy: Boolean, model: ConnectionViewModel, + onConnection: () -> Unit, onSources: () -> Unit, onJournal: () -> Unit) { + val notificationReady = settings.notifications && permissions.notifications && connected && settings.packages.isNotEmpty() + val smsReady = settings.sms && permissions.sms + val needsAccess = (settings.notifications && (!permissions.notifications || !connected)) || (settings.sms && !permissions.sms) + val title = when { + settings.paused -> R.string.forwarding_paused + !settings.ready() -> R.string.setup_connection + needsAccess -> R.string.needs_setup + !notificationReady && !smsReady -> R.string.choose_sources + else -> R.string.forwarding_ready + } + val host = runCatching { URI(settings.url).host }.getOrNull().orEmpty() + LazyColumn(contentPadding = PaddingValues(20.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) { + item { + Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer) { + Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary) { + Text(stringResource(if (settings.requireAck) R.string.n8n_destination else R.string.webhook_destination), + Modifier.padding(horizontal = 12.dp, vertical = 6.dp), style = MaterialTheme.typography.labelLarge) + } + Spacer(Modifier.weight(1f)) + Icon(if (settings.paused) Icons.Outlined.PauseCircle else Icons.Outlined.SyncAlt, null, Modifier.size(32.dp)) + } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.SemiBold) + Text(stringResource(if (settings.paused) R.string.paused_description else R.string.overview_description), + style = MaterialTheme.typography.bodyMedium) + } + HorizontalDivider(color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = .15f)) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Icon(Icons.Outlined.Smartphone, null, Modifier.size(20.dp)) + Text(settings.deviceCode, Modifier.weight(1f), style = MaterialTheme.typography.labelLarge, + maxLines = 1, overflow = TextOverflow.Ellipsis) + IconButton(onClick = onConnection) { Icon(Icons.Outlined.Edit, stringResource(R.string.edit_connection)) } + } + when { + !settings.ready() -> Button(onClick = onConnection, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.setup_connection)) } + !notificationReady && !smsReady && !settings.paused -> Button(onClick = onSources, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.choose_sources)) } + else -> FilledTonalButton(onClick = { model.pause(!settings.paused) }, enabled = !busy, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { + Icon(if (settings.paused) Icons.Outlined.PlayArrow else Icons.Outlined.Pause, null, Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(if (settings.paused) R.string.resume_forwarding else R.string.pause)) + } + } + } + } + } + item { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MetricCard(queue.pending.toString(), stringResource(R.string.in_queue), Icons.Outlined.Schedule, Modifier.weight(1f), onJournal) + MetricCard(settings.packages.size.toString(), stringResource(R.string.apps_selected), Icons.Outlined.Apps, Modifier.weight(1f), onSources) + } + } + if (settings.captureFailed) item { + Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(16.dp)) { + Text(stringResource(R.string.capture_error)) + TextButton(onClick = model::clearError, enabled = !busy) { Text(stringResource(R.string.dismiss)) } + } + } + } + item { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionTitle(stringResource(R.string.sources_tab), stringResource(R.string.manage), onSources) + Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceContainerLowest) { + Column { + SourceStatus(Icons.Outlined.Notifications, stringResource(R.string.notification_type), + stringResource(if (!settings.notifications) R.string.source_off else if (notificationReady) R.string.source_on else R.string.needs_setup), onSources) + HorizontalDivider(Modifier.padding(horizontal = 20.dp), color = MaterialTheme.colorScheme.outlineVariant) + SourceStatus(Icons.Outlined.Sms, stringResource(R.string.sms_type), + stringResource(if (!settings.sms) R.string.source_off else if (smsReady) R.string.source_on else R.string.needs_setup), onSources) + } + } + } + } + item { + Panel { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + IconTile(Icons.Outlined.Link) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(stringResource(R.string.connection), style = MaterialTheme.typography.titleMedium) + SupportingText(host.ifEmpty { stringResource(R.string.not_configured) }) + } + } + Button(onClick = model::sendTest, enabled = !busy && settings.ready() && !settings.paused, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { + Icon(Icons.AutoMirrored.Outlined.Send, null, Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.test_connection)) + } + } + } + } +} + +@Composable +private fun MetricCard(value: String, label: String, icon: ImageVector, modifier: Modifier, onClick: () -> Unit) { + Surface(onClick = onClick, modifier = modifier, shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceContainerLowest) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(value, Modifier.weight(1f), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.SemiBold) + Icon(icon, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(22.dp)) + } + SupportingText(label) + } + } +} + +@Composable +private fun SourceStatus(icon: ImageVector, title: String, status: String, onClick: () -> Unit) { + Surface(onClick = onClick, color = MaterialTheme.colorScheme.surfaceContainerLowest) { + Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + IconTile(icon) + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleSmall) + SupportingText(status) + } + Icon(Icons.AutoMirrored.Outlined.ArrowForward, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} diff --git a/app/src/main/java/life/andre/message487/SourcesScreen.kt b/app/src/main/java/life/andre/message487/SourcesScreen.kt new file mode 100644 index 0000000..7a3188f --- /dev/null +++ b/app/src/main/java/life/andre/message487/SourcesScreen.kt @@ -0,0 +1,177 @@ +package life.andre.message487 + +import android.Manifest +import android.content.Intent +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.Image +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.graphics.drawable.toBitmap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +internal fun Modifier.switchLabel(label: String) = semantics { contentDescription = label } + +@Composable +internal fun SourcesScreen(settings: ForwardingSettings, permissions: PermissionState, connected: Boolean, + apps: List, busy: Boolean, model: ConnectionViewModel) { + val context = LocalContext.current + var query by rememberSaveable { mutableStateOf("") } + var selectedOnly by rememberSaveable { mutableStateOf(false) } + var addPackage by rememberSaveable { mutableStateOf(false) } + val smsPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + model.refreshPermissions() + model.sms(granted) + } + fun openNotificationSettings() { + try { context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) } + catch (_: android.content.ActivityNotFoundException) { model.showSettingsError() } + } + val displayedApps = remember(apps, settings.packages, query, selectedOnly) { + (apps + settings.packages.filter { pkg -> apps.none { it.packageName == pkg } }.map { AppSource(it, it) }) + .filter { (!selectedOnly || it.packageName in settings.packages) && + (it.name.contains(query, true) || it.packageName.contains(query, true)) } + .sortedBy { it.name.lowercase() } + } + val allSelected = apps.isNotEmpty() && apps.all { it.packageName in settings.packages } + LazyColumn(contentPadding = PaddingValues(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + item { + SupportingText(stringResource(R.string.sources_disclosure), Modifier.padding(bottom = 8.dp)) + } + item { + Panel { + SourceSwitch(Icons.Outlined.Notifications, R.string.notifications_enabled, R.string.notifications_hint, + settings.notifications, !busy) { + model.notifications(it) + if (it && !permissions.notifications) openNotificationSettings() + } + if (settings.notifications) { + if (!permissions.notifications) { + Text(stringResource(R.string.access_needed), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelLarge) + } + TextButton(onClick = { openNotificationSettings() }, contentPadding = PaddingValues(0.dp)) { + Text(stringResource(R.string.notification_access)) + } + if (permissions.notifications && !connected) OutlinedButton(onClick = model::rebind) { + Text(stringResource(R.string.reconnect_listener)) + } + } + } + } + item { + Panel { + SourceSwitch(Icons.Outlined.Sms, R.string.sms_enabled, R.string.sms_hint, settings.sms, !busy) { + if (it && !permissions.sms) smsPermission.launch(Manifest.permission.RECEIVE_SMS) else model.sms(it) + } + if (settings.sms && !permissions.sms) { + Text(stringResource(R.string.sms_not_granted), color = MaterialTheme.colorScheme.error) + TextButton(onClick = { + context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + android.net.Uri.parse("package:${context.packageName}"))) + }) { Text(stringResource(R.string.open_settings)) } + } + } + } + item { + SectionTitle(stringResource(R.string.selected_apps, settings.packages.size), stringResource(R.string.add_package)) { addPackage = true } + SupportingText(stringResource(R.string.app_selection_short)) + OutlinedButton( + onClick = { if (allSelected) model.clearPackageSelection() else model.selectAllPackages() }, + enabled = !busy && apps.isNotEmpty(), + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) { + Icon(if (allSelected) Icons.Outlined.RemoveDone else Icons.Outlined.DoneAll, null, Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(if (allSelected) R.string.clear_app_selection else R.string.select_all_apps)) + } + } + item { + OutlinedTextField(value = query, onValueChange = { query = it }, modifier = Modifier.fillMaxWidth(), + placeholder = { Text(stringResource(R.string.search_apps)) }, label = { Text(stringResource(R.string.search_apps)) }, + leadingIcon = { Icon(Icons.Outlined.Search, null) }, singleLine = true, shape = MaterialTheme.shapes.medium, + trailingIcon = { if (query.isNotEmpty()) IconButton(onClick = { query = "" }) { Icon(Icons.Outlined.Close, stringResource(R.string.clear_search)) } }) + FilterChip(selected = selectedOnly, onClick = { selectedOnly = !selectedOnly }, label = { Text(stringResource(R.string.selected_only)) }, + leadingIcon = { if (selectedOnly) Icon(Icons.Outlined.Check, null, Modifier.size(18.dp)) }) + } + if (displayedApps.isEmpty()) item { + Panel { SupportingText(stringResource(R.string.no_matching_apps)) } + } + items(displayedApps, key = { it.packageName }) { app -> + Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.surfaceContainerLowest) { + Row(Modifier.fillMaxWidth().toggleable(value = app.packageName in settings.packages, + enabled = !busy, role = Role.Checkbox, onValueChange = { model.selectPackage(app.packageName, it) }) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + ApplicationIcon(app.packageName) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(app.name, style = MaterialTheme.typography.titleSmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(app.packageName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Checkbox(checked = app.packageName in settings.packages, onCheckedChange = null, enabled = !busy) + } + } + } + } + if (addPackage) { + var name by rememberSaveable { mutableStateOf("") } + val valid = name.trim().matches(Regex("[A-Za-z0-9_]+(\\.[A-Za-z0-9_]+)*")) && name.trim() != context.packageName + AlertDialog(onDismissRequest = { addPackage = false }, icon = { Icon(Icons.Outlined.Add, null) }, + title = { Text(stringResource(R.string.add_package)) }, text = { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + SupportingText(stringResource(R.string.manual_package_hint)) + OutlinedTextField(value = name, onValueChange = { name = it }, singleLine = true, + label = { Text(stringResource(R.string.package_name)) }, isError = name.isNotEmpty() && !valid) + } + }, confirmButton = { TextButton(onClick = { model.selectPackage(name.trim(), true); addPackage = false }, enabled = valid && !busy) { + Text(stringResource(R.string.add_package)) + } }, dismissButton = { TextButton(onClick = { addPackage = false }) { Text(stringResource(R.string.cancel)) } }) + } +} + +@Composable +private fun ApplicationIcon(packageName: String) { + val context = LocalContext.current + val bitmap by produceState(null, packageName) { + value = withContext(Dispatchers.IO) { + try { context.packageManager.getApplicationIcon(packageName).toBitmap(96, 96).asImageBitmap() } + catch (_: android.content.pm.PackageManager.NameNotFoundException) { null } + catch (_: SecurityException) { null } + } + } + bitmap?.let { Image(it, null, Modifier.size(44.dp)) } ?: IconTile(Icons.Outlined.Apps) +} + +@Composable +private fun SourceSwitch(icon: ImageVector, title: Int, hint: Int, checked: Boolean, enabled: Boolean, onChange: (Boolean) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + IconTile(icon) + Text(stringResource(title), Modifier.weight(1f), style = MaterialTheme.typography.titleMedium) + Switch(checked = checked, onCheckedChange = onChange, enabled = enabled, + modifier = Modifier.switchLabel(stringResource(title))) + } + SupportingText(stringResource(hint)) + } +} diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ee56d7f..fdba5a7 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,6 +1,8 @@ + Выбрать все приложения + Снять выбор + Назад Message487 - Подключите Android к n8n Подключение Код устройства Метка в каждом событии, например personal-phone. @@ -26,16 +28,9 @@ Истекло время ожидания ответа Не удалось завершить подключение Android может задерживать фоновую работу и скрывать чувствительное содержимое уведомлений. Повторы используют тот же ID события; потеря ответа может привести к дублю. Подтверждение webhook не означает доставку в Telegram. - Статус Источники Приостановить пересылку Приостановить приём и отправку очереди. Уже начавшийся запрос может завершиться. - Ожидают подтверждения: %1$d - Подключение настроено - Сохраните адрес webhook в разделе подключения перед включением приёма. - Слушатель уведомлений подключён - Слушатель уведомлений не подключён - Разрешение на SMS выдано Нет разрешения на SMS. Разрешите доступ в настройках приложения. Не удалось сохранить или запланировать некоторые события. Проверьте свободное место и журнал. Скрыть @@ -43,20 +38,19 @@ Не удалось открыть настройки Android. События в очереди сохраняют адрес получателя и режим подтверждения на момент приёма. Включённые источники передают текст, имя источника и время на сохранённый webhook. SMS также содержит отправителя. По умолчанию оба источника выключены. - Пересылать уведомления + Уведомления Только выбранные приложения. Постоянные уведомления и сводки групп пропускаются. Настройки доступа к уведомлениям Переподключить слушатель - Пересылать входящие SMS - Принимать новые SMS, включая составные сообщения. История SMS не читается. - Выбрано приложений: %1$d + Входящие SMS + Новые сообщения, включая составные SMS. Без доступа к истории. + Приложения · %1$d По умолчанию ничего не выбрано. Пакет без значка запуска можно добавить вручную. Не выбирайте приложения, в которые возвращаются пересланные сообщения. Имя пакета Добавить пакет Содержимое сообщений скрыто. Неподтверждённые события хранятся до подтверждения или явного удаления. В журнале до 200 записей. В очереди Ожидает автоматического повтора - Требует внимания — повторите после устранения причины Попыток отправки: %1$d Повторить сейчас Удалить событие @@ -66,5 +60,46 @@ SMS Тест Тестовое событие сохранено в очередь. Результат появится в журнале. - Отправить тест на сохранённый адрес + Обзор + Связь + Справка + О пересылке + Закрыть + Пересылка на паузе + Настроить подключение + Нужен доступ + Выберите источники + Готово к пересылке + Новые сообщения не сохраняются. + Ваши сообщения. Ваши сценарии. + n8n + Webhook + Изменить подключение + Возобновить пересылку + В очереди + Приложений выбрано + Настроить + Включено + Выключено + Нужна настройка + Не настроено + Отправить тест + Куда отправлять сообщения + Подключите сценарий n8n или свой webhook. + Получатель + Это устройство + Настройки приложения + Пересылать уведомления только выбранных приложений. + Поиск приложений + Очистить поиск + Только выбранные + Приложения не найдены + Добавьте приложение без значка запуска по имени пакета. + Содержимое сообщений скрыто + Все события + В очереди · %1$d + Очередь пуста + Нет событий, ожидающих подтверждения. + Отправьте тест или включите источник — здесь появятся результаты доставки. + Требует внимания diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7ed7b46..e97f215 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,8 @@ + Select all applications + Clear selection + Back Message487 - Connect your Android to n8n Connection Device code A label sent with each event, for example personal-phone. @@ -26,16 +28,9 @@ Response timed out Could not complete the connection Android may delay background work or hide sensitive notification content. Retries reuse the same event ID; a lost response can still cause duplicate delivery. A webhook confirmation does not confirm delivery to Telegram. - Status Sources Pause forwarding Pause capture and queued delivery. A request already in progress may finish. - Waiting for confirmation: %1$d - Connection is configured - Save a valid webhook in Connection before enabling capture. - Notification listener is connected - Notification listener is not connected - SMS permission granted SMS permission is missing. Enable SMS access in the app permissions. Some events could not be saved or scheduled. Check available storage and the journal. Dismiss @@ -43,20 +38,19 @@ Could not open Android settings. Queued events keep the recipient 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. - Forward notifications - Only selected applications. Ongoing notifications and group summaries are skipped. + Notifications + Selected apps only. Ongoing notifications and group summaries are skipped. Notification access settings Reconnect listener - Forward incoming SMS - Receive new SMS, including multipart messages. Existing SMS history is not read. - Selected applications: %1$d + Incoming SMS + New messages, including multipart SMS. No access to your message history. + Applications · %1$d No applications are selected by default. Add a package manually if it has no launcher icon. Avoid selecting apps that receive your forwarded messages. Package name Add package Message contents are hidden. Pending events remain until confirmed or explicitly deleted. The journal shows up to 200 entries. Queued Waiting for automatic retry - Needs attention — retry manually after fixing the cause Send attempts: %1$d Retry now Delete event @@ -66,5 +60,46 @@ SMS Test Test event saved to the queue. Check the journal for its result. - Send test to saved connection + Overview + Connection + Help + About forwarding + Close + Forwarding paused + Set up connection + Permission needed + Choose your sources + Ready to forward + New messages are not being captured. + Your messages. Your workflows. + n8n + Webhook + Edit connection + Resume forwarding + In queue + Apps selected + Manage + Enabled + Off + Needs setup + Not configured + Send test event + Where messages go + Connect an n8n workflow or your own webhook. + Recipient + This device + Open app settings + Forward notifications only from the apps you choose. + Search applications + Clear search + Selected only + No matching applications + Add an app without a launcher icon using its package name. + Message contents stay hidden + All events + Pending · %1$d + Nothing waiting + There are no unconfirmed events. + Send a test event or enable a source to see delivery activity here. + Needs attention diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..1d78815 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,37 @@ +# Interface design + +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, +grouped settings, rounded surfaces, readable typography and a bounded content width. + +The overview answers whether capture is ready and what needs attention. Its status does not claim +that the server is reachable: a test event and the journal provide delivery evidence. Primary +screens outside the overview have an app-bar back button returning to the overview. Primary +sections use bottom navigation on compact windows and a rail on wider windows. Connection drafts +survive section changes. Long application and event lists use lazy rendering. + +The journal prioritizes source, time and delivery state. Tap an event for its selectable ID, +HTTP response, attempts, retry and deletion. Message text remains hidden. Source selection supports +search, a selected-only filter and bulk selection of the full available list. Manual package +entry is a separate dialog. Help retains the +explanations of queue behavior, retries and system limitations. + +Keep touch targets at least 48 dp. Communicate status with text and icons as well as color. +Use the theme's semantic color roles, scalable typography, safe insets and scrolling; avoid fixed +text heights. Verify both locales, dark mode, large text and narrow/wide windows on an emulator. + +## References + +- [Google: Themes](https://developer.android.com/design/ui/mobile/guides/styles/themes) — the standard Material purple baseline. +- [Google: Layout basics](https://developer.android.com/design/ui/mobile/guides/layout-and-content/layout-basics) — grouping, consistent spacing, safe areas and reachable actions. +- [Google: Accessibility](https://developer.android.com/design/ui/mobile/guides/foundations/accessibility) — contrast, scalable text, touch targets and semantics. +- [Material 3: Navigation bar](https://m3.material.io/components/navigation-bar/guidelines) — primary destinations. +- [Google Design: Expressive design research](https://design.google/library/expressive-material-design-google-research) — color, scale and containment should emphasize useful actions while preserving familiar behavior. +- [Nielsen Norman Group: Visual hierarchy](https://www.nngroup.com/articles/visual-hierarchy-ux-definition/) — emphasize important information through scale, contrast and grouping. + +## Verification + +The redesign was checked on the API 35 emulator in English and Russian, light and dark mode, +with enlarged text on a narrow window, and with rail navigation in a wide window. Android Lint, +JVM tests and both APK builds use the existing Fastlane checks lane. From 6e92068ab8f6b1abdbd566586426a15f8667b70c Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Wed, 9 Sep 2026 00:05:20 +0300 Subject: [PATCH 4/6] Add local diagnostics and CI test coverage --- .github/workflows/ci.yml | 38 +++- .gitignore | 3 + DevServer/README.md | 2 +- DevServer/tests/smoke.py | 11 +- DevServer/tests/test_smoke.py | 57 ++++++ PRIVACY.md | 20 ++ README.md | 12 +- app/build.gradle.kts | 4 +- app/proguard-rules.pro | 2 + app/src/main/AndroidManifest.xml | 11 ++ .../andre/message487/ConnectionViewModel.kt | 14 +- .../life/andre/message487/DeliveryWorker.kt | 12 +- .../andre/message487/DiagnosticsScreen.kt | 105 +++++++++++ .../andre/message487/ForwardingSettings.kt | 2 + .../life/andre/message487/MainActivity.kt | 34 +++- .../life/andre/message487/MessageGraph.kt | 27 ++- .../message487/NotificationCaptureService.kt | 15 +- .../andre/message487/SmsCaptureReceiver.kt | 4 +- .../message487/diagnostics/CrashHandler.kt | 22 +++ .../message487/diagnostics/DiagnosticLog.kt | 176 ++++++++++++++++++ .../message487/diagnostics/FeedbackEmail.kt | 81 ++++++++ app/src/main/res/values-ru/strings.xml | 15 ++ app/src/main/res/values/strings.xml | 15 ++ app/src/main/res/xml/file_paths.xml | 4 + .../andre/message487/ResourceContractTest.kt | 55 ++++++ .../andre/message487/ScreenInteractionTest.kt | 130 +++++++++++++ .../andre/message487/ThemeContrastTest.kt | 43 +++++ .../diagnostics/DiagnosticLogTest.kt | 71 +++++++ .../diagnostics/FeedbackEmailTest.kt | 88 +++++++++ docs/diagnostics.md | 29 +++ docs/project-context.md | 4 + docs/testing.md | 35 ++++ fastlane/Fastfile | 27 +++ fastlane/README.md | 16 ++ pyproject.toml | 5 + requirements-dev.txt | 2 + 36 files changed, 1156 insertions(+), 35 deletions(-) create mode 100644 DevServer/tests/test_smoke.py create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/java/life/andre/message487/DiagnosticsScreen.kt create mode 100644 app/src/main/java/life/andre/message487/diagnostics/CrashHandler.kt create mode 100644 app/src/main/java/life/andre/message487/diagnostics/DiagnosticLog.kt create mode 100644 app/src/main/java/life/andre/message487/diagnostics/FeedbackEmail.kt create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 app/src/test/java/life/andre/message487/ResourceContractTest.kt create mode 100644 app/src/test/java/life/andre/message487/ScreenInteractionTest.kt create mode 100644 app/src/test/java/life/andre/message487/ThemeContrastTest.kt create mode 100644 app/src/test/java/life/andre/message487/diagnostics/DiagnosticLogTest.kt create mode 100644 app/src/test/java/life/andre/message487/diagnostics/FeedbackEmailTest.kt create mode 100644 docs/diagnostics.md create mode 100644 docs/testing.md create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b375ee9..de411d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: CI on: + workflow_dispatch: pull_request: push: branches: [main] @@ -32,6 +33,17 @@ jobs: - name: Install Android SDK components run: sdkmanager "platforms;android-36" "build-tools;36.0.0" - run: bundle exec fastlane android checks + - name: Publish test and lint reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: android-test-reports + path: | + app/build/reports/tests/ + app/build/test-results/ + app/build/reports/lint-results-* + if-no-files-found: warn + retention-days: 14 - uses: actions/upload-artifact@v7 id: apk with: @@ -44,6 +56,26 @@ jobs: ARTIFACT_URL: ${{ steps.apk.outputs.artifact-url }} run: echo "[Download debug and unsigned release APKs]($ARTIFACT_URL)" >> "$GITHUB_STEP_SUMMARY" + python: + name: Python tests and style + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.10" + bundler-cache: true + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: requirements-dev.txt + - run: python -m pip install -r requirements-dev.txt + - run: bundle exec fastlane android python_checks + dev-server: runs-on: ubuntu-latest timeout-minutes: 15 @@ -52,7 +84,11 @@ jobs: with: persist-credentials: false - run: docker compose -f DevServer/compose.yaml up -d --wait --wait-timeout 300 - - run: python3 DevServer/tests/smoke.py + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.10" + bundler-cache: true + - run: bundle exec fastlane android server_tests - name: Server logs if: failure() run: docker compose -f DevServer/compose.yaml logs --tail 150 diff --git a/.gitignore b/.gitignore index 88a377f..2f22884 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ google-services.json .DS_Store fastlane/report.xml DevServer/.env + +.venv/ +__pycache__/ diff --git a/DevServer/README.md b/DevServer/README.md index 75ffd29..a7e23e9 100644 --- a/DevServer/README.md +++ b/DevServer/README.md @@ -4,7 +4,7 @@ Run from the repository root with Docker Compose installed and a Docker engine r ```sh docker compose -f DevServer/compose.yaml up -d --wait -python3 DevServer/tests/smoke.py +bundle exec fastlane android server_tests ``` Open the editor at . Local development login: diff --git a/DevServer/tests/smoke.py b/DevServer/tests/smoke.py index bd302e8..234ebd9 100644 --- a/DevServer/tests/smoke.py +++ b/DevServer/tests/smoke.py @@ -5,9 +5,9 @@ import uuid -def post(scenario, payload, timeout=5): +def post(scenario, payload, timeout=5, base_url='http://127.0.0.1:5678'): request = urllib.request.Request( - f'http://127.0.0.1:5678/webhook/message487/{scenario}', + f'{base_url}/webhook/message487/{scenario}', data=json.dumps(payload).encode(), headers={'Content-Type': 'application/json'}, ) @@ -39,7 +39,8 @@ def main(): event['sender'] = '+15551234567' code, body = post('receive', event) assert code == 200 and body == { - 'status': 'accepted', 'event_id': event['event_id'] + 'status': 'accepted', + 'event_id': event['event_id'], }, (code, body) code, body = post('receive', {}) assert code == 400 and body['status'] == 'rejected', (code, body) @@ -53,7 +54,9 @@ def main(): pass else: raise AssertionError('Slow endpoint did not time out') - print('Passed: test/notification/SMS receive, validation, HTTP error, invalid ACK, timeout') + print( + 'Passed: test/notification/SMS receive, validation, HTTP error, invalid ACK, timeout' + ) if __name__ == '__main__': diff --git a/DevServer/tests/test_smoke.py b/DevServer/tests/test_smoke.py new file mode 100644 index 0000000..be1dc9f --- /dev/null +++ b/DevServer/tests/test_smoke.py @@ -0,0 +1,57 @@ +import http.server +import json +import threading +import unittest + +import smoke + + +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.send_response(500 if self.path.endswith('/error') else 200) + self.end_headers() + if self.path.endswith('/malformed'): + self.wfile.write(b'not JSON') + else: + self.wfile.write(json.dumps({'event_id': payload['event_id']}).encode()) + + def log_message(self, *_args): + pass + + +class SmokeTransportTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + cls.base_url = f'http://127.0.0.1:{cls.server.server_port}' + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + cls.thread.join(timeout=5) + + def test_posts_unicode_json_and_returns_ack(self): + event = {'event_id': 'fixture', 'text': 'Тестовое SMS\n123'} + self.assertEqual( + (200, {'event_id': 'fixture'}), + smoke.post('receive', event, base_url=self.base_url), + ) + self.assertEqual( + ('/webhook/message487/receive', 'application/json', event), + self.server.received, + ) + + def test_returns_http_failure_for_scenario_assertions(self): + self.assertEqual( + (500, {'event_id': 'fixture'}), + smoke.post('error', {'event_id': 'fixture'}, base_url=self.base_url), + ) + + def test_malformed_response_fails_instead_of_passing_smoke(self): + with self.assertRaises(json.JSONDecodeError): + smoke.post('malformed', {'event_id': 'fixture'}, base_url=self.base_url) diff --git a/PRIVACY.md b/PRIVACY.md index 13a1fa5..de90974 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -27,6 +27,26 @@ retained. Undelivered payloads are not automatically deleted by age. Database de and does not guarantee forensic erasure of previously allocated storage. Message bodies and server response bodies are not written to diagnostic logs by the app. +## Diagnostics and voluntary email reports + +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. +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. + +Diagnostic files reside in private app storage without additional encryption. Three rotating files +are limited to 256 KiB each, with a separate last-crash file up to 256 KiB. In Diagnostics you can +view recent logs, clear them, or prepare an email to **der-morgenstern@yandex.ru**. The ZIP attachment +includes these logs and an environment summary: app version, Android version/security patch, +manufacturer/model, CPU architecture, enabled-source flags and selected-app count. Up to three +archives are retained in private cache. Clearing logs also removes the last crash and cached archives. + +Nothing is sent automatically. Your selected email/sharing app receives temporary read access to +the attachment; review it before sending. Sent copies and your email address are processed by your +email provider and the recipient and are not erased by clearing local logs. Reports are used to +investigate the problem you report. You may request deletion of a received report at the address above. + ## Network requests Your selected endpoint receives event text, an event ID, installation ID, device code, timestamp, diff --git a/README.md b/README.md index 56e41d1..4109938 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Start the [development server](DevServer/README.md), which provisions n8n and pu ```sh docker compose -f DevServer/compose.yaml up -d --wait -python3 DevServer/tests/smoke.py +bundle exec fastlane android server_tests ``` Build with JDK 21, Ruby/Bundler and the Android SDK; the emulator launcher also uses Python 3. @@ -101,3 +101,13 @@ This project succeeds [sms487](https://github.com/andre487/sms487). [AndroidMegaProxy](https://github.com/andre487/AndroidMegaProxy) is the reference for project conventions. Licensed under the [MIT License](LICENSE). + +## Diagnostics + +Open the bug icon in the top bar to view or clear local diagnostic logs and prepare an email to +`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). + +Test categories, local commands, CI jobs and device-only limitations: [Testing](docs/testing.md). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aa9e3c8..08ff7f2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,7 +23,7 @@ android { buildConfigField("String", "DEFAULT_WEBHOOK_URL", "\"\"") isMinifyEnabled = true isShrinkResources = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } buildFeatures { @@ -58,6 +58,8 @@ dependencies { implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") implementation("androidx.work:work-runtime-ktx:2.11.2") testImplementation("org.robolectric:robolectric:4.16") + testImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-test-manifest") testImplementation("junit:junit:4.13.2") testImplementation("org.json:json:20250107") testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..42d1304 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,2 @@ +-keepattributes SourceFile,LineNumberTable +-keepnames class life.andre.message487.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c318f83..ee4b6d0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,10 @@ + + + + @@ -20,6 +24,13 @@ android:icon="@mipmap/ic_launcher" android:supportsRtl="true" android:theme="@style/Theme.Message487"> + + + Unit) { + if (busy) return + busy = true + failed = false + scope.launch { + try { action() } + catch (cancelled: CancellationException) { throw cancelled } + catch (_: Exception) { failed = true } + finally { busy = false } + } + } + LaunchedEffect(Unit) { + busy = true + try { refresh() } + catch (cancelled: CancellationException) { throw cancelled } + catch (_: Exception) { failed = true } + finally { busy = false } + } + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp)) { + Panel { + Text(stringResource(R.string.diagnostic_report), style = MaterialTheme.typography.titleMedium) + SupportingText(stringResource(R.string.diagnostics_description)) + Text(FeedbackEmail.ADDRESS, style = MaterialTheme.typography.bodyMedium) + Button(enabled = !busy, onClick = { runAction { + val intent = withContext(Dispatchers.IO) { FeedbackEmail.createIntent(context, log, settings) } + context.startActivity(intent) + refresh() + } }) { Text(stringResource(R.string.send_diagnostics)) } + } + if (busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + if (failed) Text(stringResource(R.string.diagnostics_error), color = MaterialTheme.colorScheme.error) + Panel { + Text(stringResource(R.string.local_log), style = MaterialTheme.typography.titleMedium) + SupportingText(stringResource(R.string.diagnostics_limits)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(enabled = !busy, onClick = { runAction { refresh() } }) { + Text(stringResource(R.string.refresh_log)) + } + TextButton(enabled = !busy, onClick = { confirmClear = true }) { + Text(stringResource(R.string.clear_log)) + } + } + SelectionContainer { + Text(text.ifEmpty { stringResource(R.string.log_empty) }, + fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) + } + } + } + if (confirmClear) AlertDialog(onDismissRequest = { confirmClear = false }, + title = { Text(stringResource(R.string.clear_log)) }, + text = { Text(stringResource(R.string.clear_log_description)) }, + confirmButton = { TextButton(onClick = { + confirmClear = false + runAction { + withContext(Dispatchers.IO) { + log.clear() + File(application.cacheDir, "feedback").listFiles().orEmpty().forEach { + if (!it.delete()) throw IOException("Could not delete report") + } + application.crashHandler.dismiss() + } + refresh() + } + }) { Text(stringResource(R.string.clear_log)) } }, + dismissButton = { TextButton(onClick = { confirmClear = false }) { Text(stringResource(R.string.cancel)) } }) +} diff --git a/app/src/main/java/life/andre/message487/ForwardingSettings.kt b/app/src/main/java/life/andre/message487/ForwardingSettings.kt index f5d0c4b..a083629 100644 --- a/app/src/main/java/life/andre/message487/ForwardingSettings.kt +++ b/app/src/main/java/life/andre/message487/ForwardingSettings.kt @@ -23,6 +23,7 @@ data class ForwardingSettings( } class SettingsStore(context: Context) { + 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() @@ -51,6 +52,7 @@ class SettingsStore(context: Context) { .putBoolean("paused", next.paused).putStringSet("packages", next.packages) .putBoolean("capture_failed", next.captureFailed).commit() ) throw IOException("Could not save settings") + diagnostics?.record(life.andre.message487.diagnostics.DiagnosticEvent.SETTINGS_SAVED) mutableState.value = next return next } diff --git a/app/src/main/java/life/andre/message487/MainActivity.kt b/app/src/main/java/life/andre/message487/MainActivity.kt index f93fa0c..f3fc9b8 100644 --- a/app/src/main/java/life/andre/message487/MainActivity.kt +++ b/app/src/main/java/life/andre/message487/MainActivity.kt @@ -1,6 +1,7 @@ package life.andre.message487 import android.os.Bundle +import androidx.compose.ui.platform.LocalContext import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent @@ -40,7 +41,7 @@ private enum class Destination(val label: Int, val icon: ImageVector) { @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun MessageScreen(model: ConnectionViewModel = viewModel()) { +internal fun MessageScreen(model: ConnectionViewModel = viewModel()) { val state by model.state.collectAsStateWithLifecycle() val settings by model.settings.collectAsStateWithLifecycle() val permissions by model.permissions.collectAsStateWithLifecycle() @@ -48,11 +49,15 @@ private fun MessageScreen(model: ConnectionViewModel = viewModel()) { val queue by model.queue.collectAsStateWithLifecycle() val apps by model.apps.collectAsStateWithLifecycle() var destination by rememberSaveable { mutableStateOf(Destination.OVERVIEW) } + val application = LocalContext.current.applicationContext as MessageApplication + var diagnostics by rememberSaveable { mutableStateOf(false) } + var crash by remember { mutableStateOf(application.crashHandler.pending) } + fun back() { if (diagnostics) diagnostics = false else destination = Destination.OVERVIEW } var help by rememberSaveable { mutableStateOf(false) } val snackbar = remember { SnackbarHostState() } val noticeText = state.notice?.let { stringResource(it) } LaunchedEffect(noticeText) { noticeText?.let { snackbar.showSnackbar(it) } } - BackHandler(destination != Destination.OVERVIEW) { destination = Destination.OVERVIEW } + BackHandler(diagnostics || destination != Destination.OVERVIEW) { back() } val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(lifecycle) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) model.refreshPermissions() } @@ -62,7 +67,7 @@ private fun MessageScreen(model: ConnectionViewModel = viewModel()) { BoxWithConstraints { val wide = maxWidth >= 600.dp Row(Modifier.fillMaxSize()) { - if (wide) { + if (wide && !diagnostics) { NavigationRail(Modifier.fillMaxHeight(), containerColor = MaterialTheme.colorScheme.surfaceContainerLow) { Spacer(Modifier.height(24.dp)) Destination.entries.forEach { item -> @@ -75,21 +80,24 @@ private fun MessageScreen(model: ConnectionViewModel = viewModel()) { modifier = Modifier.weight(1f).imePadding(), topBar = { TopAppBar(title = { - Text(stringResource(if (destination == Destination.OVERVIEW) R.string.app_name + Text(stringResource(if (diagnostics) R.string.diagnostics else if (destination == Destination.OVERVIEW) R.string.app_name else if (destination == Destination.CONNECTION) R.string.connection else destination.label), style = MaterialTheme.typography.titleLarge) }, navigationIcon = { - if (destination != Destination.OVERVIEW) { - IconButton(onClick = { destination = Destination.OVERVIEW }) { + if (diagnostics || destination != Destination.OVERVIEW) { + IconButton(onClick = { back() }) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, stringResource(R.string.back)) } } }, actions = { + if (!diagnostics) IconButton(onClick = { diagnostics = true }) { + Icon(Icons.Outlined.BugReport, stringResource(R.string.diagnostics)) + } IconButton(onClick = { help = true }) { Icon(Icons.Outlined.HelpOutline, stringResource(R.string.help)) } }) }, bottomBar = { - if (!wide) NavigationBar(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) { + if (!wide && !diagnostics) NavigationBar(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) { Destination.entries.forEach { item -> NavigationBarItem(selected = destination == item, onClick = { destination = item }, icon = { Icon(item.icon, null) }, label = { Text(stringResource(item.label)) }) @@ -102,7 +110,7 @@ private fun MessageScreen(model: ConnectionViewModel = viewModel()) { if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) key(destination) { Box(Modifier.widthIn(max = 720.dp).fillMaxSize()) { - when (destination) { + if (diagnostics) DiagnosticsScreen(application, settings) else when (destination) { Destination.OVERVIEW -> OverviewScreen(settings, permissions, connected, queue, state.busy, model, onConnection = { destination = Destination.CONNECTION }, onSources = { destination = Destination.SOURCES }, onJournal = { destination = Destination.JOURNAL }) @@ -116,6 +124,16 @@ private fun MessageScreen(model: ConnectionViewModel = viewModel()) { } } } + if (crash) AlertDialog(onDismissRequest = { crash = false; application.crashHandler.dismiss() }, + icon = { Icon(Icons.Outlined.BugReport, null) }, + title = { Text(stringResource(R.string.crash_title)) }, + text = { Text(stringResource(R.string.crash_description)) }, + confirmButton = { TextButton(onClick = { + crash = false; application.crashHandler.dismiss(); diagnostics = true + }) { Text(stringResource(R.string.review_report)) } }, + dismissButton = { TextButton(onClick = { crash = false; application.crashHandler.dismiss() }) { + Text(stringResource(R.string.close)) + } }) if (help) AlertDialog(onDismissRequest = { help = false }, icon = { Icon(Icons.Outlined.PrivacyTip, null) }, title = { Text(stringResource(R.string.delivery_help)) }, text = { HelpContent() }, diff --git a/app/src/main/java/life/andre/message487/MessageGraph.kt b/app/src/main/java/life/andre/message487/MessageGraph.kt index f0e5a52..4f01cae 100644 --- a/app/src/main/java/life/andre/message487/MessageGraph.kt +++ b/app/src/main/java/life/andre/message487/MessageGraph.kt @@ -6,17 +6,26 @@ import java.security.MessageDigest import java.time.Instant import java.util.UUID import java.util.concurrent.Executors +import java.io.File +import life.andre.message487.diagnostics.CrashHandler +import life.andre.message487.diagnostics.DiagnosticLog +import life.andre.message487.diagnostics.DiagnosticEvent -class MessageApplication : Application() { +open class MessageApplication : Application() { + internal val diagnostics by lazy { DiagnosticLog(File(filesDir, "logs")) } + internal val crashHandler by lazy { CrashHandler(this, diagnostics) } val graph by lazy { MessageGraph(this) } override fun onCreate() { super.onCreate() + crashHandler.install() + diagnostics.record(DiagnosticEvent.APP_STARTED) graph.start() } } 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 scheduler by lazy { DeliveryScheduler(context) } @@ -29,12 +38,16 @@ class MessageGraph internal constructor(private val context: Application) { settings.update { it } scheduler.startRecovery() recover() - } catch (_: Exception) { captureFailed() } + } catch (error: Exception) { captureFailed(error) } } } fun recover() { - if (!settings.state.value.paused) outbox.pendingIds().forEach { scheduler.schedule(it) } + if (!settings.state.value.paused) { + val pending = outbox.pendingIds() + pending.forEach { scheduler.schedule(it) } + diagnostics.record(DiagnosticEvent.RECOVERY, count = pending.size) + } } fun enqueueTest() { @@ -69,10 +82,14 @@ class MessageGraph internal constructor(private val context: Application) { } private fun enqueue(event: MessageEvent, config: ForwardingSettings, key: String? = null, fingerprint: String? = null) { - if (outbox.enqueue(event, config, key, fingerprint)) scheduler.schedule(event.eventId) + if (outbox.enqueue(event, config, key, fingerprint)) { + diagnostics.record(DiagnosticEvent.EVENT_QUEUED, type = event.messageType) + scheduler.schedule(event.eventId) + } else diagnostics.record(DiagnosticEvent.DUPLICATE_SKIPPED, type = event.messageType) } - fun captureFailed() { + fun captureFailed(error: Throwable? = null) { + diagnostics.record(DiagnosticEvent.CAPTURE_FAILED, error = error) try { settings.update { it.copy(captureFailed = true) } } catch (_: Exception) { } } diff --git a/app/src/main/java/life/andre/message487/NotificationCaptureService.kt b/app/src/main/java/life/andre/message487/NotificationCaptureService.kt index 51e0703..bc89e31 100644 --- a/app/src/main/java/life/andre/message487/NotificationCaptureService.kt +++ b/app/src/main/java/life/andre/message487/NotificationCaptureService.kt @@ -1,5 +1,6 @@ package life.andre.message487 +import life.andre.message487.diagnostics.DiagnosticEvent import android.app.Notification import android.service.notification.NotificationListenerService import android.service.notification.StatusBarNotification @@ -21,14 +22,18 @@ object ListenerState { class NotificationCaptureService : NotificationListenerService() { override fun onListenerConnected() { + MessageGraph.get(this).diagnostics.record(DiagnosticEvent.LISTENER_CONNECTED) ListenerState.mutableConnected.value = true val graph = MessageGraph.get(this) graph.captureExecutor.execute { - try { graph.recover() } catch (_: Exception) { graph.captureFailed() } + try { graph.recover() } catch (error: Exception) { graph.captureFailed(error) } } } - override fun onListenerDisconnected() { ListenerState.mutableConnected.value = false } + override fun onListenerDisconnected() { + MessageGraph.get(this).diagnostics.record(DiagnosticEvent.LISTENER_DISCONNECTED) + ListenerState.mutableConnected.value = false + } override fun onDestroy() { ListenerState.mutableConnected.value = false @@ -49,15 +54,15 @@ class NotificationCaptureService : NotificationListenerService() { if (title.isBlank() && text.isBlank()) return val captured = CapturedNotification(sbn.key, sbn.packageName, title, text, sbn.postTime) graph.captureExecutor.execute { - try { graph.captureNotification(captured) } catch (_: Exception) { graph.captureFailed() } + try { graph.captureNotification(captured) } catch (error: Exception) { graph.captureFailed(error) } } - } catch (_: Exception) { graph.captureFailed() } + } catch (error: Exception) { graph.captureFailed(error) } } override fun onNotificationRemoved(sbn: StatusBarNotification) { val graph = MessageGraph.get(this) graph.captureExecutor.execute { - try { graph.outbox.forgetNotification(digest(sbn.key)) } catch (_: Exception) { graph.captureFailed() } + try { graph.outbox.forgetNotification(digest(sbn.key)) } catch (error: Exception) { graph.captureFailed(error) } } } } diff --git a/app/src/main/java/life/andre/message487/SmsCaptureReceiver.kt b/app/src/main/java/life/andre/message487/SmsCaptureReceiver.kt index 9f58675..f95e0a7 100644 --- a/app/src/main/java/life/andre/message487/SmsCaptureReceiver.kt +++ b/app/src/main/java/life/andre/message487/SmsCaptureReceiver.kt @@ -19,8 +19,8 @@ class SmsCaptureReceiver : BroadcastReceiver() { val sender = messages.first().originatingAddress.orEmpty() graph.captureSms(sender, messages.joinToString("") { it.messageBody.orEmpty() }, messages.first().timestampMillis) } - } catch (_: Exception) { - graph.captureFailed() + } catch (error: Exception) { + graph.captureFailed(error) } finally { pending.finish() } diff --git a/app/src/main/java/life/andre/message487/diagnostics/CrashHandler.kt b/app/src/main/java/life/andre/message487/diagnostics/CrashHandler.kt new file mode 100644 index 0000000..6c592df --- /dev/null +++ b/app/src/main/java/life/andre/message487/diagnostics/CrashHandler.kt @@ -0,0 +1,22 @@ +package life.andre.message487.diagnostics + +import android.content.Context +import android.os.Process +import kotlin.system.exitProcess + +internal class CrashHandler(context: Context, private val log: DiagnosticLog) { + private val preferences = context.getSharedPreferences("crash_state", Context.MODE_PRIVATE) + val pending: Boolean get() = preferences.getBoolean("pending", false) + + fun install() { + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, error -> + try { log.writeCrash(thread, error) } catch (_: Throwable) { } + try { preferences.edit().putBoolean("pending", true).commit() } catch (_: Throwable) { } + if (previous != null) previous.uncaughtException(thread, error) + else { Process.killProcess(Process.myPid()); exitProcess(10) } + } + } + + fun dismiss() { preferences.edit().putBoolean("pending", false).apply() } +} diff --git a/app/src/main/java/life/andre/message487/diagnostics/DiagnosticLog.kt b/app/src/main/java/life/andre/message487/diagnostics/DiagnosticLog.kt new file mode 100644 index 0000000..0c08c34 --- /dev/null +++ b/app/src/main/java/life/andre/message487/diagnostics/DiagnosticLog.kt @@ -0,0 +1,176 @@ +package life.andre.message487.diagnostics + +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.OutputStream +import java.io.RandomAccessFile +import java.time.Instant +import java.util.Collections +import java.util.IdentityHashMap +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong + +internal enum class DiagnosticEvent { + APP_STARTED, LISTENER_CONNECTED, LISTENER_DISCONNECTED, EVENT_QUEUED, DUPLICATE_SKIPPED, + CAPTURE_FAILED, DELIVERY_STARTED, DELIVERY_FINISHED, DELIVERY_FAILED, PAYLOAD_UNREADABLE, + RECOVERY, RECOVERY_FAILED, SETTINGS_SAVED, LOCAL_OPERATION_FAILED, MANUAL_RETRY, EVENT_DELETED, + REPORT_PREPARED, LOG_CLEARED, +} + +internal class DiagnosticLog( + private val directory: File, + private val segmentBytes: Int = 256 * 1024, +) : AutoCloseable { + private val lock = Any() + private val dropped = AtomicLong() + private val executor = ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, ArrayBlockingQueue(256), + { task -> Thread(task, "message487-diagnostics").apply { isDaemon = true } }, ThreadPoolExecutor.AbortPolicy()) + @Volatile var ioFailed = false + private set + + init { require(segmentBytes >= 1024) } + + fun record(event: DiagnosticEvent, type: String? = null, outcome: String? = null, + http: Int? = null, count: Int? = null, error: Throwable? = null) { + val line = buildString { + append("${Instant.now()} event=${event.name}") + type?.let { append(" type=${if (it in TYPES) it else "unknown"}") } + outcome?.let { append(" outcome=${if (it in OUTCOMES) it else "unknown"}") } + http?.let { append(" http=$it") } + count?.let { append(" count=$it") } + appendLine() + error?.let { append(safeStackTrace(it)) } + } + try { + executor.execute { + safely { + synchronized(lock) { + val missed = dropped.getAndSet(0) + appendLocked(if (missed > 0) "${Instant.now()} dropped=$missed\n$line" else line) + } + } + } + } catch (_: RejectedExecutionException) { dropped.incrementAndGet() } + } + + // No queue on the fatal path: the runtime terminates the process immediately afterwards. + fun writeCrash(thread: Thread, error: Throwable) = safely { + synchronized(lock) { + val report = "${Instant.now()} event=UNCAUGHT_EXCEPTION thread_id=${thread.id}\n${safeStackTrace(error)}" + ensureDirectory() + FileOutputStream(File(directory, CRASH_FILE)).use { + it.write(report.toByteArray(Charsets.UTF_8).let { it.copyOf(minOf(it.size, segmentBytes)) }) + it.fd.sync() + } + appendLocked(report, sync = true) + } + } + + fun readTail(maxBytes: Int = 48 * 1024): String { + flush() + return synchronized(lock) { + ensureDirectory() + val chunks = ArrayDeque() + var remaining = maxBytes.coerceAtLeast(1) + for (file in logFiles().asReversed()) { + if (!file.isFile || remaining == 0) continue + val count = minOf(file.length(), remaining.toLong()).toInt() + RandomAccessFile(file, "r").use { + it.seek(file.length() - count) + val bytes = ByteArray(count) + it.readFully(bytes) + chunks.addFirst(bytes) + } + remaining -= count + } + val text = chunks.fold(ByteArray(0)) { bytes, chunk -> bytes + chunk }.toString(Charsets.UTF_8) + if (remaining == 0) text.substringAfter('\n', "") else text + } + } + + fun copyLogsTo(output: OutputStream) { + flush() + synchronized(lock) { + ensureDirectory() + logFiles().filter(File::isFile).forEach { it.inputStream().use { input -> input.copyTo(output) } } + } + } + + fun copyCrashTo(output: OutputStream) = synchronized(lock) { + File(directory, CRASH_FILE).takeIf(File::isFile)?.inputStream()?.use { it.copyTo(output) } + Unit + } + + fun clear() { + flush() + synchronized(lock) { + (logFiles() + File(directory, CRASH_FILE)).filter(File::exists).forEach { + if (!it.delete()) throw IOException("Could not remove diagnostic file") + } + } + record(DiagnosticEvent.LOG_CLEARED) + } + + fun flush() { + try { executor.submit {}.get(5, TimeUnit.SECONDS) } + catch (error: InterruptedException) { Thread.currentThread().interrupt(); throw IOException("Diagnostic flush interrupted", error) } + catch (error: Exception) { throw IOException("Could not flush diagnostics", error) } + } + + private fun appendLocked(text: String, sync: Boolean = false) { + ensureDirectory() + val bytes = text.toByteArray(Charsets.UTF_8).let { it.copyOf(minOf(it.size, segmentBytes)) } + val current = File(directory, "diagnostic.log") + if (current.length() + bytes.size > segmentBytes) { + val oldest = File(directory, "diagnostic.2.log") + if (oldest.exists() && !oldest.delete()) throw IOException("Could not rotate diagnostics") + for (index in 1 downTo 0) { + val from = File(directory, if (index == 0) "diagnostic.log" else "diagnostic.1.log") + if (from.exists() && !from.renameTo(File(directory, "diagnostic.${index + 1}.log"))) { + throw IOException("Could not rotate diagnostics") + } + } + } + FileOutputStream(current, true).use { it.write(bytes); if (sync) it.fd.sync() } + } + + private fun logFiles() = listOf("diagnostic.2.log", "diagnostic.1.log", "diagnostic.log").map { File(directory, it) } + private fun ensureDirectory() { + if (!directory.isDirectory && !directory.mkdirs()) throw IOException("Diagnostic storage unavailable") + } + private fun safely(block: () -> Unit): Boolean = try { + block(); ioFailed = false; true + } catch (_: IOException) { ioFailed = true; false } + catch (_: SecurityException) { ioFailed = true; false } + + override fun close() { executor.shutdown(); executor.awaitTermination(5, TimeUnit.SECONDS) } + + companion object { + private const val CRASH_FILE = "crash-latest.log" + private val TYPES = setOf("test", "sms", "notification") + private val OUTCOMES = setOf("ACCEPTED", "HTTP_SUCCESS", "HTTP_ERROR", "INVALID_ACK", "TIMEOUT", "NETWORK_ERROR") + } +} + +internal fun safeStackTrace(error: Throwable): String = buildString { + val seen = Collections.newSetFromMap(IdentityHashMap()) + fun visit(current: Throwable, label: String, depth: Int) { + if (depth >= 8 || seen.size >= 8 || length >= 16 * 1024 || !seen.add(current)) return + appendLine("$label=${safeClass(current.javaClass.name)}") + current.stackTrace.take(40).forEach { frame -> + if (length >= 16 * 1024) return@forEach + appendLine(" at ${safeClass(frame.className)}.${safeIdentifier(frame.methodName)}(${safeIdentifier(frame.fileName ?: "unknown")}:${frame.lineNumber})") + } + current.cause?.let { visit(it, "cause", depth + 1) } + current.suppressed.take(2).forEach { visit(it, "suppressed", depth + 1) } + } + visit(error, "exception", 0) +}.take(16 * 1024) + +private fun safeClass(value: String): String = if (listOf("life.andre.message487.", "android.", "androidx.", "java.", "javax.", "kotlin.", "kotlinx.") + .any(value::startsWith)) safeIdentifier(value) else "[external]" +private fun safeIdentifier(value: String): String = if (value.length <= 200 && value.matches(Regex("[A-Za-z0-9_.$<>-]+"))) value else "[redacted]" diff --git a/app/src/main/java/life/andre/message487/diagnostics/FeedbackEmail.kt b/app/src/main/java/life/andre/message487/diagnostics/FeedbackEmail.kt new file mode 100644 index 0000000..f62575d --- /dev/null +++ b/app/src/main/java/life/andre/message487/diagnostics/FeedbackEmail.kt @@ -0,0 +1,81 @@ +package life.andre.message487.diagnostics + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.core.content.FileProvider +import life.andre.message487.BuildConfig +import life.andre.message487.ForwardingSettings +import life.andre.message487.R +import java.io.File +import java.io.IOException +import java.util.UUID +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +internal object FeedbackEmail { + const val ADDRESS = "der-morgenstern@yandex.ru" + + fun createIntent(context: Context, log: DiagnosticLog, settings: ForwardingSettings): Intent { + val archive = createArchive(context.cacheDir, log, environment(settings)) + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", archive) + val send = Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_EMAIL, arrayOf(ADDRESS)) + putExtra(Intent.EXTRA_SUBJECT, "Message487 ${BuildConfig.VERSION_NAME} — diagnostics") + putExtra(Intent.EXTRA_TEXT, context.getString(R.string.feedback_body)) + putExtra(Intent.EXTRA_STREAM, uri) + clipData = ClipData.newUri(context.contentResolver, context.getString(R.string.diagnostics), uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + @Suppress("DEPRECATION") + val targets = context.packageManager.queryIntentActivities(Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:$ADDRESS")), 0) + .map { it.activityInfo.packageName }.distinct() + .mapNotNull { pkg -> Intent(send).setPackage(pkg).takeIf { it.resolveActivity(context.packageManager) != null } } + return Intent.createChooser(targets.firstOrNull() ?: send, context.getString(R.string.send_diagnostics)).apply { + if (targets.size > 1) putExtra(Intent.EXTRA_INITIAL_INTENTS, targets.drop(1).toTypedArray()) + } + } + + internal fun createArchive(cacheDir: File, log: DiagnosticLog, environment: String): File { + val directory = File(cacheDir, "feedback") + if (!directory.isDirectory && !directory.mkdirs()) throw IOException("Could not create report directory") + directory.listFiles().orEmpty().filter { it.isFile && it.extension == "zip" } + .sortedByDescending(File::lastModified).drop(2).forEach { + if (!it.delete()) throw IOException("Could not remove old diagnostic archive") + } + // A previously granted URI must never reveal a report generated later. + val archive = File(directory, "message487-diagnostics-${UUID.randomUUID()}.zip") + try { + log.record(DiagnosticEvent.REPORT_PREPARED) + ZipOutputStream(archive.outputStream().buffered()).use { zip -> + zip.putNextEntry(ZipEntry("diagnostic.log")) + log.copyLogsTo(zip) + zip.closeEntry() + zip.putNextEntry(ZipEntry("crash-latest.log")) + log.copyCrashTo(zip) + zip.closeEntry() + zip.putNextEntry(ZipEntry("environment.txt")) + zip.write(environment.toByteArray(Charsets.UTF_8)) + zip.closeEntry() + } + } catch (error: Exception) { + archive.delete() + throw error + } + return archive + } + + private fun environment(settings: ForwardingSettings) = buildString { + appendLine("App: ${BuildConfig.APPLICATION_ID} ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + appendLine("Build: ${BuildConfig.BUILD_TYPE}") + appendLine("Android: ${Build.VERSION.RELEASE}, API ${Build.VERSION.SDK_INT}, patch ${Build.VERSION.SECURITY_PATCH}") + appendLine("Device: ${Build.MANUFACTURER} ${Build.MODEL}") + appendLine("ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}") + appendLine("Configured: ${settings.ready()}; ACK: ${settings.requireAck}; paused: ${settings.paused}") + appendLine("Notifications: ${settings.notifications}; SMS: ${settings.sms}; selected apps: ${settings.packages.size}") + appendLine("Exception messages, message content, source packages, recipients, URLs and installation IDs are excluded.") + } +} diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index fdba5a7..4cf59a1 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -102,4 +102,19 @@ Нет событий, ожидающих подтверждения. Отправьте тест или включите источник — здесь появятся результаты доставки. Требует внимания + Диагностика + Сообщить о проблеме + Подготовьте письмо с ZIP-архивом: локальные логи, последний креш, версии приложения и Android, модель устройства. Тексты сообщений, отправители, URL веб-хука и идентификаторы устройства исключены. Проверьте вложение перед отправкой в почтовом приложении. + Подготовить письмо + Опишите проблему, время её возникновения и шаги для повторения. Проверьте диагностическое вложение перед отправкой. + Локальный лог + Автоматическая ротация: 3 × 256 КиБ и последний креш до 256 КиБ. Здесь показаны последние 48 КиБ и последний креш. В кеше приложения хранится до 3 архивов для писем. + Обновить + Очистить логи + Диагностических записей пока нет. + Не удалось прочитать, сохранить или передать диагностику. Проверьте свободное место и наличие почтового приложения. + Удалить локальные логи, последний креш и архивы писем из кеша? Уже отправленные копии останутся у получателей. + Приложение аварийно завершилось + Локальный отчёт о креше поможет разобраться в проблеме. Можно просмотреть его и подготовить письмо разработчику. Ничего не отправляется автоматически. + Посмотреть отчёт diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e97f215..fc94e8e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -102,4 +102,19 @@ There are no unconfirmed events. Send a test event or enable a source to see delivery activity here. Needs attention + Diagnostics + Report a problem + Prepare an email with a ZIP containing local logs, the last crash and app / Android / device versions. Message contents, senders, webhook URL and device IDs are excluded. Review the attachment before sending in your email app. + Prepare email + Please describe the problem, when it happened and how to reproduce it. Review the diagnostic attachment before sending. + Local log + Rotates automatically: 3 × 256 KiB, plus the last crash up to 256 KiB. This preview shows the latest 48 KiB and the last crash. Up to 3 email archives are kept in the app cache. + Refresh + Clear logs + No diagnostic entries yet. + Could not read, save or share diagnostics. Check available storage and that an email app is installed. + Delete local logs, the last crash and cached email archives? Copies already shared remain with their recipients. + 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 diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..0df194a --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/test/java/life/andre/message487/ResourceContractTest.kt b/app/src/test/java/life/andre/message487/ResourceContractTest.kt new file mode 100644 index 0000000..511ebdd --- /dev/null +++ b/app/src/test/java/life/andre/message487/ResourceContractTest.kt @@ -0,0 +1,55 @@ +package life.andre.message487 + +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import org.junit.Assert.* +import org.junit.Test +import org.w3c.dom.Element + +class ResourceContractTest { + private fun document(path: String) = DocumentBuilderFactory.newInstance().apply { + isNamespaceAware = true + }.newDocumentBuilder().parse(File("src/main/$path")) + private fun strings(locale: String): Map { + val nodes = document("res/$locale/strings.xml").getElementsByTagName("string") + return (0 until nodes.length).associate { + val element = nodes.item(it) as Element + element.getAttribute("name") to element.textContent + } + } + + @Test fun `English and Russian resources have matching keys and format arguments`() { + val english = strings("values") + val russian = strings("values-ru") + assertEquals(english.keys, russian.keys) + val placeholder = Regex("%([0-9]+\\$)?[a-zA-Z]") + english.forEach { (name, value) -> + assertTrue("Empty translation: $name", russian.getValue(name).isNotBlank()) + assertEquals(name, placeholder.findAll(value).map { it.value }.sorted().toList(), + placeholder.findAll(russian.getValue(name)).map { it.value }.sorted().toList()) + } + } + + @Test fun `exported components are permission protected and report provider is private`() { + val manifest = document("AndroidManifest.xml") + val namespace = "http://schemas.android.com/apk/res/android" + fun elements(tag: String): List = manifest.getElementsByTagName(tag).let { nodes -> + (0 until nodes.length).map { nodes.item(it) as Element } + } + assertEquals(listOf(".MainActivity"), elements("activity") + .filter { it.getAttributeNS(namespace, "exported") == "true" }.map { it.getAttributeNS(namespace, "name") }) + assertEquals("android.permission.BIND_NOTIFICATION_LISTENER_SERVICE", + elements("service").single().getAttributeNS(namespace, "permission")) + assertEquals("android.permission.BROADCAST_SMS", elements("receiver").single().getAttributeNS(namespace, "permission")) + val provider = elements("provider").single() + assertEquals("false", provider.getAttributeNS(namespace, "exported")) + assertEquals("true", provider.getAttributeNS(namespace, "grantUriPermissions")) + assertEquals("false", elements("application").single().getAttributeNS(namespace, "allowBackup")) + assertFalse(elements("uses-permission").any { it.getAttributeNS(namespace, "name") == "android.permission.QUERY_ALL_PACKAGES" }) + val paths = document("res/xml/file_paths.xml").documentElement.childNodes + val roots = (0 until paths.length).mapNotNull { paths.item(it) as? Element } + assertEquals(1, roots.size) + assertEquals("cache-path", roots.single().tagName) + assertEquals("feedback/", roots.single().getAttribute("path")) + } +} diff --git a/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt new file mode 100644 index 0000000..8b9d13e --- /dev/null +++ b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt @@ -0,0 +1,130 @@ +package life.andre.message487 + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import life.andre.message487.diagnostics.DiagnosticEvent +import org.junit.Before +import org.junit.After +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +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 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)) { + val info = android.content.pm.ResolveInfo().apply { + activityInfo = android.content.pm.ActivityInfo().apply { packageName = pkg; name = "$pkg.MainActivity" } + } + org.robolectric.Shadows.shadowOf(packageManager).addResolveInfoForIntent(intent, info) + } + } +} + +@RunWith(RobolectricTestRunner::class) +@Config(application = UiTestApplication::class, sdk = [35], qualifiers = "en-w411dp-h891dp") +@LooperMode(LooperMode.Mode.PAUSED) +class ScreenInteractionTest { + @get:Rule val compose = createAndroidComposeRule() + private val application get() = compose.activity.applicationContext as UiTestApplication + private fun node(id: Int) = compose.onNodeWithText(compose.activity.getString(id)) + private fun icon(id: Int) = compose.onNodeWithContentDescription(compose.activity.getString(id)) + + private lateinit var model: ConnectionViewModel + + @Before fun showScreen() { + compose.runOnIdle { + model = androidx.lifecycle.ViewModelProvider(compose.activity, object : androidx.lifecycle.ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return ConnectionViewModel(application) as T + } + })[ConnectionViewModel::class.java] + } + compose.setContent { MessageTheme { MessageScreen(model) } } + } + + @After fun close() { + application.graph.captureExecutor.shutdownNow() + application.diagnostics.close() + } + + @Test fun `tabs navigate and diagnostics back returns to previous screen`() { + for (destination in listOf(R.string.sources_tab, R.string.journal, R.string.connection_nav)) { + compose.onNode(hasText(compose.activity.getString(destination)) and hasClickAction()).performClick() + icon(R.string.back).performClick() + node(R.string.app_name).assertIsDisplayed() + } + node(R.string.connection_nav).performClick() + icon(R.string.diagnostics).performClick() + node(R.string.diagnostic_report).assertIsDisplayed() + icon(R.string.back).performClick() + node(R.string.connection_heading).assertIsDisplayed() + } + + @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") + node(R.string.save).performScrollTo().performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) { it() } + 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") + node(R.string.device_code).performScrollTo().performTextReplacement("test-phone") + node(R.string.save).performScrollTo().performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) { it() } + compose.waitForIdle() + assertSame(application.graph.settings.state, model.settings) + assertEquals("test-phone", model.state.value.deviceCode) + assertEquals("https://example.test/webhook", model.state.value.url) + compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } + assertNull("Save failed", model.state.value.notice?.takeIf { it == R.string.local_error }) + assertEquals("State: ${model.state.value}", "test-phone", application.graph.settings.state.value.deviceCode) + compose.waitForIdle() + val restored = SettingsStore(application).state.value + assertEquals("https://example.test/webhook", restored.url) + assertEquals("test-phone", restored.deviceCode) + assertTrue(restored.deviceId.isNotBlank()) + } + + @Test fun `diagnostic clear requires confirmation and removes crash and archives`() { + application.diagnostics.writeCrash(Thread.currentThread(), IllegalStateException("private fixture")) + val archive = java.io.File(application.cacheDir, "feedback/test.zip") + archive.parentFile!!.mkdirs() + archive.writeText("fixture") + icon(R.string.diagnostics).performClick() + compose.waitUntil(10_000) { node(R.string.clear_log).isEnabled() } + node(R.string.clear_log).performScrollTo().performClick() + node(R.string.cancel).performClick() + assertTrue(archive.exists()) + node(R.string.clear_log).performClick() + compose.onAllNodesWithText(compose.activity.getString(R.string.clear_log)).onLast().performClick() + compose.waitUntil(10_000) { !archive.exists() } + compose.waitUntil(10_000) { node(R.string.clear_log).isEnabled() } + assertFalse(java.io.File(application.filesDir, "logs/crash-latest.log").exists()) + assertTrue(application.diagnostics.readTail().contains(DiagnosticEvent.LOG_CLEARED.name)) + } + + @Test fun `select all preserves manual packages excludes self and clear removes selection`() { + compose.waitForIdle() + assertSame(application.graph.settings.state, model.settings) + compose.waitUntil(10_000) { model.apps.value.isNotEmpty() } + assertEquals(2, model.apps.value.size) + compose.runOnIdle { model.selectPackage("manual.hidden", true) } + compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } + assertEquals(setOf("manual.hidden"), SettingsStore(application).state.value.packages) + compose.onNode(hasText(compose.activity.getString(R.string.sources_tab)) and hasClickAction()).performClick() + node(R.string.select_all_apps).performScrollTo().performClick() + compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } + assertEquals(setOf("manual.hidden", "example.alpha", "example.beta"), SettingsStore(application).state.value.packages) + node(R.string.clear_app_selection).performScrollTo().performClick() + compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } + assertEquals(emptySet(), SettingsStore(application).state.value.packages) + } + + private fun SemanticsNodeInteraction.isEnabled(): Boolean = + !fetchSemanticsNode().config.contains(androidx.compose.ui.semantics.SemanticsProperties.Disabled) +} diff --git a/app/src/test/java/life/andre/message487/ThemeContrastTest.kt b/app/src/test/java/life/andre/message487/ThemeContrastTest.kt new file mode 100644 index 0000000..8b2b97c --- /dev/null +++ b/app/src/test/java/life/andre/message487/ThemeContrastTest.kt @@ -0,0 +1,43 @@ +package life.andre.message487 + +import android.app.Application +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.core.graphics.ColorUtils +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [35]) +class ThemeContrastTest { + @get:Rule val compose = createComposeRule() + + @Test @Config(qualifiers = "notnight") + fun `light theme has legible body and action text`() = checkTheme(dark = false) + + @Test @Config(qualifiers = "night") + fun `dark theme follows system and has legible body and action text`() = checkTheme(dark = true) + + private fun checkTheme(dark: Boolean) { + lateinit var colors: ColorScheme + compose.setContent { MessageTheme { colors = MaterialTheme.colorScheme } } + compose.runOnIdle { + val surfaceLuminance = ColorUtils.calculateLuminance(colors.surface.toArgb()) + assertTrue("Wrong system theme", if (dark) surfaceLuminance < 0.1 else surfaceLuminance > 0.8) + for ((foreground, background) in listOf( + colors.onSurface to colors.surface, + colors.onSurfaceVariant to colors.surfaceContainerLowest, + colors.onPrimary to colors.primary, + colors.error to colors.surface, + )) { + assertTrue("Text contrast below WCAG AA", ColorUtils.calculateContrast(foreground.toArgb(), background.toArgb()) >= 4.5) + } + } + } +} diff --git a/app/src/test/java/life/andre/message487/diagnostics/DiagnosticLogTest.kt b/app/src/test/java/life/andre/message487/diagnostics/DiagnosticLogTest.kt new file mode 100644 index 0000000..6e8fa38 --- /dev/null +++ b/app/src/test/java/life/andre/message487/diagnostics/DiagnosticLogTest.kt @@ -0,0 +1,71 @@ +package life.andre.message487.diagnostics + +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.ByteArrayOutputStream + +class DiagnosticLogTest { + @get:Rule val temporary = TemporaryFolder() + + @Test fun `rotation stays bounded and persists across restart`() { + val directory = temporary.newFolder() + DiagnosticLog(directory, 1024).use { log -> + repeat(150) { log.record(DiagnosticEvent.RECOVERY, count = it); log.flush() } + } + assertEquals(3, directory.listFiles()!!.size) + assertTrue(directory.listFiles()!!.all { it.length() <= 1024 }) + DiagnosticLog(directory, 1024).use { log -> + val text = log.readTail() + assertTrue(text.contains("count=149")) + assertFalse(text.contains("count=0\n")) + log.record(DiagnosticEvent.APP_STARTED) + assertTrue(log.readTail().contains("APP_STARTED")) + } + } + + @Test fun `exception messages causes suppressed and untrusted fields never enter logs`() { + val secret = "https://secret.example/path?token=private SMS 123456" + val error = IllegalStateException(secret, IOExceptionForTest(secret)) + error.addSuppressed(RuntimeException(secret)) + error.stackTrace = arrayOf(StackTraceElement("life.andre.message487.Worker", "send", "Worker.kt", 42), + StackTraceElement(secret, secret, secret, 1)) + DiagnosticLog(temporary.newFolder()).use { log -> + log.record(DiagnosticEvent.CAPTURE_FAILED, type = secret, outcome = secret, error = error) + log.writeCrash(Thread(secret), error) + val content = log.readTail() + ByteArrayOutputStream().also(log::copyCrashTo).toString("UTF-8") + assertFalse(content.contains(secret)) + assertFalse(content.contains("123456")) + assertTrue(content.contains("Worker.send(Worker.kt:42)")) + assertTrue(content.contains("UNCAUGHT_EXCEPTION")) + assertTrue(content.contains("suppressed=java.lang.RuntimeException")) + } + } + + @Test fun `crash is synchronous and survives rolling log rotation until cleared`() { + val directory = temporary.newFolder() + DiagnosticLog(directory, 1024).use { log -> + assertTrue(log.writeCrash(Thread.currentThread(), IllegalArgumentException("private"))) + assertTrue(File(directory, "crash-latest.log").readText().contains("UNCAUGHT_EXCEPTION")) + repeat(100) { log.record(DiagnosticEvent.RECOVERY, count = it); log.flush() } + assertFalse(log.readTail().contains("UNCAUGHT_EXCEPTION")) + assertTrue(File(directory, "crash-latest.log").exists()) + log.clear() + assertFalse(File(directory, "crash-latest.log").exists()) + assertTrue(log.readTail().contains("LOG_CLEARED")) + } + } + + @Test fun `storage failure does not escape normal logging or crash writing`() { + DiagnosticLog(temporary.newFile()).use { log -> + log.record(DiagnosticEvent.APP_STARTED) + log.flush() + assertTrue(log.ioFailed) + assertFalse(log.writeCrash(Thread.currentThread(), IllegalStateException())) + } + } + + private class IOExceptionForTest(message: String) : java.io.IOException(message) +} diff --git a/app/src/test/java/life/andre/message487/diagnostics/FeedbackEmailTest.kt b/app/src/test/java/life/andre/message487/diagnostics/FeedbackEmailTest.kt new file mode 100644 index 0000000..7cde688 --- /dev/null +++ b/app/src/test/java/life/andre/message487/diagnostics/FeedbackEmailTest.kt @@ -0,0 +1,88 @@ +package life.andre.message487.diagnostics + +import android.app.Application +import android.content.Intent +import android.net.Uri +import life.andre.message487.ForwardingSettings +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File +import java.util.zip.ZipFile + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [35]) +class FeedbackEmailTest { + @get:Rule val temporary = TemporaryFolder() + + @Test fun `archives contain logs crash and environment and retain only three distinct files`() { + val cache = temporary.newFolder() + DiagnosticLog(temporary.newFolder()).use { log -> + log.record(DiagnosticEvent.EVENT_QUEUED, type = "sms") + log.writeCrash(Thread.currentThread(), IllegalStateException("private body")) + val names = (1..5).map { + val report = FeedbackEmail.createArchive(cache, log, "App: test") + ZipFile(report).use { zip -> + assertEquals(3, zip.size()) + assertTrue(zip.getInputStream(zip.getEntry("diagnostic.log")).reader().readText().contains("EVENT_QUEUED")) + val crash = zip.getInputStream(zip.getEntry("crash-latest.log")).reader().readText() + assertTrue(crash.contains("UNCAUGHT_EXCEPTION")) + assertFalse(crash.contains("private body")) + assertEquals("App: test", zip.getInputStream(zip.getEntry("environment.txt")).reader().readText()) + } + report.name + } + assertEquals(5, names.toSet().size) + assertEquals(3, File(cache, "feedback").listFiles()!!.size) + } + } + + @Test fun `email uses correct recipient read-only content attachment and excludes settings secrets`() { + val context = RuntimeEnvironment.getApplication() + 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"))) + @Suppress("DEPRECATION") + val send = chooser.getParcelableExtra(Intent.EXTRA_INTENT)!! + assertArrayEquals(arrayOf("der-morgenstern@yandex.ru"), send.getStringArrayExtra(Intent.EXTRA_EMAIL)) + assertEquals(Intent.ACTION_SEND, send.action) + assertEquals(Intent.FLAG_GRANT_READ_URI_PERMISSION, send.flags and + (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)) + @Suppress("DEPRECATION") + val uri = send.getParcelableExtra(Intent.EXTRA_STREAM)!! + assertEquals("content", uri.scheme) + assertEquals(uri, send.clipData!!.getItemAt(0).uri) + val report = File(context.cacheDir, "feedback").listFiles()!!.single() + ZipFile(report).use { zip -> + val environment = zip.getInputStream(zip.getEntry("environment.txt")).reader().readText() + assertFalse(environment.contains("secret")) + } + File(context.cacheDir, "feedback").deleteRecursively() + } + } + + @Test fun `crash handler persists marker and delegates to previous handler`() { + val context = RuntimeEnvironment.getApplication() + val previous = Thread.getDefaultUncaughtExceptionHandler() + val error = IllegalStateException("private") + var delegated = false + DiagnosticLog(temporary.newFolder()).use { log -> + try { + Thread.setDefaultUncaughtExceptionHandler { _, received -> delegated = received === error } + val handler = CrashHandler(context, log) + handler.install() + Thread.getDefaultUncaughtExceptionHandler()!!.uncaughtException(Thread.currentThread(), error) + assertTrue(delegated) + assertTrue(CrashHandler(context, log).pending) + handler.dismiss() + assertFalse(handler.pending) + } finally { Thread.setDefaultUncaughtExceptionHandler(previous) } + } + } +} diff --git a/docs/diagnostics.md b/docs/diagnostics.md new file mode 100644 index 0000000..7c4ef66 --- /dev/null +++ b/docs/diagnostics.md @@ -0,0 +1,29 @@ +# Diagnostics + +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. +Clear logs removes diagnostic segments, the last crash and cached reports; forwarding data is separate. + +Private `files/logs` holds three 256 KiB segments and `crash-latest.log` (up to 256 KiB). The writer +uses a single background thread, a 256-entry bounded queue and a lock shared with rotation/export. +Overload drops new entries and records a dropped count on the next successful write. I/O failures +are contained and reported in the diagnostic screen. The preview reads the last 48 KiB plus the +last crash. Private `cache/feedback` keeps at most three uniquely named ZIPs. + +The default uncaught-exception handler writes and fsyncs the crash synchronously, persists a prompt +marker and delegates to the previous Android handler. The separate crash file survives normal log +rotation. This covers unhandled Java/Kotlin exceptions, not ANRs, native crashes, force stops or +process termination without an exception. Storage exhaustion or severe process failure can prevent +saving a report. Exception messages and thread names are excluded; causes, suppressed exceptions +and frames are bounded. App symbol names and source lines are preserved for release diagnostics. + +Logs use a fixed event vocabulary and typed/allowlisted metadata. Never add message bodies, sender +addresses, package names, URLs, event/device IDs, device codes or arbitrary exception messages. +The archive adds app/Android/device versions, architecture and nonsensitive configuration flags. + +Run `bundle exec fastlane android checks`. Diagnostic tests cover rotation/restart, crash retention, +redaction, storage failure, handler delegation, archive retention and attachment permissions. +For a manual debug-emulator check, start the app, then run +`adb shell am crash life.andre.message487` and reopen it. Review the next-launch prompt and ZIP in +the diagnostics screen. Stop at the email composer unless intentionally sending a real report. diff --git a/docs/project-context.md b/docs/project-context.md index 32fb327..74d0fe5 100644 --- a/docs/project-context.md +++ b/docs/project-context.md @@ -110,3 +110,7 @@ WorkManager не гарантирует немедленную доставку. VPN, Go/JNI, DNS-диагностика и детали публикации MegaProxy не являются требованиями Message487. Политику конфиденциальности нельзя копировать дословно: 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. + +CI test categories and commands are documented in [testing](testing.md); Compose UI tests run on Robolectric without an emulator. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..9ba0826 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,35 @@ +# Tests and CI + +Run the same suites locally and in GitHub Actions: + +| Suite | Command | Coverage | +| --- | --- | --- | +| Android | `bundle exec fastlane android checks` | JVM logic, MockWebServer HTTP integration, Robolectric database/preferences/provider integration, Compose interactions, localization, manifest security, light/dark contrast, debug/release lint and APK builds, unsigned release verification | +| Python | `PYTHON=.venv/bin/python bundle exec fastlane android python_checks` | DevServer HTTP test-client behavior against a local HTTP server; pinned Black/isort style checks | +| n8n | `bundle exec fastlane android server_tests` | Live receive/validation, notification/SMS/test payloads, HTTP failure, invalid ACK and timeout | + +For Python, create `.venv` with `python3 -m venv .venv` and install `requirements-dev.txt`. +For n8n, first run `docker compose -f DevServer/compose.yaml up -d --wait --wait-timeout 300`. +The server suite uses synthetic data and retains it in the development execution history. + +`.github/workflows/ci.yml` runs all three jobs on pull requests, main pushes and manual dispatch. +Android XML/HTML test and lint reports are uploaded even if a check fails. Successful Android jobs +also publish debug and unsigned release APKs. These checks never sign release artifacts. +Local success does not establish a GitHub run result for uncommitted/unpushed changes. + +## Comparison with MegaProxy + +The applicable categories match MegaProxy: JVM logic and Android integration, Compose interactions +under Robolectric, resource/security/UI contracts, Python tests and formatting, lint and builds. +Message487 also has a live Docker/n8n integration suite. MegaProxy's Go race tests and optional +native-parser fuzz lane apply to its Go/JNI networking core; Message487 has no native core. +MegaProxy's Python CI-history tests target scripts that Message487 does not have. + +Compose tests use the real navigation, screens, ViewModel and preferences with a test Application that +suppresses startup workers and process-wide crash-handler installation. An explicit ViewModel +factory binds each test to its own Application; background completions are drained before assertions. Tests cover navigation/back, +connection validation and persistence, bulk application selection and diagnostic deletion. They do +not prove notification/SMS permission delivery, WorkManager/OS scheduling, real Android Keystore, +process death handling or email-client behavior. Those remain device/emulator checks; see +[diagnostics](diagnostics.md) for the crash/report scenario. Required hosted CI does not depend on +an Android emulator, following MegaProxy's approach to unreliable KVM availability. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 388d7ee..627fe88 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -12,6 +12,33 @@ platform :android do flags: "--no-daemon", project_dir: project_root ) + unsigned_apk = File.join(project_root, "app/build/outputs/apk/release/app-release-unsigned.apk") + UI.user_error!("Expected unsigned release APK was not produced") unless File.file?(unsigned_apk) + local_properties = File.join(project_root, "local.properties") + sdk = ENV["ANDROID_HOME"] || ENV["ANDROID_SDK_ROOT"] + sdk ||= File.readlines(local_properties).find { |line| line.start_with?("sdk.dir=") }&.split("=", 2)&.last&.strip if File.file?(local_properties) + UI.user_error!("Android SDK location is required to verify the unsigned APK") unless sdk + apksigner = File.join(sdk, "build-tools", "36.0.0", "apksigner") + UI.user_error!("Android apksigner is unavailable") unless File.executable?(apksigner) + if system(apksigner, "verify", unsigned_apk, out: File::NULL, err: File::NULL) + UI.user_error!("CI release APK is unexpectedly signed") + end + UI.success("Verified unsigned release APK") + end + + desc "Run Python tests and formatting checks for the development server" + lane :python_checks do + python = ENV.fetch("PYTHON", "python3") + Dir.chdir(project_root) do + sh(python, "-m", "isort", "--check-only", "DevServer/tests") + sh(python, "-m", "black", "--check", "DevServer/tests") + sh(python, "-m", "unittest", "discover", "-s", "DevServer/tests", "-v") + end + end + + desc "Exercise the running development n8n server" + lane :server_tests do + sh(ENV.fetch("PYTHON", "python3"), File.join(project_root, "DevServer/tests/smoke.py")) end desc "Build a debug APK" diff --git a/fastlane/README.md b/fastlane/README.md index fd3974d..c7fdfc5 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -23,6 +23,22 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do Run JVM tests, Android lint, and build debug and unsigned release APKs +### android python_checks + +```sh +[bundle exec] fastlane android python_checks +``` + +Run Python tests and formatting checks for the development server + +### android server_tests + +```sh +[bundle exec] fastlane android server_tests +``` + +Exercise the running development n8n server + ### android debug_artifact ```sh diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1711619 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[tool.black] +skip-string-normalization = true + +[tool.isort] +profile = "black" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..80e7482 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +black==26.5.1 +isort==9.0.1 From 3660c6e10bca2f7ef973be8d9caaf1f1d59985cf Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Wed, 9 Sep 2026 00:08:15 +0300 Subject: [PATCH 5/6] Locate Android SDK manager explicitly in CI --- .github/workflows/ci.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de411d7..1cf21ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,13 @@ jobs: ruby-version: "3.4.10" bundler-cache: true - name: Install Android SDK components - run: sdkmanager "platforms;android-36" "build-tools;36.0.0" + run: | + sdkmanager="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + if [[ ! -x "$sdkmanager" ]]; then + echo "Android SDK manager is unavailable: $sdkmanager" >&2 + exit 1 + fi + "$sdkmanager" "platforms;android-36" "build-tools;36.0.0" - run: bundle exec fastlane android checks - name: Publish test and lint reports if: always() From 7ef18df883b9af7460b7f935ccfdfa41eb5afa06 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Wed, 9 Sep 2026 00:22:29 +0300 Subject: [PATCH 6/6] Add environment-based APK signing and release workflow --- .github/workflows/release.yml | 102 ++++++++++++++++++++++++++++++++++ .gitignore | 1 + README.md | 4 +- app/build.gradle.kts | 22 +++++++- docs/project-context.md | 2 + docs/releases.md | 43 ++++++++++++++ docs/testing.md | 3 + fastlane/Fastfile | 9 +++ fastlane/README.md | 8 +++ scripts/build-release-apk.sh | 70 +++++++++++++++++++++++ scripts/java-toolchain.sh | 28 ++++++++++ 11 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/releases.md create mode 100755 scripts/build-release-apk.sh create mode 100644 scripts/java-toolchain.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cb6de93 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,102 @@ +name: Release Android artifacts + +on: + push: + tags: + - "v*" + - "release-check/*" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Build and verify signed APK + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + - name: Validate publication tag + if: startsWith(github.ref, 'refs/tags/v') + run: | + version_name="$(sed -nE 's/^[[:space:]]*versionName = "([^"]+)"/\1/p' app/build.gradle.kts)" + test "$GITHUB_REF_NAME" = "v$version_name" + git merge-base --is-ancestor HEAD origin/main + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + - uses: gradle/actions/setup-gradle@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.10" + bundler-cache: true + - name: Install Android SDK components + run: '"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-36" "build-tools;36.0.0"' + - name: Restore Android signing key + env: + SIGNING_KEY_BASE64: ${{ secrets.ANDROID_SIGNING_KEY_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + run: | + test -n "$SIGNING_KEY_BASE64" && test -n "$KEYSTORE_PASSWORD" + umask 077 + keystore="$RUNNER_TEMP/message487-release.p12" + password_file="$RUNNER_TEMP/message487-keystore-password" + printf '%s' "$SIGNING_KEY_BASE64" | base64 --decode > "$keystore" + printf '%s' "$KEYSTORE_PASSWORD" > "$password_file" + echo "MESSAGE487_KEYSTORE_PATH=$keystore" >> "$GITHUB_ENV" + echo "MESSAGE487_KEY_PASSWORD_FILE=$password_file" >> "$GITHUB_ENV" + - name: Build and verify signed release + env: + MESSAGE487_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + MESSAGE487_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: bundle exec fastlane android release_artifacts + - name: Remove signing files + if: always() + run: rm -f "$RUNNER_TEMP/message487-release.p12" "$RUNNER_TEMP/message487-keystore-password" + - uses: actions/upload-artifact@v7 + with: + name: message487-signed-release + path: dist/release/* + if-no-files-found: error + retention-days: 14 + - name: Publish test and lint reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: release-test-reports + path: | + app/build/reports/tests/ + app/build/test-results/ + app/build/reports/lint-results-* + retention-days: 14 + + publish: + name: Publish GitHub Release + needs: release + if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v8 + with: + name: message487-signed-release + path: dist/release + - name: Publish verified artifacts + env: + GH_TOKEN: ${{ github.token }} + run: | + cd dist/release + sha256sum -c SHA256SUMS + gh release create "$GITHUB_REF_NAME" ./*.apk mapping.txt SHA256SUMS \ + --repo "$GITHUB_REPOSITORY" --verify-tag --generate-notes \ + --title "Message487 $GITHUB_REF_NAME" diff --git a/.gitignore b/.gitignore index 2f22884..e49cc3d 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ DevServer/.env .venv/ __pycache__/ +dist/ diff --git a/README.md b/README.md index 4109938..9932c2c 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ 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 and -production release signing are not implemented yet. +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). ## Getting started diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 08ff7f2..b955288 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,15 @@ plugins { id("org.jetbrains.kotlin.plugin.compose") } +val releaseKeystorePath = providers.environmentVariable("MESSAGE487_KEYSTORE_PATH").orNull +val releaseKeystorePassword = providers.environmentVariable("MESSAGE487_KEYSTORE_PASSWORD").orNull +val releaseKeyAlias = providers.environmentVariable("MESSAGE487_KEY_ALIAS").orNull +val releaseKeyPassword = providers.environmentVariable("MESSAGE487_KEY_PASSWORD").orNull +val signingInputs = listOf(releaseKeystorePath, releaseKeystorePassword, releaseKeyAlias, releaseKeyPassword) +if (signingInputs.any { it != null } && signingInputs.any { it.isNullOrBlank() }) { + throw GradleException("Release signing environment is incomplete") +} + android { namespace = "life.andre.message487" compileSdk = 36 @@ -13,13 +22,24 @@ android { minSdk = 26 targetSdk = 36 versionCode = 1 - versionName = "0.1.0-dev" + versionName = "0.0.1" + } + signingConfigs { + if (signingInputs.all { !it.isNullOrBlank() }) { + create("release") { + storeFile = file(releaseKeystorePath!!) + storePassword = releaseKeystorePassword + keyAlias = releaseKeyAlias + keyPassword = releaseKeyPassword + } + } } buildTypes { getByName("debug") { buildConfigField("String", "DEFAULT_WEBHOOK_URL", "\"http://10.0.2.2:5678/webhook/message487/receive\"") } getByName("release") { + signingConfig = signingConfigs.findByName("release") buildConfigField("String", "DEFAULT_WEBHOOK_URL", "\"\"") isMinifyEnabled = true isShrinkResources = true diff --git a/docs/project-context.md b/docs/project-context.md index 74d0fe5..8a99a00 100644 --- a/docs/project-context.md +++ b/docs/project-context.md @@ -114,3 +114,5 @@ VPN, Go/JNI, DNS-диагностика и детали публикации Meg Local rotating diagnostics, a next-launch crash prompt and manual ZIP email reports are implemented; see [diagnostics](diagnostics.md). Recipient: der-morgenstern@yandex.ru. CI test categories and commands are documented in [testing](testing.md); Compose UI tests run on Robolectric without an emulator. + +Signed APK releases use environment-based signing as in MegaProxy; see [releases](releases.md). diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..f835dee --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,43 @@ +# Signed APK releases + +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`, +R8 `mapping.txt` and `SHA256SUMS`. Keep the mapping with its exact APK when diagnosing crashes. + +Signing follows MegaProxy's environment contract with the `MESSAGE487_` prefix: + +| Variable | Source/default | +| --- | --- | +| `MESSAGE487_KEYSTORE_PATH` | `~/AndroidApkKey` locally; restored temporary file in CI | +| `MESSAGE487_KEY_PASSWORD_FILE` | `~/.my-tokens/android-key-password` locally; temporary file in CI | +| `MESSAGE487_KEYSTORE_PASSWORD` | Exported by the release script from the password file | +| `MESSAGE487_KEY_ALIAS` | `key0` locally; `ANDROID_KEY_ALIAS` secret in CI | +| `MESSAGE487_KEY_PASSWORD` | Password-file contents unless explicitly supplied | +| `MESSAGE487_EXPECTED_CERT_SHA256` | Expected public certificate fingerprint, pinned in the script | +| `MESSAGE487_RELEASE_DIR` | `dist/release` | + +Gradle reads only the four signing environment variables (path, store password, alias and key +password). Partial signing configuration fails closed. The PR `checks` lane rejects signing inputs +and verifies an unsigned release APK. Passwords are not command-line arguments and must never be +printed, committed or passed through Gradle `-P` properties. + +GitHub Secrets use the same names as MegaProxy: `ANDROID_SIGNING_KEY_BASE64`, +`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. The release workflow +restores the key/password with private permissions and deletes those temporary files even on failure. +Its build job has read-only repository permissions; only the separate publication job can write a +GitHub Release. PR workflows do not consume signing secrets. + +## Verification and publication + +- Push a `release-check/*` tag to build and verify a signed APK in GitHub Actions without publishing + a Release. The signed APK, checksums, mapping and test reports are available as Actions artifacts. +- After the workflow is merged into the default branch, manual dispatch also builds artifacts only. +- For publication, increment `versionCode`, set the intended `versionName` in `app/build.gradle.kts`, + and merge the reviewed change after all required PR checks pass. Push the matching `v` + tag. The workflow requires the tag commit to be contained in `main` and rejects a version mismatch. + It publishes the verified APK, mapping and checksums to GitHub Releases. An existing Release is + not overwritten by a rerun. + +The first configured version is `0.0.1` with `versionCode = 1`. Later releases must increase +`versionCode` to support Android upgrades. Keep using the same signing key for installed users. diff --git a/docs/testing.md b/docs/testing.md index 9ba0826..82f51e1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -33,3 +33,6 @@ not prove notification/SMS permission delivery, WorkManager/OS scheduling, real process death handling or email-client behavior. Those remain device/emulator checks; see [diagnostics](diagnostics.md) for the crash/report scenario. Required hosted CI does not depend on an Android emulator, following MegaProxy's approach to unreliable KVM availability. + +Signed release verification runs the Android test/lint suite separately with signing inputs. PR +checks reject those inputs and remain unsigned. See [release automation](releases.md). diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 627fe88..58cc2d4 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -7,6 +7,10 @@ project_root = File.expand_path("..", __dir__) platform :android do desc "Run JVM tests, Android lint, and build debug and unsigned release APKs" lane :checks do + signing_variables = %w[MESSAGE487_KEYSTORE_PATH MESSAGE487_KEYSTORE_PASSWORD MESSAGE487_KEY_ALIAS MESSAGE487_KEY_PASSWORD MESSAGE487_KEY_PASSWORD_FILE] + if signing_variables.any? { |name| !ENV.fetch(name, "").empty? } + UI.user_error!("Signing configuration must not be available to PR checks") + end gradle( tasks: %w[testDebugUnitTest lintDebug lintRelease assembleDebug assembleRelease], flags: "--no-daemon", @@ -41,6 +45,11 @@ platform :android do sh(ENV.fetch("PYTHON", "python3"), File.join(project_root, "DevServer/tests/smoke.py")) end + desc "Build, sign and verify the release APK and checksums" + lane :release_artifacts do + sh(File.join(project_root, "scripts/build-release-apk.sh")) + end + desc "Build a debug APK" lane :debug_artifact do gradle(task: "assembleDebug", project_dir: project_root) diff --git a/fastlane/README.md b/fastlane/README.md index c7fdfc5..47cd59a 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -39,6 +39,14 @@ Run Python tests and formatting checks for the development server Exercise the running development n8n server +### android release_artifacts + +```sh +[bundle exec] fastlane android release_artifacts +``` + +Build, sign and verify the release APK and checksums + ### android debug_artifact ```sh diff --git a/scripts/build-release-apk.sh b/scripts/build-release-apk.sh new file mode 100755 index 0000000..ea6d4b4 --- /dev/null +++ b/scripts/build-release-apk.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$project_dir/scripts/java-toolchain.sh" +: "${ANDROID_HOME:=${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}" +: "${MESSAGE487_KEYSTORE_PATH:=$HOME/AndroidApkKey}" +: "${MESSAGE487_KEY_PASSWORD_FILE:=$HOME/.my-tokens/android-key-password}" +: "${MESSAGE487_KEY_ALIAS:=key0}" +: "${MESSAGE487_RELEASE_DIR:=$project_dir/dist/release}" +: "${MESSAGE487_EXPECTED_CERT_SHA256:=8a014a2a558a75b5f900ee0c33cd50f24b7432734912406699fc08866747f822}" +export ANDROID_HOME + +if [[ ! -f "$MESSAGE487_KEYSTORE_PATH" || ! -f "$MESSAGE487_KEY_PASSWORD_FILE" ]]; then + echo "Release keystore or password file is missing" >&2 + exit 1 +fi +keystore_password="$(<"$MESSAGE487_KEY_PASSWORD_FILE")" +if [[ -z "$keystore_password" ]]; then + echo "Keystore password file is empty" >&2 + exit 1 +fi +: "${MESSAGE487_KEY_PASSWORD:=$keystore_password}" +keytool -list -keystore "$MESSAGE487_KEYSTORE_PATH" \ + -storepass:file "$MESSAGE487_KEY_PASSWORD_FILE" -alias "$MESSAGE487_KEY_ALIAS" >/dev/null +apksigner="$ANDROID_HOME/build-tools/36.0.0/apksigner" +aapt="$ANDROID_HOME/build-tools/36.0.0/aapt" +if [[ ! -x "$apksigner" || ! -x "$aapt" ]]; then + echo "Android build tools 36.0.0 are required" >&2 + exit 1 +fi +version_name="$(sed -nE 's/^[[:space:]]*versionName = "([^"]+)"/\1/p' "$project_dir/app/build.gradle.kts")" +version_code="$(sed -nE 's/^[[:space:]]*versionCode = ([0-9]+)/\1/p' "$project_dir/app/build.gradle.kts")" +if [[ ! "$version_name" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ || ! "$version_code" =~ ^[1-9][0-9]*$ ]]; then + echo "A stable versionName and positive versionCode are required" >&2 + exit 1 +fi + +export MESSAGE487_KEYSTORE_PATH MESSAGE487_KEY_ALIAS MESSAGE487_KEY_PASSWORD +export MESSAGE487_KEYSTORE_PASSWORD="$keystore_password" +cd "$project_dir" +# Separate invocations ensure clean finishes before generated-resource tasks start. +./gradlew clean --no-daemon +./gradlew testDebugUnitTest lintDebug lintRelease assembleRelease --no-daemon +apk="$project_dir/app/build/outputs/apk/release/app-release.apk" +actual_fingerprint="$("$apksigner" verify --verbose --print-certs "$apk" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p')" +if [[ "$actual_fingerprint" != "$MESSAGE487_EXPECTED_CERT_SHA256" ]]; then + echo "Unexpected release signing certificate" >&2 + exit 1 +fi +badging="$("$aapt" dump badging "$apk")" +if ! grep -Fq "package: name='life.andre.message487' versionCode='$version_code' versionName='$version_name'" <<< "$badging"; then + echo "Release APK package or version does not match the project" >&2 + exit 1 +fi +if grep -q '^application-debuggable' <<< "$badging"; then + echo "Release APK must not be debuggable" >&2 + exit 1 +fi +mkdir -p "$MESSAGE487_RELEASE_DIR" +# Only remove artifacts owned by this script so old APKs cannot enter a new release. +find "$MESSAGE487_RELEASE_DIR" -maxdepth 1 -type f \ + \( -name 'message487-*.apk' -o -name mapping.txt -o -name SHA256SUMS \) -delete +cp "$apk" "$MESSAGE487_RELEASE_DIR/message487-$version_name.apk" +cp "$project_dir/app/build/outputs/mapping/release/mapping.txt" "$MESSAGE487_RELEASE_DIR/mapping.txt" +( + cd "$MESSAGE487_RELEASE_DIR" + shasum -a 256 message487-*.apk mapping.txt > SHA256SUMS +) +echo "Verified signed release APK and checksums: $MESSAGE487_RELEASE_DIR" diff --git a/scripts/java-toolchain.sh b/scripts/java-toolchain.sh new file mode 100644 index 0000000..c281f4e --- /dev/null +++ b/scripts/java-toolchain.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Source this file to select JDK 21 without installation-specific paths. +message487_java_toolchain() { + local candidate="${JAVA_HOME:-}" properties version + if [[ -z "$candidate" && -x /usr/libexec/java_home ]]; then + candidate="$(/usr/libexec/java_home -v 21 2>/dev/null || true)" + fi + if [[ -n "$candidate" ]]; then + properties="$("$candidate/bin/java" -XshowSettings:properties -version 2>&1)" || { + echo "JAVA_HOME must point to a working JDK 21." >&2 + return 1 + } + else + properties="$(java -XshowSettings:properties -version 2>&1)" || { + echo "JDK 21 is required; configure JAVA_HOME or add it to PATH." >&2 + return 1 + } + candidate="$(sed -n 's/^[[:space:]]*java.home = //p' <<< "$properties")" + fi + version="$(sed -n 's/^[[:space:]]*java.specification.version = //p' <<< "$properties")" + if [[ "$version" != 21 || ! -x "$candidate/bin/javac" ]]; then + echo "JDK 21 is required; configure JAVA_HOME or add it to PATH." >&2 + return 1 + fi + export JAVA_HOME="$candidate" + export PATH="$JAVA_HOME/bin:$PATH" +} +message487_java_toolchain