Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ComponentActivity>()
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 }
}

}
9 changes: 8 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
Expand Down Expand Up @@ -40,6 +41,12 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Message487">
<!-- Recovery initializes WorkManager on its background executor. -->
<provider android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup" android:exported="false" tools:node="merge">
<meta-data android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup" tools:node="remove" />
</provider>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/life/andre/message487/AppUpdates.kt
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ internal fun requireUpdateMetadataStatus(status: Int) {
}

internal class AppUpdates(private val context: Context) {
private val preferences = context.getSharedPreferences("updates", Context.MODE_PRIVATE)
private val preferences by lazy { context.getSharedPreferences("updates", Context.MODE_PRIVATE) }
private val manager = context.packageManager

@Suppress("DEPRECATION")
Expand All @@ -130,7 +130,7 @@ internal class AppUpdates(private val context: Context) {
}

fun select(source: UpdateSource) {
preferences.edit().putString("source", source.name).apply()
preferences.edit().putString("source", source.name).commit()
}

suspend fun check(source: UpdateSource): AppUpdate? = withContext(Dispatchers.IO) {
Expand Down
60 changes: 47 additions & 13 deletions app/src/main/java/life/andre/message487/ConnectionViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ data class PermissionState(val notifications: Boolean = false, val sms: Boolean
class ConnectionViewModel(application: Application) : AndroidViewModel(application) {
private val graph = MessageGraph.get(application)
val settings = graph.settings.state
val settingsLoaded = graph.settings.loaded
val listenerConnected = ListenerState.connected
private val mutableState = MutableStateFlow(settings.value.let {
ConnectionState(it.url, it.deviceCode, it.requireAck, authToken = it.authToken)
ConnectionState(it.url, it.deviceCode, it.requireAck, authToken = it.authToken, busy = true)
})
val state = mutableState.asStateFlow()
private val mutableQueue = MutableStateFlow(QueueSnapshot())
Expand All @@ -55,6 +56,13 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
init {
refreshPermissions()
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) {
Expand Down Expand Up @@ -101,13 +109,24 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
}

private var permissionRefresh: kotlinx.coroutines.Job? = null
fun refreshPermissions() {
refreshApps()
val app = getApplication<Application>()
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<Application>()
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) }
Expand All @@ -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()
Expand All @@ -132,18 +152,18 @@ 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) } }
fun notificationDeduplication(enabled: Boolean) = action { graph.settings.update { it.copy(notificationDeduplication = enabled) } }
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) } }
Expand Down Expand Up @@ -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<String, kotlinx.coroutines.Job>()
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 {
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/life/andre/message487/DeliveryWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
18 changes: 16 additions & 2 deletions app/src/main/java/life/andre/message487/ForwardingSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -86,6 +97,7 @@ class SettingsStore(

@Synchronized
fun discoverPackages(packages: Set<String>, ownPackage: String) {
load()
val discovered = packages - ownPackage
if (state.value.knownPackages.containsAll(discovered)) return
update {
Expand All @@ -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))
Expand Down
Loading
Loading