From 12b5667554dcb6baf9529095f08a41058037f0c3 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 19 Oct 2026 20:45:03 +0300 Subject: [PATCH] Add delivery diagnostics, event previews and settings transfer --- PRIVACY.md | 28 ++- README.md | 37 +++- .../life/andre/message487/ConnectionScreen.kt | 5 + .../andre/message487/ConnectionViewModel.kt | 106 ++++++++++- .../life/andre/message487/DeliveryChecks.kt | 87 +++++++++ .../life/andre/message487/DeliveryWorker.kt | 8 +- .../life/andre/message487/EventPreview.kt | 91 ++++++++++ .../andre/message487/ForwardingSettings.kt | 12 +- .../life/andre/message487/MainActivity.kt | 1 + .../life/andre/message487/MessageGraph.kt | 35 ++-- .../message487/NotificationCaptureService.kt | 44 ++++- .../main/java/life/andre/message487/Outbox.kt | 3 +- .../life/andre/message487/OverviewScreen.kt | 3 +- .../life/andre/message487/SettingsTransfer.kt | 88 +++++++++ .../andre/message487/SettingsTransferPanel.kt | 64 +++++++ .../life/andre/message487/SourcesScreen.kt | 10 ++ .../life/andre/message487/WebhookClient.kt | 2 + app/src/main/res/values-ru/strings.xml | 58 ++++++ app/src/main/res/values/strings.xml | 58 ++++++ .../andre/message487/DeliveryToolsTest.kt | 169 ++++++++++++++++++ .../andre/message487/ScreenInteractionTest.kt | 51 ++++++ .../message487/UpdateNotificationsTest.kt | 3 +- docs/en/n8n-self-hosted.md | 1 + docs/ru/n8n-self-hosted.md | 1 + .../android/en-US/full_description.txt | 3 + .../android/ru-RU/full_description.txt | 3 + 26 files changed, 938 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/life/andre/message487/DeliveryChecks.kt create mode 100644 app/src/main/java/life/andre/message487/EventPreview.kt create mode 100644 app/src/main/java/life/andre/message487/SettingsTransfer.kt create mode 100644 app/src/main/java/life/andre/message487/SettingsTransferPanel.kt create mode 100644 app/src/test/java/life/andre/message487/DeliveryToolsTest.kt diff --git a/PRIVACY.md b/PRIVACY.md index cb3c9a2..b5bde89 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -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; @@ -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 diff --git a/README.md b/README.md index e1c870d..ec91830 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/src/main/java/life/andre/message487/ConnectionScreen.kt b/app/src/main/java/life/andre/message487/ConnectionScreen.kt index ba32481..3659294 100644 --- a/app/src/main/java/life/andre/message487/ConnectionScreen.kt +++ b/app/src/main/java/life/andre/message487/ConnectionScreen.kt @@ -83,6 +83,10 @@ 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)) @@ -90,6 +94,7 @@ internal fun ConnectionScreen(state: ConnectionState, model: ConnectionViewModel } } item { SupportingText(stringResource(R.string.destination_note)) } + item { SettingsTransferPanel(model, state.busy) } item { AppInfo() } } } diff --git a/app/src/main/java/life/andre/message487/ConnectionViewModel.kt b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt index aabe49c..99ed353 100644 --- a/app/src/main/java/life/andre/message487/ConnectionViewModel.kt +++ b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt @@ -35,7 +35,8 @@ data class ConnectionState( } data class QueueSnapshot(val entries: List = 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() }, @@ -55,6 +56,100 @@ class ConnectionViewModel @JvmOverloads constructor(application: Application, private val mutableApps = MutableStateFlow>(emptyList()) val apps = mutableApps.asStateFlow() private var refreshingApps = false + private val mutableImport = MutableStateFlow(null) + val pendingImport = mutableImport.asStateFlow() + private val mutablePreview = MutableStateFlow(null) + internal val preview = mutablePreview.asStateFlow() + private val mutableCandidates = MutableStateFlow?>(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().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().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().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 @@ -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) { @@ -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) } diff --git a/app/src/main/java/life/andre/message487/DeliveryChecks.kt b/app/src/main/java/life/andre/message487/DeliveryChecks.kt new file mode 100644 index 0000000..1163b02 --- /dev/null +++ b/app/src/main/java/life/andre/message487/DeliveryChecks.kt @@ -0,0 +1,87 @@ +package life.andre.message487 + +import android.content.Intent +import android.provider.Settings +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp + +internal fun deliveryAdvice(entry: QueueEntry): Int = when { + entry.delivered -> R.string.check_accepted_hint + entry.httpCode == 401 || entry.httpCode == 403 -> R.string.check_auth_hint + entry.outcome == DeliveryStatus.INVALID_ACK.name -> R.string.check_ack_hint + entry.state == QueueState.BLOCKED -> R.string.check_blocked_hint + entry.state == QueueState.RETRY -> R.string.check_retry_hint + entry.state == QueueState.SENDING -> R.string.check_sending_hint + else -> R.string.check_waiting_hint +} + +@Composable +internal fun DeliveryChecks(settings: ForwardingSettings, permissions: PermissionState, connected: Boolean, + queue: QueueSnapshot, model: ConnectionViewModel, busy: Boolean, + onConnection: () -> Unit, onSources: () -> Unit, onJournal: () -> Unit) { + var expanded by rememberSaveable { mutableStateOf(false) } + val context = LocalContext.current + Panel { + TextButton(onClick = { expanded = !expanded; if (expanded) model.refreshPermissions() }) { + Text(stringResource(R.string.delivery_checks)) + } + if (expanded) { + SupportingText(stringResource(R.string.check_limitations)) + CheckRow(R.string.connection, if (settings.ready()) R.string.check_connection_ok else R.string.check_connection_missing, + R.string.edit_connection, onConnection) + if (settings.paused) CheckRow(R.string.pause, R.string.paused_description, R.string.resume_forwarding) { if (!busy) model.pause(false) } + CheckRow(R.string.notification_type, when { + !settings.notifications -> R.string.check_source_disabled + !permissions.notifications -> R.string.access_needed + settings.packages.isEmpty() -> R.string.check_no_apps + !connected -> R.string.check_listener_disconnected + else -> R.string.check_notification_ready + }, R.string.sources_tab, onSources) + if (settings.notifications && permissions.notifications && !connected) { + OutlinedButton(onClick = model::rebind, enabled = !busy) { Text(stringResource(R.string.reconnect_listener)) } + } + CheckRow(R.string.sms_type, when { + !settings.sms -> R.string.check_source_disabled + !permissions.sms -> R.string.sms_not_granted + else -> R.string.check_sms_ready + }, R.string.sources_tab, onSources) + CheckRow(R.string.check_network, if (permissions.network) R.string.check_network_available else R.string.check_network_missing, + R.string.check_refresh) { model.refreshPermissions() } + CheckRow(R.string.check_background, if (permissions.backgroundRestricted) R.string.check_background_restricted else R.string.check_background_hint, + R.string.open_settings) { + try { context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + android.net.Uri.parse("package:${context.packageName}"))) } + catch (_: android.content.ActivityNotFoundException) { model.showSettingsError() } + catch (_: SecurityException) { model.showSettingsError() } + } + Text(stringResource(R.string.waiting_filter, queue.pending), style = MaterialTheme.typography.titleSmall) + queue.entries.firstOrNull { !it.delivered }?.let { entry -> + SupportingText(stringResource(deliveryAdvice(entry))) + entry.httpCode?.let { Text(stringResource(R.string.http_code, it)) } + } + queue.entries.firstOrNull { it.state == QueueState.BLOCKED }?.let { + SupportingText(stringResource(deliveryAdvice(it))) + } + if (settings.captureFailed) SupportingText(stringResource(R.string.capture_error)) + TextButton(onClick = onJournal) { Text(stringResource(R.string.journal)) } + OutlinedButton(onClick = model::sendTest, enabled = !busy && settings.ready()) { + Text(stringResource(R.string.test_connection)) + } + } + } +} + +@Composable +private fun CheckRow(title: Int, detail: Int, action: Int, onClick: () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(title), style = MaterialTheme.typography.titleSmall) + SupportingText(stringResource(detail)) + TextButton(onClick = onClick) { Text(stringResource(action)) } + } +} diff --git a/app/src/main/java/life/andre/message487/DeliveryWorker.kt b/app/src/main/java/life/andre/message487/DeliveryWorker.kt index 8ce40ee..f9e5823 100644 --- a/app/src/main/java/life/andre/message487/DeliveryWorker.kt +++ b/app/src/main/java/life/andre/message487/DeliveryWorker.kt @@ -19,12 +19,14 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.ensureActive import java.util.concurrent.TimeUnit +internal fun pauseBlocksDelivery(paused: Boolean, manualTest: Boolean): Boolean = paused && !manualTest + class DeliveryScheduler(context: Context) { private val manager = WorkManager.getInstance(context) - fun schedule(id: String, replace: Boolean = false): androidx.work.Operation { + fun schedule(id: String, replace: Boolean = false, manualTest: Boolean = false): androidx.work.Operation { val work = OneTimeWorkRequestBuilder() - .setInputData(workDataOf("event_id" to id)) + .setInputData(workDataOf("event_id" to id, "manual_test" to manualTest)) .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) .build() @@ -42,7 +44,7 @@ class DeliveryWorker(context: Context, parameters: WorkerParameters) : Coroutine val graph = MessageGraph.get(applicationContext) val id = inputData.getString("event_id") ?: return@withContext Result.failure() try { - if (graph.settings.load().paused) return@withContext Result.retry() + if (pauseBlocksDelivery(graph.settings.load().paused, inputData.getBoolean("manual_test", false))) return@withContext Result.retry() val attempt = try { graph.outbox.beginAttempt(id) } catch (databaseError: android.database.SQLException) { diff --git a/app/src/main/java/life/andre/message487/EventPreview.kt b/app/src/main/java/life/andre/message487/EventPreview.kt new file mode 100644 index 0000000..93cec28 --- /dev/null +++ b/app/src/main/java/life/andre/message487/EventPreview.kt @@ -0,0 +1,91 @@ +package life.andre.message487 + +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +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.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal data class EventPreview(val event: MessageEvent, val settings: ForwardingSettings, val imageWarning: Boolean = false) { + override fun toString() = "EventPreview(redacted)" +} + +@Composable +internal fun EventPreviewDialogs(model: ConnectionViewModel, busy: Boolean, paused: Boolean) { + val preview by model.preview.collectAsState() + val candidates by model.previewCandidates.collectAsState() + candidates?.let { notifications -> + AlertDialog(onDismissRequest = { if (!busy) model.dismissCandidates() }, + title = { Text(stringResource(R.string.preview_notifications)) }, + text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { + SupportingText(stringResource(R.string.preview_capture_hint)) + if (notifications.isEmpty()) Text(stringResource(R.string.preview_no_notifications)) + notifications.forEach { notification -> + OutlinedButton(onClick = { model.previewNotification(notification.key) }, enabled = !busy, + modifier = Modifier.fillMaxWidth()) { + Text(notification.packageName + "\n" + notification.title, maxLines = 3) + } + } + } }, + confirmButton = { TextButton(onClick = model::dismissCandidates, enabled = !busy) { Text(stringResource(R.string.close)) } }) + } + preview?.let { value -> + AlertDialog(onDismissRequest = { if (!busy) model.clearPreview() }, + title = { Text(stringResource(R.string.preview_event)) }, + text = { LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) { + item { + SupportingText(stringResource(R.string.preview_not_sent)) + if (paused) Text(stringResource(if (value.event.messageType == "test") R.string.preview_test_paused else R.string.preview_paused)) + if (value.imageWarning) SupportingText(stringResource(R.string.preview_image_warning)) + Text(value.event.source.name, style = MaterialTheme.typography.titleMedium) + SelectionContainer { Text(org.json.JSONObject(value.event.toJson()).toString(2), style = MaterialTheme.typography.bodySmall) } + } + items(value.event.images, key = { it.file.path }) { image -> + Column { + PreviewImage(image) + Text("${image.kind} · ${image.presentation} · ${image.width}×${image.height} · ${image.mimeType}", + style = MaterialTheme.typography.bodySmall) + } + } + item { + SupportingText(stringResource(R.string.preview_destination, value.settings.url)) + SupportingText(stringResource(R.string.preview_images_hint)) + } + } }, + confirmButton = { TextButton(onClick = model::sendPreview, enabled = !busy && !pauseBlocksDelivery(paused, value.event.messageType == "test") && value.settings.ready()) { + Text(stringResource(R.string.send_preview)) + } }, + dismissButton = { TextButton(onClick = model::clearPreview, enabled = !busy) { Text(stringResource(R.string.close)) } }) + } +} + +@Composable +private fun PreviewImage(image: NotificationImage) { + val bitmap by produceState(null, image.file) { + value = withContext(Dispatchers.IO) { + runCatching { withImageMemory { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(image.file.path, bounds) + val options = BitmapFactory.Options().apply { + inSampleSize = 1 + while (maxOf(bounds.outWidth, bounds.outHeight) / inSampleSize > 256) inSampleSize *= 2 + } + BitmapFactory.decodeFile(image.file.path, options)?.asImageBitmap() + } }.getOrNull() + } + } + bitmap?.let { Image(it, contentDescription = stringResource(R.string.preview_image), + modifier = Modifier.fillMaxWidth().heightIn(max = 180.dp)) } +} diff --git a/app/src/main/java/life/andre/message487/ForwardingSettings.kt b/app/src/main/java/life/andre/message487/ForwardingSettings.kt index 0c2a49a..95f3bb7 100644 --- a/app/src/main/java/life/andre/message487/ForwardingSettings.kt +++ b/app/src/main/java/life/andre/message487/ForwardingSettings.kt @@ -109,16 +109,18 @@ class SettingsStore( } @Synchronized - fun update(transform: (ForwardingSettings) -> ForwardingSettings): ForwardingSettings { + fun update(clearToken: Boolean = false, transform: (ForwardingSettings) -> ForwardingSettings): ForwardingSettings { load() val next = transform(mutableState.value).let { if (it.deviceId.isBlank()) it.copy(deviceId = UUID.randomUUID().toString()) else it } - if (next == mutableState.value) return next + if (next == mutableState.value && !clearToken) return next val editor = preferences.edit() - if (next.authToken != mutableState.value.authToken) { - require(validAuthToken(next.authToken)) - editor.putString("auth_token_encrypted", android.util.Base64.encodeToString( + if (next.authToken != mutableState.value.authToken || clearToken) { + require(!clearToken || next.authToken.isEmpty()) + require(next.authToken.isEmpty() || validAuthToken(next.authToken)) + if (next.authToken.isEmpty()) editor.remove("auth_token_encrypted") + else editor.putString("auth_token_encrypted", android.util.Base64.encodeToString( cipher.encrypt(next.authToken), android.util.Base64.NO_WRAP)) } if (!editor diff --git a/app/src/main/java/life/andre/message487/MainActivity.kt b/app/src/main/java/life/andre/message487/MainActivity.kt index 46ebaa8..81001c8 100644 --- a/app/src/main/java/life/andre/message487/MainActivity.kt +++ b/app/src/main/java/life/andre/message487/MainActivity.kt @@ -160,6 +160,7 @@ internal fun MessageScreen(model: ConnectionViewModel = viewModel(), checkReques } } } + EventPreviewDialogs(model, state.busy, settings.paused) if (crash) AlertDialog(onDismissRequest = { dismissCrash() }, icon = { Icon(Icons.Outlined.BugReport, null) }, title = { Text(stringResource(R.string.crash_title)) }, diff --git a/app/src/main/java/life/andre/message487/MessageGraph.kt b/app/src/main/java/life/andre/message487/MessageGraph.kt index e2bc515..5ffbc07 100644 --- a/app/src/main/java/life/andre/message487/MessageGraph.kt +++ b/app/src/main/java/life/andre/message487/MessageGraph.kt @@ -77,18 +77,31 @@ class MessageGraph internal constructor(private val context: Application) { val images = try { testMessageImages(context, config, options) } catch (error: ImageMemoryException) { captureFailed(error); emptyList() } val event = MessageEvent(config.deviceId, config.deviceCode, sources.resolve(context.packageName), images = images) - try { enqueue(event, config) } finally { discardImages(images) } + try { enqueue(event, config, manualTest = true) } finally { discardImages(images) } } fun captureNotification(notification: CapturedNotification) { val config = settings.load() 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, images = notification.images, - ) - enqueue(event, config) + enqueue(notificationEvent(notification, config), config) + } + + internal fun notificationEvent(notification: CapturedNotification, config: ForwardingSettings) = MessageEvent( + config.deviceId, config.deviceCode, sources.resolve(notification.packageName), + occurredAt = Instant.ofEpochMilli(notification.postedAt).toString(), + messageType = "notification", text = notification.text, title = notification.title, + images = notification.images, channelId = notification.channelId, + ) + + internal fun previewTest(config: ForwardingSettings, options: TestMessageOptions) = MessageEvent( + config.deviceId, config.deviceCode, sources.resolve(context.packageName), + images = testMessageImages(context, config, options), + ) + + internal fun sendPreview(event: MessageEvent, config: ForwardingSettings): Boolean { + val manualTest = event.messageType == "test" + require(config.ready() && !pauseBlocksDelivery(settings.load().paused, manualTest)) + return enqueue(event, config, manualTest = manualTest) } fun captureSms(sender: String, text: String, timestamp: Long) { @@ -102,11 +115,13 @@ class MessageGraph internal constructor(private val context: Application) { enqueue(event, config) } - private fun enqueue(event: MessageEvent, config: ForwardingSettings) { - if (outbox.enqueue(event, config)) { + private fun enqueue(event: MessageEvent, config: ForwardingSettings, manualTest: Boolean = false): Boolean { + val queued = outbox.enqueue(event, config) + if (queued) { diagnostics.record(DiagnosticEvent.EVENT_QUEUED, type = event.messageType) - scheduler.schedule(event.eventId).result.get(30, java.util.concurrent.TimeUnit.SECONDS) + scheduler.schedule(event.eventId, manualTest = manualTest).result.get(30, java.util.concurrent.TimeUnit.SECONDS) } else diagnostics.record(DiagnosticEvent.DUPLICATE_SKIPPED, type = event.messageType) + return queued } private val failurePending = java.util.concurrent.atomic.AtomicBoolean() diff --git a/app/src/main/java/life/andre/message487/NotificationCaptureService.kt b/app/src/main/java/life/andre/message487/NotificationCaptureService.kt index f79f0af..a8866e1 100644 --- a/app/src/main/java/life/andre/message487/NotificationCaptureService.kt +++ b/app/src/main/java/life/andre/message487/NotificationCaptureService.kt @@ -14,6 +14,8 @@ data class CapturedNotification( val text: String, val postedAt: Long, val images: List = emptyList(), + val channelId: String? = null, + val imageWarning: Boolean = false, ) object ListenerState { @@ -23,6 +25,7 @@ object ListenerState { class NotificationCaptureService : NotificationListenerService() { override fun onListenerConnected() { + activeListener = java.lang.ref.WeakReference(this) MessageGraph.get(this).diagnostics.record(DiagnosticEvent.LISTENER_CONNECTED) ListenerState.mutableConnected.value = true val graph = MessageGraph.get(this) @@ -32,11 +35,13 @@ class NotificationCaptureService : NotificationListenerService() { } override fun onListenerDisconnected() { + if (activeListener.get() === this) activeListener.clear() MessageGraph.get(this).diagnostics.record(DiagnosticEvent.LISTENER_DISCONNECTED) ListenerState.mutableConnected.value = false } override fun onDestroy() { + if (activeListener.get() === this) activeListener.clear() ListenerState.mutableConnected.value = false super.onDestroy() } @@ -58,12 +63,9 @@ class NotificationCaptureService : NotificationListenerService() { 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") - ?: "" - val captured = CapturedNotification(sbn.key, sbn.packageName, title, text, sbn.postTime) + val captured = notificationContent(sbn) + val title = captured.title + val text = captured.text val hasImages = config.forwardAvatars || config.forwardAppIcons || extras.containsKey(Notification.EXTRA_PICTURE) || extras.containsKey(Notification.EXTRA_MESSAGES) || (android.os.Build.VERSION.SDK_INT >= 31 && extras.containsKey(Notification.EXTRA_PICTURE_ICON)) @@ -95,4 +97,34 @@ class NotificationCaptureService : NotificationListenerService() { } catch (error: Exception) { graph.captureFailed(error) } } + companion object { + @Volatile private var activeListener = java.lang.ref.WeakReference(null) + + internal fun previewCandidates(packages: Set): List = + requireNotNull(activeListener.get()) { "Notification listener disconnected" }.activeNotifications + .filter { it.packageName in packages && it.isClearable && + it.notification.flags and Notification.FLAG_GROUP_SUMMARY == 0 } + .sortedByDescending { it.postTime }.take(20).map(::notificationContent) + + internal fun preview(key: String, config: ForwardingSettings): CapturedNotification { + val listener = requireNotNull(activeListener.get()) { "Notification listener disconnected" } + val current = listener.getActiveNotifications(arrayOf(key)).firstOrNull() + ?: error("Notification is no longer available") + require(current.packageName in config.packages && current.packageName != listener.packageName) + var imageWarning = false + val images = notificationImages(listener, current.notification, config, current.packageName) { imageWarning = true } + return notificationContent(current).copy(images = images, imageWarning = imageWarning) + } + } + +} + +internal fun notificationContent(sbn: StatusBarNotification): CapturedNotification { + val extras = sbn.notification.extras + val text = extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString() + ?: extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() + ?: extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES)?.joinToString("\n") ?: "" + return CapturedNotification(sbn.key, sbn.packageName, + extras.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty(), text, sbn.postTime, + channelId = sbn.notification.channelId) } diff --git a/app/src/main/java/life/andre/message487/Outbox.kt b/app/src/main/java/life/andre/message487/Outbox.kt index 253ac2c..0c26346 100644 --- a/app/src/main/java/life/andre/message487/Outbox.kt +++ b/app/src/main/java/life/andre/message487/Outbox.kt @@ -52,6 +52,7 @@ fun deliveryQueueState(result: DeliveryResult): QueueState = when (result.status class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHelper(context, "outbox.db", null, 4) { internal val files by lazy { File(context.noBackupFilesDir, "outbox").apply { mkdirs() } } + internal val previewDirectories: MutableSet = java.util.concurrent.ConcurrentHashMap.newKeySet() private val mutableRevision = MutableStateFlow(0L) val revision = mutableRevision.asStateFlow() @@ -352,7 +353,7 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe buildSet { while (it.moveToNext()) add(it.getString(0)) } } val cutoff = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5) - files.listFiles()?.filter { it.isDirectory && it.name !in referenced && it.lastModified() < cutoff } + files.listFiles()?.filter { it.isDirectory && it.name !in referenced && it !in previewDirectories && it.lastModified() < cutoff } ?.forEach { it.deleteRecursively() } } diff --git a/app/src/main/java/life/andre/message487/OverviewScreen.kt b/app/src/main/java/life/andre/message487/OverviewScreen.kt index b3a4258..b9a7d24 100644 --- a/app/src/main/java/life/andre/message487/OverviewScreen.kt +++ b/app/src/main/java/life/andre/message487/OverviewScreen.kt @@ -108,7 +108,7 @@ internal fun OverviewScreen(settings: ForwardingSettings, permissions: Permissio SupportingText(host.ifEmpty { stringResource(R.string.not_configured) }) } } - Button(onClick = model::sendTest, enabled = !busy && settings.ready() && !settings.paused, + Button(onClick = model::sendTest, enabled = !busy && settings.ready(), modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { Icon(Icons.AutoMirrored.Outlined.Send, null, Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) @@ -116,6 +116,7 @@ internal fun OverviewScreen(settings: ForwardingSettings, permissions: Permissio } } } + item { DeliveryChecks(settings, permissions, connected, queue, model, busy, onConnection, onSources, onJournal) } item { AppInfo() } } } diff --git a/app/src/main/java/life/andre/message487/SettingsTransfer.kt b/app/src/main/java/life/andre/message487/SettingsTransfer.kt new file mode 100644 index 0000000..a79b5ba --- /dev/null +++ b/app/src/main/java/life/andre/message487/SettingsTransfer.kt @@ -0,0 +1,88 @@ +package life.andre.message487 + +import org.json.JSONArray +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.io.InputStream + +internal const val MAX_SETTINGS_FILE_BYTES = 1024 * 1024 + +/** Deliberately excludes installation identity, permissions, queue, history and diagnostics. */ +internal fun exportSettings(settings: ForwardingSettings, includeToken: Boolean = true): String = JSONObject().apply { + put("format", "message487-settings") + put("version", 1) + put("url", settings.url) + put("device_code", settings.deviceCode) + if (includeToken) put("auth_token", settings.authToken) + put("packages", JSONArray(settings.packages.sorted())) + put("require_ack", settings.requireAck) + put("notifications", settings.notifications) + put("sms", settings.sms) + put("auto_select_new_apps", settings.autoSelectNewApps) + put("sms_deduplication", settings.smsDeduplication) + put("notification_deduplication", settings.notificationDeduplication) + put("deduplication_window_seconds", settings.deduplicationWindowSeconds) + put("image_format", settings.imageFormat.name) + put("image_quality", settings.imageQuality) + put("image_limits_enabled", settings.imageLimitsEnabled) + put("image_max_count", settings.imageMaxCount) + put("image_max_edge", settings.imageMaxEdge) + put("image_max_kib", settings.imageMaxKiB) + put("image_total_kib", settings.imageTotalKiB) + put("forward_avatars", settings.forwardAvatars) + put("forward_app_icons", settings.forwardAppIcons) +}.toString(2) + +internal fun readSettingsFile(input: InputStream): String { + val output = ByteArrayOutputStream() + val buffer = ByteArray(8192) + while (true) { + val count = input.read(buffer) + if (count < 0) break + require(output.size() + count <= MAX_SETTINGS_FILE_BYTES) + output.write(buffer, 0, count) + } + return Charsets.UTF_8.newDecoder().decode(java.nio.ByteBuffer.wrap(output.toByteArray())).toString() +} + +/** Parse everything before changing the store. Never reuse an old token for a new destination. */ +internal fun importSettings(json: String, current: ForwardingSettings, allowLocalHttp: Boolean = BuildConfig.DEBUG): ForwardingSettings { + require(json.toByteArray(Charsets.UTF_8).size <= MAX_SETTINGS_FILE_BYTES) + val data = JSONObject(json) + fun string(key: String): String = (data.get(key) as? String) ?: error("Invalid string field") + fun boolean(key: String): Boolean = (data.get(key) as? Boolean) ?: error("Invalid boolean field") + fun number(key: String): Int { + val value = data.get(key) + require(value is Int || value is Long) + val long = (value as Number).toLong() + require(long in 0..Int.MAX_VALUE.toLong()) + return long.toInt() + } + require(string("format") == "message487-settings" && number("version") == 1) + val url = string("url") + require(url.isEmpty() || validWebhookUrl(url, allowLocalHttp)) + val code = string("device_code") + require(code.isNotBlank() && code.length <= 4096) + val token = if (data.has("auth_token")) string("auth_token") else "" + require(token.isEmpty() || validAuthToken(token)) + val packages = data.getJSONArray("packages") + require(packages.length() <= 10_000) + val selected = (0 until packages.length()).map { + val pkg = packages.get(it) as? String ?: error("Invalid package") + require(pkg.length <= 255 && pkg.matches(Regex("[A-Za-z0-9_]+(\\.[A-Za-z0-9_]+)*"))) + require(pkg != "life.andre.message487") + pkg + }.toSet() + return current.copy( + url = url, deviceCode = code, authToken = token, paused = true, + packages = selected, requireAck = boolean("require_ack"), + notifications = boolean("notifications"), sms = boolean("sms"), + autoSelectNewApps = boolean("auto_select_new_apps"), + smsDeduplication = boolean("sms_deduplication"), notificationDeduplication = boolean("notification_deduplication"), + deduplicationWindowSeconds = number("deduplication_window_seconds"), + imageFormat = NotificationImageFormat.valueOf(string("image_format")), imageQuality = number("image_quality"), + imageLimitsEnabled = boolean("image_limits_enabled"), imageMaxCount = number("image_max_count"), + imageMaxEdge = number("image_max_edge"), imageMaxKiB = number("image_max_kib"), imageTotalKiB = number("image_total_kib"), + forwardAvatars = boolean("forward_avatars"), forwardAppIcons = boolean("forward_app_icons"), + ) +} diff --git a/app/src/main/java/life/andre/message487/SettingsTransferPanel.kt b/app/src/main/java/life/andre/message487/SettingsTransferPanel.kt new file mode 100644 index 0000000..75b3495 --- /dev/null +++ b/app/src/main/java/life/andre/message487/SettingsTransferPanel.kt @@ -0,0 +1,64 @@ +package life.andre.message487 + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.verticalScroll +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.semantics.Role +import androidx.compose.ui.unit.dp + +@Composable +internal fun SettingsTransferPanel(model: ConnectionViewModel, busy: Boolean) { + var includeToken by rememberSaveable { mutableStateOf(true) } + val export = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> + if (uri != null) model.exportDocument(uri, includeToken) + } + val import = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) model.importDocument(uri) + } + val pending by model.pendingImport.collectAsState() + Panel { + SectionTitle(stringResource(R.string.settings_transfer)) + SupportingText(stringResource(R.string.settings_transfer_hint)) + Surface(color = MaterialTheme.colorScheme.tertiaryContainer, shape = MaterialTheme.shapes.small) { + Column(Modifier.padding(12.dp)) { + Row(Modifier.fillMaxWidth().heightIn(min = 48.dp).toggleable( + value = includeToken, enabled = !busy, role = Role.Switch, onValueChange = { includeToken = it }), + verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.export_include_token), Modifier.weight(1f)) + Switch(checked = includeToken, onCheckedChange = null, enabled = !busy) + } + Text(stringResource(if (includeToken) R.string.export_token_warning else R.string.export_without_token), + style = MaterialTheme.typography.bodySmall) + } + } + Button(onClick = { export.launch("message487-settings.json") }, enabled = !busy, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.export_settings)) + } + OutlinedButton(onClick = { import.launch(arrayOf("application/json", "text/plain", "application/octet-stream")) }, + enabled = !busy, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.import_settings)) } + } + pending?.let { settings -> + AlertDialog(onDismissRequest = { if (!busy) model.cancelImport() }, + title = { Text(stringResource(R.string.import_settings)) }, + text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.import_confirmation)) + Text(stringResource(R.string.import_destination, settings.url)) + Text(stringResource(R.string.import_device, settings.deviceCode)) + Text(stringResource(R.string.import_sources, settings.packages.size)) + Text(stringResource(if (settings.notifications) R.string.import_notifications_on else R.string.import_notifications_off)) + Text(stringResource(if (settings.sms) R.string.import_sms_on else R.string.import_sms_off)) + Text(stringResource(if (settings.authToken.isEmpty()) R.string.import_token_missing else R.string.import_token_present)) + } }, + confirmButton = { TextButton(onClick = model::confirmImport, enabled = !busy) { Text(stringResource(R.string.apply_settings)) } }, + dismissButton = { TextButton(onClick = model::cancelImport, enabled = !busy) { Text(stringResource(R.string.cancel)) } }) + } +} diff --git a/app/src/main/java/life/andre/message487/SourcesScreen.kt b/app/src/main/java/life/andre/message487/SourcesScreen.kt index a20c53c..ccc63d0 100644 --- a/app/src/main/java/life/andre/message487/SourcesScreen.kt +++ b/app/src/main/java/life/andre/message487/SourcesScreen.kt @@ -78,6 +78,16 @@ internal fun SourcesScreen(settings: ForwardingSettings, permissions: Permission } } } + item { + Panel { + SectionTitle(stringResource(R.string.preview_notifications)) + SupportingText(stringResource(R.string.preview_capture_hint)) + OutlinedButton(onClick = model::listNotificationPreviews, + enabled = !busy && permissions.notifications && connected && settings.packages.isNotEmpty()) { + Text(stringResource(R.string.preview_notifications)) + } + } + } item { Panel { SourceSwitch(Icons.Outlined.Sms, R.string.sms_enabled, R.string.sms_hint, settings.sms, !busy) { diff --git a/app/src/main/java/life/andre/message487/WebhookClient.kt b/app/src/main/java/life/andre/message487/WebhookClient.kt index 9bfd9b7..f1960ad 100644 --- a/app/src/main/java/life/andre/message487/WebhookClient.kt +++ b/app/src/main/java/life/andre/message487/WebhookClient.kt @@ -34,6 +34,7 @@ data class MessageEvent( val title: String? = null, val sender: String? = null, val images: List = emptyList(), + val channelId: String? = null, ) { /** Image bytes are appended separately by QueuedRequest.writeTo to keep the body streaming. */ fun toJson(): String = JSONObject() @@ -48,6 +49,7 @@ data class MessageEvent( .put("text", text) .put("title", title) .put("sender", sender) + .put("channel_id", when (messageType) { "sms" -> "sms"; "test" -> "test"; else -> channelId }) .toString() } diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index a1c7048..29f4d17 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -191,4 +191,62 @@ Галерея (2 изображения) Аватар Значок приложения + Импорт и экспорт настроек + Экспортируются сохранённые подключение, источники и настройки изображений. Несохранённые изменения полей не включаются. Файл не зашифрован и может содержать секретный адрес веб-хука. + Включить Bearer-токен в экспорт + Включено по умолчанию. Токен будет в файле открытым текстом. Любой получивший файл сможет его прочитать. Выключите переключатель, чтобы исключить токен. + Токен исключён. При импорте текущий токен на принимающем устройстве будет очищен; введите его отдельно. + Экспортировать настройки + Импортировать настройки + Применить настройки + Заменить сохранённые подключение, источники и настройки изображений? Пересылка будет на паузе до проверки подключения и вашего возобновления. Очередь, история, идентификатор установки и разрешения Android сохраняются. + Веб-хук: %1$s + Код устройства: %1$s + Выбрано приложений: %1$d + Сбор уведомлений: включён + Сбор уведомлений: выключен + Сбор SMS: включён + Сбор SMS: выключен + В файле нет токена. Текущий токен будет очищен. + Токен из файла заменит текущий. + Настройки экспортированы + Настройки импортированы. Проверьте подключение и возобновите пересылку. + Не удалось импортировать настройки: неверный, неподдерживаемый, слишком большой или недоступный файл. Настройки не изменены. + Предпросмотр текущих уведомлений + Прочитать до 20 последних текущих уведомлений выбранных приложений, включая содержимое. Это не отправляет данные и не останавливает автоматическую пересылку. Для проверки до доставки сначала включите паузу. + Нет текущих уведомлений выбранных приложений. Создайте новое уведомление и попробуйте снова. + Предпросмотр события + Только предпросмотр: открытие этого окна не отправляет событие. Автоматический сбор работает независимо. + Пересылка на паузе. Закройте предпросмотр и возобновите её, когда будете готовы отправить. + Получатель: %1$s + Изображения выше обработаны с выбранными форматом и ограничениями. Их байты передаются в Base64 в массиве images; показанный JSON содержит только текстовые метаданные. Учётные данные скрыты. + Изображение для пересылки + Отправить это событие + Почему сообщение не переслалось? + Проверяйте сбор и доставку отдельно. Уведомления и SMS при выключенном сборе или паузе не сохраняются. Android может скрывать чувствительное содержимое. + Сохранённые адрес, код устройства и токен имеют допустимый формат. Отправьте тест для проверки получателя. + Укажите корректные адрес веб-хука, код устройства и Bearer-токен. + Источник выключен. Включите его для сбора новых событий. + Приложения не выбраны. Выберите источники пересылки. + Доступ выдан, но служба уведомлений отключена. Попробуйте переподключить её. + Доступ выдан, служба подключена, приложения выбраны. Создайте новое уведомление; дубли и постоянные уведомления могут пропускаться. + Разрешение на получение SMS выдано. Проверьте новое входящее SMS, а не сообщение из истории. + Сеть + Android сообщает об активной сети. Это не доказывает доступность веб-хука. + Нет активной сети. Доставка ожидает подключения. + Обновить проверки + Фоновая доставка + Android сообщает об ограничениях фоновой работы. Проверьте настройки батареи приложения. + Явные ограничения фоновой работы не обнаружены. Doze, настройки батареи и планировщик Android могут задерживать доставку. После принудительной остановки откройте приложение. + Сервер отклонил авторизацию. Проверьте Bearer-токен. События в очереди сохраняют прежние учётные данные. + Получатель не вернул status accepted с соответствующим event_id. Исправьте ответ или выберите HTTP-подтверждение для своего веб-хука. Режим подтверждения сохранённых событий не меняется. + Доставка требует внимания. Откройте событие в журнале, исправьте ошибку сервера или локального хранения и повторите вручную. + После временной ошибки ожидается автоматическая попытка с задержкой. Проверьте сеть и сервер. Можно повторить из журнала. + Запрос выполняется. Дождитесь результата в журнале. + Сохранено локально; отправка ожидает планировщика Android. Проверьте паузу, сеть и ограничения батареи. + Веб-хук принял событие; доставка в конечный сервис не подтверждена. + Часть изображений уведомления недоступна. Предпросмотр содержит только доступные текст и изображения. + Событие сохранено в очередь. Результат появится в журнале. + Событие совпало с прежним и пропущено фильтром дублей. Новый запрос не добавлен в очередь. + Автоматическая пересылка на паузе. Можно отправить этот тест, не возобновляя сбор и доставку сообщений из очереди. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2c3deda..45412ac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -191,4 +191,62 @@ Gallery (2 images) Avatar Application icon + Import and export settings + Export saved connection, sources and image settings. Changes in unsaved fields are not included. Files are not encrypted and may contain a secret webhook URL. + Include Bearer token in export + ON by default. The file contains your token in plain text. Anyone with the file can read it. Turn this switch off to exclude the token. + Token excluded. Importing this file clears the receiving device’s current token; enter it separately. + Export settings + Import settings + Apply settings + Replace saved connection, sources and image settings? Forwarding will be paused until you check the connection and resume it. The queue, history, installation identity and Android permissions are preserved. + Webhook: %1$s + Device code: %1$s + Selected apps: %1$d + Notification capture: enabled + Notification capture: disabled + SMS capture: enabled + SMS capture: disabled + No token in file. The current token will be cleared. + Token from file will replace the current token. + Settings exported + Settings imported. Check the connection, then resume forwarding. + Cannot import settings: invalid, unsupported, too large or unreadable file. Nothing was changed. + Preview current notifications + Read up to 20 recent active notifications from selected apps, including their contents. This does not send anything or pause automatic forwarding. You can pause first to inspect before delivery. + No active notifications from selected apps. Post a new notification and try again. + Preview event + Preview only: opening this view does not send the event. Automatic capture continues independently. + Forwarding is paused. Close this preview and resume when ready to send. + Destination: %1$s + Images shown above use the selected capture format and limits. Their bytes are sent as Base64 in the images array; the JSON shown contains text metadata only. Credentials are hidden. + Image to be forwarded + Send this event + Why was a message not forwarded? + Check capture and delivery separately. Notifications or SMS received while capture is off or paused are not saved. Android may hide sensitive content. + Saved URL, device code and token are valid. Send a test to check the receiver. + Configure a valid webhook URL, device code and Bearer token. + This source is disabled. Enable it if you want to capture new events. + No applications selected. Select the apps to forward. + Access is granted, but the notification listener is disconnected. Try reconnecting. + Access is granted, listener connected and apps selected. Try a new notification; duplicate and ongoing notifications may be skipped. + Receive-SMS permission granted. Test with a new incoming SMS, not an old message. + Network + Android reports an active network. This does not prove the webhook is reachable. + No active network. Delivery waits for connectivity. + Refresh checks + Background delivery + Android reports background restrictions for this app. Review its battery settings. + No explicit background restriction reported. Doze, battery settings and Android scheduling can still delay delivery. After force-stop, reopen the app. + The server rejected authorization. Check the Bearer token. Existing queued events retain their original credentials. + The receiver did not return status accepted with the matching event_id. Fix its response, or use HTTP confirmation for a custom webhook. Existing events retain their confirmation mode. + Delivery needs attention. Open the event in Journal, fix the receiver or local error, then retry manually. + A temporary failure is waiting for an automatic retry with backoff. Check the network and receiver. You can retry from Journal. + A request is in progress. Wait for its result in Journal. + Saved locally and waiting for Android to schedule delivery. Check pause, network and battery restrictions. + The webhook accepted this event; downstream delivery is not confirmed. + Some notification images could not be read. The preview includes available text and images only. + Event saved to the queue. Check Journal for its result. + This event matches a previous capture and was skipped by the duplicate filter. No new request was queued. + Automatic forwarding is paused. You can send this test without resuming automatic capture or queued message delivery. diff --git a/app/src/test/java/life/andre/message487/DeliveryToolsTest.kt b/app/src/test/java/life/andre/message487/DeliveryToolsTest.kt new file mode 100644 index 0000000..36597ec --- /dev/null +++ b/app/src/test/java/life/andre/message487/DeliveryToolsTest.kt @@ -0,0 +1,169 @@ +package life.andre.message487 + +import android.app.Notification +import android.service.notification.StatusBarNotification +import org.json.JSONObject +import org.junit.Assert.* +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 = UiTestApplication::class, sdk = [26, 35]) +class DeliveryToolsTest { + private val source = ForwardingSettings(url = "https://receiver.example/webhook", authToken = "export-token", + deviceId = "source-installation", deviceCode = "my-phone", notifications = true, sms = true, + packages = setOf("example.chat", "example.bank"), autoSelectNewApps = true, + imageFormat = NotificationImageFormat.PNG, imageMaxCount = 6, imageMaxEdge = 2048, + imageMaxKiB = 1024, imageTotalKiB = 4096, imageQuality = 43, imageLimitsEnabled = false, + smsDeduplication = false, notificationDeduplication = false, deduplicationWindowSeconds = 4, + forwardAvatars = true, forwardAppIcons = true, requireAck = false) + + @Test fun `transfer defaults to token included but opt out clears target token and preserves identity`() { + val current = ForwardingSettings(deviceId = "target-installation", authToken = "old-token", + knownPackages = setOf("example.excluded"), captureFailed = true) + val exported = exportSettings(source) + assertEquals("export-token", JSONObject(exported).getString("auth_token")) + assertFalse(JSONObject(exported).has("device_id")) + assertFalse(JSONObject(exported).has("known_packages")) + val imported = importSettings(exported, current) + assertEquals(source.copy(deviceId = current.deviceId, knownPackages = current.knownPackages, + captureFailed = true, paused = true), imported) + val withoutToken = exportSettings(source, includeToken = false) + assertFalse(JSONObject(withoutToken).has("auth_token")) + assertEquals("", importSettings(withoutToken, current).authToken) + val app = RuntimeEnvironment.getApplication() as UiTestApplication + app.graph.settings.update { source } + app.graph.settings.update { importSettings(withoutToken, it) } + assertEquals("", SettingsStore(app, app.payloadCipher).load().authToken) + } + + @Test fun `invalid imports are rejected before any settings are changed`() { + val invalid = listOf( + "version" to 2, "sms" to "true", "image_quality" to 101, + "image_max_count" to 0, "image_max_edge" to 2.5, + "image_max_kib" to 2147483648L, "image_format" to "GIF", + "url" to "http://untrusted.example/", "auth_token" to "secret\nheader", + "packages" to org.json.JSONArray(listOf("../invalid")), + ) + val app = RuntimeEnvironment.getApplication() as UiTestApplication + val store = app.graph.settings + val original = store.update { source } + for ((key, value) in invalid) { + val data = JSONObject(exportSettings(source)).put(key, value) + assertThrows(Exception::class.java) { store.update { importSettings(data.toString(), it) } } + assertEquals(original, store.load()) + } + assertThrows(Exception::class.java) { importSettings("{", original) } + assertThrows(Exception::class.java) { readSettingsFile(ByteArray(MAX_SETTINGS_FILE_BYTES + 1).inputStream()) } + assertThrows(Exception::class.java) { readSettingsFile(byteArrayOf(0xc3.toByte(), 0x28).inputStream()) } + } + + @Test fun `channel ids survive Android capture persistence and retry and have sms and test defaults`() { + val app = RuntimeEnvironment.getApplication() as UiTestApplication + val notification = Notification.Builder(app, "bank-alerts").setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle("Payment").setContentText("Synthetic amount").build() + val sbn = StatusBarNotification("example.bank", "example.bank", 1, null, 1000, 0, 0, + notification, android.os.Process.myUserHandle(), 1234) + val captured = notificationContent(sbn) + assertEquals("bank-alerts", captured.channelId) + val event = app.graph.notificationEvent(captured, source) + assertEquals("sms", JSONObject(event.copy(messageType = "sms").toJson()).getString("channel_id")) + assertEquals("test", JSONObject(event.copy(messageType = "test").toJson()).getString("channel_id")) + val queue = app.graph.outbox + assertTrue(queue.enqueue(event, source)) + assertFalse(app.graph.sendPreview(event, source.copy(notificationDeduplication = true))) + val first = requireNotNull(queue.beginAttempt(event.eventId)) + assertEquals("bank-alerts", JSONObject(first.request.json).getString("channel_id")) + queue.finish(event.eventId, first.token, DeliveryResult(event.eventId, DeliveryStatus.NETWORK_ERROR)) + val retry = requireNotNull(queue.beginAttempt(event.eventId)) + assertEquals(first.request.json, retry.request.json) + assertEquals("sms", JSONObject(MessageEvent("id", "code", AppSource("android", "Android"), + messageType = "sms").toJson()).getString("channel_id")) + assertEquals("test", JSONObject(MessageEvent("id", "code", AppSource("example", "Example")).toJson()).getString("channel_id")) + } + + @Test fun `preparing a preview does not enqueue and paused delivery blocks real notifications`() { + val app = RuntimeEnvironment.getApplication() as UiTestApplication + val graph = app.graph + val before = graph.outbox.entries() + val preview = graph.previewTest(source, TestMessageOptions()) + assertEquals(before, graph.outbox.entries()) + assertEquals("test", JSONObject(preview.toJson()).getString("channel_id")) + assertFalse(preview.toJson().contains(source.authToken)) + assertThrows(IllegalArgumentException::class.java) { + graph.settings.update { source.copy(paused = true) } + graph.sendPreview(preview.copy(messageType = "notification"), source) + } + assertEquals(before, graph.outbox.entries()) + } + + @Test fun `diagnosis distinguishes auth acknowledgement temporary and local failures`() { + val entry = QueueEntry("id", "notification", "Example", 0, QueueState.BLOCKED, 1, DeliveryStatus.HTTP_ERROR.name, 401) + assertEquals(R.string.check_auth_hint, deliveryAdvice(entry)) + assertEquals(R.string.check_ack_hint, deliveryAdvice(entry.copy(httpCode = 200, outcome = DeliveryStatus.INVALID_ACK.name))) + assertEquals(R.string.check_retry_hint, deliveryAdvice(entry.copy(httpCode = 503, state = QueueState.RETRY))) + assertEquals(R.string.check_blocked_hint, deliveryAdvice(entry.copy(httpCode = null, outcome = "LOCAL_ERROR"))) + } + @Test fun `recovery preserves active preview files and removes them after release`() { + val app = RuntimeEnvironment.getApplication() as UiTestApplication + val queue = app.graph.outbox + val directory = java.io.File(queue.files, "capture-preview-check").apply { mkdirs() } + java.io.File(directory, "image.jpg").writeBytes(byteArrayOf(1, 2, 3)) + assertTrue(directory.setLastModified(1)) + queue.previewDirectories.add(directory) + queue.cleanupFiles() + assertTrue(directory.exists()) + queue.previewDirectories.remove(directory) + queue.cleanupFiles() + assertFalse(directory.exists()) + } + + @Test fun `token excluded import also removes unreadable ciphertext`() { + val app = RuntimeEnvironment.getApplication() as UiTestApplication + app.graph.settings.update { source } + val unavailableKey = object : PayloadCipher { + override fun encrypt(value: String): ByteArray = error("Key unavailable") + override fun decrypt(value: ByteArray): String = error("Key unavailable") + } + val store = SettingsStore(app, unavailableKey) + assertEquals("", store.load().authToken) + val imported = importSettings(exportSettings(source, includeToken = false), store.load()) + store.update(clearToken = true) { imported } + assertFalse(app.getSharedPreferences("connection", android.content.Context.MODE_PRIVATE).contains("auth_token_encrypted")) + assertEquals("", SettingsStore(app, app.payloadCipher).load().authToken) + } + + @Test fun `explicit test previews bypass pause without resuming capture or ordinary delivery`() { + val app = RuntimeEnvironment.getApplication() as UiTestApplication + val tasks = java.util.concurrent.Executors.newSingleThreadExecutor() + androidx.work.WorkManager.initialize(app, androidx.work.Configuration.Builder() + .setExecutor(java.util.concurrent.Executor { }).setTaskExecutor(tasks).build()) + val graph = app.graph + try { + val config = graph.settings.update { source.copy(paused = true) } + val preview = graph.previewTest(config, TestMessageOptions()) + assertTrue(graph.sendPreview(preview, config)) + assertTrue(graph.settings.load().paused) + val manager = androidx.work.WorkManager.getInstance(app) as androidx.work.impl.WorkManagerImpl + val work = manager.getWorkInfosForUniqueWork("event-${preview.eventId}").get(5, java.util.concurrent.TimeUnit.SECONDS).single() + // Room is a runtime dependency of WorkManager, not part of its public compile classpath. + val database = manager.javaClass.getMethod("getWorkDatabase").invoke(manager) + val dao = database.javaClass.getMethod("workSpecDao").invoke(database) as androidx.work.impl.model.WorkSpecDao + val input = tasks.submit { requireNotNull(dao.getWorkSpec(work.id.toString())).input } + .get(5, java.util.concurrent.TimeUnit.SECONDS) + assertTrue(input.getBoolean("manual_test", false)) + assertFalse(pauseBlocksDelivery(true, input.getBoolean("manual_test", false))) + assertTrue(pauseBlocksDelivery(true, false)) + assertFalse(pauseBlocksDelivery(false, false)) + assertEquals(preview.toJson(), requireNotNull(graph.outbox.beginAttempt(preview.eventId)).request.json) + graph.captureSms("sender", "not captured during pause", 1234) + assertEquals(1, graph.outbox.entries().size) + } finally { + tasks.shutdownNow() + } + } + +} diff --git a/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt index 8f5bb96..042ac5e 100644 --- a/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt +++ b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt @@ -296,4 +296,55 @@ class ScreenInteractionTest { private fun SemanticsNodeInteraction.isEnabled(): Boolean = !fetchSemanticsNode().config.contains(androidx.compose.ui.semantics.SemanticsProperties.Disabled) + @Test fun `delivery checks explain unconfigured capture and link to sources`() { + compose.onNode(hasScrollToIndexAction()).performScrollToNode(hasText(application.getString(R.string.delivery_checks))) + node(R.string.delivery_checks).performClick() + node(R.string.check_connection_missing).assertExists() + node(R.string.check_no_apps).assertDoesNotExist() + compose.onAllNodesWithText(compose.activity.getString(R.string.check_source_disabled))[0].assertExists() + } + + @Test fun `export token is on by default and can be excluded and test preview does not enqueue`() { + node(R.string.connection_nav).performClick() + compose.onNode(hasScrollToIndexAction()).performScrollToNode(hasText(application.getString(R.string.export_include_token))) + node(R.string.export_include_token).assertIsOn().performClick() + node(R.string.export_include_token).assertIsOff() + node(R.string.export_without_token).assertExists() + compose.runOnIdle { + application.graph.settings.update { it.copy(paused = true) } + model.setUrl("https://example.test/receive") + model.setDeviceCode("preview-device") + model.setAuthToken("preview-token") + } + compose.onNode(hasScrollToIndexAction()).performScrollToNode(hasText(application.getString(R.string.preview_event))) + node(R.string.preview_event).performClick() + compose.waitUntil(10_000) { compose.waitForIdle(); model.preview.value != null && !model.state.value.busy } + node(R.string.preview_not_sent).assertExists() + node(R.string.preview_test_paused).assertExists() + node(R.string.send_preview).assertIsEnabled() + assertTrue(application.graph.settings.load().paused) + assertEquals(0, application.graph.outbox.pendingCount()) + node(R.string.close).performClick() + assertNull(model.preview.value) + } + + @Test fun `settings import requires confirmation then pauses and preserves local identity`() { + val settings = application.graph.settings + val original = settings.update { it.copy(deviceId = "target-installation", authToken = "old-token") } + val file = java.io.File(application.cacheDir, "import-settings.json") + file.writeText(exportSettings(original.copy(url = "https://new.example/receive", deviceCode = "imported-phone", + authToken = "new-token", notifications = true, packages = setOf("example.alpha")), includeToken = false)) + compose.runOnIdle { model.importDocument(android.net.Uri.fromFile(file)) } + compose.waitUntil(10_000) { compose.waitForIdle(); model.pendingImport.value != null && !model.state.value.busy } + assertEquals(original, settings.load()) + compose.runOnIdle { model.confirmImport() } + compose.waitUntil(10_000) { compose.waitForIdle(); model.pendingImport.value == null && !model.state.value.busy } + assertEquals("target-installation", settings.load().deviceId) + assertEquals("https://new.example/receive", settings.load().url) + assertEquals("", settings.load().authToken) + assertTrue(settings.load().paused) + assertEquals("imported-phone", model.state.value.deviceCode) + assertEquals(0, application.graph.outbox.pendingCount()) + } + } diff --git a/app/src/test/java/life/andre/message487/UpdateNotificationsTest.kt b/app/src/test/java/life/andre/message487/UpdateNotificationsTest.kt index 329d314..e6570e0 100644 --- a/app/src/test/java/life/andre/message487/UpdateNotificationsTest.kt +++ b/app/src/test/java/life/andre/message487/UpdateNotificationsTest.kt @@ -57,7 +57,8 @@ class UpdateNotificationsTest { assertEquals(OPEN_UPDATES_ACTION, open.action) assertEquals(MainActivity::class.java.name, open.component!!.className) UpdateActionReceiver().onReceive(app, shadowOf(notification.actions[1].actionIntent).savedIntent) - await { !UpdatePreferences(app).shouldNotify(update, UPDATE_REMINDER_INTERVAL * 2) } + await { !UpdatePreferences(app).shouldNotify(update, UPDATE_REMINDER_INTERVAL * 2) && + shadowOf(notifications).getNotification(UPDATE_NOTIFICATION_ID) == null } assertNull(shadowOf(notifications).getNotification(UPDATE_NOTIFICATION_ID)) assertFalse(UpdatePreferences(app).shouldNotify(update, UPDATE_REMINDER_INTERVAL * 2)) assertTrue(UpdatePreferences(app).shouldNotify(update.copy(version = "0.1.3"), 1001)) diff --git a/docs/en/n8n-self-hosted.md b/docs/en/n8n-self-hosted.md index fb92f08..1871eed 100644 --- a/docs/en/n8n-self-hosted.md +++ b/docs/en/n8n-self-hosted.md @@ -144,6 +144,7 @@ Example notification body: | `device_id` | Installation ID | | `device_code` | Editable device label | | `message_type` | `test`, `notification` or `sms` | +| `channel_id` | String: Android notification channel ID, `sms` for SMS, `test` for tests; match with `source`. May be absent in older queued events or notifications without a channel ID. | | `occurred_at` | Event timestamp in ISO 8601 format | | `source` / `source_name` | Source package / app name; SMS uses source `android` | | `title` | Notification title; absent for SMS and tests | diff --git a/docs/ru/n8n-self-hosted.md b/docs/ru/n8n-self-hosted.md index a15f8ea..762c293 100644 --- a/docs/ru/n8n-self-hosted.md +++ b/docs/ru/n8n-self-hosted.md @@ -151,6 +151,7 @@ debug-сборке. На физическом телефоне `localhost` об | `device_id` | ID установки приложения | | `device_code` | Редактируемая метка устройства | | `message_type` | `test`, `notification` или `sms` | +| `channel_id` | Строка: ID канала уведомления Android, `sms` для SMS, `test` для тестов; используйте вместе с `source`. Может отсутствовать в старой очереди или у уведомления без ID канала. | | `occurred_at` | Время события в формате ISO 8601 | | `source` / `source_name` | Пакет источника / имя приложения; для SMS источник `android` | | `title` | Заголовок уведомления; отсутствует у SMS и теста | diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 187e7aa..15c18fc 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -22,6 +22,9 @@ DELIVERY AND CONTROL • Pause capture and queued delivery, check delivery status, and manually retry or delete events in Journal. • Identify your phone using a custom device code. Retries preserve the event ID so your receiver can deduplicate requests. • Use English or Russian interfaces and light or dark themes. +• Check capture permissions, listener connectivity, network and delivery failures in one place. +• Preview test events or current notifications and images without sending them. +• Import and export settings. The unencrypted export includes the token by default, with a visible switch to exclude it. • View local diagnostics and optionally share a report by email. Journal shows delivery metadata, not message contents or image previews. Webhook acceptance does not prove delivery to Telegram or another downstream service. Android battery and network restrictions can delay delivery, and lost responses can cause duplicate requests. diff --git a/fastlane/metadata/android/ru-RU/full_description.txt b/fastlane/metadata/android/ru-RU/full_description.txt index 85f7861..6a45a9b 100644 --- a/fastlane/metadata/android/ru-RU/full_description.txt +++ b/fastlane/metadata/android/ru-RU/full_description.txt @@ -22,6 +22,9 @@ Message487 передаёт выбранные уведомления Android и • Приостанавливайте сбор и отправку очереди, проверяйте статусы, повторяйте или удаляйте события в «Журнале». • Задайте собственный код устройства. При повторах сохраняется идентификатор события, по которому сервер может устранять дубли запросов. • Используйте русский или английский интерфейс, светлую или тёмную тему. +• Проверяйте разрешения, подключение службы уведомлений, сеть и ошибки доставки на одном экране. +• Просматривайте тестовые события или текущие уведомления и изображения без отправки. +• Импортируйте и экспортируйте настройки. Незашифрованный экспорт по умолчанию включает токен; заметный переключатель позволяет исключить его. • Просматривайте локальную диагностику и по желанию отправляйте отчёт по почте. Журнал показывает метаданные доставки, а не содержимое сообщений или изображения. Приём веб-хуком не доказывает доставку в Telegram или другой конечный сервис. Ограничения батареи и сети Android могут задерживать отправку, а потеря ответа сервера — приводить к повторным запросам.