Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
102 changes: 102 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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"
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,12 @@ google-services.json

# Android Profiling
*.hprof
.bundle/
.kotlin/
.DS_Store
fastlane/report.xml
DevServer/.env

.venv/
__pycache__/
dist/
12 changes: 12 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -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}"
}
]
}
13 changes: 13 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -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" }
}
]
}
101 changes: 101 additions & 0 deletions DevServer/README.md
Original file line number Diff line number Diff line change
@@ -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 <http://localhost:5678>. 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)
44 changes: 44 additions & 0 deletions DevServer/compose.yaml
Original file line number Diff line number Diff line change
@@ -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:
Loading
Loading