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
8 changes: 5 additions & 3 deletions DevServer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ Open the editor at <http://localhost:5678>. Local development login:
- Password: `Message487-Local-Only`

These are public test credentials. This configuration is for synthetic local development data.
The webhook endpoints do not require authentication. The published port is bound to host loopback.
All webhook endpoints require `Authorization: Bearer <token>`. The public local token is
the `data.value` in [header-auth.json](credentials/header-auth.json), without the `Bearer ` prefix. The published port is bound to host loopback.
The editor account and its bcrypt password hash are provisioned through n8n environment variables.

The image version, execution retention, and runtime settings live in [compose.yaml](compose.yaml).
Expand All @@ -24,7 +25,7 @@ returns an HTTP error; its n8n execution itself may still be marked successful.
## Android connection

The standard Android Emulator reaches the host through `10.0.2.2`. The debug app starts with the
receive endpoint configured. Its **Save and send test event** button submits synthetic data and
receive endpoint configured. Enter the local token in **Webhook token** before saving. Its **Save and send test event** button submits synthetic data and
shows the event ID and confirmation result in **Journal**. Find the same ID in the workflow's execution input.
In the Webhook output, `body.device_id` is the installation UUID and `body.device_code` is the
editable device label. Both are preserved in execution history; older events may lack the label.
Expand Down Expand Up @@ -78,7 +79,8 @@ docker compose -f DevServer/compose.yaml stop
docker compose -f DevServer/compose.yaml up -d --wait
```

Bootstrap runs once per data volume. Restarts preserve editor changes. To explicitly replace the
The authorization upgrade reimports the four fixtures and their credential once on existing
volumes; subsequent bootstrap runs once per data volume. Restarts preserve editor changes. To explicitly replace the
bundled workflows with the checked-in versions, stop n8n before importing:

```sh
Expand Down
11 changes: 11 additions & 0 deletions DevServer/credentials/header-auth.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[
{
"id": "message487-header-auth",
"name": "Message487 local webhook",
"type": "httpHeaderAuth",
"data": {
"name": "Authorization",
"value": "Bearer message487-local-test-only"
}
}
]
1 change: 1 addition & 0 deletions DevServer/import.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/bin/sh
set -eu

n8n import:credentials --input=/bootstrap/credentials/header-auth.json
n8n import:workflow --separate --input=/bootstrap/workflows
for workflow in receive error slow invalid-ack; do
n8n publish:workflow --id="message487-$workflow"
Expand Down
2 changes: 1 addition & 1 deletion DevServer/start.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/bin/sh
set -eu

marker=/home/node/.n8n/message487-bootstrap-v1
marker=/home/node/.n8n/message487-bootstrap-v2-auth
if [ ! -f "$marker" ]; then
/bin/sh /bootstrap/import.sh
touch "$marker"
Expand Down
28 changes: 24 additions & 4 deletions DevServer/tests/smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,36 @@
import urllib.error
import urllib.request
import uuid
from pathlib import Path

DEV_TOKEN = json.loads(
(Path(__file__).resolve().parents[1] / 'credentials/header-auth.json').read_text()
)[0]['data']['value'].removeprefix('Bearer ')

def post(scenario, payload, timeout=5, base_url='http://127.0.0.1:5678'):

def post(
scenario, payload, timeout=5, base_url='http://127.0.0.1:5678', token=DEV_TOKEN
):
headers = {'Content-Type': 'application/json'}
if token is not None:
headers['Authorization'] = f'Bearer {token}'
request = urllib.request.Request(
f'{base_url}/webhook/message487/{scenario}',
data=json.dumps(payload).encode(),
headers={'Content-Type': 'application/json'},
headers=headers,
)
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
try:
with opener.open(request, timeout=timeout) as response:
return response.status, json.load(response)
except urllib.error.HTTPError as error:
return error.code, json.load(error)
with error:
body = error.read().decode()
try:
body = json.loads(body)
except json.JSONDecodeError:
pass
return error.code, body


