From 8a89a6410d331eb58860cbeda94d2a107968410c Mon Sep 17 00:00:00 2001 From: Electric1447 Date: Tue, 11 Aug 2026 21:48:13 +0300 Subject: [PATCH] Add auto approve scope requests & add corresponding settings in manager --- .../org/matrix/vector/daemon/VectorService.kt | 92 ++----------------- .../vector/daemon/data/ModuleDatabase.kt | 71 +++++++++++++- .../vector/daemon/data/PreferenceStore.kt | 20 +++- .../vector/daemon/ipc/ManagerService.kt | 18 ++++ .../vector/daemon/ipc/ModuleAppService.kt | 6 +- .../vector/manager/demo/FakeManagerService.kt | 14 +++ .../matrix/vector/manager/ipc/DaemonClient.kt | 19 ++++ .../ui/components/PackageActionMenu.kt | 58 +++++++++++- manager/src/main/res/values-iw/strings.xml | 3 + manager/src/main/res/values/strings.xml | 3 + .../matrix/vector/ipc/IManagerService.aidl | 16 +++- 11 files changed, 230 insertions(+), 90 deletions(-) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt index 24bdf1f8b..7de80785e 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt @@ -467,72 +467,12 @@ object VectorService : IVectorDaemon.Stub() { } when (action) { - "approve" -> { - // "system" is the framework and not a package: it names system_server, which belongs - // to no package and resolves for nobody. The lookup below therefore came back null - // for every framework prompt, and the approval the user had just given was answered - // "Package not found" — the request was closed, its notification cancelled, and no - // row written. The normalisation to user 0 further down could never once have run. - // - // Package by package rather than all-or-nothing: the prompt may have been up for an - // hour and one of the packages it named can have been uninstalled in the meantime, - // which is no reason to throw away the user's answer about the rest. - // - // Only under "approve", because only an approval has to name something real. Deny - // and the timeout used to be refused here too, so dismissing a prompt for a package - // that had since been uninstalled told the module "Package not found" when what had - // actually happened was that the user turned it down. - val granted = - scopePackageNames.filter { - it == "system" || packageManager?.getPackageInfoCompat(it, 0, userId) != null - } - if (granted.isEmpty()) { - // Logged, because until now this said nothing anywhere: the module was told - // "Package not found", the user was told nothing at all, and the daemon kept no - // record that the press had even arrived. The framework-scope failure above was - // invisible for exactly that reason. - Log.w( - TAG, - "None of ${scopePackageNames.joinToString()} resolve for user $userId;" + - " refusing the scope request of $packageName") - // Leaving the whole function here skipped the cancel below, which used to be - // merely untidy and is now a prompt nobody can use: the request has been answered, - // so every later press of its buttons is dropped. The request is over either way, - // so the notification goes with it. - iCallback.onScopeRequestFailed("Package not found") + "approve" -> ModuleDatabase.approveModuleScope(packageName, userId, scopePackageNames) + .onSuccess { granted -> iCallback.onScopeRequestApproved(granted) } + .onFailure { + iCallback.onScopeRequestFailed(it.message) return@runCatching } - val scopes = ModuleDatabase.getModuleScope(packageName) ?: mutableListOf() - var added = false - granted.forEach { scopePackageName -> - // Compared against where the row will land, not against the user who asked: the - // framework is stored under user 0 whoever requested it, so for "system" this test - // never matched and every approval appended a duplicate and rewrote the whole - // table. - val storedUserId = if (scopePackageName == "system") 0 else userId - val present = - scopes.any { it.packageName == scopePackageName && it.userId == storedUserId } - if (!present) { - scopes.add( - ScopeEntry().apply { - this.packageName = scopePackageName - this.userId = storedUserId - }) - added = true - } - } - // One write for the whole prompt, and none at all when the user approved what the - // module already had. `setModuleScope` replaces the module's rows wholesale and - // enables the module on the way through, so writing per package would rewrite the - // table once per package — leaving a window after each in which the scope is only - // partly what was agreed to — and writing unconditionally would let a module enable - // itself by asking again for what it has. - if (added) ModuleDatabase.setModuleScope(packageName, scopes) - Log.i(TAG, "Approved ${granted.joinToString()} for $packageName on user $userId") - // The packages that were granted, which is what the list in this callback is for. A - // module comparing it against what it asked for can see what it did not get. - iCallback.onScopeRequestApproved(granted) - } "deny" -> iCallback.onScopeRequestFailed("Request denied by user") "delete" -> iCallback.onScopeRequestFailed("Request timeout") } @@ -545,23 +485,8 @@ object VectorService : IVectorDaemon.Stub() { NotificationManager.cancelScopeRequest(packageName, userId, scopePackageNames) } - /** - * The modules that may not ask for scope again. - * - * Filed under "lspd" rather than under the module it names, because it records the user's decision - * about a module rather than that module's own configuration. That is also why uninstalling a - * module does not take it away — `deleteModulePrefs` deletes what is filed under the module's own - * name — and why [unblockScopeRequests] has to exist. - */ - @Suppress("UNCHECKED_CAST") - private fun blockedScopeRequests(): Set = - PreferenceStore.getModulePrefs("lspd", 0, "config")["scope_request_blocked"] as? Set - ?: emptySet() - - private fun blockScopeRequests(packageName: String) { - PreferenceStore.updateModulePref( - "lspd", 0, "config", "scope_request_blocked", blockedScopeRequests() + packageName) - } + private fun blockScopeRequests(packageName: String) = + PreferenceStore.setBlockedScopeRequests(PreferenceStore.getBlockedScopeRequests() + packageName) /** * Lets an uninstalled module ask again if it comes back. @@ -573,10 +498,9 @@ object VectorService : IVectorDaemon.Stub() { * back — and a module that is gone has no decision left to honour. */ private fun unblockScopeRequests(packageName: String) { - val blocked = blockedScopeRequests() + val blocked = PreferenceStore.getBlockedScopeRequests() if (packageName !in blocked) return - PreferenceStore.updateModulePref( - "lspd", 0, "config", "scope_request_blocked", blocked - packageName) + PreferenceStore.setBlockedScopeRequests(blocked - packageName) Log.i(TAG, "$packageName was uninstalled; it may ask for scope again if it returns") } } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt index 32a0acc85..9746db019 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt @@ -3,8 +3,10 @@ package org.matrix.vector.daemon.data import android.content.ContentValues import android.database.sqlite.SQLiteDatabase import android.util.Log -import org.matrix.vector.ipc.ScopeEntry import org.matrix.vector.daemon.system.NotificationManager +import org.matrix.vector.daemon.system.getPackageInfoCompat +import org.matrix.vector.daemon.system.packageManager +import org.matrix.vector.ipc.ScopeEntry private const val TAG = "VectorModuleDatabase" @@ -403,4 +405,71 @@ object ModuleDatabase { return changed } + + fun approveModuleScope(packageName: String, userId: Int, requested: List): Result> { + // "system" is the framework and not a package: it names system_server, which belongs + // to no package and resolves for nobody. The lookup below therefore came back null + // for every framework prompt, and the approval the user had just given was answered + // "Package not found" — the request was closed, its notification canceled, and no + // row written. The normalization to user 0 further down could never once have run. + // + // Package by package rather than all-or-nothing: the prompt may have been up for an + // hour and one of the packages it named can have been uninstalled in the meantime, + // which is no reason to throw away the user's answer about the rest. + // + // Only under "approve", because only an approval has to name something real. Deny + // and the timeout used to be refused here too, so dismissing a prompt for a package + // that had since been uninstalled told the module "Package not found" when what had + // actually happened was that the user turned it down. + val granted = + requested.filter { + it == "system" || packageManager?.getPackageInfoCompat(it, 0, userId) != null + } + if (granted.isEmpty()) { + // Logged, because until now this said nothing anywhere: the module was told + // "Package not found", the user was told nothing at all, and the daemon kept no + // record that the press had even arrived. The framework-scope failure above was + // invisible for exactly that reason. + Log.w( + TAG, + "None of ${requested.joinToString()} resolve for user $userId;" + + " refusing the scope request of $packageName" + ) + // Leaving the whole function here skipped the cancel below, which used to be + // merely untidy and is now a prompt nobody can use: the request has been answered, + // so every later press of its buttons is dropped. The request is over either way, + // so the notification goes with it. + return Result.failure(Exception("Package not found")) + } + val scopes = getModuleScope(packageName) ?: mutableListOf() + var added = false + granted.forEach { scopePackageName -> + // Compared against where the row will land, not against the user who asked: the + // framework is stored under user 0 whoever requested it, so for "system" this test + // never matched and every approval appended a duplicate and rewrote the whole + // table. + val storedUserId = if (scopePackageName == "system") 0 else userId + val present = + scopes.any { it.packageName == scopePackageName && it.userId == storedUserId } + if (!present) { + scopes.add( + ScopeEntry().apply { + this.packageName = scopePackageName + this.userId = storedUserId + }) + added = true + } + } + // One write for the whole prompt, and none at all when the user approved what the + // module already had. `setModuleScope` replaces the module's rows wholesale and + // enables the module on the way through, so writing per package would rewrite the + // table once per package — leaving a window after each in which the scope is only + // partly what was agreed to — and writing unconditionally would let a module enable + // itself by asking again for what it has. + if (added) setModuleScope(packageName, scopes) + Log.i(TAG, "Approved ${granted.joinToString()} for $packageName on user $userId") + // The packages that were granted, which is what the list in this callback is for. A + // module comparing it against what it asked for can see what it did not get. + return Result.success(granted) + } } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/PreferenceStore.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/PreferenceStore.kt index bf1f4905f..de7c40b96 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/PreferenceStore.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/PreferenceStore.kt @@ -97,7 +97,23 @@ object PreferenceStore { fun setVerboseLog(enabled: Boolean) = updateModulePref("lspd", 0, "config", "enable_verbose_log", enabled) + @Suppress("UNCHECKED_CAST") + fun getBlockedScopeRequests(): Set = + getModulePrefs("lspd", 0, "config")["scope_request_blocked"] as? Set ?: emptySet() + + fun setBlockedScopeRequests(scopes: Set) = + updateModulePref("lspd", 0, "config", "scope_request_blocked", scopes) + fun isScopeRequestBlocked(pkg: String): Boolean = - (getModulePrefs("lspd", 0, "config")["scope_request_blocked"] as? Set<*>)?.contains(pkg) == - true + getBlockedScopeRequests().contains(pkg) + + @Suppress("UNCHECKED_CAST") + fun getApprovedScopeRequests(): Set = + getModulePrefs("lspd", 0, "config")["scope_request_approved"] as? Set ?: emptySet() + + fun setApprovedScopeRequests(scopes: Set) = + updateModulePref("lspd", 0, "config", "scope_request_approved", scopes) + + fun isScopeRequestApproved(pkg: String): Boolean = + getApprovedScopeRequests().contains(pkg) } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt index 96251dffc..c2e84a714 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt @@ -272,6 +272,24 @@ object ManagerService : IManagerService.Stub() { override fun getModuleScope(packageName: String) = ModuleDatabase.getModuleScope(packageName) + override fun isScopeRequestBlocked(packageName: String) = + PreferenceStore.isScopeRequestBlocked(packageName) + + override fun setModuleScopeRequestBlocked(packageName: String, block: Boolean) { + val blocked = PreferenceStore.getBlockedScopeRequests() + if (block xor (packageName !in blocked)) return + PreferenceStore.setBlockedScopeRequests(if (block) blocked + packageName else blocked - packageName) + } + + override fun isScopeRequestApproved(packageName: String) = + PreferenceStore.isScopeRequestApproved(packageName) + + override fun setModuleScopeRequestApproved(packageName: String, approve: Boolean) { + val approved = PreferenceStore.getApprovedScopeRequests() + if (approve xor (packageName !in approved)) return + PreferenceStore.setApprovedScopeRequests(if (approve) approved + packageName else approved - packageName) +} + // Reports the setting, not the setting OR'd with the build type. It used to be // `|| BuildConfig.DEBUG`, which made the value unwritable on a debug daemon: the manager could // never read false, so its switch snapped back on every tap and had to be greyed out. The OR was diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleAppService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleAppService.kt index 916ac2cb4..8d2b7bc39 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleAppService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleAppService.kt @@ -474,7 +474,11 @@ class ModuleAppService(private val loadedModule: LoadedModule) : IXposedService. return } } - if (!PreferenceStore.isScopeRequestBlocked(loadedModule.packageName)) { + if (PreferenceStore.isScopeRequestApproved(loadedModule.packageName)) { + ModuleDatabase.approveModuleScope(loadedModule.packageName, userId, requested) + .onSuccess { granted -> callback.onScopeRequestApproved(granted) } + .onFailure { callback.onScopeRequestFailed(it.message) } + } else if (!PreferenceStore.isScopeRequestBlocked(loadedModule.packageName)) { NotificationManager.requestModuleScope(loadedModule.packageName, userId, requested, callback) } else { callback.onScopeRequestFailed("Scope request blocked by user configuration") diff --git a/manager/src/debug/kotlin/org/matrix/vector/manager/demo/FakeManagerService.kt b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/FakeManagerService.kt index a0e7964f0..4a60fef29 100644 --- a/manager/src/debug/kotlin/org/matrix/vector/manager/demo/FakeManagerService.kt +++ b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/FakeManagerService.kt @@ -218,6 +218,20 @@ class FakeManagerService( override fun getModuleScope(packageName: String?): MutableList? = if (real == null) mutableListOf() else real.getModuleScope(packageName) + override fun isScopeRequestBlocked(packageName: String?): Boolean = + real?.isScopeRequestBlocked(packageName) ?: false + + override fun setModuleScopeRequestBlocked(packageName: String?, block: Boolean) { + real?.setModuleScopeRequestBlocked(packageName, block) + } + + override fun isScopeRequestApproved(packageName: String?): Boolean = + real?.isScopeRequestApproved(packageName) ?: false + + override fun setModuleScopeRequestApproved(packageName: String?, approve: Boolean) { + real?.setModuleScopeRequestApproved(packageName, approve) + } + override fun isVerboseLogEnabled(): Boolean = real?.isVerboseLogEnabled ?: false override fun setVerboseLogEnabled(enabled: Boolean) { diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt index b4c0705f8..bb44905dd 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt @@ -205,6 +205,25 @@ class DaemonClient(private val serviceState: StateFlow) { ?: throw IllegalArgumentException("$packageName has no scope to read") } + + suspend fun isScopeRequestBlocked(packageName: String): Result = runIpc { + it.isScopeRequestBlocked(packageName) + } + + suspend fun setModuleScopeRequestBlocked( + packageName: String, + block: Boolean, + ): Result = runIpc { it.setModuleScopeRequestBlocked(packageName, block) } + + suspend fun isScopeRequestApproved(packageName: String): Result = runIpc { + it.isScopeRequestApproved(packageName) + } + + suspend fun setModuleScopeRequestApproved( + packageName: String, + approve: Boolean, + ): Result = runIpc { it.setModuleScopeRequestApproved(packageName, approve) } + suspend fun isStatusNotificationEnabled(): Result = runIpc { it.isStatusNotificationEnabled } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionMenu.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionMenu.kt index b25b2ca01..098b5d4e7 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionMenu.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionMenu.kt @@ -52,6 +52,7 @@ import org.matrix.vector.manager.ui.theme.LocalizedOverlay import org.matrix.vector.manager.R import android.text.format.Formatter import androidx.compose.material.icons.rounded.ArrowCircleUp +import androidx.compose.material.icons.rounded.Block import androidx.compose.material.icons.rounded.CloudDownload import androidx.compose.material.icons.rounded.CloudOff import androidx.compose.material.icons.rounded.NotificationsOff @@ -65,6 +66,7 @@ import org.matrix.vector.manager.data.repository.ModuleUpdateQueue import org.matrix.vector.manager.ui.screens.repo.StoreChannel import org.matrix.vector.manager.ui.screens.repo.releasesOn import androidx.compose.material.icons.rounded.RestartAlt +import androidx.compose.material.icons.rounded.VerifiedUser import androidx.compose.material3.TextButton import org.matrix.vector.manager.ui.screens.modules.ScopeViewModel import org.matrix.vector.manager.ui.screens.modules.ScopeViewModel.Companion.SYSTEM_FRAMEWORK_PACKAGE @@ -123,6 +125,9 @@ fun PackageActionSheet( // Asked once, when the sheet opens. Most modules have neither a companion nor a launcher entry, // and a row that exists only to report that it has nothing to do is worse than no row. var openable by remember(packageName, userId) { mutableStateOf(null) } + var isScopeRequestBlocked by remember(packageName) { mutableStateOf(false) } + var isScopeRequestApproved by remember(packageName) { mutableStateOf(false) } + LaunchedEffect(packageName, userId) { openable = ServiceLocator.daemon @@ -131,6 +136,12 @@ fun PackageActionSheet( logW("actions: launch target lookup for $packageName u$userId failed", e) } .getOrNull() != null + + isScopeRequestBlocked = + ServiceLocator.daemon.isScopeRequestBlocked(packageName).getOrDefault(false) + + isScopeRequestApproved = + ServiceLocator.daemon.isScopeRequestApproved(packageName).getOrDefault(false) } var confirmSoftReboot by remember { mutableStateOf(false) } @@ -181,7 +192,10 @@ fun PackageActionSheet( // thing a drag on a sheet can *do* other than dismiss it, so a sheet taller than half the // screen would open at full height and could not be made smaller. Material caps the stop at // the sheet's own height, so short sheets still open at that height and gain no useless drag. - val sheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden) + val sheetState = rememberBottomSheetState( + initialValue = SheetValue.Hidden, + enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded), + ) // `Dispatchers.Main` because [onResult] reaches a snackbar on the screen underneath, and // because that is the thread the composition scope this replaces used to resume on. @@ -363,6 +377,48 @@ LocalizedOverlay { } } + if (isModule) { + HorizontalDivider(Modifier.padding(horizontal = 24.dp, vertical = 4.dp)) + + if (!isScopeRequestApproved) + ActionToggleRow( + title = stringResource(R.string.action_block_scope_requests), + icon = Icons.Rounded.Block, + checked = isScopeRequestBlocked, + onCheckedChange = { checked -> + isScopeRequestBlocked = checked + + scope.launch(Dispatchers.Main) { + daemon.setModuleScopeRequestBlocked(packageName, checked).onFailure { + isScopeRequestBlocked = !checked + } + } + }, + ) + + ActionToggleRow( + title = stringResource(R.string.action_approve_scope_requests), + subtitle = stringResource(R.string.action_approve_scope_requests_summary), + icon = Icons.Rounded.VerifiedUser, + checked = isScopeRequestApproved, + onCheckedChange = { checked -> + isScopeRequestApproved = checked + + scope.launch(Dispatchers.Main) { + daemon.setModuleScopeRequestApproved(packageName, checked).onFailure { + isScopeRequestApproved = !checked + }.onSuccess { + if (checked && isScopeRequestBlocked) { + daemon.setModuleScopeRequestBlocked(packageName, false).onSuccess { + isScopeRequestBlocked = false + } + } + } + } + }, + ) + } + if (isModule) { HorizontalDivider(Modifier.padding(horizontal = 24.dp, vertical = 4.dp)) ActionRow( diff --git a/manager/src/main/res/values-iw/strings.xml b/manager/src/main/res/values-iw/strings.xml index c7f45c39b..739be470a 100644 --- a/manager/src/main/res/values-iw/strings.xml +++ b/manager/src/main/res/values-iw/strings.xml @@ -213,6 +213,9 @@ לא ניתן היה לבצע אופטימיזציה מחדש ל-%1$s. ‏%1$s הוסרה. לא ניתן היה להסיר את %1$s. + חסמית בקשות תחום + אישור בקשות תחום + אישור בקשות תחום באופן אוטומטי חזרה ניסיון נוסף diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml index 7a1d4dc1b..befdca82c 100644 --- a/manager/src/main/res/values/strings.xml +++ b/manager/src/main/res/values/strings.xml @@ -308,6 +308,9 @@ Could not re-optimize %1$s. Uninstalled %1$s. Could not uninstall %1$s. + Block scope requests + Approve scope requests + Approve scope requests automatically Back diff --git a/services/manager-service/src/main/aidl/org/matrix/vector/ipc/IManagerService.aidl b/services/manager-service/src/main/aidl/org/matrix/vector/ipc/IManagerService.aidl index 19319ba12..2baad4c26 100644 --- a/services/manager-service/src/main/aidl/org/matrix/vector/ipc/IManagerService.aidl +++ b/services/manager-service/src/main/aidl/org/matrix/vector/ipc/IManagerService.aidl @@ -73,7 +73,7 @@ interface IManagerService { * transaction ids follow declaration order, this number is the only thing standing between a * mismatched pair and a call that lands on the wrong method.

*/ - const int PROTOCOL_VERSION = 1; + const int PROTOCOL_VERSION = 2; /** * Which generation of this interface the daemon implements, never below 1. @@ -311,6 +311,20 @@ interface IManagerService { */ boolean setModuleScope(String packageName, in List scope); + /** + * Whether a module's scope requests are blocked. + */ + boolean isScopeRequestBlocked(String packageName); + + void setModuleScopeRequestBlocked(String packageName, boolean block); + + /** + * Whether a module's scope requests are approved automatically. + */ + boolean isScopeRequestApproved(String packageName); + + void setModuleScopeRequestApproved(String packageName, boolean approve); + /** * Whether a module is given each newly installed app automatically. *