diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1cf21ca --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,103 @@ +name: CI + +on: + workflow_dispatch: + 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="$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() + 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: + 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" + + 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 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - run: docker compose -f DevServer/compose.yaml up -d --wait --wait-timeout 300 + - 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 + - name: Stop server + if: always() + run: docker compose -f DevServer/compose.yaml down 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 e5cbb64..e49cc3d 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,12 @@ google-services.json # Android Profiling *.hprof +.bundle/ +.kotlin/ +.DS_Store +fastlane/report.xml +DevServer/.env + +.venv/ +__pycache__/ +dist/ 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 new file mode 100644 index 0000000..a7e23e9 --- /dev/null +++ b/DevServer/README.md @@ -0,0 +1,101 @@ +# 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 +bundle exec fastlane android server_tests +``` + +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 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. +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 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 + +```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..234ebd9 --- /dev/null +++ b/DevServer/tests/smoke.py @@ -0,0 +1,63 @@ +import json +import socket +import urllib.error +import urllib.request +import uuid + + +def post(scenario, payload, timeout=5, base_url='http://127.0.0.1:5678'): + request = urllib.request.Request( + f'{base_url}/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', + } + 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) + 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: test/notification/SMS receive, validation, HTTP error, invalid ACK, timeout' + ) + + +if __name__ == '__main__': + 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/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..de90974 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,81 @@ +# Privacy Policy + +Last updated: September 8, 2026. + +Message487 forwards selected notifications and new incoming SMS to a webhook you configure. +Both capture sources are off by default. Notifications require Android notification access and +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, 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. + +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. + +## 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, +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 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. + +## Controls and deletion + +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 edb8fda..9932c2c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,113 @@ -# AndroidMessage487 -N8N client for notification resending +# Message487 + +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 is not +implemented yet. Signed APK release automation is configured; see [Releases](docs/releases.md). + +## 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. + **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. + +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. 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`, +`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 + +Start the [development server](DevServer/README.md), which provisions n8n and published test workflows: + +```sh +docker compose -f DevServer/compose.yaml up -d --wait +bundle exec fastlane android server_tests +``` + +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/run-without-debugging.sh +``` + +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 debug app starts with the local n8n receive endpoint configured. Follow the capture checks in +[DevServer/README.md](DevServer/README.md) using synthetic data only. Release builds require HTTPS. +UI strings are supplied in English and Russian. The interface supports light/dark themes, +bottom navigation on phones and rail navigation on wider windows. See the [design notes](docs/design.md) +for the visual conventions and references. + +Fastlane's `debug_artifact` lane builds only the debug APK. `checks` runs JVM/Robolectric tests, +debug/release lint, and builds debug and unsigned release APKs under `app/build/outputs/apk/`. +PR CI has no release signing credentials and does not require an emulator. + +See the [project context](docs/project-context.md) for remaining product decisions. +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 new file mode 100644 index 0000000..b955288 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,86 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + 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 + buildToolsVersion = "36.0.0" + defaultConfig { + applicationId = "life.andre.message487" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + 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 + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + buildFeatures { + compose = true + buildConfig = true + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + 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 { + 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.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") + 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/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..ee4b6d0 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/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 new file mode 100644 index 0000000..cc6901d --- /dev/null +++ b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt @@ -0,0 +1,147 @@ +package life.andre.message487 + +import life.andre.message487.diagnostics.DiagnosticEvent +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 +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + + +data class ConnectionState( + val url: String = "", + val deviceCode: String = "", + val requireAck: Boolean = true, + val busy: Boolean = false, + val invalidUrl: Boolean = false, + val invalidDeviceCode: Boolean = false, + 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 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() + + 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 (error: Exception) { + graph.diagnostics.record(DiagnosticEvent.LOCAL_OPERATION_FAILED, error = error) + 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 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 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 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() + } + } + + 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) + 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.diagnostics.record(DiagnosticEvent.MANUAL_RETRY) + graph.scheduler.schedule(id, replace = true) + } + } + fun delete(id: String) = action { graph.outbox.delete(id); graph.diagnostics.record(DiagnosticEvent.EVENT_DELETED) } + 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 = try { + withContext(Dispatchers.IO) { block() } + notice + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (error: Exception) { + graph.diagnostics.record(DiagnosticEvent.LOCAL_OPERATION_FAILED, error = error) + R.string.local_error + } + 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..029a410 --- /dev/null +++ b/app/src/main/java/life/andre/message487/DeliveryWorker.kt @@ -0,0 +1,84 @@ +package life.andre.message487 + +import life.andre.message487.diagnostics.DiagnosticEvent +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 (error: Exception) { + graph.diagnostics.record(DiagnosticEvent.PAYLOAD_UNREADABLE, error = error) + graph.outbox.blockUnreadable(id) + return@withContext Result.success() + } ?: return@withContext Result.success() + graph.diagnostics.record(DiagnosticEvent.DELIVERY_STARTED) + val request = attempt.request + val result = if (validWebhookUrl(request.url, BuildConfig.DEBUG)) { + WebhookClient().sendJson(request.url, id, request.json, request.requireAck) + } else DeliveryResult(id, DeliveryStatus.HTTP_ERROR) + graph.diagnostics.record(DiagnosticEvent.DELIVERY_FINISHED, outcome = result.status.name, http = result.httpCode) + val state = graph.outbox.finish(id, attempt.token, result) + if (state == QueueState.RETRY) Result.retry() else Result.success() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + graph.diagnostics.record(DiagnosticEvent.DELIVERY_FAILED, error = error) + 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 (error: Exception) { + MessageGraph.get(applicationContext).diagnostics.record(DiagnosticEvent.RECOVERY_FAILED, error = error) + Result.retry() + } + } +} diff --git a/app/src/main/java/life/andre/message487/DiagnosticsScreen.kt b/app/src/main/java/life/andre/message487/DiagnosticsScreen.kt new file mode 100644 index 0000000..3e5590d --- /dev/null +++ b/app/src/main/java/life/andre/message487/DiagnosticsScreen.kt @@ -0,0 +1,105 @@ +package life.andre.message487 + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import life.andre.message487.diagnostics.FeedbackEmail +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import androidx.compose.ui.platform.LocalContext + +@Composable +internal fun DiagnosticsScreen(application: MessageApplication, settings: ForwardingSettings) { + val context = LocalContext.current + val log = application.diagnostics + val scope = rememberCoroutineScope() + var text by remember { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + var failed by remember { mutableStateOf(false) } + var confirmClear by remember { mutableStateOf(false) } + suspend fun refresh() { + text = withContext(Dispatchers.IO) { + val crash = ByteArrayOutputStream().also(log::copyCrashTo).toString("UTF-8") + log.readTail() + if (crash.isEmpty()) "" else "\n--- Last crash ---\n$crash" + } + failed = log.ioFailed + } + fun runAction(action: suspend () -> 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 new file mode 100644 index 0000000..a083629 --- /dev/null +++ b/app/src/main/java/life/andre/message487/ForwardingSettings.kt @@ -0,0 +1,59 @@ +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 diagnostics = (context.applicationContext as? MessageApplication)?.diagnostics + private val preferences = context.getSharedPreferences("connection", Context.MODE_PRIVATE) + private val mutableState = MutableStateFlow(read()) + val state = mutableState.asStateFlow() + + private fun read() = ForwardingSettings( + 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") + diagnostics?.record(life.andre.message487.diagnostics.DiagnosticEvent.SETTINGS_SAVED) + mutableState.value = next + return next + } +} 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 new file mode 100644 index 0000000..f3fc9b8 --- /dev/null +++ b/app/src/main/java/life/andre/message487/MainActivity.kt @@ -0,0 +1,141 @@ +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 +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.* +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.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +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 + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + 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 +internal 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 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(diagnostics || destination != Destination.OVERVIEW) { back() } + 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) } + } + BoxWithConstraints { + val wide = maxWidth >= 600.dp + Row(Modifier.fillMaxSize()) { + if (wide && !diagnostics) { + 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)) }) + } + } + } + Scaffold( + modifier = Modifier.weight(1f).imePadding(), + topBar = { + TopAppBar(title = { + 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 (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 && !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)) }) + } + } + }, + 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()) { + 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 }) + Destination.SOURCES -> SourcesScreen(settings, permissions, connected, apps, state.busy, model) + Destination.JOURNAL -> JournalScreen(queue, state.busy, model) + Destination.CONNECTION -> ConnectionScreen(state, model) + } + } + } + } + } + } + } + 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() }, + 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/MessageGraph.kt b/app/src/main/java/life/andre/message487/MessageGraph.kt new file mode 100644 index 0000000..4f01cae --- /dev/null +++ b/app/src/main/java/life/andre/message487/MessageGraph.kt @@ -0,0 +1,102 @@ +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 +import java.io.File +import life.andre.message487.diagnostics.CrashHandler +import life.andre.message487.diagnostics.DiagnosticLog +import life.andre.message487.diagnostics.DiagnosticEvent + +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) } + val captureExecutor = Executors.newSingleThreadExecutor() + private val sources = AppSourceResolver(context.packageManager) + + fun start() { + captureExecutor.execute { + try { + settings.update { it } + scheduler.startRecovery() + recover() + } catch (error: Exception) { captureFailed(error) } + } + } + + fun recover() { + if (!settings.state.value.paused) { + val pending = outbox.pendingIds() + pending.forEach { scheduler.schedule(it) } + diagnostics.record(DiagnosticEvent.RECOVERY, count = pending.size) + } + } + + 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)) { + diagnostics.record(DiagnosticEvent.EVENT_QUEUED, type = event.messageType) + scheduler.schedule(event.eventId) + } else diagnostics.record(DiagnosticEvent.DUPLICATE_SKIPPED, type = event.messageType) + } + + fun captureFailed(error: Throwable? = null) { + diagnostics.record(DiagnosticEvent.CAPTURE_FAILED, error = error) + 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/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/NotificationCaptureService.kt b/app/src/main/java/life/andre/message487/NotificationCaptureService.kt new file mode 100644 index 0000000..bc89e31 --- /dev/null +++ b/app/src/main/java/life/andre/message487/NotificationCaptureService.kt @@ -0,0 +1,68 @@ +package life.andre.message487 + +import life.andre.message487.diagnostics.DiagnosticEvent +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() { + MessageGraph.get(this).diagnostics.record(DiagnosticEvent.LISTENER_CONNECTED) + ListenerState.mutableConnected.value = true + val graph = MessageGraph.get(this) + graph.captureExecutor.execute { + try { graph.recover() } catch (error: Exception) { graph.captureFailed(error) } + } + } + + override fun onListenerDisconnected() { + MessageGraph.get(this).diagnostics.record(DiagnosticEvent.LISTENER_DISCONNECTED) + 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 (error: Exception) { graph.captureFailed(error) } + } + } 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 (error: Exception) { graph.captureFailed(error) } + } + } +} 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/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/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..f95e0a7 --- /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 (error: Exception) { + graph.captureFailed(error) + } finally { + pending.finish() + } + } + } +} 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/java/life/andre/message487/WebhookClient.kt b/app/src/main/java/life/andre/message487/WebhookClient.kt new file mode 100644 index 0000000..eb7a907 --- /dev/null +++ b/app/src/main/java/life/andre/message487/WebhookClient.kt @@ -0,0 +1,117 @@ +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 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", messageType) + .put("occurred_at", occurredAt) + .put("source", source.packageName) + .put("source_name", source.name) + .put("text", text) + .put("title", title) + .put("sender", sender) + .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: 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 + 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 = json.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), eventId) + } + } + } catch (_: SocketTimeoutException) { + DeliveryStatus.TIMEOUT + } catch (_: IOException) { + DeliveryStatus.NETWORK_ERROR + } finally { + connection?.disconnect() + } + return DeliveryResult(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/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/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..4cf59a1 --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,120 @@ + + Выбрать все приложения + Снять выбор + Назад + Message487 + Подключение + Код устройства + Метка в каждом событии, например personal-phone. + Укажите код устройства. + Адрес webhook + Укажите полный адрес опубликованного workflow. + Укажите HTTPS-адрес без встроенных учётных данных и фрагмента. В debug-сборке также разрешён HTTP к хосту эмулятора и localhost. + Подтверждение n8n + Требовать ответ accepted с идентификатором отправленного события. + Произвольный webhook: любой ответ HTTP 2xx означает успех HTTP-запроса. + Сохранить подключение + Сохранить и отправить тест + Отправляется + Подключение сохранено + Журнал + Событий пока нет + ID события: %1$s + HTTP %1$d + Принято webhook + HTTP-запрос выполнен успешно + Webhook вернул ошибку HTTP + Подтверждение отсутствует или неверно + Истекло время ожидания ответа + Не удалось завершить подключение + Android может задерживать фоновую работу и скрывать чувствительное содержимое уведомлений. Повторы используют тот же ID события; потеря ответа может привести к дублю. Подтверждение webhook не означает доставку в Telegram. + Источники + Приостановить пересылку + Приостановить приём и отправку очереди. Уже начавшийся запрос может завершиться. + Нет разрешения на SMS. Разрешите доступ в настройках приложения. + Не удалось сохранить или запланировать некоторые события. Проверьте свободное место и журнал. + Скрыть + Не удалось выполнить операцию. Проверьте место на устройстве и настройки. + Не удалось открыть настройки Android. + События в очереди сохраняют адрес получателя и режим подтверждения на момент приёма. + Включённые источники передают текст, имя источника и время на сохранённый webhook. SMS также содержит отправителя. По умолчанию оба источника выключены. + Уведомления + Только выбранные приложения. Постоянные уведомления и сводки групп пропускаются. + Настройки доступа к уведомлениям + Переподключить слушатель + Входящие SMS + Новые сообщения, включая составные SMS. Без доступа к истории. + Приложения · %1$d + По умолчанию ничего не выбрано. Пакет без значка запуска можно добавить вручную. Не выбирайте приложения, в которые возвращаются пересланные сообщения. + Имя пакета + Добавить пакет + Содержимое сообщений скрыто. Неподтверждённые события хранятся до подтверждения или явного удаления. В журнале до 200 записей. + В очереди + Ожидает автоматического повтора + Попыток отправки: %1$d + Повторить сейчас + Удалить событие + Удалить локальную запись и прекратить попытки доставки? Копии, уже полученные сервером, останутся. + Отмена + Уведомление + SMS + Тест + Тестовое событие сохранено в очередь. Результат появится в журнале. + Обзор + Связь + Справка + О пересылке + Закрыть + Пересылка на паузе + Настроить подключение + Нужен доступ + Выберите источники + Готово к пересылке + Новые сообщения не сохраняются. + Ваши сообщения. Ваши сценарии. + n8n + Webhook + Изменить подключение + Возобновить пересылку + В очереди + Приложений выбрано + Настроить + Включено + Выключено + Нужна настройка + Не настроено + Отправить тест + Куда отправлять сообщения + Подключите сценарий n8n или свой webhook. + Получатель + Это устройство + Настройки приложения + Пересылать уведомления только выбранных приложений. + Поиск приложений + Очистить поиск + Только выбранные + Приложения не найдены + Добавьте приложение без значка запуска по имени пакета. + Содержимое сообщений скрыто + Все события + В очереди · %1$d + Очередь пуста + Нет событий, ожидающих подтверждения. + Отправьте тест или включите источник — здесь появятся результаты доставки. + Требует внимания + Диагностика + Сообщить о проблеме + Подготовьте письмо с ZIP-архивом: локальные логи, последний креш, версии приложения и Android, модель устройства. Тексты сообщений, отправители, URL веб-хука и идентификаторы устройства исключены. Проверьте вложение перед отправкой в почтовом приложении. + Подготовить письмо + Опишите проблему, время её возникновения и шаги для повторения. Проверьте диагностическое вложение перед отправкой. + Локальный лог + Автоматическая ротация: 3 × 256 КиБ и последний креш до 256 КиБ. Здесь показаны последние 48 КиБ и последний креш. В кеше приложения хранится до 3 архивов для писем. + Обновить + Очистить логи + Диагностических записей пока нет. + Не удалось прочитать, сохранить или передать диагностику. Проверьте свободное место и наличие почтового приложения. + Удалить локальные логи, последний креш и архивы писем из кеша? Уже отправленные копии останутся у получателей. + Приложение аварийно завершилось + Локальный отчёт о креше поможет разобраться в проблеме. Можно просмотреть его и подготовить письмо разработчику. Ничего не отправляется автоматически. + Посмотреть отчёт + 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..fc94e8e --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,120 @@ + + Select all applications + Clear selection + Back + Message487 + 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 + Sending + Connection saved + Journal + No captured events yet + Event ID: %1$s + 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 + 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. + Sources + Pause forwarding + Pause capture and queued delivery. A request already in progress may finish. + 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. + Notifications + Selected apps only. Ongoing notifications and group summaries are skipped. + Notification access settings + Reconnect listener + 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 + 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. + 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 + 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/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/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/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/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/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/WebhookClientTest.kt b/app/src/test/java/life/andre/message487/WebhookClientTest.kt new file mode 100644 index 0000000..5db77d9 --- /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 = 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) + 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, MessageEvent("d", "test-device", source), true).status) + server.enqueue(MockResponse().setResponseCode(204)) + assertEquals(DeliveryStatus.HTTP_SUCCESS, client.send(url, MessageEvent("d", "test-device", source), false).status) + for (code in listOf(302, 500)) { + server.enqueue(MockResponse().setResponseCode(code).addHeader("Location", url)) + assertEquals(DeliveryStatus.HTTP_ERROR, client.send(url, MessageEvent("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(), 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(), MessageEvent("d", "test-device", source), true).status) + } + + private fun withServer(block: (MockWebServer) -> Unit) { + MockWebServer().use { server -> + server.start() + block(server) + } + } +} 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/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/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. 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 new file mode 100644 index 0000000..8a99a00 --- /dev/null +++ b/docs/project-context.md @@ -0,0 +1,118 @@ +# Контекст 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, постоянная очередь и повторы. +Авторизация webhook пока не реализована. +`DevServer/README.md` описывает запуск и ограничения локального стенда. + +## Риски старого клиента sms487 + +В предыдущем обсуждении и при чтении Android-кода выявлены риски: асинхронная отправка +завершается за пределами жизненного цикла Worker, SMS receiver не использует `goAsync()`, +между получением и сохранением есть окно потери события, стабильного идентификатора события нет. +Успешный HTTP callback помечает пачку отправленной до проверки ответа. В журнал попадает +начало содержимого сообщения. Эти выводы получены статически, без воспроизведения на устройстве. + +Старый клиент добавляет `/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. +- Выбор приложений и отдельное включение 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. Проверка энергосбережения, перезагрузки и ограничений разрешений на физических устройствах. +2. Подтверждение приёма: после выполнения workflow либо после устойчивой записи в серверную + очередь. Простой HTTP-успех не доказывает надёжное сохранение или конечную доставку. +3. Авторизация webhook и дальнейшее развитие контракта. Текущий формат и поведение очереди + описаны в README; лимиты неподтверждённой очереди требуют отдельного решения. +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 и последующие сервисы имеют собственные правила хранения. + +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 new file mode 100644 index 0000000..82f51e1 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,38 @@ +# 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. + +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 new file mode 100644 index 0000000..58cc2d4 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,62 @@ +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 + 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", + 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, 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) + 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..47cd59a --- /dev/null +++ b/fastlane/README.md @@ -0,0 +1,72 @@ +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 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 release_artifacts + +```sh +[bundle exec] fastlane android release_artifacts +``` + +Build, sign and verify the release APK and checksums + +### 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 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + 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/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 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/emulator.sh b/scripts/emulator.sh new file mode 100755 index 0000000..dd46176 --- /dev/null +++ b/scripts/emulator.sh @@ -0,0 +1,91 @@ +#!/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" +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 +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 + + # 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 + +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 + +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/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 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 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")