def main():
Expand All @@ -30,6 +46,10 @@ def main():
'message_type': 'test',
'text': 'Synthetic smoke test',
}
for scenario in ('receive', 'error', 'slow', 'invalid-ack'):
for token in (None, '', 'wrong-token'):
code, _ = post(scenario, event, token=token)
assert code in (401, 403), (scenario, code)
for message_type in ('test', 'notification', 'sms'):
event.update(message_type=message_type, event_id=str(uuid.uuid4()))
if message_type == 'notification':
Expand All @@ -55,7 +75,7 @@ def main():
else:
raise AssertionError('Slow endpoint did not time out')
print(
'Passed: test/notification/SMS receive, validation, HTTP error, invalid ACK, timeout'
'Passed: mandatory authorization on all endpoints, test/notification/SMS receive, validation, HTTP error, invalid ACK, timeout'
)


Expand Down
14 changes: 12 additions & 2 deletions DevServer/tests/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
class Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
payload = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
self.server.received = (self.path, self.headers['Content-Type'], payload)
self.server.received = (
self.path,
self.headers['Content-Type'],
self.headers.get('Authorization'),
payload,
)
self.send_response(500 if self.path.endswith('/error') else 200)
self.end_headers()
if self.path.endswith('/malformed'):
Expand Down Expand Up @@ -42,7 +47,12 @@ def test_posts_unicode_json_and_returns_ack(self):
smoke.post('receive', event, base_url=self.base_url),
)
self.assertEqual(
('/webhook/message487/receive', 'application/json', event),
(
'/webhook/message487/receive',
'application/json',
f'Bearer {smoke.DEV_TOKEN}',
event,
),
self.server.received,
)

