From 56e2c1254de81703266b59719d48e725ee0682a8 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Thu, 15 Oct 2026 19:49:14 +0300 Subject: [PATCH 1/2] fix: move storage and system queries off the UI thread --- app/build.gradle.kts | 2 + .../life/andre/message487/DeviceTestRunner.kt | 8 ++ .../message487/StorageDeliveryDeviceTest.kt | 2 +- .../andre/message487/UiThreadDeviceTest.kt | 95 +++++++++++++ app/src/main/AndroidManifest.xml | 9 +- .../java/life/andre/message487/AppUpdates.kt | 4 +- .../andre/message487/ConnectionViewModel.kt | 60 ++++++-- .../life/andre/message487/DeliveryWorker.kt | 2 +- .../andre/message487/ForwardingSettings.kt | 18 ++- .../life/andre/message487/JournalScreen.kt | 6 +- .../life/andre/message487/MainActivity.kt | 23 ++- .../life/andre/message487/MessageGraph.kt | 37 +++-- .../message487/NotificationCaptureService.kt | 10 ++ .../main/java/life/andre/message487/Outbox.kt | 2 +- .../andre/message487/SmsCaptureReceiver.kt | 4 +- .../life/andre/message487/SourcesScreen.kt | 19 ++- .../andre/message487/UpdateAvailableDialog.kt | 57 ++++++-- .../andre/message487/UpdateNotifications.kt | 56 ++++---- .../life/andre/message487/UpdatesScreen.kt | 134 ++++++++++++------ .../message487/diagnostics/CrashHandler.kt | 14 +- .../message487/diagnostics/DiagnosticLog.kt | 4 +- .../andre/message487/ResourceContractTest.kt | 7 +- .../andre/message487/ScreenInteractionTest.kt | 57 ++++---- .../andre/message487/SettingsStoreTest.kt | 63 +++++--- .../andre/message487/SourcesScreenTest.kt | 16 +++ .../message487/UpdateNotificationsTest.kt | 8 ++ .../life/andre/message487/UpdatesUiTest.kt | 32 ++++- .../diagnostics/FeedbackEmailTest.kt | 2 +- docs/en/release-testing.md | 7 + docs/ru/release-testing.md | 8 ++ 30 files changed, 574 insertions(+), 192 deletions(-) create mode 100644 app/src/androidTest/java/life/andre/message487/UiThreadDeviceTest.kt create mode 100644 app/src/test/java/life/andre/message487/SourcesScreenTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6944aed..10d6b21 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -101,6 +101,8 @@ dependencies { testImplementation("org.robolectric:robolectric:4.16") testImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-test-manifest") + androidTestImplementation(platform("androidx.compose:compose-bom:2025.01.01")) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation("androidx.test:runner:1.6.2") androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test:core:1.6.1") diff --git a/app/src/androidTest/java/life/andre/message487/DeviceTestRunner.kt b/app/src/androidTest/java/life/andre/message487/DeviceTestRunner.kt index 7ad7e20..43517cb 100644 --- a/app/src/androidTest/java/life/andre/message487/DeviceTestRunner.kt +++ b/app/src/androidTest/java/life/andre/message487/DeviceTestRunner.kt @@ -6,6 +6,14 @@ import androidx.test.runner.AndroidJUnitRunner // Keep production workers and crash handlers out of isolated storage/transport tests. class DeviceTestApplication : MessageApplication() { + override val payloadCipher: PayloadCipher = object : PayloadCipher { + private val delegate = KeystorePayloadCipher() + private fun checkThread() = check(android.os.Looper.myLooper() != android.os.Looper.getMainLooper()) { + "Keystore must not run on the UI thread" + } + override fun encrypt(value: String): ByteArray { checkThread(); return delegate.encrypt(value) } + override fun decrypt(value: ByteArray): String { checkThread(); return delegate.decrypt(value) } + } override fun onCreate() {} } diff --git a/app/src/androidTest/java/life/andre/message487/StorageDeliveryDeviceTest.kt b/app/src/androidTest/java/life/andre/message487/StorageDeliveryDeviceTest.kt index eb713e0..1ea1c1b 100644 --- a/app/src/androidTest/java/life/andre/message487/StorageDeliveryDeviceTest.kt +++ b/app/src/androidTest/java/life/andre/message487/StorageDeliveryDeviceTest.kt @@ -37,7 +37,7 @@ class StorageDeliveryDeviceTest { encrypted[encrypted.lastIndex] = (encrypted.last().toInt() xor 1).toByte() assertThrows(Exception::class.java) { cipher.decrypt(encrypted) } SettingsStore(context).update { it.copy(authToken = token, url = "https://example.test/hook") } - val reopened = SettingsStore(context).state.value + val reopened = SettingsStore(context).load() assertEquals(token, reopened.authToken) assertTrue(reopened.ready()) assertFalse(context.getSharedPreferences("connection", Context.MODE_PRIVATE).all.toString().contains(token)) diff --git a/app/src/androidTest/java/life/andre/message487/UiThreadDeviceTest.kt b/app/src/androidTest/java/life/andre/message487/UiThreadDeviceTest.kt new file mode 100644 index 0000000..3bbc0c0 --- /dev/null +++ b/app/src/androidTest/java/life/andre/message487/UiThreadDeviceTest.kt @@ -0,0 +1,95 @@ +package life.andre.message487 + +import android.os.StrictMode +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** Real Android disk/network detection, including first construction of the UI models. */ +@RunWith(AndroidJUnit4::class) +class UiThreadDeviceTest { + @get:Rule val compose = createAndroidComposeRule() + private var previousPolicy: StrictMode.ThreadPolicy? = null + private val app get() = compose.activity.application as MessageApplication + private lateinit var model: ConnectionViewModel + + private fun node(id: Int) = compose.onNodeWithText(app.getString(id)) + private fun icon(id: Int) = compose.onNodeWithContentDescription(app.getString(id)) + + private fun show() { + // This test never sends a message or changes the user's emulator: run on disposable devices. + app.getSharedPreferences("connection", 0).edit().clear().commit() + app.getSharedPreferences("updates", 0).edit().clear().commit() + // Exercise real persisted-token decryption when the ViewModel starts. + SettingsStore(app).update { it.copy(url = "https://example.test/hook", authToken = "synthetic-ui-token") } + compose.runOnUiThread { + previousPolicy = StrictMode.getThreadPolicy() + StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder() + .detectDiskReads().detectDiskWrites().detectNetwork().penaltyDeath().build()) + model = androidx.lifecycle.ViewModelProvider(compose.activity)[ConnectionViewModel::class.java] + } + compose.setContent { MessageTheme { + MessageScreen(model) + UpdateAvailableDialog(compose.activity, 0) {} + } } + compose.waitUntil(10_000) { model.settingsLoaded.value && !model.state.value.busy } + } + + @After fun restore() { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + previousPolicy?.let(StrictMode::setThreadPolicy) + } + } + + @Test fun navigateEditAndRefreshWithoutMainThreadIo() { + show() + node(R.string.connection_nav).performClick() + node(R.string.webhook_url).assertIsDisplayed() + icon(R.string.back).performClick() + 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") + node(R.string.image_max_edge).performTextInput("048") + compose.waitUntil(10_000) { model.settings.value.imageMaxEdge == 2048 } + node(R.string.image_max_edge).assertIsEnabled() + icon(R.string.back).performClick() + node(R.string.journal).performClick() + icon(R.string.back).performClick() + icon(R.string.diagnostics).performClick() + node(R.string.refresh_log).performClick() + icon(R.string.back).performClick() + icon(R.string.updates).performClick() + 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() + 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. + compose.activityRule.scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED) + compose.activityRule.scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED) + compose.waitUntil(10_000) { + compose.onAllNodesWithText(app.getString(R.string.update_remind_later)).fetchSemanticsNodes().isNotEmpty() + } + node(R.string.update_remind_later).performClick() + compose.waitUntil(10_000) { UpdatePreferences(app).pending() == null } + compose.waitUntil(10_000) { compose.onAllNodes(isDialog()).fetchSemanticsNodes().isEmpty() } + app.getSharedPreferences("crash_state", 0).edit().putBoolean("pending", true).commit() + app.crashHandler.load() + compose.waitUntil(10_000) { + compose.onAllNodesWithText(app.getString(R.string.crash_title)).fetchSemanticsNodes().isNotEmpty() + } + node(R.string.close).performClick() + compose.waitUntil(10_000) { !app.crashHandler.pending } + compose.runOnUiThread { app.graph.captureFailed(IllegalStateException("synthetic failure")) } + compose.waitUntil(10_000) { model.settings.value.captureFailed } + } + +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 11506f6..4b46f42 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -40,6 +41,12 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Message487"> + + + + () - mutablePermissions.value = PermissionState( - notifications = app.packageName in NotificationManagerCompat.getEnabledListenerPackages(app), - sms = ContextCompat.checkSelfPermission(app, Manifest.permission.RECEIVE_SMS) == PackageManager.PERMISSION_GRANTED, - ) + permissionRefresh?.cancel() + permissionRefresh = viewModelScope.launch { + try { + mutablePermissions.value = withContext(Dispatchers.IO) { + val app = getApplication() + PermissionState( + notifications = app.packageName in NotificationManagerCompat.getEnabledListenerPackages(app), + sms = ContextCompat.checkSelfPermission(app, Manifest.permission.RECEIVE_SMS) == PackageManager.PERMISSION_GRANTED, + ) + } + } catch (error: Exception) { + if (error is kotlinx.coroutines.CancellationException) throw error + mutableState.value = state.value.copy(notice = R.string.local_error) + } + } } fun setAuthToken(token: String) { mutableState.value = state.value.copy(authToken = token, invalidToken = false, notice = null) } @@ -118,6 +137,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati fun setTestOptions(options: TestMessageOptions) { mutableState.value = state.value.copy(testOptions = options) } fun save(sendTest: Boolean) { + if (state.value.busy) return val draft = state.value val url = draft.url.trim() val code = draft.deviceCode.trim() @@ -132,7 +152,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati fun sendTest() = action(R.string.test_queued) { graph.enqueueTest() } fun notifications(enabled: Boolean) = action { graph.settings.update { it.copy(notifications = enabled) } } - fun deduplicationWindow(seconds: Int) = action { + fun deduplicationWindow(seconds: Int) = numberSetting("deduplicationWindow") { graph.settings.update { it.copy(deduplicationWindowSeconds = seconds) } } fun smsDeduplication(enabled: Boolean) = action { graph.settings.update { it.copy(smsDeduplication = enabled) } } @@ -140,10 +160,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati fun forwardAvatars(enabled: Boolean) = action { graph.settings.update { it.copy(forwardAvatars = enabled) } } fun forwardAppIcons(enabled: Boolean) = action { graph.settings.update { it.copy(forwardAppIcons = enabled) } } fun imageLimits(enabled: Boolean) = action { graph.settings.update { it.copy(imageLimitsEnabled = enabled) } } - fun imageMaxCount(value: Int) = action { graph.settings.update { it.copy(imageMaxCount = value) } } - fun imageMaxEdge(value: Int) = action { graph.settings.update { it.copy(imageMaxEdge = value) } } - fun imageMaxKiB(value: Int) = action { graph.settings.update { it.copy(imageMaxKiB = value) } } - fun imageTotalKiB(value: Int) = action { graph.settings.update { it.copy(imageTotalKiB = value) } } + fun imageMaxCount(value: Int) = numberSetting("imageMaxCount") { graph.settings.update { it.copy(imageMaxCount = value) } } + fun imageMaxEdge(value: Int) = numberSetting("imageMaxEdge") { graph.settings.update { it.copy(imageMaxEdge = value) } } + fun imageMaxKiB(value: Int) = numberSetting("imageMaxKiB") { graph.settings.update { it.copy(imageMaxKiB = value) } } + fun imageTotalKiB(value: Int) = numberSetting("imageTotalKiB") { graph.settings.update { it.copy(imageTotalKiB = value) } } fun imageFormat(format: NotificationImageFormat) = action { graph.settings.update { it.copy(imageFormat = format) } } fun imageQuality(quality: Int) = action { graph.settings.update { it.copy(imageQuality = quality) } } fun sms(enabled: Boolean) = action { graph.settings.update { it.copy(sms = enabled) } } @@ -175,13 +195,27 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati } fun delete(id: String) = action { graph.outbox.delete(id); graph.diagnostics.record(DiagnosticEvent.EVENT_DELETED) } fun clearError() = action { graph.settings.update { it.copy(captureFailed = false) } } - fun rebind() { + fun rebind() = action { NotificationListenerService.requestRebind(ComponentName(getApplication(), NotificationCaptureService::class.java)) } + + private val numberSaves = mutableMapOf() + private fun numberSetting(key: String, block: () -> Unit) { + if (!graph.settings.loaded.value) return + numberSaves.remove(key)?.cancel() + numberSaves[key] = viewModelScope.launch { + kotlinx.coroutines.delay(300) + try { withContext(Dispatchers.IO) { block() } } + catch (error: Exception) { + if (error is kotlinx.coroutines.CancellationException) throw error + mutableState.value = state.value.copy(notice = R.string.local_error) + } + } + } fun showSettingsError() { mutableState.value = state.value.copy(notice = R.string.settings_unavailable) } private fun action(notice: Int? = null, block: () -> Unit) { - if (state.value.busy) return + if (state.value.busy || !graph.settings.loaded.value) return mutableState.value = state.value.copy(busy = true, notice = null) viewModelScope.launch { val result = try { diff --git a/app/src/main/java/life/andre/message487/DeliveryWorker.kt b/app/src/main/java/life/andre/message487/DeliveryWorker.kt index e691c11..31653bc 100644 --- a/app/src/main/java/life/andre/message487/DeliveryWorker.kt +++ b/app/src/main/java/life/andre/message487/DeliveryWorker.kt @@ -41,7 +41,7 @@ class DeliveryWorker(context: Context, parameters: WorkerParameters) : Coroutine val graph = MessageGraph.get(applicationContext) val id = inputData.getString("event_id") ?: return@withContext Result.failure() try { - if (graph.settings.state.value.paused) return@withContext Result.retry() + if (graph.settings.load().paused) return@withContext Result.retry() val attempt = try { graph.outbox.beginAttempt(id) } catch (databaseError: android.database.SQLException) { diff --git a/app/src/main/java/life/andre/message487/ForwardingSettings.kt b/app/src/main/java/life/andre/message487/ForwardingSettings.kt index 07d10b0..5ee7245 100644 --- a/app/src/main/java/life/andre/message487/ForwardingSettings.kt +++ b/app/src/main/java/life/andre/message487/ForwardingSettings.kt @@ -49,8 +49,19 @@ class SettingsStore( private val cipher: PayloadCipher = (context.applicationContext as? MessageApplication)?.payloadCipher ?: KeystorePayloadCipher(), ) { private val diagnostics = (context.applicationContext as? MessageApplication)?.diagnostics - private val preferences = context.getSharedPreferences("connection", Context.MODE_PRIVATE) - private val mutableState = MutableStateFlow(read()) + private val preferences by lazy { context.getSharedPreferences("connection", Context.MODE_PRIVATE) } + private val mutableState = MutableStateFlow(ForwardingSettings()) + private val mutableLoaded = MutableStateFlow(false) + val loaded = mutableLoaded.asStateFlow() + + @Synchronized + fun load(): ForwardingSettings { + if (!mutableLoaded.value) { + mutableState.value = read() + mutableLoaded.value = true + } + return mutableState.value + } val state = mutableState.asStateFlow() private fun read() = ForwardingSettings( @@ -86,6 +97,7 @@ class SettingsStore( @Synchronized fun discoverPackages(packages: Set, ownPackage: String) { + load() val discovered = packages - ownPackage if (state.value.knownPackages.containsAll(discovered)) return update { @@ -98,9 +110,11 @@ class SettingsStore( @Synchronized fun update(transform: (ForwardingSettings) -> ForwardingSettings): ForwardingSettings { + load() val next = transform(mutableState.value).let { if (it.deviceId.isBlank()) it.copy(deviceId = UUID.randomUUID().toString()) else it } + if (next == mutableState.value) return next val editor = preferences.edit() if (next.authToken != mutableState.value.authToken) { require(validAuthToken(next.authToken)) diff --git a/app/src/main/java/life/andre/message487/JournalScreen.kt b/app/src/main/java/life/andre/message487/JournalScreen.kt index d680972..28b6dcb 100644 --- a/app/src/main/java/life/andre/message487/JournalScreen.kt +++ b/app/src/main/java/life/andre/message487/JournalScreen.kt @@ -23,7 +23,7 @@ internal fun JournalScreen(queue: QueueSnapshot, busy: Boolean, model: Connectio var waitingOnly by rememberSaveable { mutableStateOf(false) } var selectedId by rememberSaveable { mutableStateOf(null) } var deleteId by rememberSaveable { mutableStateOf(null) } - val visible = queue.entries.filter { !waitingOnly || !it.delivered } + val visible = remember(queue.entries, waitingOnly) { queue.entries.filter { !waitingOnly || !it.delivered } } val selected = queue.entries.firstOrNull { it.id == selectedId } LazyColumn(contentPadding = PaddingValues(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { item { @@ -84,7 +84,9 @@ private fun EventRow(entry: QueueEntry, onClick: () -> Unit) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) { Text(entry.sourceName, style = MaterialTheme.typography.titleSmall) Text(stringResource(when (entry.type) { "sms" -> R.string.sms_type; "notification" -> R.string.notification_type; else -> R.string.test_type }) + - " · " + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(entry.createdAt)), + " · " + remember(entry.createdAt, androidx.compose.ui.platform.LocalConfiguration.current) { + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(entry.createdAt)) + }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) StatusLabel(entry) } diff --git a/app/src/main/java/life/andre/message487/MainActivity.kt b/app/src/main/java/life/andre/message487/MainActivity.kt index b1653bb..f49e92d 100644 --- a/app/src/main/java/life/andre/message487/MainActivity.kt +++ b/app/src/main/java/life/andre/message487/MainActivity.kt @@ -25,6 +25,9 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.Dispatchers class MainActivity : ComponentActivity() { private var updateCheckRequest by mutableIntStateOf(0) @@ -58,6 +61,7 @@ private enum class Destination(val label: Int, val icon: ImageVector) { internal fun MessageScreen(model: ConnectionViewModel = viewModel(), checkRequest: Int = 0) { val state by model.state.collectAsStateWithLifecycle() val settings by model.settings.collectAsStateWithLifecycle() + val settingsLoaded by model.settingsLoaded.collectAsStateWithLifecycle() val permissions by model.permissions.collectAsStateWithLifecycle() val connected by model.listenerConnected.collectAsStateWithLifecycle() val queue by model.queue.collectAsStateWithLifecycle() @@ -67,10 +71,16 @@ internal fun MessageScreen(model: ConnectionViewModel = viewModel(), checkReques var diagnostics by rememberSaveable { mutableStateOf(false) } var updates by rememberSaveable { mutableStateOf(false) } LaunchedEffect(checkRequest) { if (checkRequest > 0) { updates = true; diagnostics = false } } - var crash by remember { mutableStateOf(application.crashHandler.pending) } + val crash by application.crashHandler.state.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + val snackbar = remember { SnackbarHostState() } + fun dismissCrash() { scope.launch { + try { withContext(Dispatchers.IO) { application.crashHandler.dismiss() } } + catch (cancelled: kotlinx.coroutines.CancellationException) { throw cancelled } + catch (_: Exception) { snackbar.showSnackbar(application.getString(R.string.local_error)) } + } } fun back() { if (updates) updates = false else if (diagnostics) diagnostics = false else destination = Destination.OVERVIEW } var help by rememberSaveable { mutableStateOf(false) } - val snackbar = remember { SnackbarHostState() } val noticeText = state.notice?.let { stringResource(it) } LaunchedEffect(noticeText) { noticeText?.let { snackbar.showSnackbar(it) } } BackHandler(updates || diagnostics || destination != Destination.OVERVIEW) { back() } @@ -129,7 +139,8 @@ internal fun MessageScreen(model: ConnectionViewModel = viewModel(), checkReques if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) key(destination) { Box(Modifier.widthIn(max = 720.dp).fillMaxSize()) { - if (updates) UpdatesScreen(requireNotNull(LocalActivity.current), checkRequest = checkRequest) + if (!settingsLoaded) Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 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, onConnection = { destination = Destination.CONNECTION }, @@ -144,14 +155,14 @@ internal fun MessageScreen(model: ConnectionViewModel = viewModel(), checkReques } } } - if (crash) AlertDialog(onDismissRequest = { crash = false; application.crashHandler.dismiss() }, + if (crash) AlertDialog(onDismissRequest = { dismissCrash() }, icon = { Icon(Icons.Outlined.BugReport, null) }, title = { Text(stringResource(R.string.crash_title)) }, text = { Text(stringResource(R.string.crash_description)) }, confirmButton = { TextButton(onClick = { - crash = false; application.crashHandler.dismiss(); diagnostics = true + dismissCrash(); diagnostics = true }) { Text(stringResource(R.string.review_report)) } }, - dismissButton = { TextButton(onClick = { crash = false; application.crashHandler.dismiss() }) { + dismissButton = { TextButton(onClick = { dismissCrash() }) { Text(stringResource(R.string.close)) } }) if (help) AlertDialog(onDismissRequest = { help = false }, diff --git a/app/src/main/java/life/andre/message487/MessageGraph.kt b/app/src/main/java/life/andre/message487/MessageGraph.kt index 0b60902..adc11ef 100644 --- a/app/src/main/java/life/andre/message487/MessageGraph.kt +++ b/app/src/main/java/life/andre/message487/MessageGraph.kt @@ -10,18 +10,27 @@ import life.andre.message487.diagnostics.CrashHandler import life.andre.message487.diagnostics.DiagnosticLog import life.andre.message487.diagnostics.DiagnosticEvent -open class MessageApplication : Application() { +open class MessageApplication : Application(), androidx.work.Configuration.Provider { + override val workManagerConfiguration: androidx.work.Configuration + get() = androidx.work.Configuration.Builder().build() internal open val payloadCipher: PayloadCipher by lazy { KeystorePayloadCipher() } - internal val diagnostics by lazy { DiagnosticLog(File(filesDir, "logs")) } + internal val diagnostics by lazy { DiagnosticLog({ File(filesDir, "logs") }) } internal val crashHandler by lazy { CrashHandler(this, diagnostics) } val graph by lazy { MessageGraph(this) } + internal val backgroundExecutor = Executors.newSingleThreadExecutor() + override fun onCreate() { super.onCreate() + if (BuildConfig.DEBUG) android.os.StrictMode.setThreadPolicy(android.os.StrictMode.ThreadPolicy.Builder() + .detectDiskReads().detectDiskWrites().detectNetwork().penaltyLog().build()) crashHandler.install() - diagnostics.record(DiagnosticEvent.APP_STARTED) - graph.start() - UpdateNotifications.schedule(this) + backgroundExecutor.execute { + crashHandler.load() + diagnostics.record(DiagnosticEvent.APP_STARTED) + graph.start() + UpdateNotifications.schedule(this) + } } } @@ -45,6 +54,7 @@ class MessageGraph internal constructor(private val context: Application) { } fun recover() { + settings.load() if (!settings.state.value.paused) { outbox.cleanupFiles() val pending = outbox.pendingIds() @@ -54,7 +64,7 @@ class MessageGraph internal constructor(private val context: Application) { } fun enqueueTest(options: TestMessageOptions = TestMessageOptions()) { - val config = settings.state.value + val config = settings.load() require(config.ready()) val images = testMessageImages(context, config, options) val event = MessageEvent(config.deviceId, config.deviceCode, sources.resolve(context.packageName), images = images) @@ -62,7 +72,7 @@ class MessageGraph internal constructor(private val context: Application) { } fun captureNotification(notification: CapturedNotification) { - val config = settings.state.value + val config = settings.load() if (!config.acceptsPackage(notification.packageName, context.packageName)) return val event = MessageEvent( config.deviceId, config.deviceCode, sources.resolve(notification.packageName), @@ -73,7 +83,7 @@ class MessageGraph internal constructor(private val context: Application) { } fun captureSms(sender: String, text: String, timestamp: Long) { - val config = settings.state.value + val config = settings.load() if (config.paused || !config.sms || !config.ready()) return val event = MessageEvent( config.deviceId, config.deviceCode, sources.resolve("android"), @@ -90,9 +100,16 @@ class MessageGraph internal constructor(private val context: Application) { } else diagnostics.record(DiagnosticEvent.DUPLICATE_SKIPPED, type = event.messageType) } + private val failurePending = java.util.concurrent.atomic.AtomicBoolean() fun captureFailed(error: Throwable? = null) { - diagnostics.record(DiagnosticEvent.CAPTURE_FAILED, error = error) - try { settings.update { it.copy(captureFailed = true) } } catch (_: Exception) { } + if (!failurePending.compareAndSet(false, true)) return + (context as MessageApplication).backgroundExecutor.execute { + try { + diagnostics.record(DiagnosticEvent.CAPTURE_FAILED, error = error) + if (!settings.load().captureFailed) settings.update { it.copy(captureFailed = true) } + } catch (_: Exception) { } + finally { 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 3ec4427..d087c03 100644 --- a/app/src/main/java/life/andre/message487/NotificationCaptureService.kt +++ b/app/src/main/java/life/andre/message487/NotificationCaptureService.kt @@ -42,6 +42,16 @@ class NotificationCaptureService : NotificationListenerService() { } override fun onNotificationPosted(sbn: StatusBarNotification) { + val graph = MessageGraph.get(this) + if (!graph.settings.loaded.value) { + graph.captureExecutor.execute { + try { graph.settings.load(); capturePosted(sbn) } + catch (error: Exception) { graph.captureFailed(error) } + } + } else capturePosted(sbn) + } + + private fun capturePosted(sbn: StatusBarNotification) { val graph = MessageGraph.get(this) val config = graph.settings.state.value if (!config.acceptsPackage(sbn.packageName, packageName)) return diff --git a/app/src/main/java/life/andre/message487/Outbox.kt b/app/src/main/java/life/andre/message487/Outbox.kt index 35d5789..717acbf 100644 --- a/app/src/main/java/life/andre/message487/Outbox.kt +++ b/app/src/main/java/life/andre/message487/Outbox.kt @@ -49,7 +49,7 @@ fun deliveryQueueState(result: DeliveryResult): QueueState = when (result.status } class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHelper(context, "outbox.db", null, 4) { - internal val files = File(context.noBackupFilesDir, "outbox").apply { mkdirs() } + internal val files by lazy { File(context.noBackupFilesDir, "outbox").apply { mkdirs() } } private val mutableRevision = MutableStateFlow(0L) val revision = mutableRevision.asStateFlow() diff --git a/app/src/main/java/life/andre/message487/SmsCaptureReceiver.kt b/app/src/main/java/life/andre/message487/SmsCaptureReceiver.kt index f95e0a7..780d129 100644 --- a/app/src/main/java/life/andre/message487/SmsCaptureReceiver.kt +++ b/app/src/main/java/life/andre/message487/SmsCaptureReceiver.kt @@ -9,11 +9,11 @@ class SmsCaptureReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return val graph = MessageGraph.get(context) - val config = graph.settings.state.value - if (!config.sms || config.paused || !config.ready()) return val pending = goAsync() graph.captureExecutor.execute { try { + val config = graph.settings.load() + if (!config.sms || config.paused || !config.ready()) return@execute val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent).orEmpty() if (messages.isNotEmpty()) { val sender = messages.first().originatingAddress.orEmpty() diff --git a/app/src/main/java/life/andre/message487/SourcesScreen.kt b/app/src/main/java/life/andre/message487/SourcesScreen.kt index 4f7025b..5196c39 100644 --- a/app/src/main/java/life/andre/message487/SourcesScreen.kt +++ b/app/src/main/java/life/andre/message487/SourcesScreen.kt @@ -48,11 +48,8 @@ internal fun SourcesScreen(settings: ForwardingSettings, permissions: Permission try { context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) } catch (_: android.content.ActivityNotFoundException) { model.showSettingsError() } } - val displayedApps = remember(apps, settings.packages, query, selectedOnly) { - (apps + settings.packages.filter { pkg -> apps.none { it.packageName == pkg } }.map { AppSource(it, it) }) - .filter { (!selectedOnly || it.packageName in settings.packages) && - (it.name.contains(query, true) || it.packageName.contains(query, true)) } - .sortedBy { it.name.lowercase() } + val displayedApps by produceState>(emptyList(), apps, settings.packages, query, selectedOnly) { + value = withContext(Dispatchers.Default) { displayedSources(apps, settings.packages, query, selectedOnly) } } val allSelected = apps.isNotEmpty() && apps.all { it.packageName in settings.packages } LazyColumn(contentPadding = PaddingValues(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -99,7 +96,7 @@ internal fun SourcesScreen(settings: ForwardingSettings, permissions: Permission settings.smsDeduplication, !busy, model::smsDeduplication) SourceSwitch(Icons.Outlined.Notifications, R.string.notification_deduplication, R.string.deduplication_hint, settings.notificationDeduplication, !busy, model::notificationDeduplication) - var window by remember(settings.deduplicationWindowSeconds) { + var window by rememberSaveable { mutableStateOf(settings.deduplicationWindowSeconds.toString()) } val seconds = window.toIntOrNull()?.takeIf { it >= 0 } @@ -240,7 +237,7 @@ private fun SourceSwitch(icon: ImageVector, title: Int, hint: Int, checked: Bool @Composable private fun ImageLimitField(label: Int, value: Int, enabled: Boolean, onChange: (Int) -> Unit) { - var text by remember(value) { mutableStateOf(value.toString()) } + var text by rememberSaveable(label) { mutableStateOf(value.toString()) } OutlinedTextField(value = text, onValueChange = { input -> text = input input.toIntOrNull()?.takeIf { it > 0 }?.let(onChange) @@ -250,3 +247,11 @@ private fun ImageLimitField(label: Int, value: Int, enabled: Boolean, onChange: keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( keyboardType = androidx.compose.ui.text.input.KeyboardType.Number)) } + +internal fun displayedSources(apps: List, selected: Set, query: String, selectedOnly: Boolean): List { + val known = apps.mapTo(HashSet()) { it.packageName } + return (apps + (selected - known).map { AppSource(it, it) }) + .filter { (!selectedOnly || it.packageName in selected) && + (it.name.contains(query, true) || it.packageName.contains(query, true)) } + .sortedBy { it.name.lowercase() } +} diff --git a/app/src/main/java/life/andre/message487/UpdateAvailableDialog.kt b/app/src/main/java/life/andre/message487/UpdateAvailableDialog.kt index 6b26657..2aa4a89 100644 --- a/app/src/main/java/life/andre/message487/UpdateAvailableDialog.kt +++ b/app/src/main/java/life/andre/message487/UpdateAvailableDialog.kt @@ -14,6 +14,10 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.Lifecycle @@ -25,28 +29,54 @@ import androidx.compose.ui.res.stringResource internal fun UpdateAvailableDialog(activity: Activity, openRequest: Int, onOpen: () -> Unit) { val lifecycle = LocalLifecycleOwner.current val preferences = remember { UpdatePreferences(activity) } + val scope = rememberCoroutineScope() var update by remember { mutableStateOf(null) } var openFailed by remember { mutableStateOf(false) } + var saving by remember { mutableStateOf(false) } + var refreshRevision by remember { androidx.compose.runtime.mutableIntStateOf(0) } DisposableEffect(lifecycle, openRequest) { var suppressNextRefresh = openRequest > 0 + var refreshJob: kotlinx.coroutines.Job? = null fun refresh() { - update = if (suppressNextRefresh) null else preferences.pending() + if (saving) return + refreshJob?.cancel() + val revision = ++refreshRevision + val suppressed = suppressNextRefresh suppressNextRefresh = false - openFailed = false + refreshJob = scope.launch { + try { + val result = if (suppressed) null else withContext(Dispatchers.IO) { preferences.pending() } + if (revision == refreshRevision) { update = result; openFailed = false } + } catch (cancelled: kotlinx.coroutines.CancellationException) { throw cancelled } + catch (_: Exception) { if (revision == refreshRevision) openFailed = true } + } } val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) refresh() } // Registering replays the current lifecycle, including ON_RESUME. lifecycle.lifecycle.addObserver(observer) - onDispose { lifecycle.lifecycle.removeObserver(observer) } + onDispose { refreshJob?.cancel(); lifecycle.lifecycle.removeObserver(observer) } } val candidate = update ?: return - fun later() { - preferences.remindLater(candidate) - UpdateNotifications.cancel(activity) - update = null + fun dismiss(skip: Boolean = false, after: () -> Unit = {}) { + if (saving) return + saving = true + refreshRevision++ + scope.launch { + try { + withContext(Dispatchers.IO) { + if (skip) preferences.skip(UpdatePreferences.key(candidate)) else preferences.remindLater(candidate) + UpdateNotifications.cancel(activity) + } + update = null + after() + } catch (cancelled: kotlinx.coroutines.CancellationException) { throw cancelled } + catch (_: Exception) { openFailed = true } + finally { saving = false } + } } + fun later() { dismiss() } AlertDialog( onDismissRequest = ::later, title = { Text(stringResource(R.string.update_available, candidate.version)) }, @@ -55,12 +85,10 @@ internal fun UpdateAvailableDialog(activity: Activity, openRequest: Int, onOpen: val label = stringResource(if (candidate.source == UpdateSource.FDROID) R.string.update_fdroid else R.string.update_github) Text(stringResource(R.string.update_notification_text, label)) if (openFailed) Text(stringResource(R.string.update_open_error)) - TextButton(onClick = ::later) { Text(stringResource(R.string.update_remind_later)) } + TextButton(onClick = ::later, enabled = !saving) { Text(stringResource(R.string.update_remind_later)) } TextButton(onClick = { - preferences.skip(UpdatePreferences.key(candidate)) - UpdateNotifications.cancel(activity) - update = null - }) { Text(stringResource(R.string.update_skip)) } + dismiss(skip = true) + }, enabled = !saving) { Text(stringResource(R.string.update_skip)) } } }, confirmButton = { @@ -71,10 +99,9 @@ internal fun UpdateAvailableDialog(activity: Activity, openRequest: Int, onOpen: later() } catch (_: Exception) { openFailed = true } } else { - later() - onOpen() + dismiss(after = onOpen) } - }) { Text(stringResource(if (candidate.source == UpdateSource.FDROID) R.string.update_in_fdroid else R.string.update_notification_open)) } + }, enabled = !saving) { Text(stringResource(if (candidate.source == UpdateSource.FDROID) R.string.update_in_fdroid else R.string.update_notification_open)) } }, ) } diff --git a/app/src/main/java/life/andre/message487/UpdateNotifications.kt b/app/src/main/java/life/andre/message487/UpdateNotifications.kt index 9abc72d..a393b1a 100644 --- a/app/src/main/java/life/andre/message487/UpdateNotifications.kt +++ b/app/src/main/java/life/andre/message487/UpdateNotifications.kt @@ -35,10 +35,10 @@ internal fun shouldNotifyUpdate(key: String, skipped: String?, lastKey: String?, key != skipped && (key != lastKey || now - lastTime >= UPDATE_REMINDER_INTERVAL) internal class UpdatePreferences(private val context: Context) { - private val prefs = context.getSharedPreferences("updates", Context.MODE_PRIVATE) + 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).apply() } + set(value) { prefs.edit().putBoolean("automatic", value).commit() } val lastBackgroundTime: Long get() = prefs.getLong("background_at", 0) val lastBackgroundResult: String? get() = prefs.getString("background_result", null) @@ -46,11 +46,11 @@ internal class UpdatePreferences(private val context: Context) { fun backgroundStarted(source: UpdateSource, now: Long = System.currentTimeMillis()) { prefs.edit().putLong("background_at", now).putString("background_source", source.name) - .putString("background_result", "running").apply() + .putString("background_result", "running").commit() } fun backgroundFinished(result: String) { - prefs.edit().putString("background_result", result).apply() + prefs.edit().putString("background_result", result).commit() } fun detected(source: UpdateSource, update: AppUpdate?) { @@ -58,7 +58,7 @@ 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) } - }.apply() + }.commit() } fun pending(now: Long = System.currentTimeMillis()): AppUpdate? { @@ -73,19 +73,19 @@ internal class UpdatePreferences(private val context: Context) { 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).apply() + .putString("notified", key(update)).putLong("notified_at", now).commit() } 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) fun notified(update: AppUpdate, now: Long) { - prefs.edit().putString("notified", key(update)).putLong("notified_at", now).apply() + prefs.edit().putString("notified", key(update)).putLong("notified_at", now).commit() } fun skip(key: String) { val skipped = prefs.getStringSet("skipped_versions", emptySet()).orEmpty() + key - prefs.edit().putStringSet("skipped_versions", skipped).apply() + prefs.edit().putStringSet("skipped_versions", skipped).commit() } companion object { fun key(update: AppUpdate) = "${update.source.name}:${update.version}" } @@ -147,18 +147,21 @@ internal object UpdateNotifications { } class UpdateActionReceiver : BroadcastReceiver() { + companion object { private val executor = java.util.concurrent.Executors.newSingleThreadExecutor() } override fun onReceive(context: Context, intent: Intent) { - val prefs = UpdatePreferences(context) - when (intent.action) { - SKIP_UPDATE_ACTION -> { - val key = intent.getStringExtra("version_key") ?: return - prefs.skip(key) - } - DISABLE_UPDATES_ACTION -> prefs.automatic = false - else -> return + 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() } } - UpdateNotifications.cancel(context) - UpdateNotifications.schedule(context) } } @@ -167,20 +170,21 @@ class UpdateCheckService : JobService() { private var check: Job? = null override fun onStartJob(params: JobParameters): Boolean { - val updates = AppUpdates(this) - val source = updates.source() ?: return false - if (!UpdatePreferences(this).automatic) return false check = scope.launch { - val retry = runBackgroundUpdateCheck(this@UpdateCheckService, source, updates::check) + val retry = 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) + } jobFinished(params, retry) } return true } override fun onStopJob(params: JobParameters): Boolean { - UpdatePreferences(this).backgroundFinished("interrupted") check?.cancel() - return UpdatePreferences(this).automatic + return true } override fun onDestroy() { @@ -193,7 +197,7 @@ internal suspend fun runBackgroundUpdateCheck( context: Context, source: UpdateSource, check: suspend (UpdateSource) -> AppUpdate?, -): Boolean { +): Boolean = kotlinx.coroutines.withContext(Dispatchers.IO) { val prefs = UpdatePreferences(context) prefs.backgroundStarted(source) var retry = false @@ -210,5 +214,5 @@ internal suspend fun runBackgroundUpdateCheck( retry = error is java.io.IOException prefs.backgroundFinished(if (retry) "network_error" else "error") } - return retry && prefs.automatic && AppUpdates(context).source() == source + retry && prefs.automatic && AppUpdates(context).source() == source } diff --git a/app/src/main/java/life/andre/message487/UpdatesScreen.kt b/app/src/main/java/life/andre/message487/UpdatesScreen.kt index a8888cf..e2774b7 100644 --- a/app/src/main/java/life/andre/message487/UpdatesScreen.kt +++ b/app/src/main/java/life/andre/message487/UpdatesScreen.kt @@ -42,6 +42,10 @@ import androidx.lifecycle.viewmodel.compose.viewModel import java.io.File import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.first +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.res.stringResource import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.Arrangement @@ -53,15 +57,15 @@ internal class UpdatesViewModel @JvmOverloads constructor( private val checkUpdate: suspend (UpdateSource) -> AppUpdate? = updates::check, private val downloadUpdate: suspend (AppUpdate) -> File = updates::download, ) : AndroidViewModel(application) { - var backgroundTime by mutableStateOf(UpdatePreferences(application).lastBackgroundTime) + var backgroundTime by mutableStateOf(0L) private set - var backgroundResult by mutableStateOf(UpdatePreferences(application).lastBackgroundResult) + var backgroundResult by mutableStateOf(null) private set - var backgroundSource by mutableStateOf(UpdatePreferences(application).lastBackgroundSource) + var backgroundSource by mutableStateOf(null) private set - var automatic by mutableStateOf(UpdatePreferences(application).automatic) + var automatic by mutableStateOf(true) private set - var source by mutableStateOf(updates.source()) + var source by mutableStateOf(null) private set var busy by mutableStateOf(false) private set @@ -72,15 +76,23 @@ internal class UpdatesViewModel @JvmOverloads constructor( var message by mutableStateOf(null) private set + var notificationsEnabled by mutableStateOf(true) + private set + + init { refresh() } + fun select(value: UpdateSource) { if (busy) return - updates.select(value) - UpdateNotifications.cancel(getApplication()) - UpdateNotifications.schedule(getApplication()) - source = value - update = null - apk = null - message = null + runOperation { + withContext(Dispatchers.IO) { + updates.select(value) + UpdateNotifications.cancel(getApplication()) + UpdateNotifications.schedule(getApplication()) + } + source = value + update = null + apk = null + } } private var handledCheckRequest = 0 @@ -88,7 +100,10 @@ internal class UpdatesViewModel @JvmOverloads constructor( fun checkFromNotification(request: Int) { if (request <= 0 || request == handledCheckRequest) return handledCheckRequest = request - check() + viewModelScope.launch { + androidx.compose.runtime.snapshotFlow { busy }.first { !it } + check() + } } fun check() { @@ -98,7 +113,7 @@ internal class UpdatesViewModel @JvmOverloads constructor( apk = null runOperation { update = checkUpdate(selected) - UpdatePreferences(getApplication()).detected(selected, update) + withContext(Dispatchers.IO) { UpdatePreferences(getApplication()).detected(selected, update) } if (update == null) message = R.string.update_current } } @@ -110,17 +125,52 @@ internal class UpdatesViewModel @JvmOverloads constructor( } fun automatic(value: Boolean) { - UpdatePreferences(getApplication()).automatic = value - automatic = value - UpdateNotifications.schedule(getApplication()) + if (busy) return + runOperation { + withContext(Dispatchers.IO) { + UpdatePreferences(getApplication()).automatic = value + UpdateNotifications.schedule(getApplication()) + } + automatic = value + } } fun refresh() { - val prefs = UpdatePreferences(getApplication()) - automatic = prefs.automatic - backgroundTime = prefs.lastBackgroundTime - backgroundResult = prefs.lastBackgroundResult - backgroundSource = prefs.lastBackgroundSource + if (busy) return + runOperation { + val snapshot = withContext(Dispatchers.IO) { + val prefs = UpdatePreferences(getApplication()) + UpdateSettingsSnapshot(prefs.automatic, prefs.lastBackgroundTime, prefs.lastBackgroundResult, + prefs.lastBackgroundSource, updates.source(), UpdateNotifications.enabled(getApplication())) + } + automatic = snapshot.automatic + backgroundTime = snapshot.time + backgroundResult = snapshot.result + backgroundSource = snapshot.backgroundSource + source = snapshot.source + notificationsEnabled = snapshot.notificationsEnabled + } + } + + fun install(activity: Activity) { + val candidate = apk ?: return + if (busy) return + runOperation { + try { + val intent = withContext(Dispatchers.IO) { + if (!activity.packageManager.canRequestPackageInstalls()) { + Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${activity.packageName}")) + } else { + require(candidate.isFile) + val uri = FileProvider.getUriForFile(activity, "${activity.packageName}.fileprovider", candidate) + Intent(Intent.ACTION_VIEW).setDataAndType(uri, "application/vnd.android.package-archive") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + activity.startActivity(intent) + } catch (cancelled: CancellationException) { throw cancelled } + catch (_: Exception) { throw UpdateException(R.string.update_open_error) } + } } fun reportOpenError() { message = R.string.update_open_error } @@ -137,18 +187,20 @@ internal class UpdatesViewModel @JvmOverloads constructor( } } +private data class UpdateSettingsSnapshot(val automatic: Boolean, val time: Long, val result: String?, + val backgroundSource: String?, val source: UpdateSource?, val notificationsEnabled: Boolean) + @Composable internal fun UpdatesScreen(activity: Activity, model: UpdatesViewModel = viewModel(), checkRequest: Int = 0) { val lifecycle = LocalLifecycleOwner.current - var notificationsEnabled by remember { mutableStateOf(UpdateNotifications.enabled(activity)) } + val scope = rememberCoroutineScope() val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { - notificationsEnabled = UpdateNotifications.enabled(activity) + model.refresh() } DisposableEffect(lifecycle) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { model.refresh() - notificationsEnabled = UpdateNotifications.enabled(activity) } } lifecycle.lifecycle.addObserver(observer) @@ -179,20 +231,24 @@ internal fun UpdatesScreen(activity: Activity, model: UpdatesViewModel = viewMod Text(stringResource(result)) } Text(stringResource(R.string.update_automatic)) - Switch(checked = model.automatic, onCheckedChange = model::automatic, + Switch(checked = model.automatic, onCheckedChange = model::automatic, enabled = !model.busy, modifier = Modifier.semantics { contentDescription = activity.getString(R.string.update_automatic) }) - if (!notificationsEnabled) { + if (!model.notificationsEnabled) { Text(stringResource(R.string.update_notifications_disabled)) - TextButton(onClick = { + TextButton(onClick = { scope.launch { try { - if (Build.VERSION.SDK_INT >= 33 && activity.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + val needsPermission = withContext(Dispatchers.IO) { + Build.VERSION.SDK_INT >= 33 && activity.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + } + if (needsPermission) { notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) } else { activity.startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) .putExtra(Settings.EXTRA_APP_PACKAGE, activity.packageName)) } - } catch (_: Exception) { model.reportOpenError() } - }) { Text(stringResource(R.string.update_allow_notifications)) } + } catch (cancelled: CancellationException) { throw cancelled } + catch (_: Exception) { model.reportOpenError() } + } }) { Text(stringResource(R.string.update_allow_notifications)) } } Text(stringResource(R.string.update_source), style = MaterialTheme.typography.titleMedium) if (model.source == null) Text(stringResource(R.string.update_choose_source)) @@ -228,21 +284,7 @@ internal fun UpdatesScreen(activity: Activity, model: UpdatesViewModel = viewMod } else { Text(stringResource(R.string.update_ready)) Text(stringResource(R.string.update_install_permission)) - Button(onClick = { - try { - if (!activity.packageManager.canRequestPackageInstalls()) { - activity.startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, - Uri.parse("package:${activity.packageName}"))) - } else { - val apk = requireNotNull(model.apk) - require(apk.isFile) - val uri = FileProvider.getUriForFile(activity, "${activity.packageName}.fileprovider", apk) - activity.startActivity(Intent(Intent.ACTION_VIEW) - .setDataAndType(uri, "application/vnd.android.package-archive") - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)) - } - } catch (_: Exception) { model.reportOpenError() } - }, enabled = !model.busy) { Text(stringResource(R.string.update_install)) } + Button(onClick = { model.install(activity) }, enabled = !model.busy) { Text(stringResource(R.string.update_install)) } } } } 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 6c592df..6bc6758 100644 --- a/app/src/main/java/life/andre/message487/diagnostics/CrashHandler.kt +++ b/app/src/main/java/life/andre/message487/diagnostics/CrashHandler.kt @@ -2,11 +2,16 @@ package life.andre.message487.diagnostics import android.content.Context import android.os.Process +import kotlinx.coroutines.flow.asStateFlow import kotlin.system.exitProcess internal class CrashHandler(context: Context, private val log: DiagnosticLog) { - private val preferences = context.getSharedPreferences("crash_state", Context.MODE_PRIVATE) - val pending: Boolean get() = preferences.getBoolean("pending", false) + private val preferences by lazy { context.getSharedPreferences("crash_state", Context.MODE_PRIVATE) } + private val mutablePending = kotlinx.coroutines.flow.MutableStateFlow(false) + val state = mutablePending.asStateFlow() + val pending: Boolean get() = mutablePending.value + + fun load() { mutablePending.value = preferences.getBoolean("pending", false) } fun install() { val previous = Thread.getDefaultUncaughtExceptionHandler() @@ -18,5 +23,8 @@ internal class CrashHandler(context: Context, private val log: DiagnosticLog) { } } - fun dismiss() { preferences.edit().putBoolean("pending", false).apply() } + fun dismiss() { + check(preferences.edit().putBoolean("pending", false).commit()) + mutablePending.value = false + } } 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 0c08c34..5a54d42 100644 --- a/app/src/main/java/life/andre/message487/diagnostics/DiagnosticLog.kt +++ b/app/src/main/java/life/andre/message487/diagnostics/DiagnosticLog.kt @@ -22,9 +22,11 @@ internal enum class DiagnosticEvent { } internal class DiagnosticLog( - private val directory: File, + directoryProvider: () -> File, private val segmentBytes: Int = 256 * 1024, ) : AutoCloseable { + constructor(directory: File, segmentBytes: Int = 256 * 1024) : this({ directory }, segmentBytes) + private val directory by lazy(directoryProvider) private val lock = Any() private val dropped = AtomicLong() private val executor = ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, ArrayBlockingQueue(256), diff --git a/app/src/test/java/life/andre/message487/ResourceContractTest.kt b/app/src/test/java/life/andre/message487/ResourceContractTest.kt index 5278b2f..c458916 100644 --- a/app/src/test/java/life/andre/message487/ResourceContractTest.kt +++ b/app/src/test/java/life/andre/message487/ResourceContractTest.kt @@ -41,7 +41,12 @@ class ResourceContractTest { assertEquals("android.permission.BIND_NOTIFICATION_LISTENER_SERVICE", elements("service").single { it.getAttributeNS(namespace, "name") == ".NotificationCaptureService" }.getAttributeNS(namespace, "permission")) assertEquals("android.permission.BROADCAST_SMS", elements("receiver").single { it.getAttributeNS(namespace, "name") == ".SmsCaptureReceiver" }.getAttributeNS(namespace, "permission")) - val provider = elements("provider").single() + assertTrue(elements("provider").all { it.getAttributeNS(namespace, "exported") == "false" }) + val initializer = elements("provider").single { it.getAttributeNS(namespace, "name") == "androidx.startup.InitializationProvider" } + val metadata = initializer.getElementsByTagName("meta-data").item(0) as Element + assertEquals("androidx.work.WorkManagerInitializer", metadata.getAttributeNS(namespace, "name")) + assertEquals("remove", metadata.getAttributeNS("http://schemas.android.com/tools", "node")) + val provider = elements("provider").single { it.getAttributeNS(namespace, "name") == "androidx.core.content.FileProvider" } assertEquals("false", provider.getAttributeNS(namespace, "exported")) assertEquals("true", provider.getAttributeNS(namespace, "grantUriPermissions")) assertEquals("false", elements("application").single().getAttributeNS(namespace, "allowBackup")) diff --git a/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt index 6843720..a5e83c3 100644 --- a/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt +++ b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt @@ -51,6 +51,7 @@ class ScreenInteractionTest { })[ConnectionViewModel::class.java] } compose.setContent { MessageTheme { MessageScreen(model) } } + compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } } @After fun close() { @@ -63,19 +64,19 @@ class ScreenInteractionTest { compose.onNode(hasText(compose.activity.getString(R.string.sources_tab)) and hasClickAction()).performClick() icon(R.string.sms_deduplication).performScrollTo().assertIsOn().performClick() compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertFalse(SettingsStore(application).state.value.smsDeduplication) + assertFalse(SettingsStore(application).load().smsDeduplication) icon(R.string.sms_deduplication).assertIsOff().performClick() compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertTrue(SettingsStore(application).state.value.smsDeduplication) + assertTrue(SettingsStore(application).load().smsDeduplication) icon(R.string.sms_deduplication).assertIsOn() icon(R.string.notification_deduplication).performScrollTo().assertIsOn().performClick() compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertFalse(SettingsStore(application).state.value.notificationDeduplication) - assertTrue(SettingsStore(application).state.value.smsDeduplication) + assertFalse(SettingsStore(application).load().notificationDeduplication) + assertTrue(SettingsStore(application).load().smsDeduplication) node(R.string.deduplication_window).performScrollTo().performTextReplacement("3") compose.waitUntil(10_000) { compose.waitForIdle() - SettingsStore(application).state.value.deduplicationWindowSeconds == 3 + SettingsStore(application).load().deduplicationWindowSeconds == 3 } } @@ -141,7 +142,7 @@ class ScreenInteractionTest { assertNull("Save failed", model.state.value.notice?.takeIf { it == R.string.local_error }) assertEquals("State: ${model.state.value}", "test-phone", application.graph.settings.state.value.deviceCode) compose.waitForIdle() - val restored = SettingsStore(application).state.value + val restored = SettingsStore(application).load() assertEquals("https://example.test/webhook", restored.url) assertEquals("test-phone", restored.deviceCode) assertEquals("test-token", restored.authToken) @@ -191,14 +192,14 @@ class ScreenInteractionTest { icon(R.string.image_quality_label).performScrollTo().performSemanticsAction( androidx.compose.ui.semantics.SemanticsActions.SetProgress) { it(35f) } compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertEquals(35, SettingsStore(application).state.value.imageQuality) + assertEquals(35, SettingsStore(application).load().imageQuality) compose.onNodeWithText("PNG").performScrollTo().performClick() compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertEquals(NotificationImageFormat.PNG, SettingsStore(application).state.value.imageFormat) + assertEquals(NotificationImageFormat.PNG, SettingsStore(application).load().imageFormat) icon(R.string.image_quality_label).performScrollTo().assertIsNotEnabled() compose.onNodeWithText("JPEG").performScrollTo().performClick() compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertEquals(35, SettingsStore(application).state.value.imageQuality) + assertEquals(35, SettingsStore(application).load().imageQuality) icon(R.string.image_quality_label).performScrollTo().assertIsEnabled() } @@ -208,20 +209,20 @@ class ScreenInteractionTest { hasContentDescription(application.getString(R.string.image_limits_enabled))) icon(R.string.image_limits_enabled).performScrollTo().assertIsOn() node(R.string.image_max_count).performScrollTo().performTextReplacement("7") - compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertEquals(7, SettingsStore(application).state.value.imageMaxCount) + compose.waitUntil(10_000) { compose.waitForIdle(); model.settings.value.imageMaxCount == 7 } + assertEquals(7, SettingsStore(application).load().imageMaxCount) node(R.string.image_max_edge).performScrollTo().performTextReplacement("2048") - compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertEquals(2048, SettingsStore(application).state.value.imageMaxEdge) + compose.waitUntil(10_000) { compose.waitForIdle(); model.settings.value.imageMaxEdge == 2048 } + assertEquals(2048, SettingsStore(application).load().imageMaxEdge) node(R.string.image_max_kib).performScrollTo().performTextReplacement("1024") - compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertEquals(1024, SettingsStore(application).state.value.imageMaxKiB) + compose.waitUntil(10_000) { compose.waitForIdle(); model.settings.value.imageMaxKiB == 1024 } + assertEquals(1024, SettingsStore(application).load().imageMaxKiB) node(R.string.image_total_kib).performScrollTo().performTextReplacement("4096") - compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertEquals(4096, SettingsStore(application).state.value.imageTotalKiB) + compose.waitUntil(10_000) { compose.waitForIdle(); model.settings.value.imageTotalKiB == 4096 } + assertEquals(4096, SettingsStore(application).load().imageTotalKiB) icon(R.string.image_limits_enabled).performScrollTo().performClick() compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertFalse(SettingsStore(application).state.value.imageLimitsEnabled) + assertFalse(SettingsStore(application).load().imageLimitsEnabled) node(R.string.image_max_count).assertDoesNotExist() icon(R.string.image_limits_enabled).performClick() compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } @@ -235,15 +236,15 @@ class ScreenInteractionTest { hasContentDescription(application.getString(R.string.forward_avatars))) icon(R.string.forward_avatars).performScrollTo().assertIsOff().performClick() compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertTrue(SettingsStore(application).state.value.forwardAvatars) - assertFalse(SettingsStore(application).state.value.forwardAppIcons) + assertTrue(SettingsStore(application).load().forwardAvatars) + assertFalse(SettingsStore(application).load().forwardAppIcons) icon(R.string.forward_app_icons).performScrollTo().assertIsOff().performClick() compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertTrue(SettingsStore(application).state.value.forwardAppIcons) + assertTrue(SettingsStore(application).load().forwardAppIcons) icon(R.string.forward_avatars).performScrollTo().assertIsOn().performClick() compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertFalse(SettingsStore(application).state.value.forwardAvatars) - assertTrue(SettingsStore(application).state.value.forwardAppIcons) + assertFalse(SettingsStore(application).load().forwardAvatars) + assertTrue(SettingsStore(application).load().forwardAppIcons) } @Test fun `auto select switch preserves existing choices and selects newly discovered launcher apps`() { @@ -253,7 +254,7 @@ class ScreenInteractionTest { hasContentDescription(application.getString(R.string.auto_select_new_apps))) icon(R.string.auto_select_new_apps).performScrollTo().assertIsOff().performClick() compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertTrue(SettingsStore(application).state.value.autoSelectNewApps) + assertTrue(SettingsStore(application).load().autoSelectNewApps) assertTrue(model.settings.value.packages.isEmpty()) val intent = android.content.Intent(android.content.Intent.ACTION_MAIN).addCategory(android.content.Intent.CATEGORY_LAUNCHER) val info = android.content.pm.ResolveInfo().apply { @@ -270,7 +271,7 @@ class ScreenInteractionTest { assertTrue(model.settings.value.packages.isEmpty()) icon(R.string.auto_select_new_apps).performScrollTo().assertIsOn().performClick() compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertFalse(SettingsStore(application).state.value.autoSelectNewApps) + assertFalse(SettingsStore(application).load().autoSelectNewApps) } @Test fun `select all preserves manual packages excludes self and clear removes selection`() { @@ -280,16 +281,16 @@ class ScreenInteractionTest { assertEquals(2, model.apps.value.size) compose.runOnIdle { model.selectPackage("manual.hidden", true) } compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertEquals(setOf("manual.hidden"), SettingsStore(application).state.value.packages) + assertEquals(setOf("manual.hidden"), SettingsStore(application).load().packages) compose.onNode(hasText(compose.activity.getString(R.string.sources_tab)) and hasClickAction()).performClick() compose.onNode(hasScrollToNodeAction()).performScrollToNode( hasText(compose.activity.getString(R.string.select_all_apps))) node(R.string.select_all_apps).performClick() compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertEquals(setOf("manual.hidden", "example.alpha", "example.beta"), SettingsStore(application).state.value.packages) + assertEquals(setOf("manual.hidden", "example.alpha", "example.beta"), SettingsStore(application).load().packages) node(R.string.clear_app_selection).performScrollTo().performClick() compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertEquals(emptySet(), SettingsStore(application).state.value.packages) + assertEquals(emptySet(), SettingsStore(application).load().packages) } private fun SemanticsNodeInteraction.isEnabled(): Boolean = diff --git a/app/src/test/java/life/andre/message487/SettingsStoreTest.kt b/app/src/test/java/life/andre/message487/SettingsStoreTest.kt index 330e8a7..aa71828 100644 --- a/app/src/test/java/life/andre/message487/SettingsStoreTest.kt +++ b/app/src/test/java/life/andre/message487/SettingsStoreTest.kt @@ -20,11 +20,11 @@ class SettingsStoreTest { @Test fun `image limits are validated and survive disabling and restart`() { val context = RuntimeEnvironment.getApplication() - val store = SettingsStore(context, cipher) + val store = SettingsStore(context, cipher).also { it.load() } assertTrue(store.state.value.imageLimitsEnabled) store.update { it.copy(imageMaxCount = 7, imageMaxEdge = 2048, imageMaxKiB = 1024, imageTotalKiB = 4096, imageLimitsEnabled = false) } - val restored = SettingsStore(context, cipher).state.value + val restored = SettingsStore(context, cipher).load() assertFalse(restored.imageLimitsEnabled) assertEquals(7, restored.imageMaxCount) assertEquals(2048, restored.imageMaxEdge) @@ -38,26 +38,26 @@ class SettingsStoreTest { @Test fun `image format defaults to JPEG and quality settings survive restart`() { context.getSharedPreferences("connection", Context.MODE_PRIVATE).edit().clear().commit() - val store = SettingsStore(context, cipher) + val store = SettingsStore(context, cipher).also { it.load() } assertEquals(NotificationImageFormat.JPEG, store.state.value.imageFormat) assertEquals(80, store.state.value.imageQuality) assertFalse(store.state.value.forwardAvatars) assertFalse(store.state.value.forwardAppIcons) store.update { it.copy(imageFormat = NotificationImageFormat.PNG, imageQuality = 35, forwardAvatars = true, forwardAppIcons = true) } - assertEquals(NotificationImageFormat.PNG, SettingsStore(context, cipher).state.value.imageFormat) - assertEquals(35, SettingsStore(context, cipher).state.value.imageQuality) - assertTrue(SettingsStore(context, cipher).state.value.forwardAvatars) - assertTrue(SettingsStore(context, cipher).state.value.forwardAppIcons) + assertEquals(NotificationImageFormat.PNG, SettingsStore(context, cipher).load().imageFormat) + assertEquals(35, SettingsStore(context, cipher).load().imageQuality) + assertTrue(SettingsStore(context, cipher).load().forwardAvatars) + assertTrue(SettingsStore(context, cipher).load().forwardAppIcons) store.update { it.copy(forwardAvatars = false) } - assertFalse(SettingsStore(context, cipher).state.value.forwardAvatars) - assertTrue(SettingsStore(context, cipher).state.value.forwardAppIcons) + assertFalse(SettingsStore(context, cipher).load().forwardAvatars) + assertTrue(SettingsStore(context, cipher).load().forwardAppIcons) assertThrows(IllegalArgumentException::class.java) { store.update { it.copy(imageQuality = -1) } } assertThrows(IllegalArgumentException::class.java) { store.update { it.copy(imageQuality = 101) } } } @Test fun `new app selection is opt in persistent and respects manual exclusions`() { context.getSharedPreferences("connection", Context.MODE_PRIVATE).edit().clear().commit() - val store = SettingsStore(context, cipher) + val store = SettingsStore(context, cipher).also { it.load() } assertFalse(store.state.value.autoSelectNewApps) store.discoverPackages(setOf("existing.app", "own.app"), "own.app") assertTrue(store.state.value.packages.isEmpty()) @@ -66,7 +66,7 @@ class SettingsStoreTest { assertEquals(setOf("manual.hidden", "new.app"), store.state.value.packages) assertFalse("own.app" in store.state.value.knownPackages) store.update { it.copy(packages = it.packages - "new.app") } - val reopened = SettingsStore(context, cipher) + val reopened = SettingsStore(context, cipher).also { it.load() } assertTrue(reopened.state.value.autoSelectNewApps) reopened.discoverPackages(setOf("existing.app", "new.app"), "own.app") assertEquals(setOf("manual.hidden"), reopened.state.value.packages) @@ -80,36 +80,36 @@ class SettingsStoreTest { assertTrue(reopened.state.value.packages.isEmpty()) reopened.update { it.copy(autoSelectNewApps = true) } reopened.discoverPackages(setOf("while.off", "later.app"), "own.app") - assertEquals(setOf("later.app"), SettingsStore(context, cipher).state.value.packages) + assertEquals(setOf("later.app"), SettingsStore(context, cipher).load().packages) assertFalse(reopened.state.value.notifications) assertFalse(reopened.state.value.acceptsPackage("later.app", "own.app")) } @Test fun `deduplication defaults on and opt out survives restart`() { context.getSharedPreferences("connection", Context.MODE_PRIVATE).edit().clear().commit() - val store = SettingsStore(context, cipher) + val store = SettingsStore(context, cipher).also { it.load() } assertTrue(store.state.value.smsDeduplication) assertTrue(store.state.value.notificationDeduplication) assertEquals(1, store.state.value.deduplicationWindowSeconds) store.update { it.copy(smsDeduplication = false, deduplicationWindowSeconds = 3) } - val reopened = SettingsStore(context, cipher) + val reopened = SettingsStore(context, cipher).also { it.load() } assertFalse(reopened.state.value.smsDeduplication) assertTrue(reopened.state.value.notificationDeduplication) assertEquals(3, reopened.state.value.deduplicationWindowSeconds) assertThrows(IllegalArgumentException::class.java) { reopened.update { it.copy(deduplicationWindowSeconds = -1) } } reopened.update { it.copy(smsDeduplication = true) } - assertTrue(SettingsStore(context, cipher).state.value.smsDeduplication) + assertTrue(SettingsStore(context, cipher).load().smsDeduplication) } @Test fun `old shared switch migrates without overriding separate switches`() { val prefs = context.getSharedPreferences("connection", Context.MODE_PRIVATE) for (enabled in listOf(false, true)) { prefs.edit().clear().putBoolean("deduplication", enabled).commit() - val store = SettingsStore(context, cipher) + val store = SettingsStore(context, cipher).also { it.load() } assertEquals(enabled, store.state.value.smsDeduplication) assertEquals(enabled, store.state.value.notificationDeduplication) store.update { it.copy(notificationDeduplication = !enabled) } - val reopened = SettingsStore(context, cipher) + val reopened = SettingsStore(context, cipher).also { it.load() } assertEquals(enabled, reopened.state.value.smsDeduplication) assertEquals(!enabled, reopened.state.value.notificationDeduplication) assertFalse(prefs.contains("deduplication")) @@ -117,9 +117,9 @@ class SettingsStoreTest { } @Test fun `token survives restart through injected encryption and is redacted`() { - val store = SettingsStore(context, cipher) + val store = SettingsStore(context, cipher).also { it.load() } store.update { it.copy(authToken = "private-token", url = "https://example.test/hook") } - assertEquals("private-token", SettingsStore(context, cipher).state.value.authToken) + assertEquals("private-token", SettingsStore(context, cipher).load().authToken) val prefs = context.getSharedPreferences("connection", Context.MODE_PRIVATE) assertFalse(prefs.all.toString().contains("private-token")) assertFalse(store.state.value.toString().contains("private-token")) @@ -136,7 +136,7 @@ class SettingsStoreTest { override fun encrypt(value: String): ByteArray = error("Key unavailable") override fun decrypt(value: ByteArray): String = error("Key unavailable") } - val store = SettingsStore(context, broken) + val store = SettingsStore(context, broken).also { it.load() } assertFalse(store.state.value.ready()) store.update { it.copy(paused = true) } assertEquals(encrypted, prefs.getString("auth_token_encrypted", null)) @@ -144,4 +144,27 @@ class SettingsStoreTest { assertEquals("", store.state.value.authToken) assertEquals(encrypted, prefs.getString("auth_token_encrypted", null)) } + @Test fun `construction does not decrypt and first update preserves unloaded settings`() { + SettingsStore(context, cipher).update { + it.copy(url = "https://example.test/preserved", authToken = "preserved-token", deviceCode = "preserved-device") + } + var decryptions = 0 + val recordingCipher = object : PayloadCipher { + override fun encrypt(value: String) = cipher.encrypt(value) + override fun decrypt(value: ByteArray): String { decryptions++; return cipher.decrypt(value) } + } + val deferred = SettingsStore(context, recordingCipher) + assertFalse(deferred.loaded.value) + assertEquals(0, decryptions) + deferred.update { it.copy(imageMaxEdge = 2048) } + assertTrue(deferred.loaded.value) + assertEquals(1, decryptions) + assertEquals("preserved-token", deferred.state.value.authToken) + assertEquals("preserved-device", deferred.state.value.deviceCode) + assertEquals("https://example.test/preserved", deferred.state.value.url) + assertEquals(2048, SettingsStore(context, cipher).load().imageMaxEdge) + deferred.load() + assertEquals(1, decryptions) + } + } diff --git a/app/src/test/java/life/andre/message487/SourcesScreenTest.kt b/app/src/test/java/life/andre/message487/SourcesScreenTest.kt new file mode 100644 index 0000000..a78444f --- /dev/null +++ b/app/src/test/java/life/andre/message487/SourcesScreenTest.kt @@ -0,0 +1,16 @@ +package life.andre.message487 + +import org.junit.Assert.* +import org.junit.Test + +class SourcesScreenTest { + @Test fun `search merges manual packages once and respects selection and case`() { + val apps = listOf(AppSource("installed.z", "Zulu"), AppSource("installed.a", "Alpha")) + val selected = setOf("installed.a", "manual.hidden") + assertEquals(listOf("installed.a", "manual.hidden", "installed.z"), + displayedSources(apps, selected, "", false).map { it.packageName }) + assertEquals(listOf("installed.a"), displayedSources(apps, selected, "ALP", true).map { it.packageName }) + assertEquals(listOf("manual.hidden"), displayedSources(apps, selected, "HIDDEN", true).map { it.packageName }) + assertTrue(displayedSources(apps, selected, "Zulu", true).isEmpty()) + } +} diff --git a/app/src/test/java/life/andre/message487/UpdateNotificationsTest.kt b/app/src/test/java/life/andre/message487/UpdateNotificationsTest.kt index 62bcf08..329d314 100644 --- a/app/src/test/java/life/andre/message487/UpdateNotificationsTest.kt +++ b/app/src/test/java/life/andre/message487/UpdateNotificationsTest.kt @@ -20,6 +20,12 @@ class UpdateNotificationsTest { private val scheduler get() = app.getSystemService(JobScheduler::class.java) private val notifications get() = app.getSystemService(NotificationManager::class.java) + private fun await(condition: () -> Boolean) { + val deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5) + while (!condition() && System.nanoTime() < deadline) Thread.sleep(10) + assertTrue(condition()) + } + @Before fun reset() { app.getSharedPreferences("updates", 0).edit().clear().commit() scheduler.cancelAll() @@ -51,10 +57,12 @@ class UpdateNotificationsTest { assertEquals(OPEN_UPDATES_ACTION, open.action) assertEquals(MainActivity::class.java.name, open.component!!.className) UpdateActionReceiver().onReceive(app, shadowOf(notification.actions[1].actionIntent).savedIntent) + await { !UpdatePreferences(app).shouldNotify(update, UPDATE_REMINDER_INTERVAL * 2) } assertNull(shadowOf(notifications).getNotification(UPDATE_NOTIFICATION_ID)) assertFalse(UpdatePreferences(app).shouldNotify(update, UPDATE_REMINDER_INTERVAL * 2)) assertTrue(UpdatePreferences(app).shouldNotify(update.copy(version = "0.1.3"), 1001)) UpdateActionReceiver().onReceive(app, shadowOf(notification.actions[2].actionIntent).savedIntent) + await { !UpdatePreferences(app).automatic && scheduler.getPendingJob(UPDATE_JOB_ID) == null } assertFalse(UpdatePreferences(app).automatic) assertNull(scheduler.getPendingJob(UPDATE_JOB_ID)) } diff --git a/app/src/test/java/life/andre/message487/UpdatesUiTest.kt b/app/src/test/java/life/andre/message487/UpdatesUiTest.kt index ef6ab53..e1893cb 100644 --- a/app/src/test/java/life/andre/message487/UpdatesUiTest.kt +++ b/app/src/test/java/life/andre/message487/UpdatesUiTest.kt @@ -19,7 +19,17 @@ import org.robolectric.annotation.LooperMode @LooperMode(LooperMode.Mode.PAUSED) class UpdatesUiTest { @get:Rule val compose = createAndroidComposeRule() - private fun node(id: Int) = compose.onNodeWithText(compose.activity.getString(id)) + private fun node(id: Int): SemanticsNodeInteraction { + val text = compose.activity.getString(id) + compose.waitUntil(10_000) { compose.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() } + return compose.onNodeWithText(text) + } + private fun ready(model: UpdatesViewModel) { + compose.waitUntil(10_000) { compose.waitForIdle(); !model.busy } + } + private fun closedDialog() { + compose.waitUntil(10_000) { compose.onAllNodes(isDialog()).fetchSemanticsNodes().isEmpty() } + } @Before fun reset() { compose.activity.getSharedPreferences("updates", 0).edit().clear().commit() @@ -31,9 +41,12 @@ class UpdatesUiTest { checkUpdate = { AppUpdate(it, "0.0.6") }, downloadUpdate = { downloads++; error("Test failure") }) compose.setContent { MessageTheme { UpdatesScreen(compose.activity, model) } } + ready(model) node(R.string.update_check).assertIsNotEnabled() node(R.string.update_github).performScrollTo().performClick() + ready(model) node(R.string.update_check).performScrollTo().performClick() + ready(model) node(R.string.update_download).performScrollTo().performClick() node(R.string.update_github_consent).assertIsDisplayed() node(R.string.cancel).performClick() @@ -49,9 +62,13 @@ class UpdatesUiTest { val model = UpdatesViewModel(compose.activity.application, checkUpdate = { if (fail) throw UpdateException(R.string.update_not_published) else AppUpdate(it, "0.0.6") }, downloadUpdate = { error("F-Droid must never download an APK here") }) + ready(model) model.select(UpdateSource.FDROID) + ready(model) compose.setContent { MessageTheme { UpdatesScreen(compose.activity, model) } } + ready(model) node(R.string.update_check).performScrollTo().performClick() + ready(model) node(R.string.update_in_fdroid).performScrollTo().performClick() compose.runOnIdle { assertEquals(FDROID_APP_URL, shadowOf(compose.activity).nextStartedActivity.dataString) @@ -60,8 +77,9 @@ class UpdatesUiTest { fail = true } node(R.string.update_check).performScrollTo().performClick() + ready(model) node(R.string.update_not_published).assertExists() - node(R.string.update_download).assertDoesNotExist() + compose.onNodeWithText(compose.activity.getString(R.string.update_download)).assertDoesNotExist() assertEquals(UpdateSource.FDROID, model.source) } @@ -74,6 +92,7 @@ class UpdatesUiTest { node(R.string.update_skip).assertIsDisplayed() node(R.string.update_remind_later).assertIsDisplayed() node(R.string.update_notification_open).performClick() + closedDialog() compose.runOnIdle { assertEquals(1, opened) assertNull(prefs.pending()) @@ -88,6 +107,7 @@ class UpdatesUiTest { prefs.detected(update.source, update) compose.setContent { MessageTheme { UpdateAvailableDialog(compose.activity, 0) { error("F-Droid must not download") } } } node(R.string.update_in_fdroid).performClick() + closedDialog() compose.runOnIdle { assertEquals(FDROID_APP_URL, shadowOf(compose.activity).nextStartedActivity.data.toString()) assertNull(prefs.pending()) @@ -100,6 +120,7 @@ class UpdatesUiTest { prefs.detected(UpdateSource.GITHUB, AppUpdate(UpdateSource.GITHUB, "99.0.0")) compose.setContent { MessageTheme { UpdateAvailableDialog(compose.activity, 0) {} } } node(R.string.update_skip).performClick() + closedDialog() compose.runOnIdle { assertNull(UpdatePreferences(compose.activity).pending(Long.MAX_VALUE)) } compose.onNode(isDialog()).assertDoesNotExist() } @@ -112,8 +133,10 @@ class UpdatesUiTest { prefs.backgroundFinished("network_error") val model = UpdatesViewModel(compose.activity.application, checkUpdate = { null }) compose.setContent { MessageTheme { UpdatesScreen(compose.activity, model) } } + ready(model) node(R.string.update_background_retry).performScrollTo().assertIsDisplayed() node(R.string.update_check).performScrollTo().performClick() + ready(model) node(R.string.update_current).assertExists() compose.runOnIdle { assertEquals(1000, prefs.lastBackgroundTime) @@ -131,7 +154,7 @@ class UpdatesUiTest { assertNotNull(prefs.pending()) compose.activityRule.scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED) compose.activityRule.scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED) - compose.onNode(isDialog()).assertExists() + node(R.string.update_skip).assertExists() } @Test fun remindLaterSuppressesBothDialogAndNotification() { @@ -141,6 +164,7 @@ class UpdatesUiTest { prefs.detected(update.source, update) compose.setContent { MessageTheme { UpdateAvailableDialog(compose.activity, 0) {} } } node(R.string.update_remind_later).performClick() + closedDialog() compose.onNode(isDialog()).assertDoesNotExist() assertNull(UpdatePreferences(compose.activity).pending()) assertFalse(prefs.shouldNotify(update, System.currentTimeMillis())) @@ -152,7 +176,9 @@ class UpdatesUiTest { UpdatePreferences(compose.activity).skip(UpdatePreferences.key(update)) val model = UpdatesViewModel(compose.activity.application, checkUpdate = { update }) compose.setContent { MessageTheme { UpdatesScreen(compose.activity, model) } } + ready(model) node(R.string.update_check).performScrollTo().performClick() + ready(model) compose.onNodeWithText(compose.activity.getString(R.string.update_available, update.version)).assertExists() compose.runOnIdle { assertNull(UpdatePreferences(compose.activity).pending()) } } diff --git a/app/src/test/java/life/andre/message487/diagnostics/FeedbackEmailTest.kt b/app/src/test/java/life/andre/message487/diagnostics/FeedbackEmailTest.kt index 1e353e8..38823ba 100644 --- a/app/src/test/java/life/andre/message487/diagnostics/FeedbackEmailTest.kt +++ b/app/src/test/java/life/andre/message487/diagnostics/FeedbackEmailTest.kt @@ -79,7 +79,7 @@ class FeedbackEmailTest { handler.install() Thread.getDefaultUncaughtExceptionHandler()!!.uncaughtException(Thread.currentThread(), error) assertTrue(delegated) - assertTrue(CrashHandler(context, log).pending) + assertTrue(CrashHandler(context, log).also { it.load() }.pending) handler.dismiss() assertFalse(handler.pending) } finally { Thread.setDefaultUncaughtExceptionHandler(previous) } diff --git a/docs/en/release-testing.md b/docs/en/release-testing.md index 14e3e56..7d3ac53 100644 --- a/docs/en/release-testing.md +++ b/docs/en/release-testing.md @@ -233,3 +233,10 @@ Record the exact PR head/base and CI URL, individual API 26/35 verdicts, JUnit C APK artifact link and SHA-256. Download links are updated in the PR description; unsigned PR APKs and debug-emulator tests do not establish acceptance of the signed release candidate. A new commit requires a new CI run; skipped or missing emulator results block finalization. + +The emulator suite includes `UiThreadDeviceTest`: main-thread disk/network access fails the +process through StrictMode, and real Keystore calls assert that they run off the main thread. +It covers model initialization with a persisted token, navigation, numeric settings, diagnostics, +update preferences/dialogs, lifecycle refresh and capture-error reporting. Debug builds also log +StrictMode violations from `Application.onCreate()`. These checks do not measure frame times or +cover every platform/native operation; use a system trace when investigating animation or CPU jank. diff --git a/docs/ru/release-testing.md b/docs/ru/release-testing.md index 713377a..c30e245 100644 --- a/docs/ru/release-testing.md +++ b/docs/ru/release-testing.md @@ -255,3 +255,11 @@ probe APK и синтетические события; верните сеть, ссылку на APK и SHA-256. Ссылки обновляются в описании PR; неподписанные PR APK и тесты debug на эмуляторах не подтверждают приёмку подписанного релизного кандидата. Новому коммиту нужен новый CI; skipped или отсутствующие результаты эмуляторов блокируют финализацию. + +В набор эмуляторных тестов входит `UiThreadDeviceTest`: дисковое или сетевое обращение +на UI-потоке завершает процесс через StrictMode, а вызовы реального Keystore отдельно +проверяют поток. Сценарий охватывает создание моделей с сохранённым токеном, навигацию, +числовые настройки, диагностику, настройки и диалоги обновлений, lifecycle и ошибки захвата. +Debug-сборки также логируют нарушения StrictMode с `Application.onCreate()`. Эти проверки +не измеряют время кадров и не охватывают все платформенные/native-операции; для анализа +рывков анимации и нагрузки CPU нужна системная трасса. From 2530ba4dc231229a8ede85a0a087f553f37acca3 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Thu, 15 Oct 2026 19:57:17 +0300 Subject: [PATCH 2/2] test: drain the main looper while awaiting discovered apps --- .../test/java/life/andre/message487/ScreenInteractionTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt index a5e83c3..86217db 100644 --- a/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt +++ b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt @@ -248,7 +248,7 @@ class ScreenInteractionTest { } @Test fun `auto select switch preserves existing choices and selects newly discovered launcher apps`() { - compose.waitUntil(10_000) { model.apps.value.size == 2 } + compose.waitUntil(10_000) { compose.waitForIdle(); model.apps.value.size == 2 } compose.onNode(hasText(application.getString(R.string.sources_tab)) and hasClickAction()).performClick() compose.onNode(hasScrollToNodeAction()).performScrollToNode( hasContentDescription(application.getString(R.string.auto_select_new_apps))) @@ -277,7 +277,7 @@ class ScreenInteractionTest { @Test fun `select all preserves manual packages excludes self and clear removes selection`() { compose.waitForIdle() assertSame(application.graph.settings.state, model.settings) - compose.waitUntil(10_000) { model.apps.value.isNotEmpty() } + compose.waitUntil(10_000) { compose.waitForIdle(); model.apps.value.isNotEmpty() } assertEquals(2, model.apps.value.size) compose.runOnIdle { model.selectPackage("manual.hidden", true) } compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy }