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
28 changes: 27 additions & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ Last updated: October 19, 2026.

Message487 forwards selected notifications and new incoming SMS to a webhook you configure.
Notification events can include titles, text, image attachments and optional avatars or app icons.
SMS events include text, sender and time. Your n8n workflow or another HTTPS receiver decides
SMS events include text, sender and time. Events also include a string channel identifier: the
Android notification channel ID, `sms` for SMS, or `test` for test events. Your n8n workflow or another HTTPS receiver decides
how to process or forward these events; the app does not depend on Telegram.
Both capture sources are off by default. Notifications require Android notification access and
selection of source apps. Optional automatic selection of newly detected launcher apps is off by default;
Expand Down Expand Up @@ -43,6 +44,31 @@ their request and image files removed; only recent delivery metadata is retained
deletion do not guarantee forensic erasure of previously allocated storage.
Message bodies, image data and server response bodies are not written to diagnostic logs by the app.

## Event preview and settings files

Event previews are opened only by an explicit user action. They show test events or the contents
of currently active notifications from selected apps, including text, source package, channel ID
and available images. They use the same image conversion and limits as capture. Opening a preview
makes no webhook request and does not stop automatic capture; use Pause first if needed. Preview
images are temporary private files, removed when the preview is closed or its screen model is
cleared; abandoned files are handled by the existing recovery cleanup. Sending a preview requires
an explicit action and uses the normal local queue. Explicitly sent tests and their automatic retries
can be delivered during Pause; capture and delivery of ordinary messages remain paused.
The journal continues to hide message contents.

Settings export writes an unencrypted JSON file to a location you choose in Android's document
picker. It contains saved webhook URL, device code, selected source packages, capture preferences,
duplicate-filter preferences and image settings. The Bearer token is **included by default**;
a prominent switch lets you exclude it before export. A webhook URL can also contain a secret.
Anyone with access to the exported file or its storage provider can read these values. The app
does not encrypt the export, upload it separately or delete copies from your chosen provider.

Import reads a selected settings file and shows a confirmation before applying it. If the file
has no token, the previous token is cleared. Imported preferences are stored with the protections
described above, and forwarding is paused until explicitly resumed. Installation ID, Android
permissions, queue, journal, diagnostics and update preferences are not included in the file.
Settings import/export does not transfer or delete message payloads or queued credentials.

## Diagnostics and voluntary email reports

The app records local diagnostic events: startup, listener connectivity, capture and delivery
Expand Down
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,44 @@ journal persists across restarts and hides message contents. Tap an event for it
retry and deletion controls. Confirmed payloads are removed;
only a bounded recent history of delivery metadata remains. See [PRIVACY.md](PRIVACY.md).

## Troubleshooting, preview and settings transfer

On **Overview**, open **Why was a message not forwarded?** to check saved connection validity,
source enablement, permissions, listener connectivity, selected apps, active network, background
restrictions and queued delivery failures. The checks link to settings, reconnection and Journal.
They distinguish authorization failures, invalid acknowledgements and automatic retry waits;
network availability does not prove that the webhook is reachable.

**Connection → Preview event** builds a test with the selected test-image options without saving
or sending it. **Sources → Preview current notifications** lets you inspect up to 20 current
notifications from selected apps and open one using the same text extraction and image conversion
as capture. This requires notification access and a connected listener. Opening a preview does
not replay notifications or enqueue anything. Automatic capture continues independently; pause
first to inspect without automatic forwarding. Closing discards the temporary image files.
**Send this event** explicitly queues the preview with its displayed destination and stable event ID;
real-notification previews cannot be sent while paused. Explicit test sends (including preview tests)
can be delivered while paused without resuming capture or the ordinary queue. Their background
retries retain this exception. Notification previews still use the configured duplicate filter.
The JSON view shows metadata; images are previewed separately and transmitted as Base64.

