From 4e60f48e2e0c572f93c4dc70b07938e36c9366b7 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Thu, 15 Oct 2026 20:39:37 +0300 Subject: [PATCH 1/3] fix: harden Android capture, storage and background operations --- .../message487/StorageDeliveryDeviceTest.kt | 36 +++++++ .../java/life/andre/message487/AppUpdates.kt | 71 +++++++------ .../andre/message487/BackgroundOperations.kt | 48 +++++++++ .../andre/message487/ConnectionViewModel.kt | 33 +++++-- .../life/andre/message487/DeliveryWorker.kt | 8 +- .../andre/message487/ForwardingSettings.kt | 48 ++++----- .../andre/message487/HttpRequestControl.kt | 59 +++++++++++ .../java/life/andre/message487/ImageMemory.kt | 25 +++++ .../life/andre/message487/MainActivity.kt | 7 +- .../life/andre/message487/MessageGraph.kt | 48 +++++---- .../message487/NotificationCaptureService.kt | 8 +- .../message487/NotificationImageQueue.kt | 9 +- .../andre/message487/NotificationImages.kt | 14 ++- .../main/java/life/andre/message487/Outbox.kt | 44 +++++++-- .../life/andre/message487/SafePreferences.kt | 19 ++++ .../andre/message487/SmsCaptureReceiver.kt | 6 +- .../life/andre/message487/SourcesScreen.kt | 17 +++- .../andre/message487/TestMessageImages.kt | 11 ++- .../andre/message487/UpdateNotifications.kt | 71 ++++++------- .../life/andre/message487/WebhookClient.kt | 38 +++++-- .../message487/diagnostics/CrashHandler.kt | 3 +- .../message487/AndroidFailureRecoveryTest.kt | 99 +++++++++++++++++++ .../life/andre/message487/MessageGraphTest.kt | 5 +- .../java/life/andre/message487/OutboxTest.kt | 18 ++++ .../andre/message487/ScreenInteractionTest.kt | 1 + .../message487/SettingsLoadRecoveryTest.kt | 48 +++++++++ .../andre/message487/WebhookClientTest.kt | 28 ++++++ docs/en/testing.md | 12 +++ docs/ru/testing.md | 14 +++ 29 files changed, 691 insertions(+), 157 deletions(-) create mode 100644 app/src/main/java/life/andre/message487/BackgroundOperations.kt create mode 100644 app/src/main/java/life/andre/message487/HttpRequestControl.kt create mode 100644 app/src/main/java/life/andre/message487/ImageMemory.kt create mode 100644 app/src/main/java/life/andre/message487/SafePreferences.kt create mode 100644 app/src/test/java/life/andre/message487/AndroidFailureRecoveryTest.kt create mode 100644 app/src/test/java/life/andre/message487/SettingsLoadRecoveryTest.kt diff --git a/app/src/androidTest/java/life/andre/message487/StorageDeliveryDeviceTest.kt b/app/src/androidTest/java/life/andre/message487/StorageDeliveryDeviceTest.kt index 1ea1c1b..e549a80 100644 --- a/app/src/androidTest/java/life/andre/message487/StorageDeliveryDeviceTest.kt +++ b/app/src/androidTest/java/life/andre/message487/StorageDeliveryDeviceTest.kt @@ -1,5 +1,6 @@ package life.andre.message487 +import kotlinx.coroutines.async import android.content.Context import androidx.core.content.FileProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -81,6 +82,41 @@ class StorageDeliveryDeviceTest { } } + @Test fun allocationFailureFallsBackToTextAndNextCaptureStillRuns() { + val delivered = java.util.concurrent.CountDownLatch(2) + val errors = java.util.concurrent.atomic.AtomicInteger() + NotificationImageQueue().use { queue -> + queue.submit("failed", { withImageMemory> { throw OutOfMemoryError("injected") } }, + { assertTrue(it.isEmpty()); delivered.countDown() }, + { assertTrue(it is ImageMemoryException); errors.incrementAndGet() }) + queue.submit("next", { withImageMemory { emptyList() } }, { delivered.countDown() }, { throw it }) + assertTrue(delivered.await(3, TimeUnit.SECONDS)) + assertEquals(1, errors.get()) + } + } + + @Test fun androidHttpDeadlineAndCoroutineCancellationReleaseConnections() { + MockWebServer().use { server -> + server.start() + server.enqueue(MockResponse().setBody("{\"status\":\"accepted\",\"event_id\":\"a\"}") + .throttleBody(1, 50, TimeUnit.MILLISECONDS)) + val result = WebhookClient(2_000, 200).sendJson(server.url("/").toString(), "a", "{}", true, token) + assertEquals(DeliveryStatus.TIMEOUT, result.status) + assertNotNull(server.takeRequest(2, TimeUnit.SECONDS)) + server.enqueue(MockResponse().setSocketPolicy(okhttp3.mockwebserver.SocketPolicy.NO_RESPONSE)) + kotlinx.coroutines.runBlocking { + val task = async(kotlinx.coroutines.Dispatchers.IO) { + WebhookClient().sendCancellable(QueuedRequest(server.url("/").toString(), true, "{}", token), "a") + } + assertNotNull(server.takeRequest(2, TimeUnit.SECONDS)) + val start = System.nanoTime() + task.cancel(); task.join() + assertTrue(task.isCancelled) + assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) < 2_000) + } + } + } + @Test fun androidHttpTransportRequiresMatchingAckAndDoesNotFollowRedirects() { MockWebServer().use { server -> server.start() diff --git a/app/src/main/java/life/andre/message487/AppUpdates.kt b/app/src/main/java/life/andre/message487/AppUpdates.kt index 8bdee7d..515a0d1 100644 --- a/app/src/main/java/life/andre/message487/AppUpdates.kt +++ b/app/src/main/java/life/andre/message487/AppUpdates.kt @@ -13,9 +13,6 @@ import java.io.File import java.net.HttpURLConnection import java.net.URI import java.security.MessageDigest -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.withContext import org.json.JSONObject internal class UpdateException(val textId: Int) : Exception() @@ -110,7 +107,7 @@ internal class AppUpdates(private val context: Context) { @Suppress("DEPRECATION") fun source(): UpdateSource? { - val saved = preferences.getString("source", null) + val saved = preferences.readString("source", null) UpdateSource.entries.firstOrNull { it.name == saved }?.let { return it } val installer = try { if (Build.VERSION.SDK_INT >= 30) manager.getInstallSourceInfo(context.packageName).installingPackageName @@ -130,12 +127,12 @@ internal class AppUpdates(private val context: Context) { } fun select(source: UpdateSource) { - preferences.edit().putString("source", source.name).commit() + preferences.edit().putString("source", source.name).save() } - suspend fun check(source: UpdateSource): AppUpdate? = withContext(Dispatchers.IO) { + suspend fun check(source: UpdateSource): AppUpdate? = cancellableHttp { cancellation -> val url = if (source == UpdateSource.FDROID) FDROID_UPDATE_API else GITHUB_RELEASES_API - val json = request(url) { connection -> + val json = request(url, cancellation) { connection -> requireUpdateMetadataStatus(connection.responseCode) connection.inputStream.use { stream -> String(readUpdateMetadata(stream), Charsets.UTF_8) @@ -145,21 +142,22 @@ internal class AppUpdates(private val context: Context) { else parseGithubUpdate(json, BuildConfig.VERSION_NAME) } - suspend fun download(update: AppUpdate): File = withContext(Dispatchers.IO) { + suspend fun download(update: AppUpdate): File = cancellableHttp { cancellation -> require(update.source == UpdateSource.GITHUB) val directory = File(context.cacheDir, "updates").apply { mkdirs() } + cleanupUpdateFiles(directory) val partial = File.createTempFile("update-", ".part", directory) val apk = File(directory, partial.nameWithoutExtension + ".apk") try { val digest = MessageDigest.getInstance("SHA-256") - request(requireNotNull(update.apkUrl)) { connection -> + request(requireNotNull(update.apkUrl), cancellation) { connection -> require(connection.responseCode == 200) connection.inputStream.use { input -> partial.outputStream().use { output -> val buffer = ByteArray(64 * 1024) var total = 0L while (true) { - coroutineContext.ensureActive() + if (cancellation.isCanceled) throw kotlinx.coroutines.CancellationException("Download cancelled") val count = input.read(buffer) if (count < 0) break total += count @@ -174,6 +172,7 @@ internal class AppUpdates(private val context: Context) { require(digest.digest().hex() == update.sha256) verifyApk(partial, update.version) check(partial.renameTo(apk)) + cleanupUpdateFiles(directory, keep = apk) apk } finally { partial.delete() @@ -202,24 +201,31 @@ internal class AppUpdates(private val context: Context) { private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } // GitHub release assets redirect to its CDN. Only HTTPS redirects on these exact hosts are allowed. -private fun request(url: String, read: (HttpURLConnection) -> T): T { - var current = url - repeat(6) { - val uri = URI(current) - require(uri.scheme == "https" && uri.userInfo == null && (uri.port == -1 || uri.port == 443)) - require(uri.host in setOf("api.github.com", "github.com", "release-assets.githubusercontent.com", "f-droid.org")) - val connection = uri.toURL().openConnection() as HttpURLConnection - try { - connection.connectTimeout = 15_000 - connection.readTimeout = 30_000 - connection.instanceFollowRedirects = false - connection.setRequestProperty("User-Agent", "Message487-Updates") - if (connection.responseCode in listOf(301, 302, 303, 307, 308)) { - current = uri.resolve(requireNotNull(connection.getHeaderField("Location"))).toString() - } else return read(connection) - } finally { connection.disconnect() } - } - error("Too many redirects") +private fun request(url: String, cancellation: android.os.CancellationSignal, read: (HttpURLConnection) -> T): T = + HttpRequestControl(cancellation).use { control -> + var current = url + repeat(6) { + control.checkActive() + val uri = URI(current) + require(uri.scheme == "https" && uri.userInfo == null && (uri.port == -1 || uri.port == 443)) + require(uri.host in setOf("api.github.com", "github.com", "release-assets.githubusercontent.com", "f-droid.org")) + val connection = uri.toURL().openConnection() as HttpURLConnection + try { + control.attach(connection) + connection.connectTimeout = 15_000 + connection.readTimeout = 30_000 + connection.instanceFollowRedirects = false + connection.setRequestProperty("User-Agent", "Message487-Updates") + if (connection.responseCode in listOf(301, 302, 303, 307, 308)) { + current = uri.resolve(requireNotNull(connection.getHeaderField("Location"))).toString() + } else { + val result = read(connection) + control.checkActive() + return result + } + } finally { connection.disconnect() } + } + error("Too many redirects") } internal fun readUpdateMetadata(input: InputStream): ByteArray { @@ -232,3 +238,12 @@ internal fun readUpdateMetadata(input: InputStream): ByteArray { output.write(buffer, 0, count) } } + +internal fun cleanupUpdateFiles(directory: File, keep: File? = null, now: Long = System.currentTimeMillis()) { + val files = directory.listFiles().orEmpty().filter { it.name.startsWith("update-") && it.isFile } + // Keep two APKs so a previously granted installer URI can still be read. + files.filter { it.extension == "apk" && it != keep }.sortedByDescending(File::lastModified) + .drop(if (keep == null) 2 else 1).forEach { if (!it.delete()) throw java.io.IOException("Could not remove old update") } + files.filter { it.extension == "part" && now - it.lastModified() > java.util.concurrent.TimeUnit.DAYS.toMillis(1) } + .forEach { if (!it.delete()) throw java.io.IOException("Could not remove abandoned update") } +} diff --git a/app/src/main/java/life/andre/message487/BackgroundOperations.kt b/app/src/main/java/life/andre/message487/BackgroundOperations.kt new file mode 100644 index 0000000..8eb39d4 --- /dev/null +++ b/app/src/main/java/life/andre/message487/BackgroundOperations.kt @@ -0,0 +1,48 @@ +package life.andre.message487 + +import android.content.BroadcastReceiver +import android.content.Context +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.Executor +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import life.andre.message487.diagnostics.DiagnosticEvent + +internal fun boundedExecutor(name: String, capacity: Int) = ThreadPoolExecutor( + 1, 1, 0, TimeUnit.MILLISECONDS, ArrayBlockingQueue(capacity), + { task -> Thread(task, name).apply { isDaemon = true } }, ThreadPoolExecutor.AbortPolicy(), +) + +internal fun Context.reportLocalFailure(error: Exception) { + try { (applicationContext as? MessageApplication)?.diagnostics?.record(DiagnosticEvent.LOCAL_OPERATION_FAILED, error = error) } + catch (_: Exception) { /* Reporting must not turn a recoverable failure into a process crash. */ } +} + +internal fun Executor.submitSafely(onFailure: (Exception) -> Unit, block: () -> Unit): Boolean = try { + execute { try { block() } catch (error: Exception) { onFailure(error) } } + true +} catch (error: RejectedExecutionException) { onFailure(error); false } + +private val broadcastDeadlines = ScheduledThreadPoolExecutor(1) { task -> + Thread(task, "message487-broadcast-deadlines").apply { isDaemon = true } +}.apply { removeOnCancelPolicy = true } + +internal fun runBroadcast(context: Context, pending: BroadcastReceiver.PendingResult?, executor: Executor, onFailure: (Exception) -> Unit = context::reportLocalFailure, block: () -> Unit) { + val finished = AtomicBoolean() + fun finish(): Boolean { + if (!finished.compareAndSet(false, true)) return false + try { pending?.finish() } catch (error: Exception) { context.reportLocalFailure(error) } + return true + } + val deadline = broadcastDeadlines.schedule({ + if (finish()) onFailure(java.io.IOException("Broadcast processing exceeded its deadline")) + }, 8, TimeUnit.SECONDS) + val accepted = executor.submitSafely(onFailure) { + try { if (!finished.get()) block() } + finally { deadline.cancel(false); finish() } + } + if (!accepted) { deadline.cancel(false); finish() } +} diff --git a/app/src/main/java/life/andre/message487/ConnectionViewModel.kt b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt index 738738b..aabe49c 100644 --- a/app/src/main/java/life/andre/message487/ConnectionViewModel.kt +++ b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt @@ -23,6 +23,7 @@ data class ConnectionState( val deviceCode: String = "", val requireAck: Boolean = true, val busy: Boolean = false, + val initializationFailed: Boolean = false, val invalidUrl: Boolean = false, val invalidDeviceCode: Boolean = false, val notice: Int? = null, @@ -36,7 +37,9 @@ 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) -class ConnectionViewModel(application: Application) : AndroidViewModel(application) { +class ConnectionViewModel @JvmOverloads constructor(application: Application, + private val readSettings: () -> ForwardingSettings = { MessageGraph.get(application).settings.load() }, +) : AndroidViewModel(application) { private val graph = MessageGraph.get(application) val settings = graph.settings.state val settingsLoaded = graph.settings.loaded @@ -53,16 +56,13 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati val apps = mutableApps.asStateFlow() private var refreshingApps = false + private var permissionRefresh: kotlinx.coroutines.Job? = null + private var settingsLoad: kotlinx.coroutines.Job? = null + init { refreshPermissions() + retryLoadSettings() viewModelScope.launch { - try { - val saved = withContext(Dispatchers.IO) { graph.settings.load() } - mutableState.value = ConnectionState(saved.url, saved.deviceCode, saved.requireAck, authToken = saved.authToken) - } catch (error: Exception) { - if (error is kotlinx.coroutines.CancellationException) throw error - mutableState.value = state.value.copy(notice = R.string.local_error) - } graph.outbox.revision.collect { try { mutableQueue.value = withContext(Dispatchers.IO) { @@ -78,6 +78,20 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati } } + fun retryLoadSettings() { + if (settingsLoad?.isActive == true) return + mutableState.value = state.value.copy(busy = true, initializationFailed = false, notice = null) + settingsLoad = viewModelScope.launch { + try { + val saved = withContext(Dispatchers.IO) { readSettings() } + mutableState.value = ConnectionState(saved.url, saved.deviceCode, saved.requireAck, authToken = saved.authToken) + } catch (cancelled: kotlinx.coroutines.CancellationException) { throw cancelled } + catch (_: Exception) { + mutableState.value = state.value.copy(busy = false, initializationFailed = true, notice = R.string.local_error) + } + } + } + private fun installedApps(): List { val application = getApplication() val pm = application.packageManager @@ -109,7 +123,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati } } - private var permissionRefresh: kotlinx.coroutines.Job? = null fun refreshPermissions() { refreshApps() permissionRefresh?.cancel() @@ -190,7 +203,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati fun retry(id: String) = action { if (graph.outbox.retry(id)) { graph.diagnostics.record(DiagnosticEvent.MANUAL_RETRY) - graph.scheduler.schedule(id, replace = true) + graph.scheduler.schedule(id, replace = true).result.get(30, java.util.concurrent.TimeUnit.SECONDS) } } fun delete(id: String) = action { graph.outbox.delete(id); graph.diagnostics.record(DiagnosticEvent.EVENT_DELETED) } diff --git a/app/src/main/java/life/andre/message487/DeliveryWorker.kt b/app/src/main/java/life/andre/message487/DeliveryWorker.kt index 31653bc..8ce40ee 100644 --- a/app/src/main/java/life/andre/message487/DeliveryWorker.kt +++ b/app/src/main/java/life/andre/message487/DeliveryWorker.kt @@ -16,18 +16,19 @@ import androidx.work.workDataOf import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.coroutines.ensureActive import java.util.concurrent.TimeUnit class DeliveryScheduler(context: Context) { private val manager = WorkManager.getInstance(context) - fun schedule(id: String, replace: Boolean = false) { + fun schedule(id: String, replace: Boolean = false): androidx.work.Operation { val work = OneTimeWorkRequestBuilder() .setInputData(workDataOf("event_id" to id)) .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) .build() - manager.enqueueUniqueWork("event-$id", if (replace) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP, work) + return manager.enqueueUniqueWork("event-$id", if (replace) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP, work) } fun startRecovery() { @@ -54,9 +55,10 @@ class DeliveryWorker(context: Context, parameters: WorkerParameters) : Coroutine graph.diagnostics.record(DiagnosticEvent.DELIVERY_STARTED) val request = attempt.request val result = if (validWebhookUrl(request.url, BuildConfig.DEBUG)) { - WebhookClient().sendRequest(request, id) + WebhookClient().sendCancellable(request, id) } else DeliveryResult(id, DeliveryStatus.HTTP_ERROR) graph.diagnostics.record(DiagnosticEvent.DELIVERY_FINISHED, outcome = result.status.name, http = result.httpCode) + kotlinx.coroutines.currentCoroutineContext().ensureActive() val state = graph.outbox.finish(id, attempt.token, result) if (state == QueueState.RETRY) Result.retry() else Result.success() } catch (error: UnreadableImageException) { diff --git a/app/src/main/java/life/andre/message487/ForwardingSettings.kt b/app/src/main/java/life/andre/message487/ForwardingSettings.kt index 5ee7245..0c2a49a 100644 --- a/app/src/main/java/life/andre/message487/ForwardingSettings.kt +++ b/app/src/main/java/life/andre/message487/ForwardingSettings.kt @@ -66,33 +66,33 @@ class SettingsStore( private fun read() = ForwardingSettings( authToken = runCatching { - preferences.getString("auth_token_encrypted", null)?.let { + preferences.readString("auth_token_encrypted", null)?.let { cipher.decrypt(android.util.Base64.decode(it, android.util.Base64.NO_WRAP)) }.orEmpty() }.getOrDefault(""), - smsDeduplication = preferences.getBoolean("sms_deduplication", preferences.getBoolean("deduplication", true)), - notificationDeduplication = preferences.getBoolean("notification_deduplication", preferences.getBoolean("deduplication", true)), - deduplicationWindowSeconds = preferences.getInt("deduplication_window_seconds", 1), - url = preferences.getString("url", BuildConfig.DEFAULT_WEBHOOK_URL).orEmpty(), - deviceId = preferences.getString("device_id", "").orEmpty(), - deviceCode = preferences.getString("device_code", "android-device").orEmpty(), - requireAck = preferences.getBoolean("require_ack", true), - notifications = preferences.getBoolean("notifications", false), - sms = preferences.getBoolean("sms", false), - paused = preferences.getBoolean("paused", false), - packages = preferences.getStringSet("packages", emptySet()).orEmpty().toSet(), - autoSelectNewApps = preferences.getBoolean("auto_select_new_apps", false), - knownPackages = preferences.getStringSet("known_packages", emptySet()).orEmpty().toSet(), - captureFailed = preferences.getBoolean("capture_failed", false), - imageFormat = NotificationImageFormat.entries.firstOrNull { it.name == preferences.getString("image_format", null) } ?: NotificationImageFormat.JPEG, - imageQuality = preferences.getInt("image_quality", 80).coerceIn(0, 100), - imageLimitsEnabled = preferences.getBoolean("image_limits_enabled", true), - imageMaxCount = preferences.getInt("image_max_count", 3).coerceAtLeast(1), - imageMaxEdge = preferences.getInt("image_max_edge", 1024).coerceAtLeast(1), - imageMaxKiB = preferences.getInt("image_max_kib", 256).coerceAtLeast(1), - imageTotalKiB = preferences.getInt("image_total_kib", 512).coerceAtLeast(1), - forwardAvatars = preferences.getBoolean("forward_avatars", false), - forwardAppIcons = preferences.getBoolean("forward_app_icons", false), + smsDeduplication = preferences.readBoolean("sms_deduplication", preferences.readBoolean("deduplication", true)), + notificationDeduplication = preferences.readBoolean("notification_deduplication", preferences.readBoolean("deduplication", true)), + deduplicationWindowSeconds = preferences.readInt("deduplication_window_seconds", 1).takeIf { it >= 0 } ?: 1, + url = preferences.readString("url", BuildConfig.DEFAULT_WEBHOOK_URL).orEmpty(), + deviceId = preferences.readString("device_id", "").orEmpty(), + deviceCode = preferences.readString("device_code", "android-device").orEmpty(), + requireAck = preferences.readBoolean("require_ack", true), + notifications = preferences.readBoolean("notifications", false), + sms = preferences.readBoolean("sms", false), + paused = preferences.readBoolean("paused", false), + packages = preferences.readStrings("packages"), + autoSelectNewApps = preferences.readBoolean("auto_select_new_apps", false), + knownPackages = preferences.readStrings("known_packages"), + captureFailed = preferences.readBoolean("capture_failed", false), + imageFormat = NotificationImageFormat.entries.firstOrNull { it.name == preferences.readString("image_format", null) } ?: NotificationImageFormat.JPEG, + imageQuality = preferences.readInt("image_quality", 80).coerceIn(0, 100), + imageLimitsEnabled = preferences.readBoolean("image_limits_enabled", true), + imageMaxCount = preferences.readInt("image_max_count", 3).coerceAtLeast(1), + imageMaxEdge = preferences.readInt("image_max_edge", 1024).coerceAtLeast(1), + imageMaxKiB = preferences.readInt("image_max_kib", 256).coerceAtLeast(1), + imageTotalKiB = preferences.readInt("image_total_kib", 512).coerceAtLeast(1), + forwardAvatars = preferences.readBoolean("forward_avatars", false), + forwardAppIcons = preferences.readBoolean("forward_app_icons", false), ) @Synchronized diff --git a/app/src/main/java/life/andre/message487/HttpRequestControl.kt b/app/src/main/java/life/andre/message487/HttpRequestControl.kt new file mode 100644 index 0000000..df419e1 --- /dev/null +++ b/app/src/main/java/life/andre/message487/HttpRequestControl.kt @@ -0,0 +1,59 @@ +package life.andre.message487 + +import android.os.CancellationSignal +import java.net.HttpURLConnection +import java.net.SocketTimeoutException +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext + +/** Awaiting cancellation closes blocking HTTP I/O on a background thread, including during headers. */ +internal suspend fun cancellableHttp(block: (CancellationSignal) -> T): T = coroutineScope { + val cancellation = CancellationSignal() + val operation = async(Dispatchers.IO) { block(cancellation) } + try { operation.await() } + finally { withContext(NonCancellable + Dispatchers.IO) { cancellation.cancel() } } +} + +internal class HttpRequestControl( + private val cancellation: CancellationSignal, + timeoutMs: Long = 120_000, +) : AutoCloseable { + @Volatile private var connection: HttpURLConnection? = null + private val expired = AtomicBoolean() + private val deadline: java.util.concurrent.ScheduledFuture<*> + + init { + require(timeoutMs > 0) + cancellation.setOnCancelListener { connection?.disconnect() } + deadline = deadlines.schedule({ expired.set(true); cancellation.cancel() }, timeoutMs, TimeUnit.MILLISECONDS) + } + + fun attach(value: HttpURLConnection) { + connection = value + checkActive() + } + + fun checkActive() { + if (expired.get()) throw SocketTimeoutException("HTTP request deadline exceeded") + if (cancellation.isCanceled) throw CancellationException("HTTP request cancelled") + } + + override fun close() { + deadline.cancel(false) + cancellation.setOnCancelListener(null) + connection = null + } + + companion object { + private val deadlines = ScheduledThreadPoolExecutor(2) { task -> + Thread(task, "message487-http-deadlines").apply { isDaemon = true } + }.apply { removeOnCancelPolicy = true } + } +} diff --git a/app/src/main/java/life/andre/message487/ImageMemory.kt b/app/src/main/java/life/andre/message487/ImageMemory.kt new file mode 100644 index 0000000..7b948ae --- /dev/null +++ b/app/src/main/java/life/andre/message487/ImageMemory.kt @@ -0,0 +1,25 @@ +package life.andre.message487 + +import java.io.IOException +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock + +internal class ImageMemoryException : IOException("Insufficient memory for image processing") +// ponytail: serialize bitmap allocations; use budgeted parallelism only if capture throughput requires it. +private val imageMemoryLock = ReentrantLock(true) + +/** Share the allocation budget across capture, test images and application-icon rendering. */ +internal fun withImageMemory(checkActive: () -> Unit = {}, block: () -> T): T { + while (!imageMemoryLock.tryLock(100, TimeUnit.MILLISECONDS)) checkActive() + try { + checkActive() + return try { block() } catch (_: OutOfMemoryError) { throw ImageMemoryException() } + } finally { imageMemoryLock.unlock() } +} + +internal fun workingPixelLimit(): Long { + val runtime = Runtime.getRuntime() + val available = (runtime.maxMemory() - (runtime.totalMemory() - runtime.freeMemory())).coerceAtLeast(0) + // Reserve room for decoding, a software source, the output bitmap and codec scratch space. + return minOf(16_777_216L, runtime.maxMemory() / 32, available / 16).coerceAtLeast(1) +} diff --git a/app/src/main/java/life/andre/message487/MainActivity.kt b/app/src/main/java/life/andre/message487/MainActivity.kt index f49e92d..46ebaa8 100644 --- a/app/src/main/java/life/andre/message487/MainActivity.kt +++ b/app/src/main/java/life/andre/message487/MainActivity.kt @@ -139,7 +139,12 @@ internal fun MessageScreen(model: ConnectionViewModel = viewModel(), checkReques if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) key(destination) { Box(Modifier.widthIn(max = 720.dp).fillMaxSize()) { - if (!settingsLoaded) Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + if (!settingsLoaded || state.initializationFailed) Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (state.initializationFailed) Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(stringResource(R.string.local_error)) + Button(onClick = model::retryLoadSettings) { Text(stringResource(R.string.retry)) } + } else CircularProgressIndicator() + } else if (updates) UpdatesScreen(requireNotNull(LocalActivity.current), checkRequest = checkRequest) else if (diagnostics) DiagnosticsScreen(application, settings) else when (destination) { Destination.OVERVIEW -> OverviewScreen(settings, permissions, connected, queue, state.busy, model, diff --git a/app/src/main/java/life/andre/message487/MessageGraph.kt b/app/src/main/java/life/andre/message487/MessageGraph.kt index adc11ef..e2bc515 100644 --- a/app/src/main/java/life/andre/message487/MessageGraph.kt +++ b/app/src/main/java/life/andre/message487/MessageGraph.kt @@ -4,7 +4,6 @@ import android.app.Application import android.content.Context import java.security.MessageDigest import java.time.Instant -import java.util.concurrent.Executors import java.io.File import life.andre.message487.diagnostics.CrashHandler import life.andre.message487.diagnostics.DiagnosticLog @@ -18,16 +17,17 @@ open class MessageApplication : Application(), androidx.work.Configuration.Provi internal val crashHandler by lazy { CrashHandler(this, diagnostics) } val graph by lazy { MessageGraph(this) } - internal val backgroundExecutor = Executors.newSingleThreadExecutor() + internal val backgroundExecutor = boundedExecutor("message487-background", 32) override fun onCreate() { super.onCreate() if (BuildConfig.DEBUG) android.os.StrictMode.setThreadPolicy(android.os.StrictMode.ThreadPolicy.Builder() .detectDiskReads().detectDiskWrites().detectNetwork().penaltyLog().build()) crashHandler.install() - backgroundExecutor.execute { - crashHandler.load() + backgroundExecutor.submitSafely(::reportLocalFailure) { + try { crashHandler.load() } catch (error: Exception) { reportLocalFailure(error) } diagnostics.record(DiagnosticEvent.APP_STARTED) + try { cleanupUpdateFiles(File(cacheDir, "updates")) } catch (error: Exception) { reportLocalFailure(error) } graph.start() UpdateNotifications.schedule(this) } @@ -39,34 +39,43 @@ class MessageGraph internal constructor(private val context: Application) { val settings = SettingsStore(context) val outbox = Outbox(context, (context as MessageApplication).payloadCipher) val scheduler by lazy { DeliveryScheduler(context) } - val captureExecutor = Executors.newSingleThreadExecutor() + val captureExecutor = boundedExecutor("message487-notifications", 32) + internal val smsExecutor = boundedExecutor("message487-sms", 16) internal val imageCaptureQueue = NotificationImageQueue() private val sources = AppSourceResolver(context.packageManager) fun start() { - captureExecutor.execute { - try { - settings.update { it } - scheduler.startRecovery() - recover() - } catch (error: Exception) { captureFailed(error) } + submitCapture { + settings.update { it } + scheduler.startRecovery() + recover() } } + internal fun submitCapture(block: () -> Unit): Boolean = captureExecutor.submitSafely(::captureFailed, block) + + @Synchronized fun recover() { settings.load() - if (!settings.state.value.paused) { - outbox.cleanupFiles() - val pending = outbox.pendingIds() - pending.forEach { scheduler.schedule(it) } - diagnostics.record(DiagnosticEvent.RECOVERY, count = pending.size) + if (settings.state.value.paused) return + outbox.cleanupFiles() + var after: String? = null + var count = 0 + while (!settings.state.value.paused) { + val pending = outbox.pendingIds(after) + if (pending.isEmpty()) break + pending.forEach { scheduler.schedule(it).result.get(30, java.util.concurrent.TimeUnit.SECONDS) } + count += pending.size + after = pending.last() } + diagnostics.record(DiagnosticEvent.RECOVERY, count = count) } fun enqueueTest(options: TestMessageOptions = TestMessageOptions()) { val config = settings.load() require(config.ready()) - val images = testMessageImages(context, config, options) + 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) } } @@ -96,20 +105,21 @@ class MessageGraph internal constructor(private val context: Application) { private fun enqueue(event: MessageEvent, config: ForwardingSettings) { if (outbox.enqueue(event, config)) { diagnostics.record(DiagnosticEvent.EVENT_QUEUED, type = event.messageType) - scheduler.schedule(event.eventId) + scheduler.schedule(event.eventId).result.get(30, java.util.concurrent.TimeUnit.SECONDS) } else diagnostics.record(DiagnosticEvent.DUPLICATE_SKIPPED, type = event.messageType) } private val failurePending = java.util.concurrent.atomic.AtomicBoolean() fun captureFailed(error: Throwable? = null) { if (!failurePending.compareAndSet(false, true)) return - (context as MessageApplication).backgroundExecutor.execute { + val accepted = (context as MessageApplication).backgroundExecutor.submitSafely(context::reportLocalFailure) { try { diagnostics.record(DiagnosticEvent.CAPTURE_FAILED, error = error) if (!settings.load().captureFailed) settings.update { it.copy(captureFailed = true) } } catch (_: Exception) { } finally { failurePending.set(false) } } + if (!accepted) failurePending.set(false) } companion object { diff --git a/app/src/main/java/life/andre/message487/NotificationCaptureService.kt b/app/src/main/java/life/andre/message487/NotificationCaptureService.kt index d087c03..f79f0af 100644 --- a/app/src/main/java/life/andre/message487/NotificationCaptureService.kt +++ b/app/src/main/java/life/andre/message487/NotificationCaptureService.kt @@ -26,7 +26,7 @@ class NotificationCaptureService : NotificationListenerService() { MessageGraph.get(this).diagnostics.record(DiagnosticEvent.LISTENER_CONNECTED) ListenerState.mutableConnected.value = true val graph = MessageGraph.get(this) - graph.captureExecutor.execute { + graph.submitCapture { try { graph.recover() } catch (error: Exception) { graph.captureFailed(error) } } } @@ -44,7 +44,7 @@ class NotificationCaptureService : NotificationListenerService() { override fun onNotificationPosted(sbn: StatusBarNotification) { val graph = MessageGraph.get(this) if (!graph.settings.loaded.value) { - graph.captureExecutor.execute { + graph.submitCapture { try { graph.settings.load(); capturePosted(sbn) } catch (error: Exception) { graph.captureFailed(error) } } @@ -68,7 +68,7 @@ class NotificationCaptureService : NotificationListenerService() { extras.containsKey(Notification.EXTRA_MESSAGES) || (android.os.Build.VERSION.SDK_INT >= 31 && extras.containsKey(Notification.EXTRA_PICTURE_ICON)) if (!hasImages) { - if (title.isNotBlank() || text.isNotBlank()) graph.captureExecutor.execute { + if (title.isNotBlank() || text.isNotBlank()) graph.submitCapture { try { graph.captureNotification(captured) } catch (error: Exception) { graph.captureFailed(error) } } return @@ -83,7 +83,7 @@ class NotificationCaptureService : NotificationListenerService() { } }, complete = { images -> if (title.isNotBlank() || text.isNotBlank() || images.isNotEmpty()) { - if (images.isEmpty()) graph.captureExecutor.execute { + if (images.isEmpty()) graph.submitCapture { try { graph.captureNotification(captured) } catch (error: Exception) { graph.captureFailed(error) } } else { try { graph.captureNotification(captured.copy(images = images)) } diff --git a/app/src/main/java/life/andre/message487/NotificationImageQueue.kt b/app/src/main/java/life/andre/message487/NotificationImageQueue.kt index 9f399f8..40509bb 100644 --- a/app/src/main/java/life/andre/message487/NotificationImageQueue.kt +++ b/app/src/main/java/life/andre/message487/NotificationImageQueue.kt @@ -28,14 +28,17 @@ internal class NotificationImageQueue(private val timeoutMs: Long = 10_000, capa } else discardImages(images) } fun cancel() { - finish(emptyList()) - cancellation.cancel() - worker.remove(this) + try { finish(emptyList()) } + finally { cancellation.cancel(); worker.remove(this) } } override fun run() { synchronized(this@NotificationImageQueue) { pending.remove(key, this) } if (done.get()) return try { finish(capture(cancellation)) } + catch (_: OutOfMemoryError) { + if (!done.get()) onFailure(ImageMemoryException()) + finish(emptyList()) + } catch (error: Exception) { if (!done.get()) onFailure(error) finish(emptyList()) diff --git a/app/src/main/java/life/andre/message487/NotificationImages.kt b/app/src/main/java/life/andre/message487/NotificationImages.kt index 8f0c2f5..b569663 100644 --- a/app/src/main/java/life/andre/message487/NotificationImages.kt +++ b/app/src/main/java/life/andre/message487/NotificationImages.kt @@ -66,9 +66,13 @@ internal fun discardImages(images: List) { images.mapNotNull { it.file.parentFile }.distinct().forEach { if (it.name.startsWith("capture-")) it.delete() } } -private fun workingPixelLimit(): Long = minOf(16_777_216L, Runtime.getRuntime().maxMemory() / 32) internal fun encodeNotificationImage(bitmap: Bitmap, format: NotificationImageFormat, quality: Int, + directory: File, maxBytes: Long = MAX_NOTIFICATION_IMAGE_BYTES.toLong(), maxEdge: Int = MAX_IMAGE_EDGE, + orientation: Int = ExifInterface.ORIENTATION_NORMAL, checkActive: () -> Unit = {}): NotificationImage = + withImageMemory(checkActive) { encodeImage(bitmap, format, quality, directory, maxBytes, maxEdge, orientation, checkActive) } + +private fun encodeImage(bitmap: Bitmap, format: NotificationImageFormat, quality: Int, directory: File, maxBytes: Long = MAX_NOTIFICATION_IMAGE_BYTES.toLong(), maxEdge: Int = MAX_IMAGE_EDGE, orientation: Int = ExifInterface.ORIENTATION_NORMAL, checkActive: () -> Unit = {}): NotificationImage { require(quality in 0..100 && maxBytes > 0 && maxEdge > 0) @@ -147,8 +151,14 @@ private fun decodeImageFile(file: File, maxEdge: Int, limited: Boolean): Bitmap return BitmapFactory.decodeFile(file.path, options) ?: error("Cannot decode image") } -@Suppress("DEPRECATION") internal fun notificationImages(context: Context, notification: Notification, settings: ForwardingSettings, + sourcePackage: String = context.packageName, cancellation: CancellationSignal = CancellationSignal(), + onFailure: (Exception) -> Unit): List = withImageMemory({ cancellation.throwIfCanceled() }) { + captureNotificationImages(context, notification, settings, sourcePackage, cancellation, onFailure) + } + +@Suppress("DEPRECATION") +private fun captureNotificationImages(context: Context, notification: Notification, settings: ForwardingSettings, sourcePackage: String = context.packageName, cancellation: CancellationSignal = CancellationSignal(), onFailure: (Exception) -> Unit): List { val directory = File(context.noBackupFilesDir, "outbox/capture-${UUID.randomUUID()}").apply { mkdirs() } diff --git a/app/src/main/java/life/andre/message487/Outbox.kt b/app/src/main/java/life/andre/message487/Outbox.kt index 717acbf..253ac2c 100644 --- a/app/src/main/java/life/andre/message487/Outbox.kt +++ b/app/src/main/java/life/andre/message487/Outbox.kt @@ -15,6 +15,8 @@ import java.io.ByteArrayOutputStream import android.util.AtomicFile import java.util.Base64 +internal const val MAX_STORED_PAYLOAD_BYTES = 8 * 1024 * 1024 + private const val ENTRY_COLUMNS = "id,type,source_name,created_at,state,attempts,outcome,http_code" enum class QueueState { PENDING, SENDING, RETRY, BLOCKED, ACCEPTED, HTTP_SUCCESS } @@ -154,9 +156,13 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe } @Synchronized - fun pendingIds(): List = readableDatabase.rawQuery( - "SELECT id FROM events WHERE state IN ('PENDING', 'SENDING', 'RETRY') ORDER BY created_at", null - ).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.getString(0)) } } + fun pendingIds(afterId: String? = null, limit: Int = 100): List { + require(limit in 1..1000) + return readableDatabase.rawQuery( + "SELECT id FROM events WHERE state IN ('PENDING', 'SENDING', 'RETRY') AND id > ? ORDER BY id LIMIT ?", + arrayOf(afterId.orEmpty(), limit.toString()) + ).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.getString(0)) } } + } @Synchronized fun entries(): List = readableDatabase.rawQuery( @@ -178,8 +184,22 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe entry to it.getString(it.getColumnIndexOrThrow("payload_file")) } var directory = stored?.let(::snapshotDirectory) - val envelope = if (directory != null) JSONObject(cipher.decrypt(AtomicFile(File(directory, "request.enc")).readFully())) - else JSONObject(cipher.decrypt(readLegacyPayload(id))) + val envelope = try { + val bytes = if (directory != null) AtomicFile(File(directory, "request.enc")).openRead().use { input -> + val size = input.channel.size() + if (size !in 28..payloadReadLimit().toLong()) throw java.io.IOException("Queued metadata exceeds memory budget") + val bytes = ByteArray(size.toInt()) + var offset = 0 + while (offset < bytes.size) { + val count = input.read(bytes, offset, bytes.size - offset) + check(count > 0) { "Truncated queued metadata" } + offset += count + } + check(input.read() == -1) { "Queued metadata grew while reading" } + bytes + } else readLegacyPayload(id) + JSONObject(cipher.decrypt(bytes)) + } catch (_: OutOfMemoryError) { throw java.io.IOException("Queued metadata exceeds available memory") } val authToken = envelope.getString("auth_token") require(validAuthToken(authToken)) { "Missing queued authentication token" } if (directory == null) { @@ -299,15 +319,27 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe } catch (error: Exception) { directory.deleteRecursively(); throw error } } + private fun payloadReadLimit(): Int { + val runtime = Runtime.getRuntime() + val available = runtime.maxMemory() - (runtime.totalMemory() - runtime.freeMemory()) + return minOf(MAX_STORED_PAYLOAD_BYTES.toLong(), (available / 8).coerceAtLeast(0)).toInt() + } + private fun readLegacyPayload(id: String): ByteArray { // Legacy bodies can exceed CursorWindow. Read only a small SQL substr in each cursor. - val output = ByteArrayOutputStream() + val size = readableDatabase.rawQuery("SELECT length(payload) FROM events WHERE id = ?", arrayOf(id)).use { + check(it.moveToFirst() && !it.isNull(0)) { "Missing queued payload" } + it.getLong(0) + } + if (size !in 1..payloadReadLimit().toLong()) throw java.io.IOException("Legacy payload exceeds memory budget") + val output = ByteArrayOutputStream(size.toInt()) var offset = 1 while (true) { val chunk = readableDatabase.rawQuery("SELECT substr(payload, ?, 262144) FROM events WHERE id = ?", arrayOf(offset.toString(), id)).use { check(it.moveToFirst() && !it.isNull(0)) { "Missing queued payload" } it.getBlob(0) } + check(output.size().toLong() + chunk.size <= size) { "Legacy payload grew while reading" } output.write(chunk) if (chunk.size < 262144) return output.toByteArray() offset += chunk.size diff --git a/app/src/main/java/life/andre/message487/SafePreferences.kt b/app/src/main/java/life/andre/message487/SafePreferences.kt new file mode 100644 index 0000000..ff79deb --- /dev/null +++ b/app/src/main/java/life/andre/message487/SafePreferences.kt @@ -0,0 +1,19 @@ +package life.andre.message487 + +import android.content.SharedPreferences +import java.io.IOException + +// A malformed field must not discard unrelated connection details or credentials. +internal fun SharedPreferences.readString(key: String, default: String? = null): String? = + try { getString(key, default) } catch (_: ClassCastException) { default } +internal fun SharedPreferences.readBoolean(key: String, default: Boolean): Boolean = + try { getBoolean(key, default) } catch (_: ClassCastException) { default } +internal fun SharedPreferences.readInt(key: String, default: Int): Int = + try { getInt(key, default) } catch (_: ClassCastException) { default } +internal fun SharedPreferences.readLong(key: String, default: Long): Long = + try { getLong(key, default) } catch (_: ClassCastException) { default } +internal fun SharedPreferences.readStrings(key: String): Set = + try { getStringSet(key, emptySet()).orEmpty().toSet() } catch (_: ClassCastException) { emptySet() } +internal fun SharedPreferences.Editor.save() { + if (!commit()) throw IOException("Could not save preferences") +} diff --git a/app/src/main/java/life/andre/message487/SmsCaptureReceiver.kt b/app/src/main/java/life/andre/message487/SmsCaptureReceiver.kt index 780d129..f799810 100644 --- a/app/src/main/java/life/andre/message487/SmsCaptureReceiver.kt +++ b/app/src/main/java/life/andre/message487/SmsCaptureReceiver.kt @@ -10,10 +10,10 @@ class SmsCaptureReceiver : BroadcastReceiver() { if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return val graph = MessageGraph.get(context) val pending = goAsync() - graph.captureExecutor.execute { + runBroadcast(context.applicationContext, pending, graph.smsExecutor, graph::captureFailed) { try { val config = graph.settings.load() - if (!config.sms || config.paused || !config.ready()) return@execute + if (!config.sms || config.paused || !config.ready()) return@runBroadcast val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent).orEmpty() if (messages.isNotEmpty()) { val sender = messages.first().originatingAddress.orEmpty() @@ -21,8 +21,6 @@ class SmsCaptureReceiver : BroadcastReceiver() { } } catch (error: Exception) { graph.captureFailed(error) - } finally { - pending.finish() } } } diff --git a/app/src/main/java/life/andre/message487/SourcesScreen.kt b/app/src/main/java/life/andre/message487/SourcesScreen.kt index 5196c39..a20c53c 100644 --- a/app/src/main/java/life/andre/message487/SourcesScreen.kt +++ b/app/src/main/java/life/andre/message487/SourcesScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.core.graphics.drawable.toBitmap import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.coroutines.ensureActive import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -47,6 +48,7 @@ internal fun SourcesScreen(settings: ForwardingSettings, permissions: Permission fun openNotificationSettings() { try { context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) } catch (_: android.content.ActivityNotFoundException) { model.showSettingsError() } + catch (_: SecurityException) { model.showSettingsError() } } val displayedApps by produceState>(emptyList(), apps, settings.packages, query, selectedOnly) { value = withContext(Dispatchers.Default) { displayedSources(apps, settings.packages, query, selectedOnly) } @@ -84,8 +86,10 @@ internal fun SourcesScreen(settings: ForwardingSettings, permissions: Permission if (settings.sms && !permissions.sms) { Text(stringResource(R.string.sms_not_granted), color = MaterialTheme.colorScheme.error) TextButton(onClick = { - context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - android.net.Uri.parse("package:${context.packageName}"))) + 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.open_settings)) } } } @@ -214,9 +218,12 @@ private fun ApplicationIcon(packageName: String) { val context = LocalContext.current val bitmap by produceState(null, packageName) { value = withContext(Dispatchers.IO) { - try { context.packageManager.getApplicationIcon(packageName).toBitmap(96, 96).asImageBitmap() } - catch (_: android.content.pm.PackageManager.NameNotFoundException) { null } - catch (_: SecurityException) { null } + val active = coroutineContext + try { withImageMemory({ active.ensureActive() }) { + context.packageManager.getApplicationIcon(packageName).toBitmap(96, 96).asImageBitmap() + } } + catch (cancelled: kotlinx.coroutines.CancellationException) { throw cancelled } + catch (_: Exception) { null } } } bitmap?.let { Image(it, null, Modifier.size(44.dp)) } ?: IconTile(Icons.Outlined.Apps) diff --git a/app/src/main/java/life/andre/message487/TestMessageImages.kt b/app/src/main/java/life/andre/message487/TestMessageImages.kt index 30dfb08..831fa21 100644 --- a/app/src/main/java/life/andre/message487/TestMessageImages.kt +++ b/app/src/main/java/life/andre/message487/TestMessageImages.kt @@ -16,6 +16,10 @@ data class TestMessageOptions( ) internal fun testMessageImages(context: Context, settings: ForwardingSettings, + options: TestMessageOptions): List = if (options == TestMessageOptions()) emptyList() + else withImageMemory { createTestImages(context, settings, options) } + +private fun createTestImages(context: Context, settings: ForwardingSettings, options: TestMessageOptions): List { if (options == TestMessageOptions()) return emptyList() val directory = File(context.noBackupFilesDir, "outbox/capture-${UUID.randomUUID()}").apply { mkdirs() } @@ -47,6 +51,7 @@ internal fun testMessageImages(context: Context, settings: ForwardingSettings, remaining -= image.file.length() } finally { bitmap.recycle() } } + var complete = false try { if (options.bigPicture) add("attachment", "big_picture", "Big Picture", Color.rgb(40, 90, 190)) if (options.gallery) { @@ -56,9 +61,7 @@ internal fun testMessageImages(context: Context, settings: ForwardingSettings, if (options.avatar) add("avatar") if (options.appIcon) add("app_icon") if (images.isEmpty()) directory.delete() + complete = true return images - } catch (error: Exception) { - directory.deleteRecursively() - throw error - } + } finally { if (!complete) directory.deleteRecursively() } } diff --git a/app/src/main/java/life/andre/message487/UpdateNotifications.kt b/app/src/main/java/life/andre/message487/UpdateNotifications.kt index a393b1a..a525afa 100644 --- a/app/src/main/java/life/andre/message487/UpdateNotifications.kt +++ b/app/src/main/java/life/andre/message487/UpdateNotifications.kt @@ -37,20 +37,20 @@ internal fun shouldNotifyUpdate(key: String, skipped: String?, lastKey: String?, internal class UpdatePreferences(private val context: Context) { private val prefs by lazy { context.getSharedPreferences("updates", Context.MODE_PRIVATE) } var automatic: Boolean - get() = prefs.getBoolean("automatic", true) - set(value) { prefs.edit().putBoolean("automatic", value).commit() } + get() = prefs.readBoolean("automatic", true) + set(value) { prefs.edit().putBoolean("automatic", value).save() } - val lastBackgroundTime: Long get() = prefs.getLong("background_at", 0) - val lastBackgroundResult: String? get() = prefs.getString("background_result", null) - val lastBackgroundSource: String? get() = prefs.getString("background_source", null) + val lastBackgroundTime: Long get() = prefs.readLong("background_at", 0) + val lastBackgroundResult: String? get() = prefs.readString("background_result", null) + val lastBackgroundSource: String? get() = prefs.readString("background_source", null) fun backgroundStarted(source: UpdateSource, now: Long = System.currentTimeMillis()) { prefs.edit().putLong("background_at", now).putString("background_source", source.name) - .putString("background_result", "running").commit() + .putString("background_result", "running").save() } fun backgroundFinished(result: String) { - prefs.edit().putString("background_result", result).commit() + prefs.edit().putString("background_result", result).save() } fun detected(source: UpdateSource, update: AppUpdate?) { @@ -58,34 +58,34 @@ internal class UpdatePreferences(private val context: Context) { prefs.edit().apply { if (update == null) { remove("detected_source"); remove("detected_version") } else { putString("detected_source", source.name); putString("detected_version", update.version) } - }.commit() + }.save() } fun pending(now: Long = System.currentTimeMillis()): AppUpdate? { - val source = UpdateSource.entries.firstOrNull { it.name == prefs.getString("detected_source", null) } ?: return null - val version = prefs.getString("detected_version", null) ?: return null + val source = UpdateSource.entries.firstOrNull { it.name == prefs.readString("detected_source", null) } ?: return null + val version = prefs.readString("detected_version", null) ?: return null val update = AppUpdate(source, version) if (AppUpdates(context).source() != source || !runCatching { newerVersion(version, BuildConfig.VERSION_NAME) }.getOrDefault(false)) return null - if (key(update) in prefs.getStringSet("skipped_versions", emptySet()).orEmpty()) return null - if (key(update) == prefs.getString("dialog_key", null) && now < prefs.getLong("dialog_after", 0)) return null + if (key(update) in prefs.readStrings("skipped_versions")) return null + if (key(update) == prefs.readString("dialog_key", null) && now < prefs.readLong("dialog_after", 0)) return null return update } fun remindLater(update: AppUpdate, now: Long = System.currentTimeMillis()) { prefs.edit().putString("dialog_key", key(update)).putLong("dialog_after", now + UPDATE_REMINDER_INTERVAL) - .putString("notified", key(update)).putLong("notified_at", now).commit() + .putString("notified", key(update)).putLong("notified_at", now).save() } fun shouldNotify(update: AppUpdate, now: Long): Boolean = automatic && shouldNotifyUpdate( - key(update), key(update).takeIf { it in prefs.getStringSet("skipped_versions", emptySet()).orEmpty() }, prefs.getString("notified", null), prefs.getLong("notified_at", 0), now) + key(update), key(update).takeIf { it in prefs.readStrings("skipped_versions") }, prefs.readString("notified", null), prefs.readLong("notified_at", 0), now) fun notified(update: AppUpdate, now: Long) { - prefs.edit().putString("notified", key(update)).putLong("notified_at", now).commit() + prefs.edit().putString("notified", key(update)).putLong("notified_at", now).save() } fun skip(key: String) { - val skipped = prefs.getStringSet("skipped_versions", emptySet()).orEmpty() + key - prefs.edit().putStringSet("skipped_versions", skipped).commit() + val skipped = prefs.readStrings("skipped_versions") + key + prefs.edit().putStringSet("skipped_versions", skipped).save() } companion object { fun key(update: AppUpdate) = "${update.source.name}:${update.version}" } @@ -147,20 +147,18 @@ internal object UpdateNotifications { } class UpdateActionReceiver : BroadcastReceiver() { - companion object { private val executor = java.util.concurrent.Executors.newSingleThreadExecutor() } + companion object { private val executor = boundedExecutor("message487-update-actions", 16) } override fun onReceive(context: Context, intent: Intent) { if (intent.action !in setOf(SKIP_UPDATE_ACTION, DISABLE_UPDATES_ACTION)) return val pending = goAsync() - executor.execute { - try { - val prefs = UpdatePreferences(context) - when (intent.action) { - SKIP_UPDATE_ACTION -> intent.getStringExtra("version_key")?.let(prefs::skip) - DISABLE_UPDATES_ACTION -> prefs.automatic = false - } - UpdateNotifications.cancel(context) - UpdateNotifications.schedule(context) - } finally { pending?.finish() } + runBroadcast(context.applicationContext, pending, executor) { + val prefs = UpdatePreferences(context) + when (intent.action) { + SKIP_UPDATE_ACTION -> intent.getStringExtra("version_key")?.let(prefs::skip) + DISABLE_UPDATES_ACTION -> prefs.automatic = false + } + UpdateNotifications.cancel(context) + UpdateNotifications.schedule(context) } } } @@ -171,11 +169,15 @@ class UpdateCheckService : JobService() { override fun onStartJob(params: JobParameters): Boolean { check = scope.launch { - val retry = kotlinx.coroutines.withContext(Dispatchers.IO) { + val retry = try { kotlinx.coroutines.withContext(Dispatchers.IO) { val updates = AppUpdates(this@UpdateCheckService) val source = updates.source() if (source == null || !UpdatePreferences(this@UpdateCheckService).automatic) false else runBackgroundUpdateCheck(this@UpdateCheckService, source, updates::check) + } } catch (cancelled: CancellationException) { throw cancelled } + catch (error: Exception) { + reportLocalFailure(error) + error is java.io.IOException } jobFinished(params, retry) } @@ -199,20 +201,23 @@ internal suspend fun runBackgroundUpdateCheck( check: suspend (UpdateSource) -> AppUpdate?, ): Boolean = kotlinx.coroutines.withContext(Dispatchers.IO) { val prefs = UpdatePreferences(context) - prefs.backgroundStarted(source) var retry = false try { + prefs.backgroundStarted(source) val update = check(source) prefs.detected(source, update) prefs.backgroundFinished(if (update == null) "current" else "available") if (update != null) UpdateNotifications.show(context, update) else if (AppUpdates(context).source() == source) UpdateNotifications.cancel(context) } catch (cancelled: CancellationException) { - prefs.backgroundFinished("interrupted") + try { prefs.backgroundFinished("interrupted") } catch (error: Exception) { context.reportLocalFailure(error) } throw cancelled } catch (error: Exception) { retry = error is java.io.IOException - prefs.backgroundFinished(if (retry) "network_error" else "error") + context.reportLocalFailure(error) + try { prefs.backgroundFinished(if (retry) "network_error" else "error") } + catch (reportError: Exception) { context.reportLocalFailure(reportError) } } - retry && prefs.automatic && AppUpdates(context).source() == source + try { retry && prefs.automatic && AppUpdates(context).source() == source } + catch (error: Exception) { context.reportLocalFailure(error); false } } diff --git a/app/src/main/java/life/andre/message487/WebhookClient.kt b/app/src/main/java/life/andre/message487/WebhookClient.kt index eb1a1e6..9bfd9b7 100644 --- a/app/src/main/java/life/andre/message487/WebhookClient.kt +++ b/app/src/main/java/life/andre/message487/WebhookClient.kt @@ -62,27 +62,34 @@ fun validWebhookUrl(value: String, allowLocalHttp: Boolean): Boolean = runCatchi fun validAuthToken(value: String): Boolean = value.length in 1..4096 && value.matches(Regex("[A-Za-z0-9._~+/-]+=*")) -class WebhookClient(private val timeoutMs: Int = 10_000) { +class WebhookClient(private val timeoutMs: Int = 10_000, private val totalTimeoutMs: Long = 120_000) { fun send(url: String, event: MessageEvent, requireAck: Boolean, authToken: String): DeliveryResult = sendRequest(QueuedRequest(url, requireAck, event.toJson(), authToken, event.images), event.eventId) - fun sendRequest(request: QueuedRequest, eventId: String): DeliveryResult = - if (request.images.isEmpty()) sendJson(request.url, eventId, request.json, request.requireAck, request.authToken) - else sendBody(request.url, eventId, request.requireAck, request.authToken, null, request::writeTo) + fun sendRequest(request: QueuedRequest, eventId: String, cancellation: android.os.CancellationSignal = android.os.CancellationSignal()): DeliveryResult = + if (request.images.isEmpty()) sendJson(request.url, eventId, request.json, request.requireAck, request.authToken, cancellation) + else sendBody(request.url, eventId, request.requireAck, request.authToken, null, cancellation, request::writeTo) - fun sendJson(url: String, eventId: String, json: String, requireAck: Boolean, authToken: String): DeliveryResult { + internal suspend fun sendCancellable(request: QueuedRequest, eventId: String): DeliveryResult = + cancellableHttp { sendRequest(request, eventId, it) } + + fun sendJson(url: String, eventId: String, json: String, requireAck: Boolean, authToken: String, + cancellation: android.os.CancellationSignal = android.os.CancellationSignal()): DeliveryResult { val payload = json.toByteArray(StandardCharsets.UTF_8) - return sendBody(url, eventId, requireAck, authToken, payload.size.toLong()) { it.write(payload) } + return sendBody(url, eventId, requireAck, authToken, payload.size.toLong(), cancellation) { it.write(payload) } } private fun sendBody(url: String, eventId: String, requireAck: Boolean, authToken: String, length: Long?, - write: (OutputStream) -> Unit): DeliveryResult { + cancellation: android.os.CancellationSignal, write: (OutputStream) -> Unit): DeliveryResult { require(validAuthToken(authToken)) { "Invalid authentication token" } val start = System.nanoTime() var connection: HttpURLConnection? = null var httpCode: Int? = null + val control = HttpRequestControl(cancellation, totalTimeoutMs) val status = try { + control.checkActive() connection = URI(url).toURL().openConnection() as HttpURLConnection + control.attach(connection) connection.apply { requestMethod = "POST" instanceFollowRedirects = false @@ -94,8 +101,17 @@ class WebhookClient(private val timeoutMs: Int = 10_000) { setRequestProperty("Authorization", "Bearer $authToken") } if (length == null) connection.setChunkedStreamingMode(8192) else connection.setFixedLengthStreamingMode(length) - connection.outputStream.use(write) + connection.outputStream.use { output -> + write(object : FilterOutputStream(output) { + override fun write(value: Int) { control.checkActive(); out.write(value) } + override fun write(bytes: ByteArray, offset: Int, length: Int) { + control.checkActive(); out.write(bytes, offset, length) + } + }) + } + control.checkActive() httpCode = connection.responseCode + control.checkActive() when { httpCode !in 200..299 -> DeliveryStatus.HTTP_ERROR !requireAck -> DeliveryStatus.HTTP_SUCCESS @@ -104,12 +120,14 @@ class WebhookClient(private val timeoutMs: Int = 10_000) { val output = ByteArrayOutputStream() val buffer = ByteArray(4096) while (output.size() <= MAX_ACK_BYTES) { + control.checkActive() val count = input.read(buffer, 0, minOf(buffer.size, MAX_ACK_BYTES + 1 - output.size())) if (count == -1) break output.write(buffer, 0, count) } output.toByteArray() } + control.checkActive() if (bytes.size > MAX_ACK_BYTES) DeliveryStatus.INVALID_ACK else validateAck(String(bytes, StandardCharsets.UTF_8), eventId) } @@ -119,8 +137,10 @@ class WebhookClient(private val timeoutMs: Int = 10_000) { } catch (_: SocketTimeoutException) { DeliveryStatus.TIMEOUT } catch (_: IOException) { - DeliveryStatus.NETWORK_ERROR + try { control.checkActive(); DeliveryStatus.NETWORK_ERROR } + catch (_: SocketTimeoutException) { DeliveryStatus.TIMEOUT } } finally { + control.close() connection?.disconnect() } return DeliveryResult(eventId, status, httpCode, (System.nanoTime() - start) / 1_000_000) diff --git a/app/src/main/java/life/andre/message487/diagnostics/CrashHandler.kt b/app/src/main/java/life/andre/message487/diagnostics/CrashHandler.kt index 6bc6758..54eff2c 100644 --- a/app/src/main/java/life/andre/message487/diagnostics/CrashHandler.kt +++ b/app/src/main/java/life/andre/message487/diagnostics/CrashHandler.kt @@ -4,6 +4,7 @@ import android.content.Context import android.os.Process import kotlinx.coroutines.flow.asStateFlow import kotlin.system.exitProcess +import life.andre.message487.readBoolean internal class CrashHandler(context: Context, private val log: DiagnosticLog) { private val preferences by lazy { context.getSharedPreferences("crash_state", Context.MODE_PRIVATE) } @@ -11,7 +12,7 @@ internal class CrashHandler(context: Context, private val log: DiagnosticLog) { val state = mutablePending.asStateFlow() val pending: Boolean get() = mutablePending.value - fun load() { mutablePending.value = preferences.getBoolean("pending", false) } + fun load() { mutablePending.value = preferences.readBoolean("pending", false) } fun install() { val previous = Thread.getDefaultUncaughtExceptionHandler() diff --git a/app/src/test/java/life/andre/message487/AndroidFailureRecoveryTest.kt b/app/src/test/java/life/andre/message487/AndroidFailureRecoveryTest.kt new file mode 100644 index 0000000..0afea5e --- /dev/null +++ b/app/src/test/java/life/andre/message487/AndroidFailureRecoveryTest.kt @@ -0,0 +1,99 @@ +package life.andre.message487 + +import android.app.Application +import android.content.Context +import java.io.File +import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.runBlocking +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 = Application::class, sdk = [26, 35], manifest = Config.NONE) +class AndroidFailureRecoveryTest { + @Test fun `bounded notification backlog rejects overflow and leaves SMS independent`() { + val notifications = boundedExecutor("test-notifications", 1) + val sms = boundedExecutor("test-sms", 1) + val started = CountDownLatch(1) + val release = CountDownLatch(1) + val smsDone = CountDownLatch(1) + val failures = AtomicInteger() + try { + assertTrue(notifications.submitSafely({ failures.incrementAndGet() }) { + started.countDown(); release.await(3, TimeUnit.SECONDS) + }) + assertTrue(started.await(1, TimeUnit.SECONDS)) + assertTrue(notifications.submitSafely({ failures.incrementAndGet() }) { throw IOException("storage") }) + assertFalse(notifications.submitSafely({ failures.incrementAndGet() }) { fail("overflow ran") }) + assertTrue(sms.submitSafely({ throw it }) { smsDone.countDown() }) + assertTrue(smsDone.await(1, TimeUnit.SECONDS)) + release.countDown() + notifications.shutdown() + assertTrue(notifications.awaitTermination(2, TimeUnit.SECONDS)) + assertEquals(2, failures.get()) + } finally { release.countDown(); notifications.shutdownNow(); sms.shutdownNow() } + } + + @Test fun `image allocation failure delivers text and releases shared memory lock`() { + val delivered = CountDownLatch(2) + val failures = AtomicInteger() + NotificationImageQueue().use { queue -> + queue.submit("oom", { withImageMemory> { throw OutOfMemoryError("injected") } }, + { assertTrue(it.isEmpty()); delivered.countDown() }, + { assertTrue(it is ImageMemoryException); failures.incrementAndGet() }) + queue.submit("next", { withImageMemory { emptyList() } }, + { delivered.countDown() }, { throw it }) + assertTrue(delivered.await(2, TimeUnit.SECONDS)) + assertEquals(1, failures.get()) + } + } + + @Test fun `corrupt preference fields preserve valid credentials and update checks recover`() = runBlocking { + val app = RuntimeEnvironment.getApplication() + val cipher = object : PayloadCipher { + override fun encrypt(value: String) = value.toByteArray() + override fun decrypt(value: ByteArray) = String(value) + } + val store = SettingsStore(app, cipher) + store.update { it.copy(url = "https://example.test/webhook", authToken = "secret", deviceId = "device") } + app.getSharedPreferences("connection", Context.MODE_PRIVATE).edit() + .putString("image_quality", "broken").putInt("notifications", 1) + .putInt("deduplication_window_seconds", -100).putString("packages", "broken").commit() + val restored = SettingsStore(app, cipher).load() + assertEquals("https://example.test/webhook", restored.url) + assertEquals("secret", restored.authToken) + assertEquals("device", restored.deviceId) + assertEquals(80, restored.imageQuality) + assertEquals(1, restored.deduplicationWindowSeconds) + assertFalse(restored.notifications) + assertTrue(restored.packages.isEmpty()) + app.getSharedPreferences("updates", Context.MODE_PRIVATE).edit() + .putInt("source", 1).putString("automatic", "broken").commit() + // Both the failing request and the final source/preferences checks are contained. + runBackgroundUpdateCheck(app, UpdateSource.GITHUB) { throw IOException("network") } + assertEquals("network_error", UpdatePreferences(app).lastBackgroundResult) + } + + @Test fun `APK cache keeps recent installers and removes abandoned partial downloads only`() { + val dir = File(RuntimeEnvironment.getApplication().cacheDir, "cleanup-test").apply { mkdirs() } + fun file(name: String, modified: Long) = File(dir, name).apply { writeText("x"); setLastModified(modified) } + try { + val old = file("update-old.apk", 1) + val previous = file("update-previous.apk", 2) + val latest = file("update-latest.apk", 3) + val abandoned = file("update-old.part", 1) + val active = file("update-active.part", TimeUnit.DAYS.toMillis(2)) + val unrelated = file("other.apk", 1) + cleanupUpdateFiles(dir, latest, TimeUnit.DAYS.toMillis(2)) + assertFalse(old.exists()); assertFalse(abandoned.exists()) + listOf(previous, latest, active, unrelated).forEach { assertTrue(it.exists()) } + } finally { dir.deleteRecursively() } + } +} diff --git a/app/src/test/java/life/andre/message487/MessageGraphTest.kt b/app/src/test/java/life/andre/message487/MessageGraphTest.kt index 192c56f..61cee63 100644 --- a/app/src/test/java/life/andre/message487/MessageGraphTest.kt +++ b/app/src/test/java/life/andre/message487/MessageGraphTest.kt @@ -17,8 +17,9 @@ class MessageGraphTest { val app = RuntimeEnvironment.getApplication() as UiTestApplication // Queue requests without running delivery workers while inspecting payloads. val idle = java.util.concurrent.Executor { } + val tasks = java.util.concurrent.Executors.newSingleThreadExecutor() androidx.work.WorkManager.initialize(app, androidx.work.Configuration.Builder() - .setExecutor(idle).setTaskExecutor(idle).build()) + .setExecutor(idle).setTaskExecutor(tasks).build()) org.robolectric.Shadows.shadowOf(app.packageManager).setApplicationIcon( app.packageName, requireNotNull(app.getDrawable(app.applicationInfo.icon))) val graph = app.graph @@ -64,7 +65,9 @@ class MessageGraphTest { assertEquals(listOf("big_picture"), limited.map { it.presentation }) } finally { discardImages(limited) } } finally { + tasks.shutdownNow() graph.captureExecutor.shutdownNow() + graph.smsExecutor.shutdownNow() graph.imageCaptureQueue.close() graph.outbox.close() app.diagnostics.close() diff --git a/app/src/test/java/life/andre/message487/OutboxTest.kt b/app/src/test/java/life/andre/message487/OutboxTest.kt index 0c09c00..9a17207 100644 --- a/app/src/test/java/life/andre/message487/OutboxTest.kt +++ b/app/src/test/java/life/andre/message487/OutboxTest.kt @@ -34,6 +34,24 @@ class OutboxTest { @After fun close() { outbox.close(); context.deleteDatabase("outbox.db") } + @Test fun `pending recovery uses bounded keyset pages`() { + for (i in 0..6) outbox.enqueue(event("page-$i").copy(text = "unique $i"), settings) + assertEquals(listOf("page-0", "page-1", "page-2"), outbox.pendingIds(limit = 3)) + assertEquals(listOf("page-3", "page-4", "page-5"), outbox.pendingIds("page-2", 3)) + assertEquals(listOf("page-6"), outbox.pendingIds("page-5", 3)) + assertTrue(outbox.pendingIds("page-6", 3).isEmpty()) + } + + @Test fun `oversized legacy payload is refused before decryption and retained`() { + outbox.enqueue(event("huge"), settings) + outbox.writableDatabase.execSQL("UPDATE events SET payload=zeroblob(?),payload_file=NULL WHERE id='huge'", + arrayOf(MAX_STORED_PAYLOAD_BYTES + 1)) + assertThrows(java.io.IOException::class.java) { outbox.beginAttempt("huge") } + outbox.readableDatabase.rawQuery("SELECT length(payload) FROM events WHERE id='huge'", null).use { + assertTrue(it.moveToFirst()); assertEquals(MAX_STORED_PAYLOAD_BYTES + 1, it.getInt(0)) + } + } + @Test fun `image collection checksum and deduplication ignore order but preserve content roles and multiplicity`() { val first = imageFixture("first-image") val second = imageFixture("second-image") diff --git a/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt index 86217db..8f5bb96 100644 --- a/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt +++ b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt @@ -56,6 +56,7 @@ class ScreenInteractionTest { @After fun close() { application.graph.captureExecutor.shutdownNow() + application.graph.smsExecutor.shutdownNow() application.graph.imageCaptureQueue.close() application.diagnostics.close() } diff --git a/app/src/test/java/life/andre/message487/SettingsLoadRecoveryTest.kt b/app/src/test/java/life/andre/message487/SettingsLoadRecoveryTest.kt new file mode 100644 index 0000000..9c5806c --- /dev/null +++ b/app/src/test/java/life/andre/message487/SettingsLoadRecoveryTest.kt @@ -0,0 +1,48 @@ +package life.andre.message487 + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import java.io.IOException +import java.util.concurrent.atomic.AtomicInteger +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode + +@RunWith(RobolectricTestRunner::class) +@Config(application = UiTestApplication::class, sdk = [35], qualifiers = "en-w411dp-h891dp") +@LooperMode(LooperMode.Mode.PAUSED) +class SettingsLoadRecoveryTest { + @get:Rule val compose = createAndroidComposeRule() + + @Test fun `failed initialization offers retry and keeps saved credentials`() { + val app = compose.activity.application as UiTestApplication + app.graph.settings.update { it.copy(url = "https://example.test/hook", authToken = "secret") } + val reads = AtomicInteger() + lateinit var model: ConnectionViewModel + try { + compose.runOnIdle { + model = ConnectionViewModel(app) { + if (reads.incrementAndGet() == 1) throw IOException("storage unavailable") + app.graph.settings.load() + } + } + compose.setContent { MessageTheme { MessageScreen(model) } } + compose.waitUntil(10_000) { compose.waitForIdle(); model.state.value.initializationFailed } + assertFalse(model.state.value.busy) + compose.onNodeWithText(compose.activity.getString(R.string.retry)).performClick() + compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy && !model.state.value.initializationFailed } + assertEquals("https://example.test/hook", model.state.value.url) + assertEquals("secret", model.state.value.authToken) + assertEquals(2, reads.get()) + } finally { + app.graph.captureExecutor.shutdownNow() + app.graph.smsExecutor.shutdownNow() + app.graph.imageCaptureQueue.close() + app.diagnostics.close() + } + } +} diff --git a/app/src/test/java/life/andre/message487/WebhookClientTest.kt b/app/src/test/java/life/andre/message487/WebhookClientTest.kt index eef6728..cd266bc 100644 --- a/app/src/test/java/life/andre/message487/WebhookClientTest.kt +++ b/app/src/test/java/life/andre/message487/WebhookClientTest.kt @@ -1,14 +1,18 @@ package life.andre.message487 +import kotlinx.coroutines.async import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.json.JSONObject +import org.junit.Assert.assertNotNull import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import java.util.concurrent.TimeUnit +@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class) +@org.robolectric.annotation.Config(sdk = [35], manifest = org.robolectric.annotation.Config.NONE) class WebhookClientTest { private val source = AppSource("example.source", "Source app") @Test @@ -96,6 +100,30 @@ class WebhookClientTest { assertEquals(0, server.requestCount) } + @Test fun `whole request deadline stops a slow drip response`() = withServer { server -> + server.enqueue(MockResponse().setBody("{\"status\":\"accepted\",\"event_id\":\"a\"}") + .throttleBody(1, 50, TimeUnit.MILLISECONDS)) + val start = System.nanoTime() + val result = WebhookClient(2_000, 200).sendJson(server.url("/").toString(), "a", "{}", true, "token") + assertEquals(DeliveryStatus.TIMEOUT, result.status) + assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) < 2_000) + } + + @Test fun `coroutine cancellation disconnects a request waiting for headers`() = withServer { server -> + server.enqueue(MockResponse().setSocketPolicy(okhttp3.mockwebserver.SocketPolicy.NO_RESPONSE)) + kotlinx.coroutines.runBlocking { + val task = async(kotlinx.coroutines.Dispatchers.IO) { + WebhookClient().sendCancellable(QueuedRequest(server.url("/").toString(), true, "{}", "token"), "a") + } + assertNotNull(server.takeRequest(2, TimeUnit.SECONDS)) + val start = System.nanoTime() + task.cancel() + task.join() + assertTrue(task.isCancelled) + assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) < 2_000) + } + } + private fun withServer(block: (MockWebServer) -> Unit) { MockWebServer().use { server -> server.start() diff --git a/docs/en/testing.md b/docs/en/testing.md index d5d0f29..db2ac67 100644 --- a/docs/en/testing.md +++ b/docs/en/testing.md @@ -86,3 +86,15 @@ Update regression tests cover the entry dialog, weekly snooze/skip, F-Droid rout GitHub navigation without automatic download, persisted background results, transient-error retry policy, cancellation and stale-source handling. HTTP 429/5xx are retryable; 404 and invalid responses are not. These JVM/Compose checks do not establish real JobScheduler timing. + +Failure regressions cover bounded notification/SMS queues, text fallback after an injected image +allocation failure, corrupt preference fields, retrying initial settings reads, paged queue recovery, +legacy metadata size limits, APK cache cleanup, whole-request HTTP deadlines and coroutine +cancellation. Real HTTP cancellation/deadlines and image-failure recovery also run on API 26/35. +The injected OOM verifies the recovery path; it does not deliberately exhaust the emulator heap. + +Notification capture retains at most 32 queued tasks; SMS has its own 16-task queue and an +8-second broadcast completion deadline. Overflow raises the existing capture-error indicator. +HTTP requests have a 2-minute total deadline. Queued metadata (including old inline-image bodies) +is read only up to 8 MiB and the available-memory budget; unreadable entries become blocked and +retain their files for manual retry. This limit does not cap streamed image-file sizes. diff --git a/docs/ru/testing.md b/docs/ru/testing.md index b9fc88d..db566d7 100644 --- a/docs/ru/testing.md +++ b/docs/ru/testing.md @@ -94,3 +94,17 @@ CI загружает отчёты и после ошибки тестов; св результат фоновой проверки, повторы временных ошибок, отмену и смену источника. HTTP 429/5xx допускают повтор; 404 и неверные ответы — нет. JVM/Compose-проверки не подтверждают фактическое время запуска JobScheduler. + +Регрессии отказов покрывают ограниченные очереди уведомлений/SMS, передачу текста после +искусственного OOM при обработке картинки, повреждённые поля настроек, повтор начальной +загрузки, постраничное восстановление очереди, лимит старых payload, очистку APK-кэша, +общий таймаут HTTP и отмену корутин. Реальная отмена HTTP, таймауты и восстановление после +ошибки изображения также проверяются на API 26/35. Искусственный OOM проверяет обработчик, +а не намеренно исчерпывает память эмулятора. + +Очередь уведомлений вмещает 32 ожидающие задачи; у SMS отдельная очередь на 16 задач +и завершение broadcast не позднее 8 секунд. Переполнение включает существующий индикатор +ошибки захвата. Общий таймаут HTTP — 2 минуты. Метаданные очереди (включая старые payload +со встроенными картинками) читаются в пределах 8 МиБ и бюджета доступной памяти; нечитаемая +запись блокируется с сохранением файлов для ручного повтора. Этот предел не ограничивает +размер изображений, передаваемых потоком из файлов. From a855d14e3c3fd6954cd31d0cc1ebc24edbca2093 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Thu, 15 Oct 2026 20:49:44 +0300 Subject: [PATCH 2/3] fix: preserve background I/O and move error formatting off main --- .../andre/message487/UiThreadDeviceTest.kt | 43 ++++++++++ .../java/life/andre/message487/AppUpdates.kt | 84 ++++++++++--------- .../message487/diagnostics/DiagnosticLog.kt | 18 ++-- .../message487/SettingsLoadRecoveryTest.kt | 1 + .../diagnostics/DiagnosticLogTest.kt | 21 +++++ docs/en/testing.md | 3 + docs/ru/testing.md | 3 + 7 files changed, 126 insertions(+), 47 deletions(-) diff --git a/app/src/androidTest/java/life/andre/message487/UiThreadDeviceTest.kt b/app/src/androidTest/java/life/andre/message487/UiThreadDeviceTest.kt index 3bbc0c0..ff2dc4f 100644 --- a/app/src/androidTest/java/life/andre/message487/UiThreadDeviceTest.kt +++ b/app/src/androidTest/java/life/andre/message487/UiThreadDeviceTest.kt @@ -48,6 +48,49 @@ class UiThreadDeviceTest { } } + @Test fun initializationAndUpdateFailuresKeepIoAndDiagnosticsOffMainThread() { + val reads = java.util.concurrent.atomic.AtomicInteger() + val formatted = java.util.concurrent.CountDownLatch(1) + val formattedOnMain = java.util.concurrent.atomic.AtomicBoolean() + val failure = object : java.io.IOException("injected storage failure") { + override fun getStackTrace(): Array { + formattedOnMain.set(android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) + formatted.countDown() + return super.getStackTrace() + } + } + compose.runOnUiThread { + previousPolicy = StrictMode.getThreadPolicy() + StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder() + .detectDiskReads().detectDiskWrites().detectNetwork().penaltyDeath().build()) + model = ConnectionViewModel(app) { + check(android.os.Looper.myLooper() != android.os.Looper.getMainLooper()) + // Real disk I/O on both the failing read and its successful retry. + java.io.File(app.cacheDir, "settings-read-probe").writeText("probe") + if (reads.incrementAndGet() == 1) throw failure + app.graph.settings.load() + } + app.reportLocalFailure(failure) + } + compose.setContent { MessageTheme { MessageScreen(model) } } + compose.waitUntil(10_000) { model.state.value.initializationFailed && !model.state.value.busy } + node(R.string.retry).performClick() + compose.waitUntil(10_000) { !model.state.value.initializationFailed && !model.state.value.busy } + assertEquals(2, reads.get()) + assertTrue(formatted.await(3, java.util.concurrent.TimeUnit.SECONDS)) + assertFalse(formattedOnMain.get()) + kotlinx.coroutines.runBlocking { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + var failed = false + try { + // Creates/cleans a real temporary APK, then fails before any network request. + AppUpdates(app).download(AppUpdate(UpdateSource.GITHUB, "99.0.0")) + } catch (_: IllegalArgumentException) { failed = true } + assertTrue(failed) + } + } + } + @Test fun navigateEditAndRefreshWithoutMainThreadIo() { show() node(R.string.connection_nav).performClick() diff --git a/app/src/main/java/life/andre/message487/AppUpdates.kt b/app/src/main/java/life/andre/message487/AppUpdates.kt index 515a0d1..f74a985 100644 --- a/app/src/main/java/life/andre/message487/AppUpdates.kt +++ b/app/src/main/java/life/andre/message487/AppUpdates.kt @@ -13,6 +13,9 @@ import java.io.File import java.net.HttpURLConnection import java.net.URI import java.security.MessageDigest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext import org.json.JSONObject internal class UpdateException(val textId: Int) : Exception() @@ -130,52 +133,57 @@ internal class AppUpdates(private val context: Context) { preferences.edit().putString("source", source.name).save() } - suspend fun check(source: UpdateSource): AppUpdate? = cancellableHttp { cancellation -> - val url = if (source == UpdateSource.FDROID) FDROID_UPDATE_API else GITHUB_RELEASES_API - val json = request(url, cancellation) { connection -> - requireUpdateMetadataStatus(connection.responseCode) - connection.inputStream.use { stream -> - String(readUpdateMetadata(stream), Charsets.UTF_8) + suspend fun check(source: UpdateSource): AppUpdate? = withContext(Dispatchers.IO) { + cancellableHttp { cancellation -> + val url = if (source == UpdateSource.FDROID) FDROID_UPDATE_API else GITHUB_RELEASES_API + val json = request(url, cancellation) { connection -> + requireUpdateMetadataStatus(connection.responseCode) + connection.inputStream.use { stream -> + String(readUpdateMetadata(stream), Charsets.UTF_8) + } } + if (source == UpdateSource.FDROID) parseFdroidUpdate(json, installedCode()) + else parseGithubUpdate(json, BuildConfig.VERSION_NAME) } - if (source == UpdateSource.FDROID) parseFdroidUpdate(json, installedCode()) - else parseGithubUpdate(json, BuildConfig.VERSION_NAME) } - suspend fun download(update: AppUpdate): File = cancellableHttp { cancellation -> - require(update.source == UpdateSource.GITHUB) - val directory = File(context.cacheDir, "updates").apply { mkdirs() } - cleanupUpdateFiles(directory) - val partial = File.createTempFile("update-", ".part", directory) - val apk = File(directory, partial.nameWithoutExtension + ".apk") - try { - val digest = MessageDigest.getInstance("SHA-256") - request(requireNotNull(update.apkUrl), cancellation) { connection -> - require(connection.responseCode == 200) - connection.inputStream.use { input -> - partial.outputStream().use { output -> - val buffer = ByteArray(64 * 1024) - var total = 0L - while (true) { - if (cancellation.isCanceled) throw kotlinx.coroutines.CancellationException("Download cancelled") - val count = input.read(buffer) - if (count < 0) break - total += count - require(total <= update.size && total <= MAX_UPDATE_APK_BYTES) - digest.update(buffer, 0, count) - output.write(buffer, 0, count) + suspend fun download(update: AppUpdate): File = withContext(Dispatchers.IO) { + cancellableHttp { cancellation -> + require(update.source == UpdateSource.GITHUB) + val directory = File(context.cacheDir, "updates").apply { mkdirs() } + cleanupUpdateFiles(directory) + val partial = File.createTempFile("update-", ".part", directory) + val apk = File(directory, partial.nameWithoutExtension + ".apk") + try { + val digest = MessageDigest.getInstance("SHA-256") + request(requireNotNull(update.apkUrl), cancellation) { connection -> + require(connection.responseCode == 200) + connection.inputStream.use { input -> + partial.outputStream().use { output -> + val buffer = ByteArray(64 * 1024) + var total = 0L + while (true) { + coroutineContext.ensureActive() + if (cancellation.isCanceled) throw kotlinx.coroutines.CancellationException("Download cancelled") + val count = input.read(buffer) + if (count < 0) break + total += count + require(total <= update.size && total <= MAX_UPDATE_APK_BYTES) + digest.update(buffer, 0, count) + output.write(buffer, 0, count) + } + require(total == update.size) } - require(total == update.size) } } + require(digest.digest().hex() == update.sha256) + verifyApk(partial, update.version) + check(partial.renameTo(apk)) + cleanupUpdateFiles(directory, keep = apk) + apk + } finally { + partial.delete() } - require(digest.digest().hex() == update.sha256) - verifyApk(partial, update.version) - check(partial.renameTo(apk)) - cleanupUpdateFiles(directory, keep = apk) - apk - } finally { - partial.delete() } } diff --git a/app/src/main/java/life/andre/message487/diagnostics/DiagnosticLog.kt b/app/src/main/java/life/andre/message487/diagnostics/DiagnosticLog.kt index 5a54d42..6e00796 100644 --- a/app/src/main/java/life/andre/message487/diagnostics/DiagnosticLog.kt +++ b/app/src/main/java/life/andre/message487/diagnostics/DiagnosticLog.kt @@ -38,17 +38,17 @@ internal class DiagnosticLog( fun record(event: DiagnosticEvent, type: String? = null, outcome: String? = null, http: Int? = null, count: Int? = null, error: Throwable? = null) { - val line = buildString { - append("${Instant.now()} event=${event.name}") - type?.let { append(" type=${if (it in TYPES) it else "unknown"}") } - outcome?.let { append(" outcome=${if (it in OUTCOMES) it else "unknown"}") } - http?.let { append(" http=$it") } - count?.let { append(" count=$it") } - appendLine() - error?.let { append(safeStackTrace(it)) } - } try { executor.execute { + val line = buildString { + append("${Instant.now()} event=${event.name}") + type?.let { append(" type=${if (it in TYPES) it else "unknown"}") } + outcome?.let { append(" outcome=${if (it in OUTCOMES) it else "unknown"}") } + http?.let { append(" http=$it") } + count?.let { append(" count=$it") } + appendLine() + error?.let { append(safeStackTrace(it)) } + } safely { synchronized(lock) { val missed = dropped.getAndSet(0) diff --git a/app/src/test/java/life/andre/message487/SettingsLoadRecoveryTest.kt b/app/src/test/java/life/andre/message487/SettingsLoadRecoveryTest.kt index 9c5806c..54f066e 100644 --- a/app/src/test/java/life/andre/message487/SettingsLoadRecoveryTest.kt +++ b/app/src/test/java/life/andre/message487/SettingsLoadRecoveryTest.kt @@ -26,6 +26,7 @@ class SettingsLoadRecoveryTest { try { compose.runOnIdle { model = ConnectionViewModel(app) { + assertNotSame(android.os.Looper.getMainLooper(), android.os.Looper.myLooper()) if (reads.incrementAndGet() == 1) throw IOException("storage unavailable") app.graph.settings.load() } diff --git a/app/src/test/java/life/andre/message487/diagnostics/DiagnosticLogTest.kt b/app/src/test/java/life/andre/message487/diagnostics/DiagnosticLogTest.kt index 6e8fa38..f7501aa 100644 --- a/app/src/test/java/life/andre/message487/diagnostics/DiagnosticLogTest.kt +++ b/app/src/test/java/life/andre/message487/diagnostics/DiagnosticLogTest.kt @@ -10,6 +10,27 @@ import java.io.ByteArrayOutputStream class DiagnosticLogTest { @get:Rule val temporary = TemporaryFolder() + @Test fun `record formats failures and resolves storage only on its worker`() { + val caller = Thread.currentThread() + val formatted = java.util.concurrent.atomic.AtomicBoolean() + val error = object : java.io.IOException("injected") { + override fun getStackTrace(): Array { + assertNotSame(caller, Thread.currentThread()) + formatted.set(true) + return super.getStackTrace() + } + } + DiagnosticLog({ + assertNotSame(caller, Thread.currentThread()) + temporary.root + }).use { log -> + log.record(DiagnosticEvent.LOCAL_OPERATION_FAILED, error = error) + log.flush() + assertTrue(formatted.get()) + assertTrue(log.readTail().contains("LOCAL_OPERATION_FAILED")) + } + } + @Test fun `rotation stays bounded and persists across restart`() { val directory = temporary.newFolder() DiagnosticLog(directory, 1024).use { log -> diff --git a/docs/en/testing.md b/docs/en/testing.md index db2ac67..0e662e1 100644 --- a/docs/en/testing.md +++ b/docs/en/testing.md @@ -98,3 +98,6 @@ Notification capture retains at most 32 queued tasks; SMS has its own 16-task qu HTTP requests have a 2-minute total deadline. Queued metadata (including old inline-image bodies) is read only up to 8 MiB and the available-memory budget; unreadable entries become blocked and retain their files for manual retry. This limit does not cap streamed image-file sizes. + +StrictMode also covers failed initial settings reads and Retry, temporary-file cleanup after a +failed update download, and asynchronous diagnostic formatting from a UI error handler. diff --git a/docs/ru/testing.md b/docs/ru/testing.md index db566d7..40b04a1 100644 --- a/docs/ru/testing.md +++ b/docs/ru/testing.md @@ -108,3 +108,6 @@ HTTP 429/5xx допускают повтор; 404 и неверные ответ со встроенными картинками) читаются в пределах 8 МиБ и бюджета доступной памяти; нечитаемая запись блокируется с сохранением файлов для ручного повтора. Этот предел не ограничивает размер изображений, передаваемых потоком из файлов. + +StrictMode также покрывает ошибку начальной загрузки настроек и повтор, очистку временного +файла при ошибке скачивания обновления и фоновое форматирование диагностики из UI-обработчика. From c9c4c87578b96249e00f12cc3f9d4becd1b7bf56 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Thu, 15 Oct 2026 20:58:38 +0300 Subject: [PATCH 3/3] test: await enabled UI controls after asynchronous settings writes --- .../life/andre/message487/UiThreadDeviceTest.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/life/andre/message487/UiThreadDeviceTest.kt b/app/src/androidTest/java/life/andre/message487/UiThreadDeviceTest.kt index ff2dc4f..cfb8221 100644 --- a/app/src/androidTest/java/life/andre/message487/UiThreadDeviceTest.kt +++ b/app/src/androidTest/java/life/andre/message487/UiThreadDeviceTest.kt @@ -99,8 +99,15 @@ class UiThreadDeviceTest { compose.onNode(hasText(app.getString(R.string.sources_tab)) and hasClickAction()).performClick() compose.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(app.getString(R.string.image_max_edge))) node(R.string.image_max_edge).performScrollTo().performTextReplacement("2") + compose.waitUntil(10_000) { + compose.waitForIdle() + model.settings.value.imageMaxEdge == 2 && !model.state.value.busy + } node(R.string.image_max_edge).performTextInput("048") - compose.waitUntil(10_000) { model.settings.value.imageMaxEdge == 2048 } + compose.waitUntil(10_000) { + compose.waitForIdle() + model.settings.value.imageMaxEdge == 2048 && !model.state.value.busy + } node(R.string.image_max_edge).assertIsEnabled() icon(R.string.back).performClick() node(R.string.journal).performClick() @@ -112,7 +119,12 @@ class UiThreadDeviceTest { node(R.string.update_github).performScrollTo().performClick() compose.waitUntil(10_000) { AppUpdates(app).source() == UpdateSource.GITHUB } node(R.string.update_automatic).performScrollTo() - compose.onNodeWithContentDescription(app.getString(R.string.update_automatic)).performClick() + // The preference write can finish before the ViewModel clears busy on the main thread. + val automatic = hasContentDescription(app.getString(R.string.update_automatic)) + compose.waitUntil(10_000) { + compose.onAllNodes(automatic and isEnabled()).fetchSemanticsNodes().isNotEmpty() + } + compose.onNode(automatic).assertIsOn().performClick() compose.waitUntil(10_000) { !UpdatePreferences(app).automatic } UpdatePreferences(app).detected(UpdateSource.GITHUB, AppUpdate(UpdateSource.GITHUB, "99.0.0")) // App settings, permission checks and the update dialog refresh on ON_RESUME.