Expand Down
11 changes: 9 additions & 2 deletions DevServer/workflows/error.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"httpMethod": "POST",
"path": "message487/error",
"responseMode": "responseNode",
"options": {}
"options": {},
"authentication": "headerAuth"
},
"id": "webhook",
"name": "Webhook",
Expand All @@ -18,7 +19,13 @@
0,
0
],
"webhookId": "message487-error"
"webhookId": "message487-error",
"credentials": {
"httpHeaderAuth": {
"id": "message487-header-auth",
"name": "Message487 local webhook"
}
}
},
{
"parameters": {
Expand Down
11 changes: 9 additions & 2 deletions DevServer/workflows/invalid-ack.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"httpMethod": "POST",
"path": "message487/invalid-ack",
"responseMode": "responseNode",
"options": {}
"options": {},
"authentication": "headerAuth"
},
"id": "webhook",
"name": "Webhook",
Expand All @@ -18,7 +19,13 @@
0,
0
],
"webhookId": "message487-invalid-ack"
"webhookId": "message487-invalid-ack",
"credentials": {
"httpHeaderAuth": {
"id": "message487-header-auth",
"name": "Message487 local webhook"
}
}
},
{
"parameters": {
Expand Down
11 changes: 9 additions & 2 deletions DevServer/workflows/receive.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"httpMethod": "POST",
"path": "message487/receive",
"responseMode": "responseNode",
"options": {}
"options": {},
"authentication": "headerAuth"
},
"id": "webhook",
"name": "Webhook",
Expand All @@ -18,7 +19,13 @@
0,
0
],
"webhookId": "message487-receive"
"webhookId": "message487-receive",
"credentials": {
"httpHeaderAuth": {
"id": "message487-header-auth",
"name": "Message487 local webhook"
}
}
},
{
"parameters": {
Expand Down
11 changes: 9 additions & 2 deletions DevServer/workflows/slow.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"httpMethod": "POST",
"path": "message487/slow",
"responseMode": "responseNode",
"options": {}
"options": {},
"authentication": "headerAuth"
},
"id": "webhook",
"name": "Webhook",
Expand All @@ -18,7 +19,13 @@
0,
0
],
"webhookId": "message487-slow"
"webhookId": "message487-slow",
"credentials": {
"httpHeaderAuth": {
"id": "message487-header-auth",
"name": "Message487 local webhook"
}
}
},
{
"parameters": {
Expand Down
12 changes: 8 additions & 4 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Privacy Policy

Last updated: September 8, 2026.
Last updated: September 9, 2026.

Message487 forwards selected notifications and new incoming SMS to a webhook you configure.
Both capture sources are off by default. Notifications require Android notification access and
Expand All @@ -14,9 +14,12 @@ selected package names, pause state and a random installation ID in private pref
preferences do not have additional application-level encryption. A webhook URL may itself contain
a secret, so treat it as sensitive. Android cloud backup and device transfer are disabled for app data.

The webhook Bearer token is encrypted with AES-GCM and an Android Keystore key before
being saved in preferences. It is sent in the Authorization header to your configured endpoint.

Captured event bodies, including message text, notification titles and SMS senders, are stored in
a private SQLite outbox encrypted with AES-GCM and a key held in Android Keystore. Each queued
request includes its original destination and confirmation mode. Delivery metadata (event ID,
request includes its original destination, encrypted authentication token and confirmation mode. Delivery metadata (event ID,
source display name, type, timestamps, state, attempt count and HTTP result) is stored without
additional application-level encryption. Notification duplicate detection stores hashes of keys
and contents; these hashes are not a substitute for encryption against guesses of known content.
Expand All @@ -31,7 +34,7 @@ Message bodies and server response bodies are not written to diagnostic logs by

The app records local diagnostic events: startup, listener connectivity, capture and delivery
outcomes, HTTP status codes, queue counts and failures. Logs exclude message and response bodies,
notification titles, SMS senders, source packages, webhook URLs, device codes and installation IDs.
notification titles, SMS senders, source packages, webhook URLs, authentication tokens, device codes and installation IDs.
Exception messages are omitted; limited stack traces contain exception classes and code locations.
Unhandled Java/Kotlin exceptions are saved synchronously and a report prompt appears on next launch.

Expand All @@ -58,7 +61,8 @@ The endpoint operator can also see connection metadata such as your IP address.
any downstream services process data under their own policies. An n8n instance may retain complete
event bodies in its execution history; the bundled development server does so. Retries can produce
more than one server-side copy of an event. Changing the webhook affects new events; existing
queued events keep their previous destination.
queued events keep their previous destination and token. Server execution history may also
include request headers; restrict access and retention.

The app lists visible launcher apps locally to let you select sources and resolve display names.
It does not upload an installed-app inventory or request visibility of all installed packages.
Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ A custom webhook is also supported. Telegram forwarding is one possible workflow
app does not depend on Telegram.

**Status:** development preview with notification/SMS capture, a persistent encrypted outbox,
background delivery, automatic retries and a delivery journal. Webhook authentication is not
implemented yet. Signed APK release automation is configured; see [Releases](docs/releases.md).
background delivery, automatic retries and a delivery journal. Webhook requests require a Bearer token, stored encrypted on the device. Signed APK release automation is configured; see [Releases](docs/en/releases.md).

## Getting started

Guides: [n8n webhook](docs/en/n8n-webhook.md) · [Telegram forwarding](docs/en/n8n-telegram.md).
На русском: [n8n webhook](docs/ru/n8n-webhook.md) · [Пересылка в Telegram](docs/ru/n8n-telegram.md).

1. Save the full published webhook URL and a device code in **Connection**. Send a test event.
2. Check **Journal** and find the same event ID in n8n **Executions**.
3. In **Sources**, enable notification forwarding, grant notification access in Android settings,
Expand Down Expand Up @@ -89,14 +91,14 @@ In VS Code, select **Run Message487 on Emulator** and **Run Without Debugging**,
The debug app starts with the local n8n receive endpoint configured. Follow the capture checks in
[DevServer/README.md](DevServer/README.md) using synthetic data only. Release builds require HTTPS.
UI strings are supplied in English and Russian. The interface supports light/dark themes,
bottom navigation on phones and rail navigation on wider windows. See the [design notes](docs/design.md)
bottom navigation on phones and rail navigation on wider windows. See the [design notes](docs/en/design.md)
for the visual conventions and references.

Fastlane's `debug_artifact` lane builds only the debug APK. `checks` runs JVM/Robolectric tests,
debug/release lint, and builds debug and unsigned release APKs under `app/build/outputs/apk/`.
PR CI has no release signing credentials and does not require an emulator.

See the [project context](docs/project-context.md) for remaining product decisions.
See the [project context](docs/en/project-context.md) for remaining product decisions.
This project succeeds [sms487](https://github.com/andre487/sms487).
[AndroidMegaProxy](https://github.com/andre487/AndroidMegaProxy) is the reference for project conventions.

Expand All @@ -108,6 +110,6 @@ Open the bug icon in the top bar to view or clear local diagnostic logs and prep
`der-morgenstern@yandex.ru`. A ZIP contains rotating logs, the last crash and device/app information;
message content and connection secrets are excluded. Sending requires action in your email app.
After an unhandled crash the next launch offers to review the report.
See [diagnostic behavior and development checks](docs/diagnostics.md) and [privacy details](PRIVACY.md).
See [diagnostic behavior and development checks](docs/en/diagnostics.md) and [privacy details](PRIVACY.md).

Test categories, local commands, CI jobs and device-only limitations: [Testing](docs/testing.md).
Test categories, local commands, CI jobs and device-only limitations: [Testing](docs/en/testing.md).
6 changes: 6 additions & 0 deletions app/src/main/java/life/andre/message487/ConnectionScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ internal fun ConnectionScreen(state: ConnectionState, model: ConnectionViewModel
isError = state.invalidUrl, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
shape = MaterialTheme.shapes.small,
supportingText = { Text(stringResource(if (state.invalidUrl) R.string.invalid_url else R.string.url_hint)) })
OutlinedTextField(value = state.authToken, onValueChange = model::setAuthToken,
label = { Text(stringResource(R.string.auth_token)) }, modifier = Modifier.fillMaxWidth(),
enabled = !state.busy, singleLine = true, isError = state.invalidToken,
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
supportingText = { Text(stringResource(if (state.invalidToken) R.string.invalid_token else R.string.token_hint)) })
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(Icons.Outlined.VerifiedUser, null, tint = MaterialTheme.colorScheme.primary)
Expand Down
12 changes: 9 additions & 3 deletions app/src/main/java/life/andre/message487/ConnectionViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ data class ConnectionState(
val invalidUrl: Boolean = false,
val invalidDeviceCode: Boolean = false,
val notice: Int? = null,
)
val authToken: String = "",
val invalidToken: Boolean = false,
) {
override fun toString(): String = "ConnectionState(redacted)"
}

data class QueueSnapshot(val entries: List<QueueEntry> = emptyList(), val pending: Int = 0)
data class PermissionState(val notifications: Boolean = false, val sms: Boolean = false)
Expand All @@ -36,7 +40,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val settings = graph.settings.state
val listenerConnected = ListenerState.connected
private val mutableState = MutableStateFlow(settings.value.let {
ConnectionState(it.url, it.deviceCode, it.requireAck)
ConnectionState(it.url, it.deviceCode, it.requireAck, authToken = it.authToken)
})
val state = mutableState.asStateFlow()
private val mutableQueue = MutableStateFlow(QueueSnapshot())
Expand Down Expand Up @@ -82,6 +86,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
)
}

fun setAuthToken(token: String) { mutableState.value = state.value.copy(authToken = token, invalidToken = false, notice = null) }
fun setUrl(url: String) { mutableState.value = state.value.copy(url = url, invalidUrl = false, notice = null) }
fun setDeviceCode(code: String) { mutableState.value = state.value.copy(deviceCode = code, invalidDeviceCode = false, notice = null) }
fun setRequireAck(value: Boolean) { mutableState.value = state.value.copy(requireAck = value, notice = null) }
Expand All @@ -92,8 +97,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val code = draft.deviceCode.trim()
if (code.isBlank()) { mutableState.value = draft.copy(invalidDeviceCode = true); return }
if (!validWebhookUrl(url, BuildConfig.DEBUG)) { mutableState.value = draft.copy(invalidUrl = true); return }
if (!validAuthToken(draft.authToken)) { mutableState.value = draft.copy(invalidToken = true); return }
action(if (sendTest) R.string.test_queued else R.string.saved) {
graph.settings.update { it.copy(url = url, deviceCode = code, requireAck = draft.requireAck) }
graph.settings.update { it.copy(url = url, deviceCode = code, requireAck = draft.requireAck, authToken = draft.authToken) }
if (sendTest) graph.enqueueTest()
}
}
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/life/andre/message487/DeliveryWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class DeliveryWorker(context: Context, parameters: WorkerParameters) : Coroutine
graph.diagnostics.record(DiagnosticEvent.DELIVERY_STARTED)
val request = attempt.request
val result = if (validWebhookUrl(request.url, BuildConfig.DEBUG)) {
WebhookClient().sendJson(request.url, id, request.json, request.requireAck)
WebhookClient().sendJson(request.url, id, request.json, request.requireAck, request.authToken)
} else DeliveryResult(id, DeliveryStatus.HTTP_ERROR)
graph.diagnostics.record(DiagnosticEvent.DELIVERY_FINISHED, outcome = result.status.name, http = result.httpCode)
val state = graph.outbox.finish(id, attempt.token, result)
Expand Down
Loading
Loading