**Connection → Import and export settings** uses Android's document picker. Export includes saved
connection, selected sources, duplicate filters and graphics settings. **Include Bearer token in export**
is on by default, with a prominent switch to opt out. The JSON file is not encrypted; the token and
possibly secret webhook URL are readable by anyone who obtains it. Unsaved form edits are excluded.
Import validates the whole file (version 1, at most 1 MiB) and shows a confirmation before replacing
settings. A file without a token clears the existing token. Forwarding is paused after import so you
can check the connection and resume explicitly. Android permissions, installation identity, queue,
journal, diagnostic logs and update preferences are not transferred. Existing queued events retain
their original destinations and credentials. New-app selection starts from the target device's
current launcher-app inventory so importing does not immediately select all its existing apps.

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.
`sender`. `channel_id` is a string: the Android channel ID for notifications, `sms` for
incoming SMS and `test` for test events. Match it together with `source` when filtering on the
server, because channel IDs belong to each source app. Notifications without a system channel ID
omit the field. This is an additive field in `schema_version: 1`; existing queued events keep their
original bodies and may not contain it. `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.
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/life/andre/message487/ConnectionScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,18 @@ internal fun ConnectionScreen(state: ConnectionState, model: ConnectionViewModel
TestImageOption(R.string.test_app_icon, state.testOptions.appIcon, !state.busy) {
model.setTestOptions(state.testOptions.copy(appIcon = it))
}
OutlinedButton(onClick = model::previewTest, enabled = !state.busy,
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
Text(stringResource(R.string.preview_event))
}
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)) }
item { SettingsTransferPanel(model, state.busy) }
item { AppInfo() }
}
}
Expand Down
106 changes: 102 additions & 4 deletions app/src/main/java/life/andre/message487/ConnectionViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ data class ConnectionState(
}

data class QueueSnapshot(val entries: List<QueueEntry> = emptyList(), val pending: Int = 0)
data class PermissionState(val notifications: Boolean = false, val sms: Boolean = false)
data class PermissionState(val notifications: Boolean = false, val sms: Boolean = false,
val network: Boolean = true, val backgroundRestricted: Boolean = false)

class ConnectionViewModel @JvmOverloads constructor(application: Application,
private val readSettings: () -> ForwardingSettings = { MessageGraph.get(application).settings.load() },
Expand All @@ -55,6 +56,100 @@ class ConnectionViewModel @JvmOverloads constructor(application: Application,
private val mutableApps = MutableStateFlow<List<AppSource>>(emptyList())
val apps = mutableApps.asStateFlow()
private var refreshingApps = false
private val mutableImport = MutableStateFlow<ForwardingSettings?>(null)
val pendingImport = mutableImport.asStateFlow()
private val mutablePreview = MutableStateFlow<EventPreview?>(null)
internal val preview = mutablePreview.asStateFlow()
private val mutableCandidates = MutableStateFlow<List<CapturedNotification>?>(null)
internal val previewCandidates = mutableCandidates.asStateFlow()
private var previewClosed = false

fun exportDocument(uri: android.net.Uri, includeToken: Boolean) = action(R.string.settings_exported) {
val json = exportSettings(graph.settings.load(), includeToken)
require(json.toByteArray(Charsets.UTF_8).size <= MAX_SETTINGS_FILE_BYTES)
requireNotNull(getApplication<Application>().contentResolver.openOutputStream(uri, "wt")).use {
it.write(json.toByteArray(Charsets.UTF_8))
}
}

fun importDocument(uri: android.net.Uri) = action(failureNotice = R.string.invalid_settings_file) {
try {
val text = requireNotNull(getApplication<Application>().contentResolver.openInputStream(uri)).use(::readSettingsFile)
mutableImport.value = importSettings(text, graph.settings.load())
} catch (error: Exception) {
// Parser/provider errors can include file contents or credentials. Do not log them.
throw java.io.IOException("Settings file could not be imported")
}
}

fun cancelImport() { mutableImport.value = null }

fun confirmImport() = action(R.string.settings_imported) {
val imported = requireNotNull(mutableImport.value)
val baseline = installedApps().map { it.packageName }.toSet()
val saved = graph.settings.update(clearToken = imported.authToken.isEmpty()) { current -> imported.copy(
deviceId = current.deviceId, captureFailed = current.captureFailed,
knownPackages = current.knownPackages + baseline + imported.packages,
) }
mutableState.value = state.value.copy(url = saved.url, deviceCode = saved.deviceCode,
authToken = saved.authToken, requireAck = saved.requireAck,
invalidUrl = false, invalidToken = false, invalidDeviceCode = false)
mutableImport.value = null
refreshPermissions()
}

internal fun previewTest() {
val draft = state.value
if (!validWebhookUrl(draft.url.trim(), BuildConfig.DEBUG)) { mutableState.value = draft.copy(invalidUrl = true); return }
if (!validAuthToken(draft.authToken)) { mutableState.value = draft.copy(invalidToken = true); return }
if (draft.deviceCode.isBlank()) { mutableState.value = draft.copy(invalidDeviceCode = true); return }
val config = settings.value.copy(url = draft.url.trim(), deviceCode = draft.deviceCode.trim(),
authToken = draft.authToken, requireAck = draft.requireAck)
action { replacePreview(EventPreview(graph.previewTest(config, draft.testOptions), config)) }
}

internal fun listNotificationPreviews() = action {
mutableCandidates.value = NotificationCaptureService.previewCandidates(settings.value.packages)
}

internal fun previewNotification(key: String) = action {
val config = graph.settings.load()
val notification = NotificationCaptureService.preview(key, config)
try { replacePreview(EventPreview(graph.notificationEvent(notification, config), config, notification.imageWarning)) }
catch (error: Exception) { discardImages(notification.images); throw error }
mutableCandidates.value = null
}

internal fun dismissCandidates() { mutableCandidates.value = null }

@Synchronized private fun replacePreview(value: EventPreview) {
if (previewClosed) { discardImages(value.event.images); return }
clearPreview()
graph.outbox.previewDirectories.addAll(value.event.images.mapNotNull { it.file.parentFile })
mutablePreview.value = value
}

@Synchronized internal fun clearPreview() {
val images = mutablePreview.value?.event?.images.orEmpty()
mutablePreview.value = null
val cleanup = {
discardImages(images)
graph.outbox.previewDirectories.removeAll(images.mapNotNull { it.file.parentFile }.toSet())
}
if (!getApplication<MessageApplication>().backgroundExecutor.submitSafely({}) { cleanup() }) cleanup()
}

internal fun sendPreview() = action() {
val value = requireNotNull(mutablePreview.value)
val queued = graph.sendPreview(value.event, value.settings)
mutableState.value = state.value.copy(notice = if (queued) R.string.event_queued else R.string.preview_duplicate)
clearPreview()
}

override fun onCleared() {
synchronized(this) { previewClosed = true; clearPreview() }
super.onCleared()
}

private var permissionRefresh: kotlinx.coroutines.Job? = null
private var settingsLoad: kotlinx.coroutines.Job? = null
Expand Down Expand Up @@ -133,6 +228,9 @@ class ConnectionViewModel @JvmOverloads constructor(application: Application,
PermissionState(
notifications = app.packageName in NotificationManagerCompat.getEnabledListenerPackages(app),
sms = ContextCompat.checkSelfPermission(app, Manifest.permission.RECEIVE_SMS) == PackageManager.PERMISSION_GRANTED,
network = app.getSystemService(android.net.ConnectivityManager::class.java).activeNetwork != null,
backgroundRestricted = android.os.Build.VERSION.SDK_INT >= 28 &&
app.getSystemService(android.app.ActivityManager::class.java).isBackgroundRestricted,
)
}
} catch (error: Exception) {
Expand Down Expand Up @@ -227,18 +325,18 @@ class ConnectionViewModel @JvmOverloads constructor(application: Application,
}
fun showSettingsError() { mutableState.value = state.value.copy(notice = R.string.settings_unavailable) }

private fun action(notice: Int? = null, block: () -> Unit) {
private fun action(notice: Int? = null, failureNotice: Int = R.string.local_error, block: () -> Unit) {
if (state.value.busy || !graph.settings.loaded.value) return
mutableState.value = state.value.copy(busy = true, notice = null)
viewModelScope.launch {
val result = try {
withContext(Dispatchers.IO) { block() }
notice
notice ?: state.value.notice
} catch (cancelled: kotlinx.coroutines.CancellationException) {
throw cancelled
} catch (error: Exception) {
graph.diagnostics.record(DiagnosticEvent.LOCAL_OPERATION_FAILED, error = error)
R.string.local_error
failureNotice
}
mutableState.value = state.value.copy(busy = false, notice = result)
}
Expand Down
Loading
Loading