From abc880af89f37544e15dbe300f742634fb1a5f6c Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Mon, 7 Sep 2026 10:45:39 +0200
Subject: [PATCH 01/20] Fail the package list rather than lose a user from it
getInstalledPackagesFromAllUsers asks system_server for every package,
once per user, and accumulates the answers. getInstalledPackagesReflect
turned any failure of that query into an empty list, and the loop reads
an empty list as a user with nothing installed and moves on -- so a
transaction that died came back as a device where that profile holds no
apps at all, which the manager then draws as fact.
The kernel says what actually fails, and it is not one oversized reply.
A report on a Samsung A52 with a Shelter work profile has 409 of
binder_alloc: 1519: binder_alloc_buf size 197464 failed, no address space
binder_alloc: allocated: 463248 (num: 532 largest: 235880),
free: 577136 (num: 114 largest: 118688)
against pid 1519, the daemon: 577 KB free but no block larger than
118 KB for a 197 KB reply, with 532 buffers outstanding and peaks at
687. system_server then fails the reply with -28, ENOSPC, and the
DeadObjectException the daemon sees says "remote process probably died,
but this could also be caused by running out of binder buffer" -- there
is no tombstone for system_server anywhere near it, so it is the second
clause. The daemon's binder heap is a fixed megabyte and roughly sixty
manager threads were enumerating packages into it at once; one
enumeration on its own is fine, and the same device answers a correct
831 when it is not racing itself.
The manager side of that is a separate commit. What is wrong here is
that the daemon translates ENOSPC into "this user has no packages", and
the caller has no way to tell the difference. v2.0 did not:
PackageService.getInstalledPackagesFromAllUsers was declared throws
RemoteException and called getInstalledPackages straight, so a dead
transaction failed the whole call. The Kotlin rewrite wrapped it in
runCatching and answered emptyList(), and the flags are unchanged either
side -- v2.0's MATCH_ALL_FLAGS already carried MATCH_ANY_USER -- so the
swallow is the whole of the regression.
So the reflection answers null for a query that did not happen, which is
not the same answer as a user holding nothing, and the caller refuses to
build a list it knows is short.
---
.../vector/daemon/system/SystemExtensions.kt | 30 +++++++++++++++----
1 file changed, 25 insertions(+), 5 deletions(-)
diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt
index b4dd39e5ac..3383914f4d 100644
--- a/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt
+++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt
@@ -197,18 +197,25 @@ private val getInstalledPackagesMethod: Method? by lazy {
?.apply { isAccessible = true }
}
-/** Reflectively calls getInstalledPackages and casts to ParceledListSlice. */
+/**
+ * Reflectively calls getInstalledPackages and casts to ParceledListSlice.
+ *
+ * Answers `null` when the query did not happen, which is not the same answer as an empty list and
+ * must never be flattened into one: a user that holds no packages and a user the platform refused
+ * to answer for look identical once both are `emptyList()`, and the caller builds the manager's
+ * whole app list out of this.
+ */
private fun IPackageManager.getInstalledPackagesReflect(
flags: Any,
userId: Int
-): List {
- val method = getInstalledPackagesMethod ?: return emptyList()
+): List? {
+ val method = getInstalledPackagesMethod ?: return null
return runCatching {
val result = method.invoke(this, flags, userId)
@Suppress("UNCHECKED_CAST") (result as? ParceledListSlice)?.list
}
.onFailure { Log.e(TAG, "Reflection call failed", it.cause ?: it) }
- .getOrNull() ?: emptyList()
+ .getOrNull()
}
fun IPackageManager.getInstalledPackagesFromAllUsers(
@@ -224,7 +231,20 @@ fun IPackageManager.getInstalledPackagesFromAllUsers(
val flagParam: Any =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) flags.toLong() else flags
- val infos = getInstalledPackagesReflect(flagParam, user.id)
+ // A user the platform would not answer for fails the whole call, as it did before the daemon
+ // was rewritten in Kotlin: `PackageService.getInstalledPackagesFromAllUsers` was declared
+ // `throws RemoteException` and called `getInstalledPackages` straight, so a dead transaction
+ // came back to the manager as a failure. Catching it per user and carrying on turns that into
+ // a list that is short by one whole profile, and nothing downstream can tell it from the truth
+ // — the manager draws it as the device's apps, so a work profile whose query died is a work
+ // profile the reader is told does not exist. The list is worth having only entire.
+ //
+ // This is reachable on an ordinary device: `getInstalledPackages` returns every package with
+ // its metadata for each user in turn, and on a large one that repeatedly runs the binder
+ // buffer out and answers DeadObjectException.
+ val infos =
+ getInstalledPackagesReflect(flagParam, user.id)
+ ?: throw IllegalStateException("No package list for user ${user.id}")
if (infos.isEmpty()) continue
val validUserApps =
From 2d3bde3e509d9cca841bdf98e45262259ed28f93 Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Mon, 7 Sep 2026 10:45:39 +0200
Subject: [PATCH 02/20] Offer to put a module into a user that does not have it
A module reaches a profile's apps only from inside that profile: the
scope list is filtered to the module's own user, because that is the
boundary the daemon enforces. So a module the owner holds and the work
profile does not can never be scoped there, and the manager -- which
runs in one user -- had nothing that could put it in the other. The
module list then hides a profile with no modules in it, which is right
on its own but closes the circle: the profile is invisible because it
has no module, and no module can be sent to it.
Android keeps one APK per package name for the whole device and varies
only who has it installed, so this is not a copy. It is the platform's
own installExistingPackageAsUser, the same call pm install-existing
makes, which flips the install state for that user and nothing else --
the version, the signature and the scope rows are all untouched.
Offered only where it can do something: on a module, and only for the
users that do not already hold it. Those are read off the module list's
own per-user tabs rather than asked of the daemon, so the row cannot
disagree with the list behind it, and a user with no tab holds nothing
and is exactly the user this exists to reach. A device with one user
never sees the row.
The users are listed rather than confirmed, because the question is
which user, and on a device with both a work profile and a private space
there is no sensible one to preselect.
---
.../vector/daemon/ipc/ManagerService.kt | 45 ++++++++
.../vector/manager/demo/FakeManagerService.kt | 3 +
.../matrix/vector/manager/ipc/DaemonClient.kt | 10 ++
.../ui/components/PackageActionMenu.kt | 104 ++++++++++++++++++
.../ui/screens/modules/ModulesScreen.kt | 24 +++-
manager/src/main/res/values-ar/strings.xml | 5 +
manager/src/main/res/values-de/strings.xml | 5 +
manager/src/main/res/values-es/strings.xml | 5 +
manager/src/main/res/values-fa/strings.xml | 5 +
manager/src/main/res/values-fr/strings.xml | 5 +
manager/src/main/res/values-in/strings.xml | 5 +
manager/src/main/res/values-it/strings.xml | 5 +
manager/src/main/res/values-iw/strings.xml | 5 +
manager/src/main/res/values-ja/strings.xml | 5 +
manager/src/main/res/values-ko/strings.xml | 5 +
manager/src/main/res/values-pl/strings.xml | 5 +
.../src/main/res/values-pt-rBR/strings.xml | 5 +
manager/src/main/res/values-ru/strings.xml | 5 +
manager/src/main/res/values-tr/strings.xml | 5 +
manager/src/main/res/values-uk/strings.xml | 5 +
manager/src/main/res/values-vi/strings.xml | 5 +
.../src/main/res/values-zh-rCN/strings.xml | 5 +
.../src/main/res/values-zh-rTW/strings.xml | 5 +
manager/src/main/res/values/strings.xml | 5 +
.../matrix/vector/ipc/IManagerService.aidl | 20 ++++
25 files changed, 298 insertions(+), 3 deletions(-)
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 96251dffc9..6e9fdd94a6 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
@@ -61,6 +61,15 @@ object ManagerService : IManagerService.Stub() {
*/
private const val UNINSTALL_TIMEOUT_SECONDS = 60L
+ /**
+ * `PackageManager.INSTALL_SUCCEEDED`, which is hidden and so cannot be named from here.
+ *
+ * The one non-negative code the installer answers with; every failure is a negative
+ * `INSTALL_FAILED_*`. Written out rather than tested as `>= 0` so that a future code that is
+ * neither is treated as the refusal it would be.
+ */
+ private const val INSTALL_SUCCEEDED = 1
+
private var managerPid = -1
private var pendingManager = false
@@ -314,6 +323,42 @@ object ManagerService : IManagerService.Stub() {
activityManager?.forceStopPackage(packageName, userId)
}
+ /**
+ * Puts a package an existing user already holds into another user.
+ *
+ * The platform grew a fifth parameter in Android 10 — the permissions to allowlist — and the
+ * daemon still runs on 8.1, so the call is split on the version the parameter arrived in.
+ * Passing `null` for that list is what the shell's own `pm install-existing` passes: it means
+ * "allowlist nothing extra", not "grant nothing", and the package keeps the grants its install
+ * state already implies.
+ *
+ * `INSTALL_REASON_USER` rather than `UNKNOWN`, because a person tapped this in the module list.
+ * The platform records the reason and shows it in `dumpsys package`.
+ */
+ override fun installExistingPackageAsUser(packageName: String, userId: Int): Boolean {
+ val pm = packageManager ?: return false
+ return runCatching {
+ val status =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ pm.installExistingPackageAsUser(
+ packageName, userId, 0, PackageManager.INSTALL_REASON_USER, null)
+ } else {
+ pm.installExistingPackageAsUser(
+ packageName, userId, 0, PackageManager.INSTALL_REASON_USER)
+ }
+ // The platform answers with an INSTALL_* code, where the single success is
+ // INSTALL_SUCCEEDED (1) and every refusal is negative. Anything else is a refusal we
+ // have no name for, and reporting it as success would leave the manager claiming a
+ // module is in a profile that never received it.
+ if (status != INSTALL_SUCCEEDED) {
+ Log.w(TAG, "install-existing of $packageName for user $userId answered $status")
+ }
+ status == INSTALL_SUCCEEDED
+ }
+ .onFailure { Log.e(TAG, "Failed to install $packageName for user $userId", it) }
+ .getOrDefault(false)
+ }
+
override fun softReboot() = VectorDaemon.softReboot()
/**
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 a0e7964f0f..202b3dd632 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
@@ -257,6 +257,9 @@ class FakeManagerService(
override fun uninstallPackage(packageName: String?, userId: Int): Boolean =
real?.uninstallPackage(packageName, userId) ?: false
+ override fun installExistingPackageAsUser(packageName: String?, userId: Int): Boolean =
+ real?.installExistingPackageAsUser(packageName, userId) ?: false
+
override fun getUsers(): MutableList = real?.users ?: mutableListOf()
override fun startActivityAsUser(intent: Intent?, userId: Int, noUserSwitch: Boolean): Int =
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 b4c0705f89..e1f9f9e629 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
@@ -264,6 +264,16 @@ class DaemonClient(private val serviceState: StateFlow) {
suspend fun uninstallPackage(packageName: String, userId: Int): Result = runIpc { it.uninstallPackage(packageName, userId)
}
+ /**
+ * Installs a module another user already holds into [userId].
+ *
+ * No APK travels: the device keeps one copy per package name and this flips whether [userId]
+ * has it. It is the only way the manager can put a module into a profile it does not run in,
+ * and so the only way a profile with no modules ever gets its first one.
+ */
+ suspend fun installExistingPackageAsUser(packageName: String, userId: Int): Result =
+ runIpc { it.installExistingPackageAsUser(packageName, userId) }
+
suspend fun isSepolicyLoaded(): Result = runIpc { it.isSepolicyLoaded }
suspend fun getUsers(): Result> = runIpc { it.users
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 6e64fc7bea..323587da82 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
@@ -44,6 +44,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import androidx.compose.material.icons.rounded.PersonAdd
+import org.matrix.vector.ipc.DeviceUser
import org.matrix.vector.manager.logE
import org.matrix.vector.manager.logW
import org.matrix.vector.manager.ui.theme.LocalizedOverlay
@@ -113,6 +115,15 @@ fun PackageActionSheet(
* module and has no page. Null there rather than a row that leads nowhere.
*/
onOpenStore: ((String) -> Unit)? = null,
+ /**
+ * The users that already hold this package, when the caller knows.
+ *
+ * Null from the Scope screen, where the sheet is over a hook target rather than a module and
+ * installing it elsewhere is not an act that screen is about. Given from the module list,
+ * where it is the difference between offering a profile that needs this module and offering
+ * one that already has it.
+ */
+ installedUserIds: Set? = null,
) {
// The framework is a scope target, not an app. It has no launcher entry, no settings page in
// Settings, and nothing ART could re-optimize, so those three rows would lead nowhere. What it
@@ -134,6 +145,26 @@ fun PackageActionSheet(
}
var confirmSoftReboot by remember { mutableStateOf(false) }
+ // The users this module could still be put into. Asked once with the sheet, and only for a
+ // module the caller told us about — the list is short and the call is cheap, but it is an IPC
+ // and the Scope screen has no use for the answer.
+ var installTargets by
+ remember(packageName) { mutableStateOf>(emptyList()) }
+ var choosingUser by remember { mutableStateOf(false) }
+ // Bound to a local so the lambda below closes over a plain Set rather than relying on the
+ // parameter still being non-null across the coroutine boundary.
+ val holders = installedUserIds
+ if (isModule && holders != null && !isSystemFramework) {
+ LaunchedEffect(packageName, holders) {
+ installTargets =
+ ServiceLocator.daemon
+ .getUsers()
+ .onFailure { e -> logW("actions: user list for $packageName failed", e) }
+ .getOrDefault(emptyList())
+ .filter { it.id !in holders }
+ }
+ }
+
// Deliberately not `rememberCoroutineScope()`. Every action on this sheet dismisses it before
// it starts working, and the dismissal takes this composable out of the composition — which
// cancels the scope a composition remembered for it. The work launched into that scope then
@@ -190,6 +221,64 @@ fun PackageActionSheet(
scope.launch(Dispatchers.Main) { onResult(block()) }
}
+ // A list rather than a confirmation, because the question is *which* user and there is no
+ // sensible default to preselect: on a device with a work profile and a private space, either
+ // may be the one meant.
+ if (choosingUser) {
+ SharedAlertDialog(
+ onDismissRequest = { choosingUser = false },
+ icon = { Icon(Icons.Rounded.PersonAdd, contentDescription = null) },
+ title = { Text(stringResource(R.string.action_install_other_user)) },
+ text = {
+ Column {
+ Text(stringResource(R.string.action_install_other_user_prompt))
+ Spacer(Modifier.height(8.dp))
+ installTargets.forEach { target ->
+ TextButton(
+ onClick = {
+ choosingUser = false
+ finish {
+ val result =
+ daemon.installExistingPackageAsUser(packageName, target.id)
+ val ok = result.getOrDefault(false)
+ // A device-policy refusal and a user that has gone away both
+ // come back as a plain false, which onFailure never sees.
+ if (!ok) {
+ logE(
+ "actions: install of $packageName for user " +
+ "${target.id} failed",
+ result.exceptionOrNull(),
+ )
+ }
+ PackageActionResult(
+ if (ok) R.string.action_installed_other_user
+ else R.string.action_install_other_user_failed,
+ target.name ?: target.id.toString(),
+ tone =
+ if (ok) SnackbarTone.Success
+ else SnackbarTone.Failure,
+ )
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = target.name ?: target.id.toString(),
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ }
+ },
+ confirmButton = {},
+ dismissButton = {
+ TextButton(onClick = { choosingUser = false }) {
+ Text(stringResource(UiR.string.store_cancel))
+ }
+ },
+ )
+ }
+
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
LocalizedOverlay {
@@ -363,6 +452,21 @@ LocalizedOverlay {
}
}
+ // Only when there is a user this module is not already in. A module every profile holds
+ // would otherwise carry a row whose whole function is to report that there is nowhere to
+ // send it, and on a single-user device — which is most of them — the row would never do
+ // anything at all.
+ if (installTargets.isNotEmpty()) {
+ ActionDrawerItem(
+ icon = Icons.Rounded.PersonAdd,
+ title = stringResource(R.string.action_install_other_user),
+ subtitle = stringResource(R.string.action_install_other_user_summary),
+ tint = colors.primary,
+ ) {
+ choosingUser = true
+ }
+ }
+
if (isModule) {
HorizontalDivider(Modifier.padding(horizontal = 24.dp, vertical = 4.dp))
ActionDrawerItem(
diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt
index 3496443fe5..e746b5ca45 100644
--- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt
+++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt
@@ -156,6 +156,20 @@ fun ModulesScreen(
viewModel: ModulesViewModel = viewModel(factory = ModulesViewModelFactory()),
) {
val tabs by viewModel.userModulesTabs.collectAsStateWithLifecycle()
+
+ // Which users already hold a given module, read off the tabs rather than asked of the daemon:
+ // the tabs are built from the same per-user scan, so this costs nothing and cannot disagree
+ // with what the list is showing. A user with no modules has no tab and therefore holds none,
+ // which is the right answer for every package — and is exactly the user the install action
+ // below exists to reach.
+ val holdersOf: (String) -> Set =
+ remember(tabs) {
+ { pkg ->
+ tabs.filter { tab -> tab.modules.any { it.packageName == pkg } }
+ .map { it.user.id }
+ .toSet()
+ }
+ }
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val query by viewModel.query.collectAsStateWithLifecycle()
val filter by viewModel.filter.collectAsStateWithLifecycle()
@@ -411,7 +425,7 @@ fun ModulesScreen(
stickyHeader(key = "h:active") {
SectionHeader(stringResource(R.string.modules_section_active), active.size)
}
- moduleRows(active, facts, selection, upgradable, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report)
+ moduleRows(active, facts, selection, upgradable, holdersOf, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report)
}
if (inactive.isNotEmpty()) {
stickyHeader(key = "h:inactive") {
@@ -420,10 +434,10 @@ fun ModulesScreen(
inactive.size,
)
}
- moduleRows(inactive, facts, selection, upgradable, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report)
+ moduleRows(inactive, facts, selection, upgradable, holdersOf, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report)
}
} else {
- moduleRows(modules, facts, selection, upgradable, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report)
+ moduleRows(modules, facts, selection, upgradable, holdersOf, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report)
}
}
}
@@ -904,6 +918,7 @@ private fun androidx.compose.foundation.lazy.LazyListScope.moduleRows(
facts: Map,
selection: Set,
upgradable: Set,
+ holdersOf: (String) -> Set,
onModuleClick: (String, Int) -> Unit,
onOpenStore: (String) -> Unit,
onSelect: (InstalledModule) -> Unit,
@@ -916,6 +931,7 @@ private fun androidx.compose.foundation.lazy.LazyListScope.moduleRows(
hasUpdate = module.packageName in upgradable,
selected = ModuleKey(module.packageName, module.userId) in selection,
selectionActive = selection.isNotEmpty(),
+ holders = holdersOf(module.packageName),
onClick = { onModuleClick(module.packageName, module.userId) },
onOpenStore = { onOpenStore(module.packageName) },
onSelect = { onSelect(module) },
@@ -947,6 +963,7 @@ private fun ModuleListItem(
hasUpdate: Boolean,
selected: Boolean,
selectionActive: Boolean,
+ holders: Set,
onClick: () -> Unit,
onOpenStore: () -> Unit,
onSelect: () -> Unit,
@@ -982,6 +999,7 @@ private fun ModuleListItem(
appName = module.appName,
applicationInfo = module.applicationInfo,
isModule = true,
+ installedUserIds = holders,
onDismiss = { menuOpen = false },
onResult = onAction,
onOpenStore = { onOpenStore() },
diff --git a/manager/src/main/res/values-ar/strings.xml b/manager/src/main/res/values-ar/strings.xml
index 932f431a3d..cf4bd87d94 100644
--- a/manager/src/main/res/values-ar/strings.xml
+++ b/manager/src/main/res/values-ar/strings.xml
@@ -464,4 +464,9 @@
ليس لـ Vector أيقونة بعد
يعمل Vector داخل عملية أخرى بدل أن يكون مثبَّتًا، فلا يظهر شيء في المشغّل ولا توجد طريقة واضحة للعودة إليه. امنحه اختصارًا على الشاشة الرئيسية، أو ثبِّته كتطبيق عادي.
عدم السؤال مجددًا
+ تثبيت لمستخدم آخر
+ يضيف هذه الوحدة إلى ملف تعريف لا يملكها، ليتسنّى تحديد نطاقها هناك
+ اختر المستخدم الذي تريد تثبيت هذه الوحدة له.
+ تم التثبيت لـ %1$s.
+ تعذّر التثبيت لـ %1$s.
diff --git a/manager/src/main/res/values-de/strings.xml b/manager/src/main/res/values-de/strings.xml
index 55a712de70..6039875573 100644
--- a/manager/src/main/res/values-de/strings.xml
+++ b/manager/src/main/res/values-de/strings.xml
@@ -420,4 +420,9 @@
Vector hat noch kein Symbol
Vector läuft in einem fremden Prozess, statt installiert zu sein — im Launcher erscheint also nichts, und es gibt keinen offensichtlichen Weg zurück. Gib ihm eine Verknüpfung auf dem Startbildschirm, oder installiere es als gewöhnliche App.
Nicht mehr fragen
+ Für anderen Nutzer installieren
+ Fügt dieses Modul einem Profil hinzu, das es noch nicht hat, damit es dort einen Geltungsbereich bekommen kann
+ Wähle den Nutzer, für den dieses Modul installiert werden soll.
+ Für %1$s installiert.
+ Konnte nicht für %1$s installiert werden.
diff --git a/manager/src/main/res/values-es/strings.xml b/manager/src/main/res/values-es/strings.xml
index d3fc8ad5cd..cc36b0b737 100644
--- a/manager/src/main/res/values-es/strings.xml
+++ b/manager/src/main/res/values-es/strings.xml
@@ -420,4 +420,9 @@
Vector todavía no tiene icono
Vector se ejecuta dentro de otro proceso en lugar de estar instalado, así que no aparece nada en tu launcher y no hay una forma evidente de volver. Dale un acceso directo en la pantalla de inicio, o instálalo como una app normal.
No volver a preguntar
+ Instalar para otro usuario
+ Añade este módulo a un perfil que no lo tiene, para poder darle un ámbito allí
+ Elige el usuario para el que instalar este módulo.
+ Instalado para %1$s.
+ No se pudo instalar para %1$s.
diff --git a/manager/src/main/res/values-fa/strings.xml b/manager/src/main/res/values-fa/strings.xml
index d4c8a06702..e6c5724512 100644
--- a/manager/src/main/res/values-fa/strings.xml
+++ b/manager/src/main/res/values-fa/strings.xml
@@ -420,4 +420,9 @@
Vector هنوز نمادی ندارد
Vector بهجای آنکه نصب شده باشد درون فرایندی دیگر اجرا میشود، پس چیزی در لانچر پیدا نمیشود و راه روشنی برای بازگشت به آن نیست. به آن میانبری در صفحهٔ اصلی بدهید، یا آن را مانند برنامهای معمولی نصب کنید.
دیگر پرسیده نشود
+ نصب برای کاربر دیگر
+ این ماژول را به نمایهای که آن را ندارد اضافه میکند تا بتوان دامنهاش را آنجا تعیین کرد
+ کاربری را که میخواهید این ماژول برایش نصب شود انتخاب کنید.
+ برای %1$s نصب شد.
+ نصب برای %1$s ممکن نشد.
diff --git a/manager/src/main/res/values-fr/strings.xml b/manager/src/main/res/values-fr/strings.xml
index 3a401d36b6..0b98c9968a 100644
--- a/manager/src/main/res/values-fr/strings.xml
+++ b/manager/src/main/res/values-fr/strings.xml
@@ -420,4 +420,9 @@
Vector n\'a pas encore d\'icône
Vector s\'exécute dans un autre processus au lieu d\'être installé : rien n\'apparaît dans votre lanceur et il n\'y a pas de moyen évident d\'y revenir. Donnez-lui un raccourci sur l\'écran d\'accueil, ou installez-le comme une application ordinaire.
Ne plus demander
+ Installer pour un autre utilisateur
+ Ajoute ce module à un profil qui ne l’a pas, afin de lui donner une portée là-bas
+ Choisissez l’utilisateur pour lequel installer ce module.
+ Installé pour %1$s.
+ Impossible d’installer pour %1$s.
diff --git a/manager/src/main/res/values-in/strings.xml b/manager/src/main/res/values-in/strings.xml
index 3c6c6390e6..d1b25f8daf 100644
--- a/manager/src/main/res/values-in/strings.xml
+++ b/manager/src/main/res/values-in/strings.xml
@@ -413,4 +413,9 @@
Vector belum punya ikon
Vector berjalan di dalam proses lain alih-alih dipasang, jadi tidak ada yang muncul di launcher Anda dan tidak ada jalan kembali yang jelas. Beri dia pintasan di layar utama, atau pasang sebagai aplikasi biasa.
Jangan tanya lagi
+ Pasang untuk pengguna lain
+ Menambahkan modul ini ke profil yang belum memilikinya, agar cakupannya bisa diatur di sana
+ Pilih pengguna untuk memasang modul ini.
+ Terpasang untuk %1$s.
+ Tidak dapat memasang untuk %1$s.
diff --git a/manager/src/main/res/values-it/strings.xml b/manager/src/main/res/values-it/strings.xml
index e71e14e18c..4423169d87 100644
--- a/manager/src/main/res/values-it/strings.xml
+++ b/manager/src/main/res/values-it/strings.xml
@@ -420,4 +420,9 @@
Vector non ha ancora un\'icona
Vector gira dentro un altro processo invece di essere installato, quindi nel launcher non compare nulla e non c\'è un modo evidente per tornarci. Dagli una scorciatoia nella schermata Home, oppure installalo come una normale app.
Non chiedere più
+ Installa per un altro utente
+ Aggiunge questo modulo a un profilo che non lo ha, così da poterne definire l’ambito lì
+ Scegli l’utente per cui installare questo modulo.
+ Installato per %1$s.
+ Impossibile installare per %1$s.
diff --git a/manager/src/main/res/values-iw/strings.xml b/manager/src/main/res/values-iw/strings.xml
index 385c875830..59be1565f6 100644
--- a/manager/src/main/res/values-iw/strings.xml
+++ b/manager/src/main/res/values-iw/strings.xml
@@ -446,4 +446,9 @@
ל-Vector עדיין אין סמל
Vector פועל בתוך תהליך אחר במקום להיות מותקן, ולכן שום דבר לא מופיע במסך הבית ואין דרך ברורה לחזור אליו. תנו לו קיצור דרך במסך הבית, או התקינו אותו כאפליקציה רגילה.
לא לשאול שוב
+ התקנה עבור משתמש אחר
+ מוסיף את המודול הזה לפרופיל שאין לו אותו, כדי שאפשר יהיה להגדיר לו טווח שם
+ בחר את המשתמש שעבורו להתקין את המודול הזה.
+ הותקן עבור %1$s.
+ לא ניתן היה להתקין עבור %1$s.
diff --git a/manager/src/main/res/values-ja/strings.xml b/manager/src/main/res/values-ja/strings.xml
index 3b75f0c3f6..ab213f087c 100644
--- a/manager/src/main/res/values-ja/strings.xml
+++ b/manager/src/main/res/values-ja/strings.xml
@@ -399,4 +399,9 @@
Vector のアイコンはまだありません
Vector はインストールされるのではなく、別のプロセス内で実行されるため、ランチャーには何も表示されず、Vector に戻ってくる手段もありません。ホーム画面にショートカットを作成するか、通常のアプリとしてインストールしてください。
今後表示しない
+ 他のユーザーにインストール
+ このモジュールを持っていないプロファイルに追加し、そこでスコープを設定できるようにします
+ このモジュールをインストールするユーザーを選択してください。
+ %1$s にインストールしました。
+ %1$s にインストールできませんでした。
diff --git a/manager/src/main/res/values-ko/strings.xml b/manager/src/main/res/values-ko/strings.xml
index 533f397354..c81bb56217 100644
--- a/manager/src/main/res/values-ko/strings.xml
+++ b/manager/src/main/res/values-ko/strings.xml
@@ -409,4 +409,9 @@
Vector에 아직 아이콘이 없습니다
Vector는 설치되는 대신 다른 프로세스 안에서 실행되므로 런처에 아무것도 나타나지 않고 다시 들어올 뚜렷한 방법도 없습니다. 홈 화면 바로가기를 만들거나, 일반 앱으로 설치하세요.
다시 묻지 않기
+ 다른 사용자에게 설치
+ 이 모듈이 없는 프로필에 추가하여 거기서 범위를 지정할 수 있게 합니다
+ 이 모듈을 설치할 사용자를 선택하세요.
+ %1$s에 설치했습니다.
+ %1$s에 설치하지 못했습니다.
diff --git a/manager/src/main/res/values-pl/strings.xml b/manager/src/main/res/values-pl/strings.xml
index e8f40a59c6..dca8127e01 100644
--- a/manager/src/main/res/values-pl/strings.xml
+++ b/manager/src/main/res/values-pl/strings.xml
@@ -482,4 +482,9 @@
Vector nie ma jeszcze ikony
Vector działa w ramach innego procesu i nie jest instalowany, więc nic nie pojawia się w launcherze, więc nie ma oczywistej drogi powrotu. Dodaj skrót na ekranie głównym lub zainstaluj go jako zwykłą aplikację.
Nie pytaj ponownie
+ Zainstaluj dla innego użytkownika
+ Dodaje ten moduł do profilu, który go nie ma, aby można było określić tam jego zakres
+ Wybierz użytkownika, dla którego zainstalować ten moduł.
+ Zainstalowano dla %1$s.
+ Nie udało się zainstalować dla %1$s.
diff --git a/manager/src/main/res/values-pt-rBR/strings.xml b/manager/src/main/res/values-pt-rBR/strings.xml
index 3977bb60ae..819805870f 100644
--- a/manager/src/main/res/values-pt-rBR/strings.xml
+++ b/manager/src/main/res/values-pt-rBR/strings.xml
@@ -420,4 +420,9 @@
O Vector ainda não tem ícone
O Vector roda dentro de outro processo em vez de estar instalado, então nada aparece no seu launcher e não há um caminho óbvio de volta. Dê a ele um atalho na tela inicial, ou instale-o como um app comum.
Não perguntar de novo
+ Instalar para outro usuário
+ Adiciona este módulo a um perfil que não o tem, para que possa ter escopo ali
+ Escolha o usuário para instalar este módulo.
+ Instalado para %1$s.
+ Não foi possível instalar para %1$s.
diff --git a/manager/src/main/res/values-ru/strings.xml b/manager/src/main/res/values-ru/strings.xml
index c0c7f53709..afa46ebb4c 100644
--- a/manager/src/main/res/values-ru/strings.xml
+++ b/manager/src/main/res/values-ru/strings.xml
@@ -425,4 +425,9 @@
У Vector пока нет значка
Vector работает внутри чужого процесса, а не установлен, поэтому в лаунчере ничего не появляется и очевидного пути обратно нет. Добавьте ярлык на главный экран или установите Vector как обычное приложение.
Больше не спрашивать
+ Установить для другого пользователя
+ Добавляет модуль в профиль, где его нет, чтобы задать там его область действия
+ Выберите пользователя, для которого установить модуль.
+ Установлено для %1$s.
+ Не удалось установить для %1$s.
diff --git a/manager/src/main/res/values-tr/strings.xml b/manager/src/main/res/values-tr/strings.xml
index 701a438e64..7c96d97155 100644
--- a/manager/src/main/res/values-tr/strings.xml
+++ b/manager/src/main/res/values-tr/strings.xml
@@ -420,4 +420,9 @@
Vector\'ün henüz bir simgesi yok
Vector kurulmak yerine başka bir sürecin içinde çalışır; bu yüzden başlatıcınızda hiçbir şey görünmez ve geri dönmenin bariz bir yolu yoktur. Ona ana ekranda bir kısayol verin ya da sıradan bir uygulama olarak kurun.
Bir daha sorma
+ Başka kullanıcı için yükle
+ Bu modülü, onda olmayan bir profile ekler; böylece orada kapsam verilebilir
+ Bu modülün yükleneceği kullanıcıyı seçin.
+ %1$s için yüklendi.
+ %1$s için yüklenemedi.
diff --git a/manager/src/main/res/values-uk/strings.xml b/manager/src/main/res/values-uk/strings.xml
index 29cb7052f0..c9d63bd097 100644
--- a/manager/src/main/res/values-uk/strings.xml
+++ b/manager/src/main/res/values-uk/strings.xml
@@ -462,4 +462,9 @@
У Vector ще немає значка
Vector працює всередині чужого процесу, а не встановлений, тому в лаунчері нічого не з\'являється і очевидного шляху назад немає. Додайте ярлик на головний екран або встановіть Vector як звичайний застосунок.
Більше не питати
+ Встановити для іншого користувача
+ Додає модуль до профілю, де його немає, щоб задати там його область дії
+ Виберіть користувача, для якого встановити модуль.
+ Встановлено для %1$s.
+ Не вдалося встановити для %1$s.
diff --git a/manager/src/main/res/values-vi/strings.xml b/manager/src/main/res/values-vi/strings.xml
index c592b94e78..b7a4eea728 100644
--- a/manager/src/main/res/values-vi/strings.xml
+++ b/manager/src/main/res/values-vi/strings.xml
@@ -409,4 +409,9 @@
Vector vẫn chưa có biểu tượng
Vector chạy bên trong một tiến trình khác thay vì được cài đặt, nên không có gì hiện ra trong trình khởi chạy và cũng không có cách quay lại rõ ràng. Hãy tạo cho nó một lối tắt trên màn hình chính, hoặc cài nó như một ứng dụng bình thường.
Đừng hỏi lại
+ Cài cho người dùng khác
+ Thêm mô-đun này vào hồ sơ chưa có nó, để có thể đặt phạm vi ở đó
+ Chọn người dùng để cài mô-đun này.
+ Đã cài cho %1$s.
+ Không thể cài cho %1$s.
diff --git a/manager/src/main/res/values-zh-rCN/strings.xml b/manager/src/main/res/values-zh-rCN/strings.xml
index 5068e3cf5a..b93191cdcb 100644
--- a/manager/src/main/res/values-zh-rCN/strings.xml
+++ b/manager/src/main/res/values-zh-rCN/strings.xml
@@ -410,4 +410,9 @@
Vector 还没有图标
Vector 运行在别的进程里,而不是被安装到系统中,所以桌面上看不到它,也没有明显的途径再打开它。给它一个桌面快捷方式,或者把它安装成一个普通应用。
不再询问
+ 为其他用户安装
+ 把此模块添加到尚未安装它的用户空间,以便在那里设置作用域
+ 选择要为其安装此模块的用户。
+ 已为 %1$s 安装。
+ 无法为 %1$s 安装。
diff --git a/manager/src/main/res/values-zh-rTW/strings.xml b/manager/src/main/res/values-zh-rTW/strings.xml
index 76a243e73c..ecb859b937 100644
--- a/manager/src/main/res/values-zh-rTW/strings.xml
+++ b/manager/src/main/res/values-zh-rTW/strings.xml
@@ -410,4 +410,9 @@
Vector 還沒有圖示
Vector 執行在別的程序裡,而不是被安裝到系統中,所以啟動器上看不到它,也沒有明顯的途徑再開啟它。給它一個主畫面捷徑,或者把它安裝成一般的應用程式。
不再詢問
+ 為其他使用者安裝
+ 把此模組加入尚未安裝它的使用者空間,以便在那裡設定作用域
+ 選擇要為其安裝此模組的使用者。
+ 已為 %1$s 安裝。
+ 無法為 %1$s 安裝。
diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml
index ec54516e14..71e7a97cdf 100644
--- a/manager/src/main/res/values/strings.xml
+++ b/manager/src/main/res/values/strings.xml
@@ -464,4 +464,9 @@
Vector has no icon yet
Vector runs inside another process rather than being installed, so nothing appears in your launcher and there is no obvious way back in. Give it a home screen shortcut, or install it as an ordinary app.
Don\'t ask again
+ Install for another user
+ Puts this module into a profile that does not have it, so it can be scoped there
+ Choose the user to install this module for.
+ Installed for %1$s.
+ Could not install for %1$s.
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 19319ba12a..21389a296b 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
@@ -594,6 +594,26 @@ interface IManagerService {
*/
boolean uninstallPackage(String packageName, int userId);
+ /**
+ * Installs a package another user already holds into {@code userId}, without an APK.
+ *
+ * Android keeps one APK per package name for the whole device and varies only who has it
+ * installed, so putting a module into a work profile is not a copy - it is the platform's own
+ * {@code installExistingPackageAsUser}, which flips the install state for that user and
+ * nothing else. The module keeps its version, its signature and its uid derivation; only the
+ * user's set of installed packages changes.
+ *
+ * This is what the module list needs to be able to offer at all. A module reaches a
+ * profile's apps only from inside that profile, so a module the owner holds and the profile
+ * does not can never be scoped there - and the manager runs in one user, so without this call
+ * there is nothing in the product that can put it in the other. A profile with no modules is
+ * then a profile that can never get one.
+ *
+ * @return whether the platform reported the install. A user that does not exist, a package no
+ * user holds, and a device-policy refusal all come back false
+ */
+ boolean installExistingPackageAsUser(String packageName, int userId);
+
/**
* Clears an app's ART profiles and forces a profile-guided recompile.
*
From 53187f068cb32c35d653fd1dad66b104e3d2d936 Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Mon, 7 Sep 2026 10:46:02 +0200
Subject: [PATCH 03/20] Share the module scan the way the app list is already
shared
f93a416d6 gave AppRepository a job that concurrent readers join rather
than each starting their own, because enumerating packages through the
daemon is expensive in a way its call site does not look. The Modules
panel was left out of it: discover() called
daemonClient.getInstalledPackagesFromAllUsers directly, with no cache
and nothing to coalesce two of them.
It is the worst place to have left out. The scan is driven by
packageRevision, which observePackageChanges bumps from two merged
sources -- the platform's broadcast and the daemon's re-broadcast -- so
one install starts two enumerations by design, and a profile whose
packages churn keeps many more than two alive. A report on a Samsung A52
with a Shelter work profile has 148 scans in eight minutes, five inside
one second, across 59 manager threads.
The daemon's binder heap is a fixed megabyte, and that many chunked
ParceledListSlices in flight together do not fit in it:
binder_alloc: 1519: binder_alloc_buf size 197464 failed, no address space
binder_alloc: allocated: 463248 (num: 532 largest: 235880),
free: 577136 (num: 114 largest: 118688)
409 of those, against the daemon, with 687 buffers outstanding at the
peak. Note the shape of it: 577 KB free and not one block big enough for
a 197 KB reply. It is saturation and fragmentation, not a reply that was
ever too large -- one enumeration answers a correct 831 packages on the
same device, and the failures only start once the scans pile up.
What that costs is not time. A per-user query answered ENOSPC used to
take that user out of the list, so the same device reported 27 different
totals between 0 and 831, and 697 of them -- 831 less the work profile
-- is a reader being told they have no work profile. The commit before
this one stops the daemon lying about that; this one stops the manager
asking sixty times at once.
A second enumeration rather than a filter over getInstalledApps: this
one needs MATCH_ANY_USER and uninstalled packages so a module held only
by a profile is still seen, and must not set filterNoProcess, because a
module with no components of its own is still a module. It answers a
Result, so a failed read stays distinguishable from a device with no
packages, and only a success is cached.
---
.../manager/data/repository/AppRepository.kt | 62 +++++++++++++++++++
.../ui/screens/modules/ModulesViewModel.kt | 22 +++----
2 files changed, 72 insertions(+), 12 deletions(-)
diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt
index 6865d71bac..616dadc0b3 100644
--- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt
+++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt
@@ -1,5 +1,6 @@
package org.matrix.vector.manager.data.repository
import android.content.pm.ApplicationInfo
+import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CancellationException
@@ -15,6 +16,7 @@ import org.matrix.vector.manager.data.model.ModuleDetectionCache
import org.matrix.vector.manager.data.model.versionCodeCompat
import org.matrix.vector.manager.ipc.DaemonClient
import org.matrix.vector.manager.logW
+import org.matrix.vector.ui.module.MATCH_ANY_USER
/** Fetches and caches the list of installed applications from the daemon. */
class AppRepository(
@@ -63,6 +65,13 @@ class AppRepository(
*/
private var inFlight: Deferred>? = null
+ /** The same arrangement for [moduleScanPackages], which asks the daemon a different question. */
+ @Volatile private var cachedModuleScan: List? = null
+
+ private val moduleScanLock = Mutex()
+
+ private var moduleScanInFlight: Deferred>>? = null
+
/**
* Drops the cache so the next read goes back to the daemon.
*
@@ -73,6 +82,59 @@ class AppRepository(
generation.incrementAndGet()
cachedApps = null
cachedModulePackages = null
+ cachedModuleScan = null
+ }
+
+ /**
+ * The raw package list the Modules panel scans, shared the same way [getInstalledApps] is.
+ *
+ * A second enumeration rather than a filter over the first, because the two ask the daemon
+ * different questions: this one wants `MATCH_ANY_USER` and uninstalled packages so a module
+ * held only by a work profile is still seen, and it must *not* set `filterNoProcess`, because a
+ * module with no components of its own is still a module.
+ *
+ * Shared because the module list is the heaviest caller of it and had nothing stopping it
+ * running against itself. Every package event drops the caches and bumps the revision this
+ * feeds, and that event arrives twice by design — once from the platform, once from the
+ * daemon's re-broadcast — so a single install started two full enumerations, and a device whose
+ * packages churn kept dozens alive at once. That is not a slow path, it is a broken one: each
+ * enumeration pulls every package with its metadata, per user, as a chunked
+ * `ParceledListSlice`, and the daemon's binder heap is a fixed megabyte. One report has 409
+ * `binder_alloc_buf ... failed, no address space` against the daemon, with 687 buffers
+ * outstanding and 577 KB free in blocks too small to hold a 197 KB reply — system_server's
+ * answers failing with ENOSPC not because any one of them was too large, but because sixty
+ * threads were asking at once.
+ *
+ * Answers a [Result] rather than a bare list: a failed enumeration is not a device with no
+ * packages, and the caller has to be able to tell. Only a success is cached.
+ */
+ suspend fun moduleScanPackages(): Result> {
+ cachedModuleScan?.let {
+ return Result.success(it)
+ }
+ val job =
+ moduleScanLock.withLock {
+ cachedModuleScan?.let {
+ return Result.success(it)
+ }
+ moduleScanInFlight?.takeIf { it.isActive }
+ ?: scope.async(Dispatchers.IO) { fetchModuleScanPackages() }
+ .also { moduleScanInFlight = it }
+ }
+ return job.await()
+ }
+
+ private suspend fun fetchModuleScanPackages(): Result> {
+ val startedAt = generation.get()
+ val flags =
+ PackageManager.GET_META_DATA or
+ PackageManager.MATCH_UNINSTALLED_PACKAGES or
+ MATCH_ANY_USER
+
+ val result = daemonClient.getInstalledPackagesFromAllUsers(flags, filterNoProcess = false)
+ val packages = result.getOrElse { return result }
+ if (generation.get() == startedAt) cachedModuleScan = packages
+ return Result.success(packages)
}
suspend fun getInstalledApps(): List {
diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt
index c7da5e5013..73cb099598 100644
--- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt
+++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt
@@ -20,7 +20,6 @@ import org.matrix.vector.ipc.DeviceUser
import org.matrix.vector.ipc.ScopeEntry
import org.matrix.vector.manager.data.model.InstalledModule
import org.matrix.vector.ui.REACH_PREVIEW_LIMIT
-import org.matrix.vector.ui.module.MATCH_ANY_USER
import org.matrix.vector.ui.module.PER_USER_RANGE
import org.matrix.vector.manager.data.repository.ModuleRepository
import org.matrix.vector.ui.store.StoreEntry
@@ -483,18 +482,17 @@ class ModulesViewModel(
_daemonAvailable.value = usersResult.isSuccess
val users = usersResult.getOrNull() ?: emptyList()
- val flags =
- PackageManager.GET_META_DATA or
- PackageManager.MATCH_UNINSTALLED_PACKAGES or
- MATCH_ANY_USER
-
+ // Through the repository rather than straight to the daemon, so that concurrent rescans
+ // join one enumeration instead of each starting their own. This collector is driven by a
+ // package event that is delivered twice on purpose, and the daemon's binder heap is a
+ // fixed megabyte that a handful of these in flight together will exhaust — at which point
+ // it stops being a performance question and becomes a wrong answer, because a per-user
+ // query that fails takes that whole profile out of the list.
val packages =
- daemonClient
- .getInstalledPackagesFromAllUsers(flags, filterNoProcess = false)
- .getOrElse { e ->
- logE("modules: installed package list unavailable, showing no modules", e)
- emptyList()
- }
+ ServiceLocator.apps.moduleScanPackages().getOrElse { e ->
+ logE("modules: installed package list unavailable, showing no modules", e)
+ emptyList()
+ }
// Through the cache, not straight to ModuleDetection: inspecting a package means opening
// its APK and every split as a zip, and there are ~550 of those on a normal device. Keyed
From c89f33977cf794106113807274df48888c64b461 Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Thu, 5 Nov 2026 16:55:45 +0100
Subject: [PATCH 04/20] Refuse an empty user list, honour a pull, name a failed
scan
`getInstalledPackagesFromAllUsers` unions one `getInstalledPackages` per
user, and the per-user term is refused now that the platform may decline
it. The term above it is still swallowed: `getRealUsers` turns a null
binder and any failure of `IUserManager.getUsers` into an empty list, so
a union over no users is a list of no packages, and no device has none --
user 0 always exists. `ConfigCache.rebuild` already refuses an empty user
list, where answering it would delete the configuration of every module;
this is the call the manager draws every list from, and answering it drew
"No modules installed." over a device full of them. The flag conversion
moves out of the loop while here, chosen as it is by a version test that
cannot differ between users.
Sharing the module scan made its cache the only answer any reader gets,
and nothing but a package broadcast drops it. Pull-to-refresh then handed
back the list it was pulled to replace -- on a device whose daemon was
down when the module arrived, the one gesture that could have found it
did nothing -- and the reload after a batch uninstall redrew the modules
the batch had just removed. Both ask for a re-read now. A re-read still
joins a live enumeration rather than racing a second one against it: one
in flight is milliseconds old, and never running two at once is what the
sharing is for.
An enumeration that failed still drew "No modules installed.", the same
claim about an absent answer, in the one place a person reads it. The
user list cannot speak for the enumeration, which is the expensive half
and the half that exhausts the binder heap, so the scan reports its own
failure and the empty state says which of the two it is. On that path the
detection cache is left alone: `flush` keeps only the keys whose package
is in the set it is handed, so flushing the empty set would write an
empty cache and make the next scan that does succeed reopen every APK on
the device.
Also here: the module list builds its holders once per tab set. The
lambda ran for every visible row on every recomposition and handed each
one a freshly built set, equal to the last but not the same object, which
is enough to stop the row skipping.
---
.../vector/daemon/system/SystemExtensions.kt | 21 ++++++---
.../manager/data/repository/AppRepository.kt | 30 ++++++++++--
.../ui/screens/modules/ModulesScreen.kt | 38 ++++++++++-----
.../ui/screens/modules/ModulesViewModel.kt | 46 +++++++++++++++----
manager/src/main/res/values-ar/strings.xml | 1 +
manager/src/main/res/values-de/strings.xml | 1 +
manager/src/main/res/values-es/strings.xml | 1 +
manager/src/main/res/values-fa/strings.xml | 1 +
manager/src/main/res/values-fr/strings.xml | 1 +
manager/src/main/res/values-in/strings.xml | 1 +
manager/src/main/res/values-it/strings.xml | 1 +
manager/src/main/res/values-iw/strings.xml | 1 +
manager/src/main/res/values-ja/strings.xml | 1 +
manager/src/main/res/values-ko/strings.xml | 1 +
manager/src/main/res/values-pl/strings.xml | 1 +
.../src/main/res/values-pt-rBR/strings.xml | 1 +
manager/src/main/res/values-ru/strings.xml | 1 +
manager/src/main/res/values-tr/strings.xml | 1 +
manager/src/main/res/values-uk/strings.xml | 1 +
manager/src/main/res/values-vi/strings.xml | 1 +
.../src/main/res/values-zh-rCN/strings.xml | 1 +
.../src/main/res/values-zh-rTW/strings.xml | 1 +
manager/src/main/res/values/strings.xml | 1 +
23 files changed, 121 insertions(+), 33 deletions(-)
diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt
index 3383914f4d..cf1bf8cd29 100644
--- a/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt
+++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt
@@ -223,14 +223,23 @@ fun IPackageManager.getInstalledPackagesFromAllUsers(
filterNoProcess: Boolean
): List {
val result = mutableListOf()
- // Assuming userManager is available in this scope as in original code
- val users = userManager?.getRealUsers() ?: emptyList()
+ // The same refusal as the per-user term below, one level up, and for the same reason.
+ // `getRealUsers` swallows a null binder and any failure of `IUserManager.getUsers` into an empty
+ // list, so a union over no users is a list of no packages — and there is no device where that is
+ // the truth, because user 0 always exists. Answering it would hand the manager an empty app and
+ // module list and let it draw "No modules installed." over a device full of them, which is the
+ // symptom this whole call is being fixed for. `ConfigCache.rebuild` already refuses an empty
+ // user list for the same reason; this is the call the manager builds every list from.
+ val users = userManager?.getRealUsers().orEmpty()
+ if (users.isEmpty()) throw IllegalStateException("No users to enumerate packages for")
- for (user in users) {
- // We pass flags as Any so the reflective invoke handles Long or Int correctly
- val flagParam: Any =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) flags.toLong() else flags
+ // Loop-invariant: the reflective method is chosen once by the same version test, so hoisting
+ // this only stops it being recomputed per user.
+ // Passed as Any so the reflective invoke boxes it as Long or Int to match.
+ val flagParam: Any =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) flags.toLong() else flags
+ for (user in users) {
// A user the platform would not answer for fails the whole call, as it did before the daemon
// was rewritten in Kotlin: `PackageService.getInstalledPackagesFromAllUsers` was declared
// `throws RemoteException` and called `getInstalledPackages` straight, so a dead transaction
diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt
index 616dadc0b3..6c89360913 100644
--- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt
+++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt
@@ -107,16 +107,36 @@ class AppRepository(
*
* Answers a [Result] rather than a bare list: a failed enumeration is not a device with no
* packages, and the caller has to be able to tell. Only a success is cached.
+ *
+ * [refresh] is for the caller who already knows the cache is stale — a pull on the Modules
+ * panel, or a reload straight after an uninstall. Sharing the enumeration made the cache
+ * authoritative for every reader, and those two readers are exactly the ones for whom it is
+ * not: the cache is dropped only by a package broadcast, so a pull-to-refresh would otherwise
+ * hand back the list it was pulled to replace, and a post-uninstall reload would redraw the
+ * module it had just removed until the broadcast caught up.
*/
- suspend fun moduleScanPackages(): Result> {
- cachedModuleScan?.let {
- return Result.success(it)
+ suspend fun moduleScanPackages(refresh: Boolean = false): Result> {
+ if (!refresh) {
+ cachedModuleScan?.let {
+ return Result.success(it)
+ }
}
val job =
moduleScanLock.withLock {
- cachedModuleScan?.let {
- return Result.success(it)
+ if (refresh) {
+ // Under the lock, so a reader arriving behind this one cannot still be served
+ // the list the refresh exists to replace.
+ cachedModuleScan = null
+ } else {
+ cachedModuleScan?.let {
+ return Result.success(it)
+ }
}
+ // A refresh joins a live enumeration rather than racing a second one against it.
+ // One already in flight is at most a few hundred milliseconds old, and never
+ // running two of these at once is the whole point of sharing them — the daemon's
+ // binder heap is a fixed megabyte, and a pull is the one input a person can
+ // repeat as fast as they can move their thumb.
moduleScanInFlight?.takeIf { it.isActive }
?: scope.async(Dispatchers.IO) { fetchModuleScanPackages() }
.also { moduleScanInFlight = it }
diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt
index e746b5ca45..beed27deed 100644
--- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt
+++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt
@@ -162,12 +162,17 @@ fun ModulesScreen(
// with what the list is showing. A user with no modules has no tab and therefore holds none,
// which is the right answer for every package — and is exactly the user the install action
// below exists to reach.
- val holdersOf: (String) -> Set =
+ //
+ // Built once per tab set rather than computed per row. A lambda that walked the tabs on each
+ // call ran once for every visible row on every recomposition, and handed each row a freshly
+ // built set — equal to the last one but not the same object, which is enough to stop the row
+ // skipping. One map, and every row gets back the identical instance it had before.
+ val holders: Map> =
remember(tabs) {
- { pkg ->
- tabs.filter { tab -> tab.modules.any { it.packageName == pkg } }
- .map { it.user.id }
- .toSet()
+ buildMap> {
+ tabs.forEach { tab ->
+ tab.modules.forEach { getOrPut(it.packageName) { mutableSetOf() }.add(tab.user.id) }
+ }
}
}
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
@@ -177,6 +182,7 @@ fun ModulesScreen(
val facts by viewModel.facts.collectAsStateWithLifecycle()
val counts by viewModel.counts.collectAsStateWithLifecycle()
val daemonAvailable by viewModel.daemonAvailable.collectAsStateWithLifecycle()
+ val scanFailed by viewModel.scanFailed.collectAsStateWithLifecycle()
val selection by viewModel.selection.collectAsStateWithLifecycle()
val upgradable by viewModel.upgradable.collectAsStateWithLifecycle()
@@ -370,6 +376,7 @@ fun ModulesScreen(
// have no modules installed" over a list the filter had just hidden.
EmptyState(
daemonAvailable = daemonAvailable,
+ scanFailed = scanFailed,
filtered = query.isNotBlank() || filter != ModuleFilter.All,
)
return@Column
@@ -393,7 +400,10 @@ fun ModulesScreen(
// that catches it does not fire for every route in.
PullToRefreshBox(
isRefreshing = isLoading,
- onRefresh = { viewModel.loadModules() },
+ // Forced past the shared cache: that cache is dropped only by a package
+ // broadcast, and a pull is precisely the gesture for the change no broadcast
+ // brought — one made while the daemon was down, or on a route in that missed it.
+ onRefresh = { viewModel.loadModules(refresh = true) },
) {
val modules = tabs[page].modules
// Sections only make sense when the order is by state. Under any other sort the
@@ -425,7 +435,7 @@ fun ModulesScreen(
stickyHeader(key = "h:active") {
SectionHeader(stringResource(R.string.modules_section_active), active.size)
}
- moduleRows(active, facts, selection, upgradable, holdersOf, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report)
+ moduleRows(active, facts, selection, upgradable, holders, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report)
}
if (inactive.isNotEmpty()) {
stickyHeader(key = "h:inactive") {
@@ -434,10 +444,10 @@ fun ModulesScreen(
inactive.size,
)
}
- moduleRows(inactive, facts, selection, upgradable, holdersOf, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report)
+ moduleRows(inactive, facts, selection, upgradable, holders, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report)
}
} else {
- moduleRows(modules, facts, selection, upgradable, holdersOf, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report)
+ moduleRows(modules, facts, selection, upgradable, holders, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report)
}
}
}
@@ -871,7 +881,7 @@ private fun ApiBadge(module: InstalledModule, incompatible: Boolean) {
}
@Composable
-private fun EmptyState(daemonAvailable: Boolean, filtered: Boolean) {
+private fun EmptyState(daemonAvailable: Boolean, scanFailed: Boolean, filtered: Boolean) {
Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
@@ -886,6 +896,10 @@ private fun EmptyState(daemonAvailable: Boolean, filtered: Boolean) {
stringResource(
when {
!daemonAvailable -> R.string.modules_no_daemon
+ // Before the filter, because a narrowed list over a scan that never
+ // happened is still a scan that never happened, and "no modules match"
+ // would invite the reader to widen the filter and find nothing.
+ scanFailed -> R.string.modules_scan_failed
filtered -> R.string.modules_no_match
else -> R.string.modules_empty
}
@@ -918,7 +932,7 @@ private fun androidx.compose.foundation.lazy.LazyListScope.moduleRows(
facts: Map,
selection: Set,
upgradable: Set,
- holdersOf: (String) -> Set,
+ holders: Map>,
onModuleClick: (String, Int) -> Unit,
onOpenStore: (String) -> Unit,
onSelect: (InstalledModule) -> Unit,
@@ -931,7 +945,7 @@ private fun androidx.compose.foundation.lazy.LazyListScope.moduleRows(
hasUpdate = module.packageName in upgradable,
selected = ModuleKey(module.packageName, module.userId) in selection,
selectionActive = selection.isNotEmpty(),
- holders = holdersOf(module.packageName),
+ holders = holders[module.packageName].orEmpty(),
onClick = { onModuleClick(module.packageName, module.userId) },
onOpenStore = { onOpenStore(module.packageName) },
onSelect = { onSelect(module) },
diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt
index 73cb099598..81d12be8d4 100644
--- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt
+++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt
@@ -118,6 +118,19 @@ class ModulesViewModel(
private val _daemonAvailable = MutableStateFlow(true)
val daemonAvailable: StateFlow = _daemonAvailable.asStateFlow()
+ /**
+ * Whether the last scan failed to read the installed packages at all.
+ *
+ * Separate from [daemonAvailable], which is set from the user list: the daemon can answer that
+ * and still fail to enumerate packages, because enumerating is the expensive half and the half
+ * that runs its binder heap out. Both of those come back as no modules, and "no modules
+ * installed" is a claim about the device that neither of them supports. Without this the panel
+ * reported a failed enumeration as an empty one, which is the same mistake one level up that
+ * the daemon stopped making per user.
+ */
+ private val _scanFailed = MutableStateFlow(false)
+ val scanFailed: StateFlow = _scanFailed.asStateFlow()
+
private val _query = MutableStateFlow("")
val query: StateFlow = _query.asStateFlow()
@@ -366,7 +379,9 @@ class ModulesViewModel(
}
}
moduleRepository.refresh()
- loadModules()
+ // Forced: the package broadcast that drops the shared cache has not arrived yet, so an
+ // ordinary reload would redraw the modules this call just uninstalled.
+ loadModules(refresh = true)
_selection.value = emptySet()
onResult(BatchOutcome(changed = removed, already = 0, failed = failed))
}
@@ -382,10 +397,18 @@ class ModulesViewModel(
}
}
- fun loadModules() {
+ /**
+ * Rediscovers the installed modules.
+ *
+ * [refresh] goes through to the shared enumeration and says "do not answer me from the cache".
+ * Set by a pull and by the reload after an uninstall, which are the two callers who know the
+ * cached list is behind the device; everything else arrives either on a package broadcast,
+ * which has already dropped the cache, or on first open, where there is nothing to be behind.
+ */
+ fun loadModules(refresh: Boolean = false) {
viewModelScope.launch {
_isLoading.update { true }
- val tabs = withContext(Dispatchers.IO) { discover() }
+ val tabs = withContext(Dispatchers.IO) { discover(refresh) }
_discovered.update { tabs }
_isLoading.update { false }
loadFacts(tabs)
@@ -474,7 +497,7 @@ class ModulesViewModel(
}
}
- private suspend fun discover(): List {
+ private suspend fun discover(refresh: Boolean): List {
val usersResult = daemonClient.getUsers()
usersResult.onFailure { e ->
logW("modules: user list unavailable, treating the daemon as unreachable", e)
@@ -488,11 +511,10 @@ class ModulesViewModel(
// fixed megabyte that a handful of these in flight together will exhaust — at which point
// it stops being a performance question and becomes a wrong answer, because a per-user
// query that fails takes that whole profile out of the list.
- val packages =
- ServiceLocator.apps.moduleScanPackages().getOrElse { e ->
- logE("modules: installed package list unavailable, showing no modules", e)
- emptyList()
- }
+ val scan = ServiceLocator.apps.moduleScanPackages(refresh)
+ scan.onFailure { e -> logE("modules: installed package list unavailable", e) }
+ _scanFailed.value = scan.isFailure
+ val packages = scan.getOrDefault(emptyList())
// Through the cache, not straight to ModuleDetection: inspecting a package means opening
// its APK and every split as a zip, and there are ~550 of those on a normal device. Keyed
@@ -529,7 +551,11 @@ class ModulesViewModel(
)
}
- detection.flush(packages.mapNotNull { it.packageName }.toSet())
+ // Not on a failed scan. [ModuleDetectionCache.flush] retains only the keys whose package
+ // is in the set it is handed, so flushing an empty set while anything else in the process
+ // has marked the cache dirty writes an empty cache to disk — and the next scan that does
+ // succeed then reopens every APK on the device instead of reading a line each.
+ if (scan.isSuccess) detection.flush(packages.mapTo(mutableSetOf()) { it.packageName })
// The one number that explains a slow Modules panel: how many APKs this scan had to open.
// Everything else is a map lookup, so a large figure here on a second run means the cache
// key is wrong rather than that the device is slow.
diff --git a/manager/src/main/res/values-ar/strings.xml b/manager/src/main/res/values-ar/strings.xml
index cf4bd87d94..bbee7abe25 100644
--- a/manager/src/main/res/values-ar/strings.xml
+++ b/manager/src/main/res/values-ar/strings.xml
@@ -116,6 +116,7 @@
مرة أخرى…
لا وحدات مثبّتة.
+ تعذّرت قراءة قائمة الحِزم المثبّتة، فلا يُعرف ما الوحدات الموجودة على هذا الجهاز. اسحب للمحاولة مرة أخرى.
%1$d من %2$d نشطة
لا وحدة تطابق هذا البحث أو هذه التصفية.
نسخ الوحدات احتياطيًا
diff --git a/manager/src/main/res/values-de/strings.xml b/manager/src/main/res/values-de/strings.xml
index 6039875573..6533159862 100644
--- a/manager/src/main/res/values-de/strings.xml
+++ b/manager/src/main/res/values-de/strings.xml
@@ -96,6 +96,7 @@
Noch ein Mal…
Keine Module installiert.
+ Die Liste der installierten Pakete konnte nicht gelesen werden, deshalb ist unbekannt, welche Module dieses Gerät hat. Zum erneuten Versuch ziehen.
%1$d von %2$d aktiv
Kein Modul passt zu dieser Suche oder diesem Filter.
Module sichern
diff --git a/manager/src/main/res/values-es/strings.xml b/manager/src/main/res/values-es/strings.xml
index cc36b0b737..e42754b9f6 100644
--- a/manager/src/main/res/values-es/strings.xml
+++ b/manager/src/main/res/values-es/strings.xml
@@ -96,6 +96,7 @@
Una vez más…
No hay módulos instalados.
+ No se pudo leer la lista de paquetes instalados, así que no se sabe qué módulos tiene este dispositivo. Tira para volver a intentarlo.
%1$d de %2$d activos
Ningún módulo coincide con esa búsqueda o ese filtro.
Guardar copia de los módulos
diff --git a/manager/src/main/res/values-fa/strings.xml b/manager/src/main/res/values-fa/strings.xml
index e6c5724512..86328e66c5 100644
--- a/manager/src/main/res/values-fa/strings.xml
+++ b/manager/src/main/res/values-fa/strings.xml
@@ -96,6 +96,7 @@
یک بار دیگر…
هیچ ماژولی نصب نشده است.
+ خواندن فهرست بستههای نصبشده ممکن نشد، پس دانسته نیست این دستگاه چه ماژولهایی دارد. برای تلاش دوباره بکشید.
%1$d از %2$d فعال
ماژولی با این جستجو یا پالایه همخوان نیست.
پشتیبانگیری از ماژولها
diff --git a/manager/src/main/res/values-fr/strings.xml b/manager/src/main/res/values-fr/strings.xml
index 0b98c9968a..93b9cc5042 100644
--- a/manager/src/main/res/values-fr/strings.xml
+++ b/manager/src/main/res/values-fr/strings.xml
@@ -96,6 +96,7 @@
Encore une fois…
Aucun module installé.
+ La liste des paquets installés n\'a pas pu être lue, les modules présents sur cet appareil sont donc inconnus. Tirez pour réessayer.
%1$d actifs sur %2$d
Aucun module ne correspond à cette recherche ou à ce filtre.
Sauvegarder les modules
diff --git a/manager/src/main/res/values-in/strings.xml b/manager/src/main/res/values-in/strings.xml
index d1b25f8daf..cfab1fdb2d 100644
--- a/manager/src/main/res/values-in/strings.xml
+++ b/manager/src/main/res/values-in/strings.xml
@@ -95,6 +95,7 @@
Sekali lagi…
Belum ada modul terpasang.
+ Daftar paket terpasang tidak dapat dibaca, jadi tidak diketahui modul apa saja yang ada di perangkat ini. Tarik untuk mencoba lagi.
%1$d dari %2$d aktif
Tidak ada modul yang cocok dengan pencarian atau saringan itu.
Cadangkan modul
diff --git a/manager/src/main/res/values-it/strings.xml b/manager/src/main/res/values-it/strings.xml
index 4423169d87..89a9c32ea2 100644
--- a/manager/src/main/res/values-it/strings.xml
+++ b/manager/src/main/res/values-it/strings.xml
@@ -96,6 +96,7 @@
Ancora una volta…
Nessun modulo installato.
+ Non è stato possibile leggere l\'elenco dei pacchetti installati, quindi non si sa quali moduli abbia questo dispositivo. Tira per riprovare.
%1$d di %2$d attivi
Nessun modulo corrisponde a questa ricerca o a questo filtro.
Salva una copia dei moduli
diff --git a/manager/src/main/res/values-iw/strings.xml b/manager/src/main/res/values-iw/strings.xml
index 59be1565f6..d5168b089b 100644
--- a/manager/src/main/res/values-iw/strings.xml
+++ b/manager/src/main/res/values-iw/strings.xml
@@ -110,6 +110,7 @@
עוד פעם אחת…
לא מותקנים מודולים.
+ לא ניתן היה לקרוא את רשימת החבילות המותקנות, ולכן לא ידוע אילו מודולים יש במכשיר הזה. משכו כדי לנסות שוב.
%1$d מתוך %2$d פעילים
אין מודול שתואם לחיפוש הזה או לסינון הזה.
גיבוי המודולים
diff --git a/manager/src/main/res/values-ja/strings.xml b/manager/src/main/res/values-ja/strings.xml
index ab213f087c..f0c101d446 100644
--- a/manager/src/main/res/values-ja/strings.xml
+++ b/manager/src/main/res/values-ja/strings.xml
@@ -91,6 +91,7 @@
あと 1 回…
モジュールがインストールされていません。
+ インストール済みパッケージの一覧を取得できなかったため、この端末にどのモジュールがあるか分かりません。下にスワイプすると再試行します。
%2$d 個中 %1$d 個が有効
その検索や絞り込みに一致するモジュールはありません。
モジュールをバックアップ
diff --git a/manager/src/main/res/values-ko/strings.xml b/manager/src/main/res/values-ko/strings.xml
index c81bb56217..c8c9efc77d 100644
--- a/manager/src/main/res/values-ko/strings.xml
+++ b/manager/src/main/res/values-ko/strings.xml
@@ -91,6 +91,7 @@
한 번 더…
설치된 모듈이 없습니다.
+ 설치된 패키지 목록을 읽지 못해 이 기기에 어떤 모듈이 있는지 알 수 없습니다. 당겨서 다시 시도하세요.
%2$d개 중 %1$d개 활성
그 검색어나 필터와 일치하는 모듈이 없습니다.
모듈 백업
diff --git a/manager/src/main/res/values-pl/strings.xml b/manager/src/main/res/values-pl/strings.xml
index dca8127e01..02fc1012a9 100644
--- a/manager/src/main/res/values-pl/strings.xml
+++ b/manager/src/main/res/values-pl/strings.xml
@@ -159,6 +159,7 @@
Jeszcze raz…
Nie zainstalowano żadnych modułów.
+ Nie udało się odczytać listy zainstalowanych pakietów, więc nie wiadomo, jakie moduły ma to urządzenie. Pociągnij, aby spróbować ponownie.
- %1$d moduł można zaktualizować
- %1$d moduły można zaktualizować
diff --git a/manager/src/main/res/values-pt-rBR/strings.xml b/manager/src/main/res/values-pt-rBR/strings.xml
index 819805870f..c99656a40c 100644
--- a/manager/src/main/res/values-pt-rBR/strings.xml
+++ b/manager/src/main/res/values-pt-rBR/strings.xml
@@ -96,6 +96,7 @@
Mais uma vez…
Nenhum módulo instalado.
+ Não foi possível ler a lista de pacotes instalados, então não se sabe quais módulos este dispositivo tem. Puxe para tentar de novo.
%1$d de %2$d ativos
Nenhum módulo corresponde a essa busca ou filtro.
Fazer backup dos módulos
diff --git a/manager/src/main/res/values-ru/strings.xml b/manager/src/main/res/values-ru/strings.xml
index afa46ebb4c..9f42cd79cc 100644
--- a/manager/src/main/res/values-ru/strings.xml
+++ b/manager/src/main/res/values-ru/strings.xml
@@ -97,6 +97,7 @@
Ещё два раза…
Ещё раз…
Модули не установлены.
+ Не удалось прочитать список установленных пакетов, поэтому неизвестно, какие модули есть на этом устройстве. Потяните, чтобы повторить.
%1$d из %2$d активны
Ни один модуль не подходит под этот запрос или фильтр.
Сохранить модули
diff --git a/manager/src/main/res/values-tr/strings.xml b/manager/src/main/res/values-tr/strings.xml
index 7c96d97155..15765ad482 100644
--- a/manager/src/main/res/values-tr/strings.xml
+++ b/manager/src/main/res/values-tr/strings.xml
@@ -96,6 +96,7 @@
Bir kez daha…
Kurulu modül yok.
+ Kurulu paketlerin listesi okunamadı, bu yüzden bu cihazda hangi modüllerin bulunduğu bilinmiyor. Yeniden denemek için çekin.
%2$d modülden %1$d tanesi etkin
Bu arama veya süzgeçle eşleşen modül yok.
Modülleri yedekle
diff --git a/manager/src/main/res/values-uk/strings.xml b/manager/src/main/res/values-uk/strings.xml
index c9d63bd097..d2bffe3e23 100644
--- a/manager/src/main/res/values-uk/strings.xml
+++ b/manager/src/main/res/values-uk/strings.xml
@@ -106,6 +106,7 @@
Ще раз…
Модулів не встановлено.
+ Не вдалося прочитати перелік установлених пакетів, тож невідомо, які модулі є на цьому пристрої. Потягніть, щоб повторити.
%1$d з %2$d активні
Жоден модуль не відповідає цьому запиту чи фільтру.
Створити копію модулів
diff --git a/manager/src/main/res/values-vi/strings.xml b/manager/src/main/res/values-vi/strings.xml
index b7a4eea728..e596037110 100644
--- a/manager/src/main/res/values-vi/strings.xml
+++ b/manager/src/main/res/values-vi/strings.xml
@@ -91,6 +91,7 @@
Một lần nữa…
Chưa cài mô-đun nào.
+ Không đọc được danh sách gói đã cài nên không rõ thiết bị này có những mô-đun nào. Kéo xuống để thử lại.
%1$d trong %2$d đang bật
Không có mô-đun nào khớp với tìm kiếm hoặc bộ lọc đó.
Sao lưu mô-đun
diff --git a/manager/src/main/res/values-zh-rCN/strings.xml b/manager/src/main/res/values-zh-rCN/strings.xml
index b93191cdcb..9e95025969 100644
--- a/manager/src/main/res/values-zh-rCN/strings.xml
+++ b/manager/src/main/res/values-zh-rCN/strings.xml
@@ -91,6 +91,7 @@
还差一次…
尚未安装任何模块。
+ 无法读取已安装的软件包列表,因此无从得知本设备有哪些模块。下拉可重试。
%2$d 个模块中有 %1$d 个已启用
没有符合该搜索或筛选条件的模块。
备份模块
diff --git a/manager/src/main/res/values-zh-rTW/strings.xml b/manager/src/main/res/values-zh-rTW/strings.xml
index ecb859b937..a0e48b4c4f 100644
--- a/manager/src/main/res/values-zh-rTW/strings.xml
+++ b/manager/src/main/res/values-zh-rTW/strings.xml
@@ -91,6 +91,7 @@
還差一次…
尚未安裝任何模組。
+ 無法讀取已安裝的套件清單,因此無從得知本裝置有哪些模組。下拉即可重試。
%2$d 個模組中有 %1$d 個已啟用
沒有符合該搜尋或篩選條件的模組。
備份模組
diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml
index 71e7a97cdf..69a48de6a1 100644
--- a/manager/src/main/res/values/strings.xml
+++ b/manager/src/main/res/values/strings.xml
@@ -158,6 +158,7 @@
No modules installed.
+ The list of installed packages could not be read, so which modules this device has is unknown. Pull down to try again.
- %1$d module can be updated
- %1$d modules can be updated
From 9a1956e0a9686148b33f95cb2db52311054d6a6b Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Thu, 5 Nov 2026 17:41:48 +0100
Subject: [PATCH 05/20] Key include-new-apps by user, and bump the protocol it
moved
`auto_include` was a column on `modules`, a table keyed by package name
alone, so one module held one flag for the whole device. Turning the
switch on from a work profile's scope screen turned it on for the owner
as well, and the install handler then read it without a user at all: the
next app installed anywhere was handed to every auto-including module and
written under whichever user it arrived in, so an app installed in one
profile entered the scope of a module configured in another. A module the
owner and a profile both hold is two copies with two scopes, and this was
the one thing about it that could not differ between them.
It is a table now, `auto_include (mid, user_id)`, where a row's existence
is the whole value and there is no second column to disagree with it.
Keyed by mid, so uninstalling a module takes these rows with it exactly
as it takes its scope. The column stays where it is, dead and selected by
nothing: SQLite gained DROP COLUMN in 3.35 and this supports API 27.
What the old flag meant in effect was every user, so the migration writes
one row per user to preserve it, and takes the users from `scope` -- the
only table that names any -- unioned with user 0. Not from `IUserManager`:
a migration runs wherever the database is first opened, which is early,
and one that silently found no users would read as a setting nobody had
ever made. A user that neither holds scope nor is 0 loses it, and a user
created afterwards never had it; both are what the switch now means
rather than a gap in it, since nothing can say a person wanted this in a
profile they have never configured.
The scope screen passes the user whose copy it is already showing, and
says so in the switch's subtitle where there is more than one user to
distinguish -- the arrangement the framework row above it already uses.
Also here: `PROTOCOL_VERSION`, which `installExistingPackageAsUser`
should have carried. It was declared in the middle of the interface, and
ids follow declaration order, so every method below it moved, and this
file's own rule is to bump the number in the same commit.
The daemon reuse policy is what makes that rule bite on the ordinary
upgrade path rather than in some corner. One daemon is started per boot
by service.sh; a module update replaces files on disk and starts nothing,
a replayed service.sh finds the lockfile held and exits, and `softReboot`
restarts zygote rather than the daemon. So from the moment a module is
installed until the next real boot, the manager APK on disk is the new
build and the daemon answering it is the old one -- which is every update
that touches this file, on every device. The descriptor still matches
across that pair, so without the number they bind and then call a
different method for each shifted id, which is the failure the handshake
replaced hand-written ids to prevent. Two signature changes here need the
same bump, and one covers both.
---
.../org/matrix/vector/daemon/VectorService.kt | 6 +-
.../org/matrix/vector/daemon/data/Database.kt | 72 +++++++++--
.../vector/daemon/data/ModuleDatabase.kt | 113 +++++++++++++-----
.../vector/daemon/ipc/ManagerService.kt | 7 +-
.../vector/manager/demo/FakeManagerService.kt | 8 +-
.../matrix/vector/manager/ipc/DaemonClient.kt | 23 ++--
.../manager/ui/screens/modules/ScopeScreen.kt | 16 ++-
.../ui/screens/modules/ScopeViewModel.kt | 11 +-
manager/src/main/res/values-ar/strings.xml | 1 +
manager/src/main/res/values-de/strings.xml | 1 +
manager/src/main/res/values-es/strings.xml | 1 +
manager/src/main/res/values-fa/strings.xml | 1 +
manager/src/main/res/values-fr/strings.xml | 1 +
manager/src/main/res/values-in/strings.xml | 1 +
manager/src/main/res/values-it/strings.xml | 1 +
manager/src/main/res/values-iw/strings.xml | 1 +
manager/src/main/res/values-ja/strings.xml | 1 +
manager/src/main/res/values-ko/strings.xml | 1 +
manager/src/main/res/values-pl/strings.xml | 1 +
.../src/main/res/values-pt-rBR/strings.xml | 1 +
manager/src/main/res/values-ru/strings.xml | 1 +
manager/src/main/res/values-tr/strings.xml | 1 +
manager/src/main/res/values-uk/strings.xml | 1 +
manager/src/main/res/values-vi/strings.xml | 1 +
.../src/main/res/values-zh-rCN/strings.xml | 1 +
.../src/main/res/values-zh-rTW/strings.xml | 1 +
manager/src/main/res/values/strings.xml | 1 +
.../matrix/vector/ipc/IManagerService.aidl | 23 +++-
28 files changed, 234 insertions(+), 64 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 685a3664a0..15b3705a3d 100644
--- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt
+++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt
@@ -357,7 +357,11 @@ object VectorService : IVectorDaemon.Stub() {
!intent.getBooleanExtra(Intent.EXTRA_REPLACING, false) &&
moduleName != null) {
- ModuleDatabase.modulesIncludingNewApps().forEach { xposedModule ->
+ // Only the modules auto-including in the user this app was installed into. Asked
+ // without a user, this handed the new package to every auto-including module on the
+ // device and then wrote it under the installing user, so an app installed in a work
+ // profile landed in the scope of a module configured only for the owner.
+ ModuleDatabase.modulesIncludingNewApps(userId).forEach { xposedModule ->
val scopeList = ModuleDatabase.getModuleScope(xposedModule) ?: mutableListOf()
val newScope =
diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/Database.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/Database.kt
index 54f1e0179e..77991806c8 100644
--- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/Database.kt
+++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/Database.kt
@@ -8,7 +8,7 @@ import java.io.File
import org.matrix.vector.daemon.utils.FakeContext
private const val TAG = "VectorDatabase"
-private const val DB_VERSION = 4
+private const val DB_VERSION = 5
class Database(context: Context? = FakeContext()) :
SQLiteOpenHelper(context, FileSystem.dbPath.absolutePath, null, DB_VERSION) {
@@ -30,12 +30,14 @@ class Database(context: Context? = FakeContext()) :
module_pkg_name text NOT NULL UNIQUE,
apk_path text NOT NULL,
enabled BOOLEAN DEFAULT 0 CHECK (enabled IN (0, 1)),
- -- `include new apps` everywhere else: when set, a package installed from now on is
- -- added to this module's scope. The column keeps the older name on purpose. SQLite
- -- gained ALTER TABLE RENAME COLUMN in 3.25 and this project supports API 27, whose
- -- SQLite is older, so renaming means rebuilding the table behind a version bump —
- -- and onDowngrade wipes the module configuration, so anyone who then flashed an
- -- older build would lose theirs. That is a real cost for a name no user ever sees.
+ -- Dead since v5, and read by nothing. `include new apps` is per user now and lives
+ -- in the table below, because this column cannot be: a module is one row keyed by
+ -- package name, so one flag here is one answer for every user at once, and turning
+ -- it on while looking at a work profile turned it on for the owner too.
+ --
+ -- Kept rather than dropped. SQLite gained DROP COLUMN in 3.35 and this project
+ -- supports API 27, whose SQLite is older, so removing it means rebuilding the table
+ -- for a column that costs one byte a module and is never selected.
auto_include BOOLEAN DEFAULT 0 CHECK (auto_include IN (0, 1))
);
"""
@@ -69,6 +71,28 @@ class Database(context: Context? = FakeContext()) :
"""
.trimIndent())
+ db.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS auto_include (
+ -- One row per (module, user) that is auto-including, and the row's existence is the
+ -- whole value: there is no `enabled` column to disagree with it.
+ --
+ -- Per user because the switch is read from a module's scope screen, and that screen is
+ -- opened for one installed copy of the module. A module the owner and a work profile
+ -- both hold is two scopes, so it is two answers to this question as well, and the
+ -- single column this replaces could only ever give one.
+ --
+ -- Keyed by mid rather than by package name so that uninstalling a module takes these
+ -- rows with it, exactly as it takes its scope: a package name that comes back later is
+ -- a different install and inherits nothing it was never given.
+ mid integer NOT NULL,
+ user_id integer NOT NULL,
+ PRIMARY KEY (mid, user_id),
+ CONSTRAINT auto_include_module_constraint FOREIGN KEY (mid) REFERENCES modules (mid) ON DELETE CASCADE
+ );
+ """
+ .trimIndent())
+
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS configs (
@@ -196,5 +220,39 @@ class Database(context: Context? = FakeContext()) :
"ALTER TABLE modules ADD COLUMN auto_include BOOLEAN DEFAULT 0 CHECK (auto_include IN (0, 1));")
}
}
+ if (oldVersion < 5) {
+ db.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS auto_include (
+ mid integer NOT NULL,
+ user_id integer NOT NULL,
+ PRIMARY KEY (mid, user_id),
+ CONSTRAINT auto_include_module_constraint FOREIGN KEY (mid) REFERENCES modules (mid) ON DELETE CASCADE
+ );
+ """
+ .trimIndent())
+ // What the old column meant in effect was every user: the install handler read it without a
+ // user and added the new package for whichever user the install was in. So the rows that
+ // preserve a person's setting are one per user, and the users are taken from the
+ // configuration itself rather than from `IUserManager` -- a migration runs wherever the
+ // database is first opened, which is early and is not a place to need a system service, and
+ // one that silently found no users would read as "you never turned this on".
+ //
+ // `scope` is the only table that names users, so a device that has scoped something into a
+ // work profile is a device whose profile is kept here. User 0 is unioned in because it
+ // always exists and may well name nothing. A user that neither holds scope nor is 0 loses
+ // the setting, and a user created after the upgrade never had it: both are the new meaning
+ // of the switch rather than a gap in it, since nothing can say a person wanted this in a
+ // profile they have never configured.
+ db.execSQL(
+ """
+ INSERT OR IGNORE INTO auto_include (mid, user_id)
+ SELECT m.mid, u.user_id
+ FROM modules m
+ JOIN (SELECT DISTINCT user_id FROM scope UNION SELECT 0 AS user_id) u
+ WHERE m.auto_include = 1;
+ """
+ .trimIndent())
+ }
}
}
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 914317aaa8..2ec6c894ba 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
@@ -63,35 +63,50 @@ object ModuleDatabase {
return result
}
- fun getIncludeNewApps(packageName: String): Boolean {
+ /**
+ * Whether this copy of the module takes every newly installed app.
+ *
+ * Per user, because a module the owner and a work profile both hold is two copies with two
+ * scopes, and this question is about one of them. The column it replaces could hold only one
+ * answer for the whole device, so turning the switch on from a work profile's scope screen also
+ * turned it on for the owner -- and the next app installed anywhere was then added to both.
+ */
+ fun getIncludeNewApps(packageName: String, userId: Int): Boolean {
if (packageName == "lspd") return false
- var isIncludeNewApps = false
- dbHelper.readableDatabase
- .query(
- "modules",
- arrayOf("auto_include"),
- "module_pkg_name = ?",
- arrayOf(packageName),
- null,
- null,
- null)
- .use { cursor ->
- if (cursor.moveToFirst()) {
- isIncludeNewApps = cursor.getInt(0) == 1
- }
- }
- return isIncludeNewApps
+ return dbHelper.readableDatabase
+ .rawQuery(
+ """
+ SELECT 1 FROM auto_include a
+ JOIN modules m ON m.mid = a.mid
+ WHERE m.module_pkg_name = ? AND a.user_id = ?
+ """
+ .trimIndent(),
+ arrayOf(packageName, userId.toString()))
+ .use { it.moveToFirst() }
}
- fun modulesIncludingNewApps(): List {
+ /**
+ * The modules auto-including in [userId], which is the user an app has just been installed into.
+ *
+ * Asked per user rather than once for the device: a module that wants new apps in a work profile
+ * is not thereby asking for the owner's, and answering the device-wide question here is what put
+ * an app installed in one user into the scope of a module configured in another.
+ */
+ fun modulesIncludingNewApps(userId: Int): List {
val result = mutableListOf()
dbHelper.readableDatabase
- .query("modules", arrayOf("module_pkg_name"), "auto_include = 1", null, null, null, null)
+ .rawQuery(
+ """
+ SELECT m.module_pkg_name FROM auto_include a
+ JOIN modules m ON m.mid = a.mid
+ WHERE a.user_id = ?
+ """
+ .trimIndent(),
+ arrayOf(userId.toString()))
.use { cursor ->
- val idx = cursor.getColumnIndexOrThrow("module_pkg_name")
while (cursor.moveToNext()) {
- val pkgName = cursor.getString(idx)
+ val pkgName = cursor.getString(0)
if (pkgName != "lspd") result.add(pkgName)
}
}
@@ -409,20 +424,56 @@ object ModuleDatabase {
return res
}
- fun setIncludeNewApps(packageName: String, enabled: Boolean): Boolean {
+ /**
+ * Sets it for one user.
+ *
+ * Refuses a package no module row exists for, which is what the single `UPDATE` this replaces did
+ * by matching nothing: the manager shows the switch as unsaved on a false, and a row written
+ * against a module the daemon does not know would be a setting nothing could ever read back.
+ *
+ * On rather than off is `INSERT OR REPLACE` instead of `OR IGNORE`, so that setting a value the
+ * table already holds still answers true -- the AIDL promises that, and a switch that reported
+ * failure for being already correct would be worse than one that said nothing.
+ */
+ fun setIncludeNewApps(packageName: String, userId: Int, enabled: Boolean): Boolean {
if (packageName == "lspd") return false
- val values = ContentValues().apply { put("auto_include", if (enabled) 1 else 0) }
-
- val changed =
- dbHelper.writableDatabase.update(
- "modules", values, "module_pkg_name = ?", arrayOf(packageName)) > 0
+ val db = dbHelper.writableDatabase
+ val mid = moduleIdOf(db, packageName) ?: return false
- // If the auto_include flag changes, we should rebuild the scope cache
- if (changed) {
- ConfigCache.requestCacheUpdate()
+ if (enabled) {
+ val values =
+ ContentValues().apply {
+ put("mid", mid)
+ put("user_id", userId)
+ }
+ if (db.insertWithOnConflict(
+ "auto_include", null, values, SQLiteDatabase.CONFLICT_REPLACE) == -1L) {
+ Log.e(TAG, "Failed to store include-new-apps for $packageName in user $userId")
+ return false
+ }
+ } else {
+ db.delete("auto_include", "mid = ? AND user_id = ?", arrayOf(mid.toString(), userId.toString()))
}
- return changed
+ // As the column this replaces did. The switch changes no scope by itself -- it decides what a
+ // future install does -- so this is kept only because removing it is a separate question from
+ // whether the flag is per user.
+ ConfigCache.requestCacheUpdate()
+ return true
}
+
+ /**
+ * The `mid` of a module row, or null when there is none.
+ *
+ * `simpleQueryForLong` throws `SQLiteDoneException` on no row rather than answering, so the
+ * callers that have to tell an unknown package from a known one cannot use it bare.
+ */
+ private fun moduleIdOf(db: SQLiteDatabase, packageName: String): Long? =
+ runCatching {
+ db.compileStatement("SELECT mid FROM modules WHERE module_pkg_name = ?")
+ .apply { bindString(1, packageName) }
+ .simpleQueryForLong()
+ }
+ .getOrNull()
}
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 6e9fdd94a6..84fe10ef21 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
@@ -567,10 +567,11 @@ object ManagerService : IManagerService.Stub() {
override fun getDex2OatWrapperState() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) Dex2OatServer.compatibility else 0
- override fun setIncludeNewApps(packageName: String, enabled: Boolean) =
- ModuleDatabase.setIncludeNewApps(packageName, enabled)
+ override fun setIncludeNewApps(packageName: String, userId: Int, enabled: Boolean) =
+ ModuleDatabase.setIncludeNewApps(packageName, userId, enabled)
- override fun getIncludeNewApps(packageName: String) = ModuleDatabase.getIncludeNewApps(packageName)
+ override fun getIncludeNewApps(packageName: String, userId: Int) =
+ ModuleDatabase.getIncludeNewApps(packageName, userId)
override fun getRootImplementation() = RootImplementation.implementation
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 202b3dd632..de07c1b00e 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
@@ -303,11 +303,11 @@ class FakeManagerService(
real?.setStatusNotificationEnabled(enabled)
}
- override fun getIncludeNewApps(packageName: String?): Boolean =
- real?.getIncludeNewApps(packageName) ?: false
+ override fun getIncludeNewApps(packageName: String?, userId: Int): Boolean =
+ real?.getIncludeNewApps(packageName, userId) ?: false
- override fun setIncludeNewApps(packageName: String?, enable: Boolean): Boolean =
- real?.setIncludeNewApps(packageName, enable) ?: false
+ override fun setIncludeNewApps(packageName: String?, userId: Int, enable: Boolean): Boolean =
+ real?.setIncludeNewApps(packageName, userId, enable) ?: false
}
/**
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 e1f9f9e629..d556b074b5 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
@@ -343,19 +343,28 @@ class DaemonClient(private val serviceState: StateFlow) {
it.setForcedLauncherIcons(force)
}
- suspend fun getIncludeNewApps(packageName: String): Result = runIpc { it.getIncludeNewApps(packageName)
+ /**
+ * Whether this module's copy in [userId] takes newly installed apps.
+ *
+ * [userId] is the module's own user, the one whose scope screen is open — not a user whose
+ * apps are being asked about. The same module in another profile answers separately.
+ */
+ suspend fun getIncludeNewApps(packageName: String, userId: Int): Result = runIpc {
+ it.getIncludeNewApps(packageName, userId)
}
/**
* Returns whether the daemon stored it, which is not the same as whether the call arrived.
*
- * `ModuleDatabase.setIncludeNewApps` answers false when no row was updated — the package is
- * not a known module, or it is the framework itself — and the switch has to follow that answer
- * rather than assume the write landed.
+ * `ModuleDatabase.setIncludeNewApps` answers false when it has no module row to hang the
+ * setting on — the package is not a known module, or it is the framework itself — and the
+ * switch has to follow that answer rather than assume the write landed.
*/
- suspend fun setIncludeNewApps(packageName: String, enable: Boolean): Result = runIpc {
- it.setIncludeNewApps(packageName, enable)
- }
+ suspend fun setIncludeNewApps(
+ packageName: String,
+ userId: Int,
+ enable: Boolean
+ ): Result = runIpc { it.setIncludeNewApps(packageName, userId, enable) }
suspend fun getRootImplementation(): Result = runIpc { it.rootImplementation }
diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeScreen.kt
index d6b76db811..b77b8b5e2e 100644
--- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeScreen.kt
+++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeScreen.kt
@@ -360,6 +360,7 @@ fun ScopeScreen(
ScopeSelectMenu(
hasRecommended = !state.recommended.isEmpty,
includeNewApps = state.includeNewApps,
+ multipleUsers = state.multipleUsers,
onUseRecommended = viewModel::useRecommended,
onSelectAll = viewModel::selectAllVisible,
onSelectNone = viewModel::clearAllVisible,
@@ -597,6 +598,15 @@ fun ScopeScreen(
private fun ScopeSelectMenu(
hasRecommended: Boolean,
includeNewApps: Boolean,
+ /**
+ * Whether the device has more than one user, which is the only case where the switch needs to
+ * say whose apps it means.
+ *
+ * The setting is per user either way. On a single-user device naming the user would raise a
+ * distinction the reader cannot act on, so the shorter sentence stays there — the same
+ * arrangement the framework row above this uses.
+ */
+ multipleUsers: Boolean,
onUseRecommended: () -> Unit,
onSelectAll: () -> Unit,
onSelectNone: () -> Unit,
@@ -648,7 +658,11 @@ private fun ScopeSelectMenu(
// present, so its label says exactly that and not something narrower.
ToggleRow(
title = stringResource(R.string.scope_include_new_apps),
- subtitle = stringResource(R.string.scope_include_new_apps_summary),
+ subtitle =
+ stringResource(
+ if (multipleUsers) R.string.scope_include_new_apps_summary_user
+ else R.string.scope_include_new_apps_summary
+ ),
icon = Icons.AutoMirrored.Rounded.PlaylistAdd,
checked = includeNewApps,
onCheckedChange = onIncludeNewApps,
diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeViewModel.kt
index 441276bd29..02948ac581 100644
--- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeViewModel.kt
+++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeViewModel.kt
@@ -549,7 +549,9 @@ class ScopeViewModel(
info?.loadLabel(packageManager)?.toString() ?: modulePackageName,
isEnabled = modulePackageName in moduleRepository.enabledModulesState.value,
includeNewApps =
- daemonClient.getIncludeNewApps(modulePackageName).getOrDefault(false),
+ daemonClient
+ .getIncludeNewApps(modulePackageName, userId)
+ .getOrDefault(false),
recommended = recommended,
loading = false,
multipleUsers = userCount > 1,
@@ -729,7 +731,7 @@ class ScopeViewModel(
fun setIncludeNewApps(enabled: Boolean) {
viewModelScope.launch {
daemonClient
- .setIncludeNewApps(modulePackageName, enabled)
+ .setIncludeNewApps(modulePackageName, userId, enabled)
.onSuccess { stored ->
// The daemon's answer, not merely the fact that it answered: it refuses a
// package it holds no row for, and moving the switch on a refusal would show a
@@ -739,14 +741,15 @@ class ScopeViewModel(
} else {
logE(
"scope: daemon refused include-new-apps=$enabled for " +
- modulePackageName,
+ "$modulePackageName in user $userId",
)
_message.value = ScopeMessage.IncludeNewAppsFailed
}
}
.onFailure { e ->
logE(
- "scope: setting include-new-apps=$enabled for $modulePackageName failed",
+ "scope: setting include-new-apps=$enabled for $modulePackageName " +
+ "in user $userId failed",
e,
)
_message.value = ScopeMessage.IncludeNewAppsFailed
diff --git a/manager/src/main/res/values-ar/strings.xml b/manager/src/main/res/values-ar/strings.xml
index bbee7abe25..9fa35637cd 100644
--- a/manager/src/main/res/values-ar/strings.xml
+++ b/manager/src/main/res/values-ar/strings.xml
@@ -178,6 +178,7 @@
وحدات أخرى
إضافة التطبيقات الجديدة تلقائيًا
التطبيقات التي تُثبَّت من الآن تنضم إلى نطاق هذه الوحدة.
+ التطبيقات التي تُثبَّت لهذا المستخدم من الآن تنضم إلى نطاق هذه الوحدة. لكل مستخدم إعداده الخاص.
نسخ هذا النطاق احتياطيًا
استعادة هذا النطاق
حسب اسم الحزمة
diff --git a/manager/src/main/res/values-de/strings.xml b/manager/src/main/res/values-de/strings.xml
index 6533159862..ca9469d613 100644
--- a/manager/src/main/res/values-de/strings.xml
+++ b/manager/src/main/res/values-de/strings.xml
@@ -154,6 +154,7 @@
Andere Module
Neue Apps automatisch hinzufügen
Ab jetzt installierte Apps kommen in den Geltungsbereich dieses Moduls.
+ Ab jetzt für diesen Benutzer installierte Apps kommen in den Geltungsbereich dieses Moduls. Jeder Benutzer wird einzeln eingestellt.
Diesen Geltungsbereich sichern
Diesen Geltungsbereich wiederherstellen
Nach Paketname
diff --git a/manager/src/main/res/values-es/strings.xml b/manager/src/main/res/values-es/strings.xml
index e42754b9f6..aff9e863a7 100644
--- a/manager/src/main/res/values-es/strings.xml
+++ b/manager/src/main/res/values-es/strings.xml
@@ -154,6 +154,7 @@
Otros módulos
Añadir apps nuevas automáticamente
Las apps que se instalen a partir de ahora entrarán en el ámbito de este módulo.
+ Las apps que se instalen para este usuario a partir de ahora entrarán en el ámbito de este módulo. Cada usuario se configura por separado.
Guardar copia de este ámbito
Restaurar este ámbito
Por nombre de paquete
diff --git a/manager/src/main/res/values-fa/strings.xml b/manager/src/main/res/values-fa/strings.xml
index 86328e66c5..06c0624f8a 100644
--- a/manager/src/main/res/values-fa/strings.xml
+++ b/manager/src/main/res/values-fa/strings.xml
@@ -154,6 +154,7 @@
ماژولهای دیگر
افزودن خودکار برنامههای جدید
برنامههایی که از این پس نصب شوند به دامنهٔ این ماژول افزوده میشوند.
+ برنامههایی که از این پس برای این کاربر نصب شوند به دامنهٔ این ماژول افزوده میشوند. هر کاربر جداگانه تنظیم میشود.
پشتیبانگیری از این دامنه
بازگردانی این دامنه
بر پایهٔ نام بسته
diff --git a/manager/src/main/res/values-fr/strings.xml b/manager/src/main/res/values-fr/strings.xml
index 93b9cc5042..e554d73486 100644
--- a/manager/src/main/res/values-fr/strings.xml
+++ b/manager/src/main/res/values-fr/strings.xml
@@ -154,6 +154,7 @@
Autres modules
Ajouter automatiquement les nouvelles applis
Les applis installées à partir de maintenant rejoignent la portée de ce module.
+ Les applis installées pour cet utilisateur à partir de maintenant rejoignent la portée de ce module. Chaque utilisateur se règle séparément.
Sauvegarder cette portée
Restaurer cette portée
Par nom de paquet
diff --git a/manager/src/main/res/values-in/strings.xml b/manager/src/main/res/values-in/strings.xml
index cfab1fdb2d..a77806624a 100644
--- a/manager/src/main/res/values-in/strings.xml
+++ b/manager/src/main/res/values-in/strings.xml
@@ -152,6 +152,7 @@
Modul lain
Tambahkan aplikasi baru otomatis
Aplikasi yang dipasang mulai sekarang masuk ke cakupan modul ini.
+ Aplikasi yang dipasang untuk pengguna ini mulai sekarang masuk ke cakupan modul ini. Setiap pengguna diatur terpisah.
Cadangkan cakupan ini
Pulihkan cakupan ini
Menurut nama paket
diff --git a/manager/src/main/res/values-it/strings.xml b/manager/src/main/res/values-it/strings.xml
index 89a9c32ea2..1e44d58712 100644
--- a/manager/src/main/res/values-it/strings.xml
+++ b/manager/src/main/res/values-it/strings.xml
@@ -154,6 +154,7 @@
Altri moduli
Aggiungi automaticamente le nuove app
Le app installate d’ora in poi entrano nell’ambito di questo modulo.
+ Le app installate per questo utente d’ora in poi entrano nell’ambito di questo modulo. Ogni utente si imposta separatamente.
Salva una copia di questo ambito
Ripristina questo ambito
Per nome del pacchetto
diff --git a/manager/src/main/res/values-iw/strings.xml b/manager/src/main/res/values-iw/strings.xml
index d5168b089b..00d4a03e60 100644
--- a/manager/src/main/res/values-iw/strings.xml
+++ b/manager/src/main/res/values-iw/strings.xml
@@ -170,6 +170,7 @@
מודולים אחרים
הוספת אפליקציות חדשות אוטומטית
אפליקציות שיותקנו מעכשיו יצטרפו לטווח של המודול הזה.
+ אפליקציות שיותקנו למשתמש הזה מעכשיו יצטרפו לטווח של המודול הזה. כל משתמש מוגדר בנפרד.
גיבוי התחום הזה
שחזור התחום הזה
לפי שם החבילה
diff --git a/manager/src/main/res/values-ja/strings.xml b/manager/src/main/res/values-ja/strings.xml
index f0c101d446..f0b844148b 100644
--- a/manager/src/main/res/values-ja/strings.xml
+++ b/manager/src/main/res/values-ja/strings.xml
@@ -148,6 +148,7 @@
ほかのモジュール
新しいアプリを自動で追加
今後インストールされるアプリは、このモジュールの適用範囲に含まれます。
+ 今後このユーザーにインストールされるアプリは、このモジュールの適用範囲に含まれます。設定はユーザーごとに別です。
この適用範囲をバックアップ
この適用範囲を復元
パッケージ名順
diff --git a/manager/src/main/res/values-ko/strings.xml b/manager/src/main/res/values-ko/strings.xml
index c8c9efc77d..acb5519a9d 100644
--- a/manager/src/main/res/values-ko/strings.xml
+++ b/manager/src/main/res/values-ko/strings.xml
@@ -148,6 +148,7 @@
다른 모듈
새 앱 자동 추가
지금부터 설치되는 앱이 이 모듈의 범위에 포함됩니다.
+ 지금부터 이 사용자에 설치되는 앱이 이 모듈의 범위에 포함됩니다. 설정은 사용자별로 따로입니다.
이 적용 범위 백업
이 적용 범위 복원
패키지 이름순
diff --git a/manager/src/main/res/values-pl/strings.xml b/manager/src/main/res/values-pl/strings.xml
index 02fc1012a9..6217b3a51b 100644
--- a/manager/src/main/res/values-pl/strings.xml
+++ b/manager/src/main/res/values-pl/strings.xml
@@ -246,6 +246,7 @@
Inne moduły
Dodaj nowe aplikacje automatycznie
Aplikacje zainstalowane od teraz trafią do zakresu tego modułu.
+ Aplikacje zainstalowane od teraz dla tego użytkownika trafią do zakresu tego modułu. Każdy użytkownik ma własne ustawienie.
Utwórz kopię tego zakresu
Przywróć ten zakres
Według nazwy pakietu
diff --git a/manager/src/main/res/values-pt-rBR/strings.xml b/manager/src/main/res/values-pt-rBR/strings.xml
index c99656a40c..374552707b 100644
--- a/manager/src/main/res/values-pt-rBR/strings.xml
+++ b/manager/src/main/res/values-pt-rBR/strings.xml
@@ -154,6 +154,7 @@
Outros módulos
Adicionar novos apps automaticamente
Apps instalados de agora em diante entram no escopo deste módulo.
+ Apps instalados para este usuário de agora em diante entram no escopo deste módulo. Cada usuário é definido separadamente.
Fazer backup deste escopo
Restaurar este escopo
Por nome do pacote
diff --git a/manager/src/main/res/values-ru/strings.xml b/manager/src/main/res/values-ru/strings.xml
index 9f42cd79cc..9697759d38 100644
--- a/manager/src/main/res/values-ru/strings.xml
+++ b/manager/src/main/res/values-ru/strings.xml
@@ -153,6 +153,7 @@
Другие модули
Добавлять новые приложения автоматически
Приложения, установленные с этого момента, попадут в область действия модуля.
+ Приложения, установленные для этого пользователя с этого момента, попадут в область действия модуля. Для каждого пользователя задаётся отдельно.
Сохранить эту область
Восстановить эту область
По имени пакета
diff --git a/manager/src/main/res/values-tr/strings.xml b/manager/src/main/res/values-tr/strings.xml
index 15765ad482..6aafa3fb1a 100644
--- a/manager/src/main/res/values-tr/strings.xml
+++ b/manager/src/main/res/values-tr/strings.xml
@@ -154,6 +154,7 @@
Diğer modüller
Yeni uygulamaları otomatik ekle
Bundan sonra kurulan uygulamalar bu modülün kapsamına girer.
+ Bundan sonra bu kullanıcıya kurulan uygulamalar bu modülün kapsamına girer. Her kullanıcı ayrı ayarlanır.
Bu kapsamı yedekle
Bu kapsamı geri yükle
Paket adına göre
diff --git a/manager/src/main/res/values-uk/strings.xml b/manager/src/main/res/values-uk/strings.xml
index d2bffe3e23..00527b5d6e 100644
--- a/manager/src/main/res/values-uk/strings.xml
+++ b/manager/src/main/res/values-uk/strings.xml
@@ -166,6 +166,7 @@
Інші модулі
Додавати нові застосунки автоматично
Застосунки, встановлені відтепер, потраплять в область дії цього модуля.
+ Застосунки, встановлені відтепер для цього користувача, потраплять в область дії цього модуля. Для кожного користувача налаштовується окремо.
Зберегти цю область дії
Відновити цю область дії
За назвою пакета
diff --git a/manager/src/main/res/values-vi/strings.xml b/manager/src/main/res/values-vi/strings.xml
index e596037110..9c02dbcbde 100644
--- a/manager/src/main/res/values-vi/strings.xml
+++ b/manager/src/main/res/values-vi/strings.xml
@@ -148,6 +148,7 @@
Mô-đun khác
Tự động thêm ứng dụng mới
Ứng dụng cài từ nay sẽ vào phạm vi của mô-đun này.
+ Ứng dụng cài cho người dùng này từ nay sẽ vào phạm vi của mô-đun này. Mỗi người dùng được đặt riêng.
Sao lưu phạm vi này
Khôi phục phạm vi này
Theo tên gói
diff --git a/manager/src/main/res/values-zh-rCN/strings.xml b/manager/src/main/res/values-zh-rCN/strings.xml
index 9e95025969..5ddbe58b35 100644
--- a/manager/src/main/res/values-zh-rCN/strings.xml
+++ b/manager/src/main/res/values-zh-rCN/strings.xml
@@ -148,6 +148,7 @@
其他模块
自动加入新安装的应用
此后安装的应用会自动加入此模块的作用域。
+ 此后为该用户安装的应用会自动加入此模块的作用域。每个用户分别设置。
备份此作用域
恢复此作用域
按包名
diff --git a/manager/src/main/res/values-zh-rTW/strings.xml b/manager/src/main/res/values-zh-rTW/strings.xml
index a0e48b4c4f..9ebf28384e 100644
--- a/manager/src/main/res/values-zh-rTW/strings.xml
+++ b/manager/src/main/res/values-zh-rTW/strings.xml
@@ -148,6 +148,7 @@
其他模組
自動加入新安裝的應用程式
此後安裝的應用程式會自動加入此模組的作用域。
+ 此後為該使用者安裝的應用程式會自動加入此模組的作用域。每位使用者分別設定。
備份此範圍
還原此範圍
依套件名稱
diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml
index 69a48de6a1..17e8f2f04f 100644
--- a/manager/src/main/res/values/strings.xml
+++ b/manager/src/main/res/values/strings.xml
@@ -244,6 +244,7 @@
Other modules
Add new apps automatically
Apps installed from now on join this module\'s scope.
+ Apps installed into this user from now on join this module\'s scope. Each user is set separately.
Back up this scope
Restore this scope
By package name
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 21389a296b..03e7767819 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.
@@ -312,21 +312,32 @@ interface IManagerService {
boolean setModuleScope(String packageName, in List scope);
/**
- * Whether a module is given each newly installed app automatically.
+ * Whether this copy of a module is given each newly installed app automatically.
+ *
+ * Per user, and {@code userId} names which copy is being asked about - not which apps it
+ * would take. A module the owner and a work profile both hold is two copies with two scopes,
+ * so it is two answers here; a new app is offered only to the copies in the user it was
+ * installed into. This was one flag for the whole device until it was keyed by user, which
+ * meant turning it on from a work profile's scope screen turned it on for the owner, and the
+ * next app installed anywhere was added to both.
*
* @return false for a package the daemon holds no module row for, and for the framework's own
* pseudo-module - so a false here is not evidence that a module exists
*/
- boolean getIncludeNewApps(String packageName);
+ boolean getIncludeNewApps(String packageName, int userId);
/**
- * Sets that flag.
+ * Sets that flag for one user.
+ *
+ * Changes nothing about the other users' copies, and changes no scope that already exists:
+ * it decides what the next install does. Switching it off likewise leaves behind the rows it
+ * previously added, as {@link #getModuleScope} describes.
*
* @return whether the daemon stored it, which is not whether the call arrived: no row is
* written for a package that is not a known module, or for the framework's own
- * pseudo-module. Writing the value the row already held still answers true
+ * pseudo-module. Writing the value the table already held still answers true
*/
- boolean setIncludeNewApps(String packageName, boolean enable);
+ boolean setIncludeNewApps(String packageName, int userId, boolean enable);
/**
* Every module that is installed and switched on and that the framework still cannot load, with
From 0f05cec84903c9d0555ae1df21ab88b2c81e5fff Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Thu, 5 Nov 2026 17:41:52 +0100
Subject: [PATCH 06/20] Offer the install to an app, and ask the daemon who
holds it
The action was offered for a module only, and only for the users the
module list draws. Those are not the same set as the device's: a profile
holding no modules has no tab, which is exactly the profile that needs
this, and the tabs cannot speak for an app at all.
So it is asked rather than derived. `getPackageUsers` answers which users
hold one package, by the same `isPackageAvailable` probe that the package
enumeration filters each user's rows by -- so a user it reports and a user
whose apps the manager draws mean the same thing. One transaction per
user: the question is about a single package, and enumerating every
package of every user to answer it is what exhausts the daemon's binder
heap. The sheet subtracts that from `getUsers` rather than inverting it,
because a daemon that cannot reach the user service answers empty to
both, and an empty holder set inverted against a user list of our own
would offer every user on the device.
That drops the holder map the module list was passing down, and with it
the parameter and the two rows of plumbing that carried it.
An app earns the row for the opposite reason a module does -- a module is
put into a profile so it can be scoped there, an app so a module already
in that profile can reach it -- so the prompt and the subtitle each have
a second wording rather than calling a hook target a module.
---
.../vector/daemon/ipc/ManagerService.kt | 19 +++++
.../vector/manager/demo/FakeManagerService.kt | 3 +
.../matrix/vector/manager/ipc/DaemonClient.kt | 14 ++++
.../ui/components/PackageActionMenu.kt | 75 ++++++++++++-------
.../ui/screens/modules/ModulesScreen.kt | 28 +------
manager/src/main/res/values-ar/strings.xml | 2 +
manager/src/main/res/values-de/strings.xml | 2 +
manager/src/main/res/values-es/strings.xml | 2 +
manager/src/main/res/values-fa/strings.xml | 2 +
manager/src/main/res/values-fr/strings.xml | 2 +
manager/src/main/res/values-in/strings.xml | 2 +
manager/src/main/res/values-it/strings.xml | 2 +
manager/src/main/res/values-iw/strings.xml | 2 +
manager/src/main/res/values-ja/strings.xml | 2 +
manager/src/main/res/values-ko/strings.xml | 2 +
manager/src/main/res/values-pl/strings.xml | 2 +
.../src/main/res/values-pt-rBR/strings.xml | 2 +
manager/src/main/res/values-ru/strings.xml | 2 +
manager/src/main/res/values-tr/strings.xml | 2 +
manager/src/main/res/values-uk/strings.xml | 2 +
manager/src/main/res/values-vi/strings.xml | 2 +
.../src/main/res/values-zh-rCN/strings.xml | 2 +
.../src/main/res/values-zh-rTW/strings.xml | 2 +
manager/src/main/res/values/strings.xml | 2 +
.../matrix/vector/ipc/IManagerService.aidl | 18 +++++
25 files changed, 143 insertions(+), 52 deletions(-)
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 84fe10ef21..9f71c5e94e 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
@@ -467,6 +467,25 @@ object ManagerService : IManagerService.Stub() {
} ?: emptyList()
}
+ /**
+ * The users holding [packageName].
+ *
+ * `isPackageAvailable` with hidden packages allowed, which is exactly the predicate
+ * [getInstalledPackagesFromAllUsers] filters each user's rows by — so a user this reports and a
+ * user whose apps the manager draws mean the same thing by "holds". One transaction per user
+ * rather than an enumeration: the question is about one package, and enumerating every package of
+ * every user to answer it is what exhausts this process's binder heap.
+ *
+ * Empty when the user service cannot be reached, which is the same answer as "no user holds it".
+ * Callers subtract this from [getUsers], which is empty under that same failure, so the
+ * difference stays empty rather than becoming every user.
+ */
+ override fun getPackageUsers(packageName: String): IntArray {
+ val pm = packageManager ?: return IntArray(0)
+ val users = userManager?.getRealUsers() ?: return IntArray(0)
+ return users.filter { pm.isPackageAvailable(packageName, it.id, true) }.map { it.id }.toIntArray()
+ }
+
override fun isSystemServerAttached() = SystemServerService.systemServerRequested
override fun startActivityAsUser(intent: Intent, userId: Int, noUserSwitch: Boolean): Int {
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 de07c1b00e..3ce69df960 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
@@ -262,6 +262,9 @@ class FakeManagerService(
override fun getUsers(): MutableList = real?.users ?: mutableListOf()
+ override fun getPackageUsers(packageName: String?): IntArray =
+ real?.getPackageUsers(packageName) ?: IntArray(0)
+
override fun startActivityAsUser(intent: Intent?, userId: Int, noUserSwitch: Boolean): Int =
// -1, not 0: the AIDL documents 0..99 as "the activity started", so a benign-looking
// 0 would report a successful start with no daemon behind it.
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 d556b074b5..fd0a65c476 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
@@ -276,6 +276,20 @@ class DaemonClient(private val serviceState: StateFlow) {
suspend fun isSepolicyLoaded(): Result = runIpc { it.isSepolicyLoaded }
+ /**
+ * Which users hold [packageName].
+ *
+ * Answered by the daemon per user rather than read out of a package list here: the lists this
+ * manager caches are each filtered for their own purpose — the app list drops packages with no
+ * process, the module scan is only modules — so neither can be asked about an arbitrary
+ * package. Empty is also what a daemon that cannot reach the user service answers, which is
+ * why callers subtract this from [getUsers] rather than inverting it against a user list of
+ * their own.
+ */
+ suspend fun getPackageUsers(packageName: String): Result> = runIpc {
+ it.getPackageUsers(packageName).toList()
+ }
+
suspend fun getUsers(): Result> = runIpc { it.users
}
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 323587da82..cc5a46602d 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
@@ -115,15 +115,6 @@ fun PackageActionSheet(
* module and has no page. Null there rather than a row that leads nowhere.
*/
onOpenStore: ((String) -> Unit)? = null,
- /**
- * The users that already hold this package, when the caller knows.
- *
- * Null from the Scope screen, where the sheet is over a hook target rather than a module and
- * installing it elsewhere is not an act that screen is about. Given from the module list,
- * where it is the difference between offering a profile that needs this module and offering
- * one that already has it.
- */
- installedUserIds: Set? = null,
) {
// The framework is a scope target, not an app. It has no launcher entry, no settings page in
// Settings, and nothing ART could re-optimize, so those three rows would lead nowhere. What it
@@ -145,23 +136,41 @@ fun PackageActionSheet(
}
var confirmSoftReboot by remember { mutableStateOf(false) }
- // The users this module could still be put into. Asked once with the sheet, and only for a
- // module the caller told us about — the list is short and the call is cheap, but it is an IPC
- // and the Scope screen has no use for the answer.
- var installTargets by
- remember(packageName) { mutableStateOf>(emptyList()) }
+ // The users this package could still be put into: every user the device has, less the ones
+ // already holding it. Both halves come from the daemon, which is the only side that can see
+ // another profile at all — and which is asked about *this* package rather than handed a list
+ // to filter, because every package list the manager caches is filtered for its own purpose.
+ // The module list's own tabs would have answered for a module, but only for the users it
+ // draws: a profile holding no modules has no tab, and that is exactly the profile worth
+ // offering.
+ //
+ // Offered for an app as well as a module. A profile cannot be given a module it lacks from
+ // anywhere else in the product, and it cannot be given an app to hook either; the sheet is
+ // where a package's own actions already live.
+ var installTargets by remember(packageName) { mutableStateOf>(emptyList()) }
var choosingUser by remember { mutableStateOf(false) }
- // Bound to a local so the lambda below closes over a plain Set rather than relying on the
- // parameter still being non-null across the coroutine boundary.
- val holders = installedUserIds
- if (isModule && holders != null && !isSystemFramework) {
- LaunchedEffect(packageName, holders) {
- installTargets =
- ServiceLocator.daemon
+ // Not the framework: it is a scope target rather than an installed package, so there is no
+ // install state for another user to be missing.
+ if (!isSystemFramework) {
+ LaunchedEffect(packageName) {
+ val daemonService = ServiceLocator.daemon
+ val users =
+ daemonService
.getUsers()
.onFailure { e -> logW("actions: user list for $packageName failed", e) }
.getOrDefault(emptyList())
- .filter { it.id !in holders }
+ // Subtracted from the user list rather than inverted on its own, because the daemon
+ // answers empty both for "no user holds it" and for a user service it cannot reach.
+ // Under that failure the user list is empty too, so the difference is empty and the
+ // row stays away — where inverting an empty holder set against a user list of our own
+ // would offer every user on the device.
+ val holders =
+ daemonService
+ .getPackageUsers(packageName)
+ .onFailure { e -> logW("actions: holders of $packageName failed", e) }
+ .getOrDefault(emptyList())
+ .toSet()
+ installTargets = users.filter { it.id !in holders }
}
}
@@ -231,7 +240,12 @@ fun PackageActionSheet(
title = { Text(stringResource(R.string.action_install_other_user)) },
text = {
Column {
- Text(stringResource(R.string.action_install_other_user_prompt))
+ Text(
+ stringResource(
+ if (isModule) R.string.action_install_other_user_prompt
+ else R.string.action_install_other_user_prompt_app
+ )
+ )
Spacer(Modifier.height(8.dp))
installTargets.forEach { target ->
TextButton(
@@ -452,15 +466,22 @@ LocalizedOverlay {
}
}
- // Only when there is a user this module is not already in. A module every profile holds
- // would otherwise carry a row whose whole function is to report that there is nowhere to
- // send it, and on a single-user device — which is most of them — the row would never do
+ // Only when there is a user this package is not already in. One every profile holds would
+ // otherwise carry a row whose whole function is to report that there is nowhere to send
+ // it, and on a single-user device — which is most of them — the row would never do
// anything at all.
if (installTargets.isNotEmpty()) {
ActionDrawerItem(
icon = Icons.Rounded.PersonAdd,
title = stringResource(R.string.action_install_other_user),
- subtitle = stringResource(R.string.action_install_other_user_summary),
+ // A module is put into a profile so it can be scoped there; an app is put there so
+ // a module already in that profile can reach it. Same call, opposite ends of the
+ // same relation, so saying "this module" over a hook target would be wrong.
+ subtitle =
+ stringResource(
+ if (isModule) R.string.action_install_other_user_summary
+ else R.string.action_install_other_user_summary_app
+ ),
tint = colors.primary,
) {
choosingUser = true
diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt
index beed27deed..60aaf9acff 100644
--- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt
+++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt
@@ -157,24 +157,6 @@ fun ModulesScreen(
) {
val tabs by viewModel.userModulesTabs.collectAsStateWithLifecycle()
- // Which users already hold a given module, read off the tabs rather than asked of the daemon:
- // the tabs are built from the same per-user scan, so this costs nothing and cannot disagree
- // with what the list is showing. A user with no modules has no tab and therefore holds none,
- // which is the right answer for every package — and is exactly the user the install action
- // below exists to reach.
- //
- // Built once per tab set rather than computed per row. A lambda that walked the tabs on each
- // call ran once for every visible row on every recomposition, and handed each row a freshly
- // built set — equal to the last one but not the same object, which is enough to stop the row
- // skipping. One map, and every row gets back the identical instance it had before.
- val holders: Map> =
- remember(tabs) {
- buildMap> {
- tabs.forEach { tab ->
- tab.modules.forEach { getOrPut(it.packageName) { mutableSetOf() }.add(tab.user.id) }
- }
- }
- }
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val query by viewModel.query.collectAsStateWithLifecycle()
val filter by viewModel.filter.collectAsStateWithLifecycle()
@@ -435,7 +417,7 @@ fun ModulesScreen(
stickyHeader(key = "h:active") {
SectionHeader(stringResource(R.string.modules_section_active), active.size)
}
- moduleRows(active, facts, selection, upgradable, holders, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report)
+ moduleRows(active, facts, selection, upgradable, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report)
}
if (inactive.isNotEmpty()) {
stickyHeader(key = "h:inactive") {
@@ -444,10 +426,10 @@ fun ModulesScreen(
inactive.size,
)
}
- moduleRows(inactive, facts, selection, upgradable, holders, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report)
+ moduleRows(inactive, facts, selection, upgradable, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report)
}
} else {
- moduleRows(modules, facts, selection, upgradable, holders, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report)
+ moduleRows(modules, facts, selection, upgradable, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report)
}
}
}
@@ -932,7 +914,6 @@ private fun androidx.compose.foundation.lazy.LazyListScope.moduleRows(
facts: Map,
selection: Set,
upgradable: Set,
- holders: Map>,
onModuleClick: (String, Int) -> Unit,
onOpenStore: (String) -> Unit,
onSelect: (InstalledModule) -> Unit,
@@ -945,7 +926,6 @@ private fun androidx.compose.foundation.lazy.LazyListScope.moduleRows(
hasUpdate = module.packageName in upgradable,
selected = ModuleKey(module.packageName, module.userId) in selection,
selectionActive = selection.isNotEmpty(),
- holders = holders[module.packageName].orEmpty(),
onClick = { onModuleClick(module.packageName, module.userId) },
onOpenStore = { onOpenStore(module.packageName) },
onSelect = { onSelect(module) },
@@ -977,7 +957,6 @@ private fun ModuleListItem(
hasUpdate: Boolean,
selected: Boolean,
selectionActive: Boolean,
- holders: Set,
onClick: () -> Unit,
onOpenStore: () -> Unit,
onSelect: () -> Unit,
@@ -1013,7 +992,6 @@ private fun ModuleListItem(
appName = module.appName,
applicationInfo = module.applicationInfo,
isModule = true,
- installedUserIds = holders,
onDismiss = { menuOpen = false },
onResult = onAction,
onOpenStore = { onOpenStore() },
diff --git a/manager/src/main/res/values-ar/strings.xml b/manager/src/main/res/values-ar/strings.xml
index 9fa35637cd..e7c7fba71b 100644
--- a/manager/src/main/res/values-ar/strings.xml
+++ b/manager/src/main/res/values-ar/strings.xml
@@ -468,7 +468,9 @@
عدم السؤال مجددًا
تثبيت لمستخدم آخر
يضيف هذه الوحدة إلى ملف تعريف لا يملكها، ليتسنّى تحديد نطاقها هناك
+ يضيف هذا التطبيق إلى ملف تعريف لا يملكه، لتتمكن وحدة في ذلك الملف من الوصول إليه
اختر المستخدم الذي تريد تثبيت هذه الوحدة له.
+ اختر المستخدم الذي تريد تثبيت هذا التطبيق له.
تم التثبيت لـ %1$s.
تعذّر التثبيت لـ %1$s.
diff --git a/manager/src/main/res/values-de/strings.xml b/manager/src/main/res/values-de/strings.xml
index ca9469d613..d7123aa987 100644
--- a/manager/src/main/res/values-de/strings.xml
+++ b/manager/src/main/res/values-de/strings.xml
@@ -424,7 +424,9 @@
Nicht mehr fragen
Für anderen Nutzer installieren
Fügt dieses Modul einem Profil hinzu, das es noch nicht hat, damit es dort einen Geltungsbereich bekommen kann
+ Fügt diese App einem Profil hinzu, das sie nicht hat, damit ein Modul in jenem Profil sie erreichen kann
Wähle den Nutzer, für den dieses Modul installiert werden soll.
+ Wähle den Nutzer, für den diese App installiert werden soll.
Für %1$s installiert.
Konnte nicht für %1$s installiert werden.
diff --git a/manager/src/main/res/values-es/strings.xml b/manager/src/main/res/values-es/strings.xml
index aff9e863a7..66fcacf791 100644
--- a/manager/src/main/res/values-es/strings.xml
+++ b/manager/src/main/res/values-es/strings.xml
@@ -424,7 +424,9 @@
No volver a preguntar
Instalar para otro usuario
Añade este módulo a un perfil que no lo tiene, para poder darle un ámbito allí
+ Añade esta app a un perfil que no la tiene, para que un módulo de ese perfil pueda alcanzarla
Elige el usuario para el que instalar este módulo.
+ Elige el usuario para el que instalar esta app.
Instalado para %1$s.
No se pudo instalar para %1$s.
diff --git a/manager/src/main/res/values-fa/strings.xml b/manager/src/main/res/values-fa/strings.xml
index 06c0624f8a..f287db67c3 100644
--- a/manager/src/main/res/values-fa/strings.xml
+++ b/manager/src/main/res/values-fa/strings.xml
@@ -424,7 +424,9 @@
دیگر پرسیده نشود
نصب برای کاربر دیگر
این ماژول را به نمایهای که آن را ندارد اضافه میکند تا بتوان دامنهاش را آنجا تعیین کرد
+ این برنامه را به نمایهای که آن را ندارد اضافه میکند تا ماژولی در آن نمایه بتواند به آن برسد
کاربری را که میخواهید این ماژول برایش نصب شود انتخاب کنید.
+ کاربری را که میخواهید این برنامه برایش نصب شود انتخاب کنید.
برای %1$s نصب شد.
نصب برای %1$s ممکن نشد.
diff --git a/manager/src/main/res/values-fr/strings.xml b/manager/src/main/res/values-fr/strings.xml
index e554d73486..74a4e07977 100644
--- a/manager/src/main/res/values-fr/strings.xml
+++ b/manager/src/main/res/values-fr/strings.xml
@@ -424,7 +424,9 @@
Ne plus demander
Installer pour un autre utilisateur
Ajoute ce module à un profil qui ne l’a pas, afin de lui donner une portée là-bas
+ Ajoute cette appli à un profil qui ne l’a pas, afin qu’un module de ce profil puisse l’atteindre
Choisissez l’utilisateur pour lequel installer ce module.
+ Choisissez l’utilisateur pour lequel installer cette appli.
Installé pour %1$s.
Impossible d’installer pour %1$s.
diff --git a/manager/src/main/res/values-in/strings.xml b/manager/src/main/res/values-in/strings.xml
index a77806624a..0e10583461 100644
--- a/manager/src/main/res/values-in/strings.xml
+++ b/manager/src/main/res/values-in/strings.xml
@@ -417,7 +417,9 @@
Jangan tanya lagi
Pasang untuk pengguna lain
Menambahkan modul ini ke profil yang belum memilikinya, agar cakupannya bisa diatur di sana
+ Menambahkan aplikasi ini ke profil yang belum memilikinya, agar modul di profil itu bisa menjangkaunya
Pilih pengguna untuk memasang modul ini.
+ Pilih pengguna untuk memasang aplikasi ini.
Terpasang untuk %1$s.
Tidak dapat memasang untuk %1$s.
diff --git a/manager/src/main/res/values-it/strings.xml b/manager/src/main/res/values-it/strings.xml
index 1e44d58712..497758e999 100644
--- a/manager/src/main/res/values-it/strings.xml
+++ b/manager/src/main/res/values-it/strings.xml
@@ -424,7 +424,9 @@
Non chiedere più
Installa per un altro utente
Aggiunge questo modulo a un profilo che non lo ha, così da poterne definire l’ambito lì
+ Aggiunge questa app a un profilo che non la ha, così un modulo in quel profilo può raggiungerla
Scegli l’utente per cui installare questo modulo.
+ Scegli l’utente per cui installare questa app.
Installato per %1$s.
Impossibile installare per %1$s.
diff --git a/manager/src/main/res/values-iw/strings.xml b/manager/src/main/res/values-iw/strings.xml
index 00d4a03e60..4e28aad805 100644
--- a/manager/src/main/res/values-iw/strings.xml
+++ b/manager/src/main/res/values-iw/strings.xml
@@ -450,7 +450,9 @@
לא לשאול שוב
התקנה עבור משתמש אחר
מוסיף את המודול הזה לפרופיל שאין לו אותו, כדי שאפשר יהיה להגדיר לו טווח שם
+ מוסיף את האפליקציה הזאת לפרופיל שאין לו אותה, כדי שמודול באותו פרופיל יוכל להגיע אליה
בחר את המשתמש שעבורו להתקין את המודול הזה.
+ בחר את המשתמש שעבורו להתקין את האפליקציה הזאת.
הותקן עבור %1$s.
לא ניתן היה להתקין עבור %1$s.
diff --git a/manager/src/main/res/values-ja/strings.xml b/manager/src/main/res/values-ja/strings.xml
index f0b844148b..fadc36ec6f 100644
--- a/manager/src/main/res/values-ja/strings.xml
+++ b/manager/src/main/res/values-ja/strings.xml
@@ -403,7 +403,9 @@
今後表示しない
他のユーザーにインストール
このモジュールを持っていないプロファイルに追加し、そこでスコープを設定できるようにします
+ このアプリを、持っていないプロファイルに追加します。そのプロファイルのモジュールが対象にできるようになります
このモジュールをインストールするユーザーを選択してください。
+ このアプリをインストールするユーザーを選択してください。
%1$s にインストールしました。
%1$s にインストールできませんでした。
diff --git a/manager/src/main/res/values-ko/strings.xml b/manager/src/main/res/values-ko/strings.xml
index acb5519a9d..2c43a7f52a 100644
--- a/manager/src/main/res/values-ko/strings.xml
+++ b/manager/src/main/res/values-ko/strings.xml
@@ -413,7 +413,9 @@
다시 묻지 않기
다른 사용자에게 설치
이 모듈이 없는 프로필에 추가하여 거기서 범위를 지정할 수 있게 합니다
+ 이 앱을 가지고 있지 않은 프로필에 추가해, 그 프로필의 모듈이 접근할 수 있게 합니다
이 모듈을 설치할 사용자를 선택하세요.
+ 이 앱을 설치할 사용자를 선택하세요.
%1$s에 설치했습니다.
%1$s에 설치하지 못했습니다.
diff --git a/manager/src/main/res/values-pl/strings.xml b/manager/src/main/res/values-pl/strings.xml
index 6217b3a51b..53f86a0d20 100644
--- a/manager/src/main/res/values-pl/strings.xml
+++ b/manager/src/main/res/values-pl/strings.xml
@@ -486,7 +486,9 @@
Nie pytaj ponownie
Zainstaluj dla innego użytkownika
Dodaje ten moduł do profilu, który go nie ma, aby można było określić tam jego zakres
+ Dodaje tę aplikację do profilu, który jej nie ma, aby moduł w tym profilu mógł ją objąć
Wybierz użytkownika, dla którego zainstalować ten moduł.
+ Wybierz użytkownika, dla którego zainstalować tę aplikację.
Zainstalowano dla %1$s.
Nie udało się zainstalować dla %1$s.
diff --git a/manager/src/main/res/values-pt-rBR/strings.xml b/manager/src/main/res/values-pt-rBR/strings.xml
index 374552707b..ff943b3082 100644
--- a/manager/src/main/res/values-pt-rBR/strings.xml
+++ b/manager/src/main/res/values-pt-rBR/strings.xml
@@ -424,7 +424,9 @@
Não perguntar de novo
Instalar para outro usuário
Adiciona este módulo a um perfil que não o tem, para que possa ter escopo ali
+ Adiciona este app a um perfil que não o tem, para que um módulo nesse perfil possa alcançá-lo
Escolha o usuário para instalar este módulo.
+ Escolha o usuário para instalar este app.
Instalado para %1$s.
Não foi possível instalar para %1$s.
diff --git a/manager/src/main/res/values-ru/strings.xml b/manager/src/main/res/values-ru/strings.xml
index 9697759d38..b57b59fbab 100644
--- a/manager/src/main/res/values-ru/strings.xml
+++ b/manager/src/main/res/values-ru/strings.xml
@@ -429,7 +429,9 @@
Больше не спрашивать
Установить для другого пользователя
Добавляет модуль в профиль, где его нет, чтобы задать там его область действия
+ Добавляет это приложение в профиль, где его нет, чтобы модуль в том профиле мог до него дотянуться
Выберите пользователя, для которого установить модуль.
+ Выберите пользователя, для которого установить приложение.
Установлено для %1$s.
Не удалось установить для %1$s.
diff --git a/manager/src/main/res/values-tr/strings.xml b/manager/src/main/res/values-tr/strings.xml
index 6aafa3fb1a..26e88837a6 100644
--- a/manager/src/main/res/values-tr/strings.xml
+++ b/manager/src/main/res/values-tr/strings.xml
@@ -424,7 +424,9 @@
Bir daha sorma
Başka kullanıcı için yükle
Bu modülü, onda olmayan bir profile ekler; böylece orada kapsam verilebilir
+ Bu uygulamayı, ona sahip olmayan bir profile ekler; böylece o profildeki bir modül ona erişebilir
Bu modülün yükleneceği kullanıcıyı seçin.
+ Bu uygulamanın yükleneceği kullanıcıyı seçin.
%1$s için yüklendi.
%1$s için yüklenemedi.
diff --git a/manager/src/main/res/values-uk/strings.xml b/manager/src/main/res/values-uk/strings.xml
index 00527b5d6e..f00d4ea676 100644
--- a/manager/src/main/res/values-uk/strings.xml
+++ b/manager/src/main/res/values-uk/strings.xml
@@ -466,7 +466,9 @@
Більше не питати
Встановити для іншого користувача
Додає модуль до профілю, де його немає, щоб задати там його область дії
+ Додає цей застосунок до профілю, який його не має, щоб модуль у тому профілі міг до нього дістатися
Виберіть користувача, для якого встановити модуль.
+ Виберіть користувача, для якого встановити застосунок.
Встановлено для %1$s.
Не вдалося встановити для %1$s.
diff --git a/manager/src/main/res/values-vi/strings.xml b/manager/src/main/res/values-vi/strings.xml
index 9c02dbcbde..91d530a921 100644
--- a/manager/src/main/res/values-vi/strings.xml
+++ b/manager/src/main/res/values-vi/strings.xml
@@ -413,7 +413,9 @@
Đừng hỏi lại
Cài cho người dùng khác
Thêm mô-đun này vào hồ sơ chưa có nó, để có thể đặt phạm vi ở đó
+ Thêm ứng dụng này vào một hồ sơ chưa có nó, để mô-đun trong hồ sơ đó có thể nhắm tới nó
Chọn người dùng để cài mô-đun này.
+ Chọn người dùng để cài ứng dụng này.
Đã cài cho %1$s.
Không thể cài cho %1$s.
diff --git a/manager/src/main/res/values-zh-rCN/strings.xml b/manager/src/main/res/values-zh-rCN/strings.xml
index 5ddbe58b35..c121f928b2 100644
--- a/manager/src/main/res/values-zh-rCN/strings.xml
+++ b/manager/src/main/res/values-zh-rCN/strings.xml
@@ -414,7 +414,9 @@
不再询问
为其他用户安装
把此模块添加到尚未安装它的用户空间,以便在那里设置作用域
+ 将此应用添加到尚未拥有它的配置文件,使该配置文件中的模块可以作用于它
选择要为其安装此模块的用户。
+ 选择要为其安装此应用的用户。
已为 %1$s 安装。
无法为 %1$s 安装。
diff --git a/manager/src/main/res/values-zh-rTW/strings.xml b/manager/src/main/res/values-zh-rTW/strings.xml
index 9ebf28384e..d47c705979 100644
--- a/manager/src/main/res/values-zh-rTW/strings.xml
+++ b/manager/src/main/res/values-zh-rTW/strings.xml
@@ -414,7 +414,9 @@
不再詢問
為其他使用者安裝
把此模組加入尚未安裝它的使用者空間,以便在那裡設定作用域
+ 將此應用程式加入尚未擁有它的設定檔,使該設定檔中的模組可以作用於它
選擇要為其安裝此模組的使用者。
+ 選擇要為其安裝此應用程式的使用者。
已為 %1$s 安裝。
無法為 %1$s 安裝。
diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml
index 17e8f2f04f..b1c60a26d1 100644
--- a/manager/src/main/res/values/strings.xml
+++ b/manager/src/main/res/values/strings.xml
@@ -468,7 +468,9 @@
Don\'t ask again
Install for another user
Puts this module into a profile that does not have it, so it can be scoped there
+ Puts this app into a profile that does not have it, so a module in that profile can reach it
Choose the user to install this module for.
+ Choose the user to install this app for.
Installed for %1$s.
Could not install for %1$s.
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 03e7767819..7888490e64 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
@@ -546,6 +546,24 @@ interface IManagerService {
*/
List getUsers();
+ /**
+ * Which of those users hold this package, by id.
+ *
+ * Asked of the platform per user rather than derived from a package list, so it is cheap and
+ * it answers for a package no list the manager holds would carry: {@link
+ * #getInstalledPackagesFromAllUsers} filtered to apps with a process does not include a module
+ * that ships no components, and neither list says anything about a package the caller has not
+ * enumerated. The predicate is the one that call filters each user's rows by, so a user here
+ * and a user whose apps the manager draws are the same notion of "holds".
+ *
+ * Empty for a package no user holds, and empty when the user service cannot be reached -
+ * the two are not distinguished, and a caller that offers an action per user it is missing from
+ * would offer every user on a device whose daemon has just lost that service. Subtract it from
+ * {@link #getUsers}, which is empty under the same failure, and the difference is empty too.
+ *
+ */
+ int[] getPackageUsers(String packageName);
+
// ---- things done to the device on the manager's behalf ------------------------------------------
/**
From b42261e5fb69c008d59feb2f482321790d23f718 Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Thu, 5 Nov 2026 18:26:44 +0100
Subject: [PATCH 07/20] Install the manager without rebooting for it
Every root provider's install stages into /data/adb/modules_update and
leaves the next boot to promote the directory, while the daemon resolves
the manager APK beside its own -- in /data/adb/modules, the live one. So
a one-line change to a Compose file cost a reboot, and the manager that
came back was the one from before it.
`restartManager` puts the APK where the daemon will read it and
restarts the process holding it: three seconds, against a boot. The APK
is written *through* the live file rather than moved over it, so the
owner and the SELinux label the flash gave it survive -- a fresh file
carries the label of whatever made it, and the daemon only reasserts that
label at startup, which is the reboot being avoided. The relaunch is the
line module/action.sh uses, because the redirection in system_server
fires for an intent carrying the manager's LAUNCH_MANAGER category and
for nothing else; without it the host's own activity opens instead.
The redirection is also quoted for `su` rather than left to `adb shell`,
which joins its arguments and hands them to a shell running as `shell` --
which cannot create that file, and says so.
The manager only. A daemon, AIDL or native change still needs the full
install and a reboot, and nothing here checks for one: the handshake does
that at runtime and names the side that is behind.
---
zygisk/build.gradle.kts | 78 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 78 insertions(+)
diff --git a/zygisk/build.gradle.kts b/zygisk/build.gradle.kts
index a105a84c59..b3075666b8 100644
--- a/zygisk/build.gradle.kts
+++ b/zygisk/build.gradle.kts
@@ -32,6 +32,15 @@ val injectedPackageName = rootProject.extra["injectedPackageName"] as String
val injectedPackageUid = rootProject.extra["injectedPackageUid"] as Int
val defaultManagerPackageName = rootProject.extra["defaultManagerPackageName"] as String
+// Read rather than repeated: the install directory below is named by it, and a copy here that
+// drifted from module.prop would push the manager into a directory no daemon is running from.
+val moduleId =
+ file("$projectDir/module/module.prop")
+ .readLines()
+ .first { it.startsWith("id=") }
+ .removePrefix("id=")
+ .trim()
+
android {
namespace = "org.matrix.vector"
@@ -229,6 +238,75 @@ androidComponents {
}
}
+ // The manager alone, for the edit-build-look loop.
+ //
+ // Every root provider's install stages into /data/adb/modules_update and lets the next
+ // boot promote the directory, while the daemon resolves the manager APK beside its own -
+ // in /data/adb/modules, the live one. So through the tasks above, a one-line change to a
+ // Compose file costs a reboot. These three put the APK where the daemon will actually read
+ // it and restart the process holding it, which is the whole loop in a few seconds.
+ //
+ // Deliberately not solved by teaching the daemon to prefer modules_update. A staged update
+ // sits beside a daemon still running from the last boot, so serving a manager out of it
+ // pairs a new manager with an old daemon - the exact mismatch PROTOCOL_VERSION exists to
+ // refuse, and it would duly refuse it, having gained nothing.
+ //
+ // The manager only. A daemon, AIDL or native change still needs a full install and a
+ // reboot, and nothing here checks that: the handshake does, at runtime, and says which
+ // side is the old one.
+ val pushManagerTask =
+ tasks.register("pushManagerApk$variantCapped") {
+ group = "Zygisk Module Installation"
+ description = "Pushes the ${variant.name} manager APK to the device."
+ dependsOn(prepareModuleFilesTask)
+ commandLine(
+ "adb",
+ "push",
+ tempModuleDir.get().asFile.resolve("manager.apk"),
+ "/data/local/tmp/manager.apk",
+ )
+ }
+
+ val replaceManagerTask =
+ tasks.register("replaceManager$variantCapped") {
+ group = "Zygisk Module Installation"
+ description = "Swaps the live ${variant.name} manager APK for the pushed one."
+ dependsOn(pushManagerTask)
+ // Written *through* the existing file rather than moved over it, so the owner and
+ // the SELinux label the flash gave it survive. A fresh file would carry the label
+ // of whatever created it, and the daemon only reasserts that label at startup -
+ // which is the reboot this task exists to avoid.
+ // Quoted so that the redirection reaches the shell `su` starts. `adb shell`
+ // joins its arguments and hands them to a shell running as `shell`, which would
+ // otherwise be the process trying to create the file - and it cannot.
+ commandLine(
+ "adb",
+ "shell",
+ "su",
+ "-c",
+ "'cat /data/local/tmp/manager.apk > /data/adb/modules/$moduleId/manager.apk && " +
+ "rm -f /data/local/tmp/manager.apk'",
+ )
+ }
+
+ // Stopping the host is what unloads the old APK; the category is what brings the manager
+ // back rather than the host's own activity, since the redirection in system_server only
+ // fires for an intent carrying it. The same line module/action.sh uses.
+ tasks.register("restartManager$variantCapped") {
+ group = "Zygisk Module Installation"
+ description = "Replaces the live ${variant.name} manager APK and restarts it."
+ dependsOn(replaceManagerTask)
+ commandLine(
+ "adb",
+ "shell",
+ "su",
+ "-c",
+ "am force-stop $injectedPackageName; " +
+ "am start -c $defaultManagerPackageName.LAUNCH_MANAGER " +
+ "$injectedPackageName/.BugreportWarningActivity",
+ )
+ }
+
createInstallTasks("Magisk", "magisk --install-module")
createInstallTasks("Ksu", "ksud module install")
createInstallTasks("Apatch", "/data/adb/apd module install")
From 7fe30d4655146806ff5c282b15a8e3f83ad39900 Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Thu, 5 Nov 2026 18:26:44 +0100
Subject: [PATCH 08/20] Open the long-press sheet at the stop it settles on
It opened at full height and jumped down a third of a second later.
Material offers the PartiallyExpanded anchor only to a sheet taller than
half its container and places it at exactly half. Three rows here are
decided by a binder call -- the companion row by `findAppUi`, the install
row by `getUsers` and `getPackageUsers` -- so the sheet is first measured
without them and lands on the short side of that line. `show()` picks
PartiallyExpanded, loses the anchor a frame later and retargets to
Expanded; the rows then arrive mid-animation, the sheet crosses the line,
the anchor returns, and the correction waits for the animation to end and
arrives as a snap. On a 2400px screen: 1131px measured, 1483px once the
rows land, against a stop at 1200.
A floor at that stop takes the question away rather than racing it. The
anchor exists at the first measure whatever has answered by then, so the
target chosen when the sheet is shown is the one it keeps -- on any
screen, in any locale, however slow the daemon is that day. Dragging up
to Expanded is untouched. A sheet with little in it now opens at the same
height as every other rather than at its own, which is the point: where
it opens stops depending on what happens to be inside it.
---
.../ui/components/PackageActionMenu.kt | 36 +++++++++++++++++--
1 file changed, 33 insertions(+), 3 deletions(-)
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 cc5a46602d..13fc1a1bc9 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
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.toggleable
@@ -59,6 +60,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalWindowInfo
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.matrix.vector.ui.ActionDrawerItem
import org.matrix.vector.ui.store.ConfirmInstall
@@ -219,10 +222,33 @@ fun PackageActionSheet(
val colors = MaterialTheme.colorScheme
// Every value left enabled, rather than dropping PartiallyExpanded: that stop is the only
// 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.
+ // screen would open at full height and could not be made smaller.
val sheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden)
+ // The sheet is never shorter than the stop it is supposed to open at.
+ //
+ // Material offers the PartiallyExpanded anchor only to a sheet taller than half its container,
+ // and places it at exactly half. Three of the rows below are decided by a binder call -- the
+ // companion row by `findAppUi`, the install row by `getUsers` and `getPackageUsers` -- so the
+ // sheet is first measured without them, lands on the short side of that line, and is given no
+ // such anchor: `show()` picks PartiallyExpanded, loses it one frame later, and retargets to
+ // Expanded. The rows then arrive mid-animation, the sheet crosses the line, the anchor appears,
+ // and the correction is deferred until the animation ends and applied as a snap. Measured on a
+ // 2400px screen: 1131px without those rows, 1483 with, against a stop at 1200 -- it opened at
+ // full height and jumped down a third of a second later.
+ //
+ // A floor at the stop takes the question away rather than racing it. The anchor exists at the
+ // first measure whatever has answered by then, so the target chosen when the sheet is shown is
+ // the one it keeps, on any screen, in any locale, however slow the daemon is. Dragging up to
+ // Expanded is untouched, and a sheet with little in it now opens at the same height as every
+ // other rather than at its own -- which is the point: where it opens stops depending on what
+ // happens to be in it.
+ val minSheetHeight =
+ with(LocalDensity.current) {
+ // One pixel past half, because the anchor is offered to a sheet *taller* than half.
+ (LocalWindowInfo.current.containerSize.height / 2 + 1).toDp()
+ }
+
// `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.
fun finish(block: suspend () -> PackageActionResult) {
@@ -293,7 +319,11 @@ fun PackageActionSheet(
)
}
- ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ modifier = Modifier.heightIn(min = minSheetHeight),
+ sheetState = sheetState,
+ ) {
LocalizedOverlay {
Row(
From f10fcd68f707941168b5228ab9b5b63e024523d8 Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Thu, 5 Nov 2026 18:32:27 +0100
Subject: [PATCH 09/20] Show the user's own picture when offering to install
for them
The dialog listed users as text buttons, so a device with two profiles
called something close to one another offered two rows that differed by a
word. A profile is recognised by its picture first, and the platform
already holds one: `IUserManager.getUserIcon` opens the photo a user was
given, for a caller that can reach /data/system/users -- which the daemon
can and the manager cannot.
Reflectively, because the IUserManager this compiles against is a stub in
a plain Java library with no Android SDK on its classpath, so it can name
only the classes it declares itself and ParcelFileDescriptor is not one.
The descriptor rather than the bytes, since opening the file is the whole
of what the daemon has to do; the manager decodes it and drops it, rather
than holding a descriptor a composable would have to remember to close.
Most users have never been given a photo and answer null, so the fallback
is the ordinary case rather than the error case: a circle carrying the
first letter of the name, tinted from the user id so that two profiles
differ at a glance and one profile looks the same every time.
---
.../vector/daemon/ipc/ManagerService.kt | 17 +++
.../vector/manager/demo/FakeManagerService.kt | 3 +
.../matrix/vector/manager/ipc/DaemonClient.kt | 12 ++
.../ui/components/PackageActionMenu.kt | 123 +++++++++++++-----
.../matrix/vector/ipc/IManagerService.aidl | 10 ++
5 files changed, 134 insertions(+), 31 deletions(-)
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 9f71c5e94e..f67951e7f2 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
@@ -486,6 +486,23 @@ object ManagerService : IManagerService.Stub() {
return users.filter { pm.isPackageAvailable(packageName, it.id, true) }.map { it.id }.toIntArray()
}
+ /**
+ * The user's photo, opened for the manager to read. Null for a user that has none, which is most.
+ *
+ * Reflectively, because the `IUserManager` this compiles against is a stub in a plain Java
+ * library with no Android SDK on its classpath: it can only name classes it declares itself, and
+ * `ParcelFileDescriptor` is not one of them.
+ */
+ override fun getUserIcon(userId: Int): ParcelFileDescriptor? {
+ val manager = userManager ?: return null
+ return runCatching {
+ manager.javaClass
+ .getMethod("getUserIcon", Int::class.javaPrimitiveType)
+ .invoke(manager, userId) as? ParcelFileDescriptor
+ }
+ .getOrNull()
+ }
+
override fun isSystemServerAttached() = SystemServerService.systemServerRequested
override fun startActivityAsUser(intent: Intent, userId: Int, noUserSwitch: Boolean): Int {
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 3ce69df960..e41aaaeb7f 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
@@ -262,6 +262,9 @@ class FakeManagerService(
override fun getUsers(): MutableList = real?.users ?: mutableListOf()
+ override fun getUserIcon(userId: Int): android.os.ParcelFileDescriptor? =
+ real?.getUserIcon(userId)
+
override fun getPackageUsers(packageName: String?): IntArray =
real?.getPackageUsers(packageName) ?: IntArray(0)
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 fd0a65c476..0a543d118e 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
@@ -6,6 +6,8 @@ import kotlinx.coroutines.withContext
import org.matrix.vector.ipc.IFrameworkInstallReceiver
import android.content.Intent
import android.content.pm.ActivityInfo
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
import org.matrix.vector.ipc.IManagerService
import org.matrix.vector.manager.logE
import org.matrix.vector.manager.logW
@@ -290,6 +292,16 @@ class DaemonClient(private val serviceState: StateFlow) {
it.getPackageUsers(packageName).toList()
}
+ /**
+ * A user's photo, decoded, or null when it has none - which is most users.
+ *
+ * Decoded here rather than handed on as a descriptor, because a descriptor has to be closed
+ * and a composable that forgets leaks one per recomposition.
+ */
+ suspend fun getUserIcon(userId: Int): Result = runIpc { service ->
+ service.getUserIcon(userId)?.use { BitmapFactory.decodeFileDescriptor(it.fileDescriptor) }
+ }
+
suspend fun getUsers(): Result> = runIpc { it.users
}
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 13fc1a1bc9..877114bd72 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
@@ -9,6 +9,10 @@ import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -16,7 +20,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Launch
@@ -38,6 +44,11 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
@@ -256,6 +267,22 @@ fun PackageActionSheet(
scope.launch(Dispatchers.Main) { onResult(block()) }
}
+ fun install(target: DeviceUser, label: String) = finish {
+ val result = daemon.installExistingPackageAsUser(packageName, target.id)
+ val ok = result.getOrDefault(false)
+ // A device-policy refusal and a user that has gone away both come back as a plain false,
+ // which onFailure never sees.
+ if (!ok) {
+ logE("actions: install of $packageName for user ${target.id} failed", result.exceptionOrNull())
+ }
+ PackageActionResult(
+ if (ok) R.string.action_installed_other_user
+ else R.string.action_install_other_user_failed,
+ label,
+ tone = if (ok) SnackbarTone.Success else SnackbarTone.Failure,
+ )
+ }
+
// A list rather than a confirmation, because the question is *which* user and there is no
// sensible default to preselect: on a device with a work profile and a private space, either
// may be the one meant.
@@ -273,39 +300,23 @@ fun PackageActionSheet(
)
)
Spacer(Modifier.height(8.dp))
+ // A row rather than a text button: a profile is recognised by its picture
+ // before its name, and on a device where two of them are called something
+ // like "Work" and "Work profile" the picture is the only thing that tells
+ // them apart at a glance.
installTargets.forEach { target ->
- TextButton(
- onClick = {
- choosingUser = false
- finish {
- val result =
- daemon.installExistingPackageAsUser(packageName, target.id)
- val ok = result.getOrDefault(false)
- // A device-policy refusal and a user that has gone away both
- // come back as a plain false, which onFailure never sees.
- if (!ok) {
- logE(
- "actions: install of $packageName for user " +
- "${target.id} failed",
- result.exceptionOrNull(),
- )
- }
- PackageActionResult(
- if (ok) R.string.action_installed_other_user
- else R.string.action_install_other_user_failed,
- target.name ?: target.id.toString(),
- tone =
- if (ok) SnackbarTone.Success
- else SnackbarTone.Failure,
- )
- }
- },
- modifier = Modifier.fillMaxWidth(),
+ val label = target.name ?: target.id.toString()
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ Modifier.fillMaxWidth()
+ .clip(MaterialTheme.shapes.large)
+ .clickable { choosingUser = false; install(target, label) }
+ .padding(horizontal = 8.dp, vertical = 10.dp),
) {
- Text(
- text = target.name ?: target.id.toString(),
- modifier = Modifier.weight(1f),
- )
+ UserAvatar(userId = target.id, name = label)
+ Spacer(Modifier.width(16.dp))
+ Text(text = label, modifier = Modifier.weight(1f))
}
}
}
@@ -744,3 +755,53 @@ private fun ModuleUpdateSection(
)
}
}
+
+/**
+ * A user's photo, or a letter in a circle when it has none.
+ *
+ * Most users have never been given one, so the circle is the ordinary case rather than a failure.
+ * Its colour comes from the user id, so two profiles are told apart at a glance and the same
+ * profile looks the same every time it is offered.
+ */
+@Composable
+private fun UserAvatar(userId: Int, name: String) {
+ var photo by remember(userId) { mutableStateOf(null) }
+ LaunchedEffect(userId) {
+ photo =
+ ServiceLocator.daemon
+ .getUserIcon(userId)
+ .onFailure { e -> logW("actions: photo of user $userId unavailable", e) }
+ .getOrNull()
+ ?.asImageBitmap()
+ }
+
+ val shown = photo
+ if (shown != null) {
+ Image(
+ bitmap = shown,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.size(36.dp).clip(CircleShape),
+ )
+ return
+ }
+
+ val colors = MaterialTheme.colorScheme
+ val tones: List> =
+ listOf(
+ colors.primaryContainer to colors.onPrimaryContainer,
+ colors.tertiaryContainer to colors.onTertiaryContainer,
+ colors.secondaryContainer to colors.onSecondaryContainer,
+ )
+ val (fill, ink) = tones[userId.mod(tones.size)]
+ Box(
+ modifier = Modifier.size(36.dp).clip(CircleShape).background(fill),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = name.firstOrNull()?.uppercase() ?: "?",
+ style = MaterialTheme.typography.titleMedium,
+ color = ink,
+ )
+ }
+}
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 7888490e64..1fc3c7a07b 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
@@ -564,6 +564,16 @@ interface IManagerService {
*/
int[] getPackageUsers(String packageName);
+ /**
+ * The photo a user has, as a readable descriptor, or null when it has none.
+ *
+ * A descriptor rather than the bytes: the daemon opens a file the manager cannot reach, and
+ * handing over the open file is the whole of what it has to do. Most users have never been
+ * given a photo and answer null, which is the ordinary case and not a failure - the caller
+ * draws its own avatar for them.
+ */
+ @nullable ParcelFileDescriptor getUserIcon(int userId);
+
// ---- things done to the device on the manager's behalf ------------------------------------------
/**
From 70c6cc6b7cf64a9ae5bd0a007fe3165104c89f1f Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Thu, 5 Nov 2026 18:40:09 +0100
Subject: [PATCH 10/20] Let the reader put the sheet's actions in their own
order
The rows were emitted where they were written, so the order was the
order the file happened to declare them in. They are collected into a
list now and drawn in whichever order the reader has dragged them into,
each keeping its own condition: a row nobody can use is still absent
rather than present and inert.
Long press then drag, rather than handles behind an edit mode. A plain
drag anywhere on this sheet already means "move the sheet" and has to
keep working; long press is the one gesture these rows do not use, and
`detectDragGesturesAfterLongPress` consumes nothing until the press has
been held, so the two never contend for the same movement.
Heights are measured rather than assumed, because the rows are not the
same height -- one with a subtitle is half again as tall as one without,
and a swap decided on an average would jump two places one way and none
the other. The order is written on the swap rather than when the finger
lifts: the sheet takes the pointer back if the drag strays, which ends
the gesture as a cancel, and an order the reader watched move and then
lost is worse than one that never moved.
Stored as ids, per kind of package, so an action added or retired in a
later build leaves the rest where they are: one the stored order does not
name keeps its place in declaration order, and a name this build no
longer has is ignored.
---
.../data/repository/SettingsRepository.kt | 19 +++
.../ui/components/PackageActionMenu.kt | 140 +++++++++++++++++-
2 files changed, 152 insertions(+), 7 deletions(-)
diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt
index 86a99dce1d..ee39ad3d0e 100644
--- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt
+++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt
@@ -519,6 +519,25 @@ class SettingsRepository(context: Context) : StoreSettings, NetworkSettings {
_dohEnabled.value = enabled
}
+ /**
+ * The order the long-press sheet lists its actions in, per kind of package.
+ *
+ * Ids rather than indices, so an action added or retired in a later build does not shuffle the
+ * rest: one the stored order does not name keeps its place in declaration order, and a name the
+ * build no longer has is ignored. Empty until someone drags something, which is the usual case.
+ */
+ fun actionOrder(kind: String): List =
+ prefs.getString("action_order_$kind", "").orEmpty().split(',').filter { it.isNotEmpty() }
+
+ fun setActionOrder(kind: String, ids: List) {
+ prefs.edit().putString("action_order_$kind", ids.joinToString(",")).apply()
+ _actionOrderRevision.value++
+ }
+
+ /** Bumped on every write, so a sheet already open re-reads the order it is drawing from. */
+ private val _actionOrderRevision = MutableStateFlow(0)
+ val actionOrderRevision: StateFlow = _actionOrderRevision.asStateFlow()
+
private companion object {
/** The Winged Victory's patina. Kept as a literal so this file needs no UI imports. */
const val DEFAULT_SEED_COLOR = 0xFF6ABFCF.toInt()
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 877114bd72..3f2dd62ac5 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
@@ -1,5 +1,7 @@
package org.matrix.vector.manager.ui.components
+import kotlin.math.abs
+import kotlin.math.roundToInt
import org.matrix.vector.ui.AppIcon
import org.matrix.vector.ui.SnackbarTone
import org.matrix.vector.ui.SharedAlertDialog
@@ -13,13 +15,16 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
@@ -44,6 +49,10 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.zIndex
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
@@ -68,6 +77,8 @@ import androidx.compose.material.icons.rounded.CloudDownload
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.NotificationsOff
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
@@ -87,6 +98,23 @@ import org.matrix.vector.manager.ui.screens.modules.ScopeViewModel.Companion.SYS
import org.matrix.vector.manager.di.ServiceLocator
import org.matrix.vector.ui.theme.Mono
+/**
+ * The actions the long-press sheet can offer, named so an order can outlive them.
+ *
+ * Declaration order is the default order. A stored order names ids, so an action added or retired
+ * in a later build leaves the rest where they are: one nobody has dragged keeps its place here, and
+ * a name this build no longer has is ignored when the order is read back.
+ */
+private enum class ActionId {
+ Open,
+ AppInfo,
+ SoftReboot,
+ ForceStop,
+ Reoptimize,
+ InstallOtherUser,
+ Uninstall,
+}
+
/** What a long press did, and how it went. */
data class PackageActionResult(
val messageRes: Int,
@@ -372,11 +400,20 @@ LocalizedOverlay {
)
}
+ // Collected rather than emitted where they are written, so the order can be the
+ // reader's. Each entry keeps its own condition: a row nobody can use is still absent
+ // rather than present and inert.
+ val rows = remember { mutableListOf Unit>>() }
+ rows.clear()
+ fun row(id: ActionId, show: Boolean, content: @Composable () -> Unit) {
+ if (show) rows.add(id to content)
+ }
+
// A module is not an app you "open" — most have nothing to look at. What it may have is a
// companion: the screen its author wrote to configure it, which is what the Xposed settings
// category marks. Naming it that way is the difference between a control that looks
// pointless and one that says what it is for.
- if (!isSystemFramework && openable == true)
+ row(ActionId.Open, !isSystemFramework && openable == true) {
ActionDrawerItem(
icon = Icons.AutoMirrored.Rounded.Launch,
title =
@@ -404,7 +441,9 @@ LocalizedOverlay {
}
}
- if (!isSystemFramework)
+ }
+
+ row(ActionId.AppInfo, !isSystemFramework) {
ActionDrawerItem(icon = Icons.Rounded.Info, title = stringResource(R.string.action_app_info)) {
finish {
val intent =
@@ -432,13 +471,14 @@ LocalizedOverlay {
PackageActionResult(R.string.action_opened_info)
}
}
+ }
// Force-stopping the framework is a soft reboot, and calling it anything else would hide
// what the button does: the daemon restarts the primary zygote, so `system_server` and
// every app forked from it go down together. Named and explained accordingly, and
// confirmed first — this is the one action on this sheet that ends what the reader is
// doing everywhere else on the phone.
- if (isSystemFramework) {
+ row(ActionId.SoftReboot, isSystemFramework) {
ActionDrawerItem(
icon = Icons.Rounded.RestartAlt,
title = stringResource(R.string.action_soft_reboot),
@@ -447,7 +487,9 @@ LocalizedOverlay {
) {
confirmSoftReboot = true
}
- } else {
+ }
+
+ row(ActionId.ForceStop, !isSystemFramework) {
ActionDrawerItem(
icon = Icons.Rounded.Stop,
title = stringResource(R.string.action_force_stop),
@@ -474,7 +516,7 @@ LocalizedOverlay {
// Only for a hook target. Re-optimizing recompiles an app so that ART stops inlining the
// methods a module wants to hook — which is about the app being hooked, not about the
// module doing the hooking, so on a module it would be an expensive button for nothing.
- if (!isModule && !isSystemFramework) {
+ row(ActionId.Reoptimize, !isModule && !isSystemFramework) {
ActionDrawerItem(
icon = Icons.Rounded.Bolt,
title = stringResource(R.string.action_optimize),
@@ -511,7 +553,7 @@ LocalizedOverlay {
// otherwise carry a row whose whole function is to report that there is nowhere to send
// it, and on a single-user device — which is most of them — the row would never do
// anything at all.
- if (installTargets.isNotEmpty()) {
+ row(ActionId.InstallOtherUser, installTargets.isNotEmpty()) {
ActionDrawerItem(
icon = Icons.Rounded.PersonAdd,
title = stringResource(R.string.action_install_other_user),
@@ -529,7 +571,7 @@ LocalizedOverlay {
}
}
- if (isModule) {
+ row(ActionId.Uninstall, isModule) {
HorizontalDivider(Modifier.padding(horizontal = 24.dp, vertical = 4.dp))
ActionDrawerItem(
icon = Icons.Rounded.Delete,
@@ -556,11 +598,95 @@ LocalizedOverlay {
}
}
+ OrderedActions(kind = if (isModule) "module" else "app", rows = rows.toList())
+
Spacer(Modifier.height(24.dp))
}
}
}
+/**
+ * The collected rows, in the reader's order, each one draggable into another place.
+ *
+ * Long press then drag, rather than a handle behind an edit mode. A plain drag anywhere on this
+ * sheet already means "move the sheet", and that has to keep working; long press is the one
+ * gesture these rows do not use, and `detectDragGesturesAfterLongPress` only starts consuming
+ * once the press has been held, so the two never contend for the same movement.
+ *
+ * Heights are measured rather than assumed, because the rows are not the same height: one with a
+ * subtitle is half again as tall as one without, and a swap decided on an average would jump two
+ * places in one direction and none in the other.
+ */
+@Composable
+private fun ColumnScope.OrderedActions(kind: String, rows: List Unit>>) {
+ val settings = ServiceLocator.settings
+ val revision by settings.actionOrderRevision.collectAsStateWithLifecycle()
+ // Sorted by where the stored order names each id, with anything it does not name kept in
+ // declaration order behind what it does.
+ var order by
+ remember(rows.map { it.first }, revision) {
+ val stored = settings.actionOrder(kind)
+ mutableStateOf(
+ rows.map { it.first }.sortedBy { id ->
+ stored.indexOf(id.name).takeIf { it >= 0 } ?: (stored.size + id.ordinal)
+ }
+ )
+ }
+
+ val content = rows.toMap()
+ val heights = remember { mutableStateMapOf() }
+ var dragging by remember { mutableStateOf(null) }
+ var offset by remember { mutableFloatStateOf(0f) }
+
+ order.forEach { id ->
+ val draw = content[id] ?: return@forEach
+ Box(
+ modifier =
+ Modifier.zIndex(if (id == dragging) 1f else 0f)
+ .offset { IntOffset(0, if (id == dragging) offset.roundToInt() else 0) }
+ .onSizeChanged { heights[id] = it.height }
+ .pointerInput(id, order) {
+ detectDragGesturesAfterLongPress(
+ onDragStart = {
+ dragging = id
+ offset = 0f
+ },
+ onDragEnd = {
+ dragging = null
+ offset = 0f
+ },
+ onDragCancel = {
+ dragging = null
+ offset = 0f
+ },
+ ) { change, amount ->
+ change.consume()
+ offset += amount.y
+ val at = order.indexOf(id)
+ // One neighbour at a time: past half of the row being crossed, the two
+ // swap and that row's height comes off the offset, so the dragged row
+ // stays under the finger rather than drifting from it.
+ val step = if (offset > 0) 1 else -1
+ val next = order.getOrNull(at + step) ?: return@detectDragGesturesAfterLongPress
+ val span = heights[next] ?: return@detectDragGesturesAfterLongPress
+ if (abs(offset) > span / 2f) {
+ order = order.toMutableList().apply { add(at + step, removeAt(at)) }
+ offset -= step * span
+ // Stored on the swap rather than when the finger lifts. The sheet
+ // is itself draggable and takes the pointer back if the drag
+ // strays, which ends the gesture as a cancel -- and an order kept
+ // until an end that never comes is an order the reader watched
+ // move and then lost.
+ settings.setActionOrder(kind, order.map { it.name })
+ }
+ }
+ }
+ ) {
+ draw()
+ }
+ }
+}
+
/**
* One setting, in the same shape — the shared [ActionDrawerItem] disc layout — as the actions it
* sits among.
From 3753029d4ce94c16345ed4150b532a4e701c0b90 Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Thu, 5 Nov 2026 18:44:12 +0100
Subject: [PATCH 11/20] Say the same things in fewer words
The comments across this branch had grown to explain a mechanism twice
and then restate the conclusion: 442 of the lines it adds were comment,
some blocks running fifteen lines for a point made in three. Every one
that carried a reason nobody could recover from the code is still here --
why an empty user list is refused, why the sheet needs a floor, why the
order is written on the swap -- but said once.
No behaviour changes; the diff is comments and KDoc alone.
---
.../org/matrix/vector/daemon/VectorService.kt | 7 +-
.../org/matrix/vector/daemon/data/Database.kt | 44 +++-----
.../vector/daemon/data/ModuleDatabase.kt | 34 ++----
.../vector/daemon/ipc/ManagerService.kt | 44 +++-----
.../vector/daemon/system/SystemExtensions.kt | 35 ++----
.../manager/data/repository/AppRepository.kt | 33 ++----
.../matrix/vector/manager/ipc/DaemonClient.kt | 31 ++----
.../ui/components/PackageActionMenu.kt | 101 ++++++------------
.../ui/screens/modules/ModulesScreen.kt | 10 +-
.../ui/screens/modules/ModulesViewModel.kt | 34 +++---
.../manager/ui/screens/modules/ScopeScreen.kt | 9 +-
.../matrix/vector/ipc/IManagerService.aidl | 55 ++++------
zygisk/build.gradle.kts | 34 +++---
13 files changed, 151 insertions(+), 320 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 15b3705a3d..aa22bc6a19 100644
--- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt
+++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt
@@ -357,10 +357,9 @@ object VectorService : IVectorDaemon.Stub() {
!intent.getBooleanExtra(Intent.EXTRA_REPLACING, false) &&
moduleName != null) {
- // Only the modules auto-including in the user this app was installed into. Asked
- // without a user, this handed the new package to every auto-including module on the
- // device and then wrote it under the installing user, so an app installed in a work
- // profile landed in the scope of a module configured only for the owner.
+ // Only the modules auto-including in the user this app arrived in. Asked without
+ // one, an app installed in a work profile landed in the scope of a module configured
+ // only for the owner.
ModuleDatabase.modulesIncludingNewApps(userId).forEach { xposedModule ->
val scopeList = ModuleDatabase.getModuleScope(xposedModule) ?: mutableListOf()
diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/Database.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/Database.kt
index 77991806c8..ac84c7f770 100644
--- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/Database.kt
+++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/Database.kt
@@ -30,14 +30,10 @@ class Database(context: Context? = FakeContext()) :
module_pkg_name text NOT NULL UNIQUE,
apk_path text NOT NULL,
enabled BOOLEAN DEFAULT 0 CHECK (enabled IN (0, 1)),
- -- Dead since v5, and read by nothing. `include new apps` is per user now and lives
- -- in the table below, because this column cannot be: a module is one row keyed by
- -- package name, so one flag here is one answer for every user at once, and turning
- -- it on while looking at a work profile turned it on for the owner too.
- --
- -- Kept rather than dropped. SQLite gained DROP COLUMN in 3.35 and this project
- -- supports API 27, whose SQLite is older, so removing it means rebuilding the table
- -- for a column that costs one byte a module and is never selected.
+ -- Dead since v5, read by nothing: `include new apps` is per user now and lives in
+ -- the table below, which this column cannot be -- one row per package is one answer
+ -- for every user at once. Kept because SQLite gained DROP COLUMN in 3.35 and this
+ -- supports API 27, so removing it means rebuilding the table.
auto_include BOOLEAN DEFAULT 0 CHECK (auto_include IN (0, 1))
);
"""
@@ -74,17 +70,10 @@ class Database(context: Context? = FakeContext()) :
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS auto_include (
- -- One row per (module, user) that is auto-including, and the row's existence is the
- -- whole value: there is no `enabled` column to disagree with it.
- --
- -- Per user because the switch is read from a module's scope screen, and that screen is
- -- opened for one installed copy of the module. A module the owner and a work profile
- -- both hold is two scopes, so it is two answers to this question as well, and the
- -- single column this replaces could only ever give one.
- --
- -- Keyed by mid rather than by package name so that uninstalling a module takes these
- -- rows with it, exactly as it takes its scope: a package name that comes back later is
- -- a different install and inherits nothing it was never given.
+ -- One row per (module, user) that is auto-including; its existence is the whole
+ -- value, with no `enabled` column to disagree with it. Per user because a module two
+ -- profiles hold is two scopes, so it is two answers here too. Keyed by mid, so
+ -- uninstalling takes these rows with it exactly as it takes the module's scope.
mid integer NOT NULL,
user_id integer NOT NULL,
PRIMARY KEY (mid, user_id),
@@ -231,19 +220,10 @@ class Database(context: Context? = FakeContext()) :
);
"""
.trimIndent())
- // What the old column meant in effect was every user: the install handler read it without a
- // user and added the new package for whichever user the install was in. So the rows that
- // preserve a person's setting are one per user, and the users are taken from the
- // configuration itself rather than from `IUserManager` -- a migration runs wherever the
- // database is first opened, which is early and is not a place to need a system service, and
- // one that silently found no users would read as "you never turned this on".
- //
- // `scope` is the only table that names users, so a device that has scoped something into a
- // work profile is a device whose profile is kept here. User 0 is unioned in because it
- // always exists and may well name nothing. A user that neither holds scope nor is 0 loses
- // the setting, and a user created after the upgrade never had it: both are the new meaning
- // of the switch rather than a gap in it, since nothing can say a person wanted this in a
- // profile they have never configured.
+ // The old column meant every user in effect, so one row per user preserves it. The users
+ // come from `scope`, the only table naming any, unioned with user 0 -- not from
+ // `IUserManager`: a migration runs wherever the database is first opened, which is early,
+ // and one that silently found no users would read as a setting nobody ever made.
db.execSQL(
"""
INSERT OR IGNORE INTO auto_include (mid, user_id)
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 2ec6c894ba..569d37ca81 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
@@ -66,10 +66,8 @@ object ModuleDatabase {
/**
* Whether this copy of the module takes every newly installed app.
*
- * Per user, because a module the owner and a work profile both hold is two copies with two
- * scopes, and this question is about one of them. The column it replaces could hold only one
- * answer for the whole device, so turning the switch on from a work profile's scope screen also
- * turned it on for the owner -- and the next app installed anywhere was then added to both.
+ * Per user: a module two profiles hold is two copies with two scopes, and the column this
+ * replaces held one answer for the whole device.
*/
fun getIncludeNewApps(packageName: String, userId: Int): Boolean {
if (packageName == "lspd") return false
@@ -87,11 +85,8 @@ object ModuleDatabase {
}
/**
- * The modules auto-including in [userId], which is the user an app has just been installed into.
- *
- * Asked per user rather than once for the device: a module that wants new apps in a work profile
- * is not thereby asking for the owner's, and answering the device-wide question here is what put
- * an app installed in one user into the scope of a module configured in another.
+ * The modules auto-including in [userId], the user an app has just been installed into. A module
+ * wanting new apps in a work profile is not asking for the owner's.
*/
fun modulesIncludingNewApps(userId: Int): List {
val result = mutableListOf()
@@ -427,13 +422,9 @@ object ModuleDatabase {
/**
* Sets it for one user.
*
- * Refuses a package no module row exists for, which is what the single `UPDATE` this replaces did
- * by matching nothing: the manager shows the switch as unsaved on a false, and a row written
- * against a module the daemon does not know would be a setting nothing could ever read back.
- *
- * On rather than off is `INSERT OR REPLACE` instead of `OR IGNORE`, so that setting a value the
- * table already holds still answers true -- the AIDL promises that, and a switch that reported
- * failure for being already correct would be worse than one that said nothing.
+ * Refuses a package no module row exists for, as the `UPDATE` this replaces did by matching
+ * nothing. `INSERT OR REPLACE` rather than `OR IGNORE`, so writing a value the table already
+ * holds still answers true, which the AIDL promises.
*/
fun setIncludeNewApps(packageName: String, userId: Int, enabled: Boolean): Boolean {
if (packageName == "lspd") return false
@@ -456,18 +447,15 @@ object ModuleDatabase {
db.delete("auto_include", "mid = ? AND user_id = ?", arrayOf(mid.toString(), userId.toString()))
}
- // As the column this replaces did. The switch changes no scope by itself -- it decides what a
- // future install does -- so this is kept only because removing it is a separate question from
- // whether the flag is per user.
+ // As the column this replaces did. The switch changes no scope by itself, so dropping this
+ // is a separate question from whether the flag is per user.
ConfigCache.requestCacheUpdate()
return true
}
/**
- * The `mid` of a module row, or null when there is none.
- *
- * `simpleQueryForLong` throws `SQLiteDoneException` on no row rather than answering, so the
- * callers that have to tell an unknown package from a known one cannot use it bare.
+ * The `mid` of a module row, or null. `simpleQueryForLong` throws on no row, so a caller that
+ * must tell an unknown package from a known one cannot use it bare.
*/
private fun moduleIdOf(db: SQLiteDatabase, packageName: String): Long? =
runCatching {
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 f67951e7f2..dc7437b05d 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
@@ -62,11 +62,8 @@ object ManagerService : IManagerService.Stub() {
private const val UNINSTALL_TIMEOUT_SECONDS = 60L
/**
- * `PackageManager.INSTALL_SUCCEEDED`, which is hidden and so cannot be named from here.
- *
- * The one non-negative code the installer answers with; every failure is a negative
- * `INSTALL_FAILED_*`. Written out rather than tested as `>= 0` so that a future code that is
- * neither is treated as the refusal it would be.
+ * `PackageManager.INSTALL_SUCCEEDED`, which is hidden. Written out rather than tested as `>= 0`,
+ * so a future code that is neither success nor a negative `INSTALL_FAILED_*` reads as refusal.
*/
private const val INSTALL_SUCCEEDED = 1
@@ -326,14 +323,8 @@ object ManagerService : IManagerService.Stub() {
/**
* Puts a package an existing user already holds into another user.
*
- * The platform grew a fifth parameter in Android 10 — the permissions to allowlist — and the
- * daemon still runs on 8.1, so the call is split on the version the parameter arrived in.
- * Passing `null` for that list is what the shell's own `pm install-existing` passes: it means
- * "allowlist nothing extra", not "grant nothing", and the package keeps the grants its install
- * state already implies.
- *
- * `INSTALL_REASON_USER` rather than `UNKNOWN`, because a person tapped this in the module list.
- * The platform records the reason and shows it in `dumpsys package`.
+ * Android 10 grew a fifth parameter, the permissions to allowlist, and this runs on 8.1 too.
+ * `null` there is what `pm install-existing` passes: allowlist nothing extra, not grant nothing.
*/
override fun installExistingPackageAsUser(packageName: String, userId: Int): Boolean {
val pm = packageManager ?: return false
@@ -346,10 +337,8 @@ object ManagerService : IManagerService.Stub() {
pm.installExistingPackageAsUser(
packageName, userId, 0, PackageManager.INSTALL_REASON_USER)
}
- // The platform answers with an INSTALL_* code, where the single success is
- // INSTALL_SUCCEEDED (1) and every refusal is negative. Anything else is a refusal we
- // have no name for, and reporting it as success would leave the manager claiming a
- // module is in a profile that never received it.
+ // Reporting an unnamed code as success would leave the manager claiming a module is
+ // in a profile that never received it.
if (status != INSTALL_SUCCEEDED) {
Log.w(TAG, "install-existing of $packageName for user $userId answered $status")
}
@@ -468,17 +457,13 @@ object ManagerService : IManagerService.Stub() {
}
/**
- * The users holding [packageName].
- *
- * `isPackageAvailable` with hidden packages allowed, which is exactly the predicate
- * [getInstalledPackagesFromAllUsers] filters each user's rows by — so a user this reports and a
- * user whose apps the manager draws mean the same thing by "holds". One transaction per user
- * rather than an enumeration: the question is about one package, and enumerating every package of
- * every user to answer it is what exhausts this process's binder heap.
+ * The users holding [packageName], by the same `isPackageAvailable` predicate
+ * [getInstalledPackagesFromAllUsers] filters each user's rows by, so both mean one thing by
+ * "holds". One transaction per user: enumerating every package of every user to answer a
+ * question about one package is what exhausts this process's binder heap.
*
- * Empty when the user service cannot be reached, which is the same answer as "no user holds it".
- * Callers subtract this from [getUsers], which is empty under that same failure, so the
- * difference stays empty rather than becoming every user.
+ * Empty both for "no user holds it" and for a user service out of reach, which is why callers
+ * subtract it from [getUsers] rather than inverting it.
*/
override fun getPackageUsers(packageName: String): IntArray {
val pm = packageManager ?: return IntArray(0)
@@ -489,9 +474,8 @@ object ManagerService : IManagerService.Stub() {
/**
* The user's photo, opened for the manager to read. Null for a user that has none, which is most.
*
- * Reflectively, because the `IUserManager` this compiles against is a stub in a plain Java
- * library with no Android SDK on its classpath: it can only name classes it declares itself, and
- * `ParcelFileDescriptor` is not one of them.
+ * Reflectively: the `IUserManager` stub is a plain Java library with no Android SDK on its
+ * classpath, so it can name only the classes it declares and `ParcelFileDescriptor` is not one.
*/
override fun getUserIcon(userId: Int): ParcelFileDescriptor? {
val manager = userManager ?: return null
diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt
index cf1bf8cd29..65020bd383 100644
--- a/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt
+++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt
@@ -200,10 +200,8 @@ private val getInstalledPackagesMethod: Method? by lazy {
/**
* Reflectively calls getInstalledPackages and casts to ParceledListSlice.
*
- * Answers `null` when the query did not happen, which is not the same answer as an empty list and
- * must never be flattened into one: a user that holds no packages and a user the platform refused
- * to answer for look identical once both are `emptyList()`, and the caller builds the manager's
- * whole app list out of this.
+ * Null when the query did not happen, which an empty list would flatten into "this user holds
+ * nothing" — and the manager builds its whole app list out of this.
*/
private fun IPackageManager.getInstalledPackagesReflect(
flags: Any,
@@ -223,34 +221,21 @@ fun IPackageManager.getInstalledPackagesFromAllUsers(
filterNoProcess: Boolean
): List {
val result = mutableListOf()
- // The same refusal as the per-user term below, one level up, and for the same reason.
- // `getRealUsers` swallows a null binder and any failure of `IUserManager.getUsers` into an empty
- // list, so a union over no users is a list of no packages — and there is no device where that is
- // the truth, because user 0 always exists. Answering it would hand the manager an empty app and
- // module list and let it draw "No modules installed." over a device full of them, which is the
- // symptom this whole call is being fixed for. `ConfigCache.rebuild` already refuses an empty
- // user list for the same reason; this is the call the manager builds every list from.
+ // `getRealUsers` swallows a null binder and any failure into an empty list, and no device has
+ // no users, so an empty one is a question that could not be asked rather than an answer.
+ // `ConfigCache.rebuild` refuses it for the same reason.
val users = userManager?.getRealUsers().orEmpty()
if (users.isEmpty()) throw IllegalStateException("No users to enumerate packages for")
- // Loop-invariant: the reflective method is chosen once by the same version test, so hoisting
- // this only stops it being recomputed per user.
- // Passed as Any so the reflective invoke boxes it as Long or Int to match.
+ // Any, so the reflective invoke boxes it as Long or Int to match.
val flagParam: Any =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) flags.toLong() else flags
for (user in users) {
- // A user the platform would not answer for fails the whole call, as it did before the daemon
- // was rewritten in Kotlin: `PackageService.getInstalledPackagesFromAllUsers` was declared
- // `throws RemoteException` and called `getInstalledPackages` straight, so a dead transaction
- // came back to the manager as a failure. Catching it per user and carrying on turns that into
- // a list that is short by one whole profile, and nothing downstream can tell it from the truth
- // — the manager draws it as the device's apps, so a work profile whose query died is a work
- // profile the reader is told does not exist. The list is worth having only entire.
- //
- // This is reachable on an ordinary device: `getInstalledPackages` returns every package with
- // its metadata for each user in turn, and on a large one that repeatedly runs the binder
- // buffer out and answers DeadObjectException.
+ // A user the platform will not answer for fails the whole call, as it did before the Kotlin
+ // rewrite: carrying on leaves a list short by a whole profile that nothing downstream can
+ // tell from the truth. Reachable on an ordinary device, where enumerating every package per
+ // user runs the binder buffer out and answers DeadObjectException.
val infos =
getInstalledPackagesReflect(flagParam, user.id)
?: throw IllegalStateException("No package list for user ${user.id}")
diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt
index 6c89360913..b6dbc282cb 100644
--- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt
+++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt
@@ -93,27 +93,18 @@ class AppRepository(
* held only by a work profile is still seen, and it must *not* set `filterNoProcess`, because a
* module with no components of its own is still a module.
*
- * Shared because the module list is the heaviest caller of it and had nothing stopping it
- * running against itself. Every package event drops the caches and bumps the revision this
- * feeds, and that event arrives twice by design — once from the platform, once from the
- * daemon's re-broadcast — so a single install started two full enumerations, and a device whose
- * packages churn kept dozens alive at once. That is not a slow path, it is a broken one: each
- * enumeration pulls every package with its metadata, per user, as a chunked
- * `ParceledListSlice`, and the daemon's binder heap is a fixed megabyte. One report has 409
- * `binder_alloc_buf ... failed, no address space` against the daemon, with 687 buffers
- * outstanding and 577 KB free in blocks too small to hold a 197 KB reply — system_server's
- * answers failing with ENOSPC not because any one of them was too large, but because sixty
- * threads were asking at once.
+ * Shared because a package event arrives twice by design — once from the platform, once from
+ * the daemon's re-broadcast — so one install started two full enumerations and a churning
+ * device kept dozens alive. Each pulls every package with its metadata, per user, against a
+ * binder heap that is a fixed megabyte: one report has 409 `binder_alloc_buf ... failed, no
+ * address space` with 687 buffers outstanding.
*
* Answers a [Result] rather than a bare list: a failed enumeration is not a device with no
* packages, and the caller has to be able to tell. Only a success is cached.
*
- * [refresh] is for the caller who already knows the cache is stale — a pull on the Modules
- * panel, or a reload straight after an uninstall. Sharing the enumeration made the cache
- * authoritative for every reader, and those two readers are exactly the ones for whom it is
- * not: the cache is dropped only by a package broadcast, so a pull-to-refresh would otherwise
- * hand back the list it was pulled to replace, and a post-uninstall reload would redraw the
- * module it had just removed until the broadcast caught up.
+ * [refresh] is for the caller that already knows the cache is stale. Only a package
+ * broadcast drops it, so a pull would otherwise hand back the list it was pulled to replace,
+ * and a post-uninstall reload would redraw what it had just removed.
*/
suspend fun moduleScanPackages(refresh: Boolean = false): Result> {
if (!refresh) {
@@ -132,11 +123,9 @@ class AppRepository(
return Result.success(it)
}
}
- // A refresh joins a live enumeration rather than racing a second one against it.
- // One already in flight is at most a few hundred milliseconds old, and never
- // running two of these at once is the whole point of sharing them — the daemon's
- // binder heap is a fixed megabyte, and a pull is the one input a person can
- // repeat as fast as they can move their thumb.
+ // A refresh joins a live enumeration rather than racing a second one: never
+ // running two at once is the point of sharing them, and a pull is the one input a
+ // person can repeat as fast as they can move their thumb.
moduleScanInFlight?.takeIf { it.isActive }
?: scope.async(Dispatchers.IO) { fetchModuleScanPackages() }
.also { moduleScanInFlight = it }
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 0a543d118e..9000b99373 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
@@ -267,11 +267,8 @@ class DaemonClient(private val serviceState: StateFlow) {
}
/**
- * Installs a module another user already holds into [userId].
- *
- * No APK travels: the device keeps one copy per package name and this flips whether [userId]
- * has it. It is the only way the manager can put a module into a profile it does not run in,
- * and so the only way a profile with no modules ever gets its first one.
+ * Installs a package another user already holds into [userId]. No APK travels: the device
+ * keeps one copy per package name and this flips whether [userId] has it.
*/
suspend fun installExistingPackageAsUser(packageName: String, userId: Int): Result =
runIpc { it.installExistingPackageAsUser(packageName, userId) }
@@ -279,25 +276,15 @@ class DaemonClient(private val serviceState: StateFlow) {
suspend fun isSepolicyLoaded(): Result = runIpc { it.isSepolicyLoaded }
/**
- * Which users hold [packageName].
- *
- * Answered by the daemon per user rather than read out of a package list here: the lists this
- * manager caches are each filtered for their own purpose — the app list drops packages with no
- * process, the module scan is only modules — so neither can be asked about an arbitrary
- * package. Empty is also what a daemon that cannot reach the user service answers, which is
- * why callers subtract this from [getUsers] rather than inverting it against a user list of
- * their own.
+ * Which users hold [packageName]. Asked of the daemon rather than read from a cached list,
+ * since each of those is filtered for its own purpose. Empty also means a user service out of
+ * reach, which is why callers subtract it from [getUsers] rather than inverting it.
*/
suspend fun getPackageUsers(packageName: String): Result> = runIpc {
it.getPackageUsers(packageName).toList()
}
- /**
- * A user's photo, decoded, or null when it has none - which is most users.
- *
- * Decoded here rather than handed on as a descriptor, because a descriptor has to be closed
- * and a composable that forgets leaks one per recomposition.
- */
+ /** A user's photo, decoded, or null when it has none. Decoded here so no descriptor escapes. */
suspend fun getUserIcon(userId: Int): Result = runIpc { service ->
service.getUserIcon(userId)?.use { BitmapFactory.decodeFileDescriptor(it.fileDescriptor) }
}
@@ -370,10 +357,8 @@ class DaemonClient(private val serviceState: StateFlow) {
}
/**
- * Whether this module's copy in [userId] takes newly installed apps.
- *
- * [userId] is the module's own user, the one whose scope screen is open — not a user whose
- * apps are being asked about. The same module in another profile answers separately.
+ * Whether this module's copy in [userId] takes newly installed apps. [userId] is the module's
+ * own user, not a user whose apps are being asked about.
*/
suspend fun getIncludeNewApps(packageName: String, userId: Int): Result = runIpc {
it.getIncludeNewApps(packageName, userId)
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 3f2dd62ac5..3e1fdadeb6 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
@@ -178,17 +178,10 @@ fun PackageActionSheet(
}
var confirmSoftReboot by remember { mutableStateOf(false) }
- // The users this package could still be put into: every user the device has, less the ones
- // already holding it. Both halves come from the daemon, which is the only side that can see
- // another profile at all — and which is asked about *this* package rather than handed a list
- // to filter, because every package list the manager caches is filtered for its own purpose.
- // The module list's own tabs would have answered for a module, but only for the users it
- // draws: a profile holding no modules has no tab, and that is exactly the profile worth
- // offering.
- //
- // Offered for an app as well as a module. A profile cannot be given a module it lacks from
- // anywhere else in the product, and it cannot be given an app to hook either; the sheet is
- // where a package's own actions already live.
+ // Every user the device has, less the ones already holding this package. Asked of the
+ // daemon about *this* package rather than filtered from a cached list, since each of those is
+ // filtered for its own purpose — and the module list's tabs cover only users it draws, while
+ // a profile holding no modules has no tab and is exactly the one worth offering.
var installTargets by remember(packageName) { mutableStateOf>(emptyList()) }
var choosingUser by remember { mutableStateOf(false) }
// Not the framework: it is a scope target rather than an installed package, so there is no
@@ -201,11 +194,9 @@ fun PackageActionSheet(
.getUsers()
.onFailure { e -> logW("actions: user list for $packageName failed", e) }
.getOrDefault(emptyList())
- // Subtracted from the user list rather than inverted on its own, because the daemon
- // answers empty both for "no user holds it" and for a user service it cannot reach.
- // Under that failure the user list is empty too, so the difference is empty and the
- // row stays away — where inverting an empty holder set against a user list of our own
- // would offer every user on the device.
+ // Subtracted from the user list rather than inverted: the daemon answers empty
+ // both for "no user holds it" and for a user service out of reach, and under that
+ // failure the user list is empty too, so the difference stays empty.
val holders =
daemonService
.getPackageUsers(packageName)
@@ -264,24 +255,14 @@ fun PackageActionSheet(
// screen would open at full height and could not be made smaller.
val sheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden)
- // The sheet is never shorter than the stop it is supposed to open at.
- //
- // Material offers the PartiallyExpanded anchor only to a sheet taller than half its container,
- // and places it at exactly half. Three of the rows below are decided by a binder call -- the
- // companion row by `findAppUi`, the install row by `getUsers` and `getPackageUsers` -- so the
- // sheet is first measured without them, lands on the short side of that line, and is given no
- // such anchor: `show()` picks PartiallyExpanded, loses it one frame later, and retargets to
- // Expanded. The rows then arrive mid-animation, the sheet crosses the line, the anchor appears,
- // and the correction is deferred until the animation ends and applied as a snap. Measured on a
- // 2400px screen: 1131px without those rows, 1483 with, against a stop at 1200 -- it opened at
- // full height and jumped down a third of a second later.
+ // Never shorter than the stop it opens at.
//
- // A floor at the stop takes the question away rather than racing it. The anchor exists at the
- // first measure whatever has answered by then, so the target chosen when the sheet is shown is
- // the one it keeps, on any screen, in any locale, however slow the daemon is. Dragging up to
- // Expanded is untouched, and a sheet with little in it now opens at the same height as every
- // other rather than at its own -- which is the point: where it opens stops depending on what
- // happens to be in it.
+ // Material offers the PartiallyExpanded anchor only to a sheet taller than half its container
+ // and places it at exactly half. Three rows below wait on a binder call, so the sheet is first
+ // measured without them, lands short of that line and is given no anchor: it retargets to
+ // Expanded, the rows arrive mid-animation, and the correction lands as a snap. Measured at
+ // 1131px without them and 1483 with, against a stop at 1200. A floor at the stop makes the
+ // first target the one it keeps, whatever has answered by then. Dragging up is untouched.
val minSheetHeight =
with(LocalDensity.current) {
// One pixel past half, because the anchor is offered to a sheet *taller* than half.
@@ -329,9 +310,7 @@ fun PackageActionSheet(
)
Spacer(Modifier.height(8.dp))
// A row rather than a text button: a profile is recognised by its picture
- // before its name, and on a device where two of them are called something
- // like "Work" and "Work profile" the picture is the only thing that tells
- // them apart at a glance.
+ // before its name.
installTargets.forEach { target ->
val label = target.name ?: target.id.toString()
Row(
@@ -400,9 +379,8 @@ LocalizedOverlay {
)
}
- // Collected rather than emitted where they are written, so the order can be the
- // reader's. Each entry keeps its own condition: a row nobody can use is still absent
- // rather than present and inert.
+ // Collected rather than emitted where written, so the order can be the reader's. Each
+ // keeps its own condition: an unusable row is absent, not present and inert.
val rows = remember { mutableListOf Unit>>() }
rows.clear()
fun row(id: ActionId, show: Boolean, content: @Composable () -> Unit) {
@@ -549,17 +527,14 @@ LocalizedOverlay {
}
}
- // Only when there is a user this package is not already in. One every profile holds would
- // otherwise carry a row whose whole function is to report that there is nowhere to send
- // it, and on a single-user device — which is most of them — the row would never do
- // anything at all.
+ // Only when some user lacks it. Otherwise the row's whole function is to report that
+ // there is nowhere to send it, and on a single-user device it would never do anything.
row(ActionId.InstallOtherUser, installTargets.isNotEmpty()) {
ActionDrawerItem(
icon = Icons.Rounded.PersonAdd,
title = stringResource(R.string.action_install_other_user),
- // A module is put into a profile so it can be scoped there; an app is put there so
- // a module already in that profile can reach it. Same call, opposite ends of the
- // same relation, so saying "this module" over a hook target would be wrong.
+ // Opposite ends of the same relation: a module goes into a profile to be
+ // scoped there, an app so a module already there can reach it.
subtitle =
stringResource(
if (isModule) R.string.action_install_other_user_summary
@@ -606,16 +581,12 @@ LocalizedOverlay {
}
/**
- * The collected rows, in the reader's order, each one draggable into another place.
+ * The collected rows, in the reader's order, each draggable into another place.
*
- * Long press then drag, rather than a handle behind an edit mode. A plain drag anywhere on this
- * sheet already means "move the sheet", and that has to keep working; long press is the one
- * gesture these rows do not use, and `detectDragGesturesAfterLongPress` only starts consuming
- * once the press has been held, so the two never contend for the same movement.
- *
- * Heights are measured rather than assumed, because the rows are not the same height: one with a
- * subtitle is half again as tall as one without, and a swap decided on an average would jump two
- * places in one direction and none in the other.
+ * Long press then drag, not a handle behind an edit mode: a plain drag already means "move the
+ * sheet" and has to keep working, and `detectDragGesturesAfterLongPress` consumes nothing until
+ * the press is held, so the two never contend. Heights are measured because the rows differ —
+ * a swap decided on an average would jump two places one way and none the other.
*/
@Composable
private fun ColumnScope.OrderedActions(kind: String, rows: List Unit>>) {
@@ -663,20 +634,17 @@ private fun ColumnScope.OrderedActions(kind: String, rows: List 0) 1 else -1
val next = order.getOrNull(at + step) ?: return@detectDragGesturesAfterLongPress
val span = heights[next] ?: return@detectDragGesturesAfterLongPress
if (abs(offset) > span / 2f) {
order = order.toMutableList().apply { add(at + step, removeAt(at)) }
offset -= step * span
- // Stored on the swap rather than when the finger lifts. The sheet
- // is itself draggable and takes the pointer back if the drag
- // strays, which ends the gesture as a cancel -- and an order kept
- // until an end that never comes is an order the reader watched
- // move and then lost.
+ // On the swap, not when the finger lifts: the sheet takes the
+ // pointer back if the drag strays, ending the gesture as a cancel.
settings.setActionOrder(kind, order.map { it.name })
}
}
@@ -883,11 +851,8 @@ private fun ModuleUpdateSection(
}
/**
- * A user's photo, or a letter in a circle when it has none.
- *
- * Most users have never been given one, so the circle is the ordinary case rather than a failure.
- * Its colour comes from the user id, so two profiles are told apart at a glance and the same
- * profile looks the same every time it is offered.
+ * A user's photo, or a letter in a circle when it has none — which is most users, so the circle is
+ * the ordinary case. Its colour comes from the user id, so one profile looks the same every time.
*/
@Composable
private fun UserAvatar(userId: Int, name: String) {
diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt
index 60aaf9acff..eabdf349f1 100644
--- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt
+++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt
@@ -382,9 +382,8 @@ fun ModulesScreen(
// that catches it does not fire for every route in.
PullToRefreshBox(
isRefreshing = isLoading,
- // Forced past the shared cache: that cache is dropped only by a package
- // broadcast, and a pull is precisely the gesture for the change no broadcast
- // brought — one made while the daemon was down, or on a route in that missed it.
+ // Past the shared cache, which only a package broadcast drops: a pull is the
+ // gesture for the change no broadcast brought.
onRefresh = { viewModel.loadModules(refresh = true) },
) {
val modules = tabs[page].modules
@@ -878,9 +877,8 @@ private fun EmptyState(daemonAvailable: Boolean, scanFailed: Boolean, filtered:
stringResource(
when {
!daemonAvailable -> R.string.modules_no_daemon
- // Before the filter, because a narrowed list over a scan that never
- // happened is still a scan that never happened, and "no modules match"
- // would invite the reader to widen the filter and find nothing.
+ // Before the filter: "no modules match" would invite the reader to
+ // widen it and find nothing.
scanFailed -> R.string.modules_scan_failed
filtered -> R.string.modules_no_match
else -> R.string.modules_empty
diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt
index 81d12be8d4..cc5ba2330c 100644
--- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt
+++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt
@@ -121,12 +121,9 @@ class ModulesViewModel(
/**
* Whether the last scan failed to read the installed packages at all.
*
- * Separate from [daemonAvailable], which is set from the user list: the daemon can answer that
- * and still fail to enumerate packages, because enumerating is the expensive half and the half
- * that runs its binder heap out. Both of those come back as no modules, and "no modules
- * installed" is a claim about the device that neither of them supports. Without this the panel
- * reported a failed enumeration as an empty one, which is the same mistake one level up that
- * the daemon stopped making per user.
+ * Separate from [daemonAvailable], which comes from the user list: the daemon can answer that
+ * and still fail to enumerate, since enumerating is the half that runs its binder heap out.
+ * Both arrive as no modules, and "no modules installed" is a claim neither supports.
*/
private val _scanFailed = MutableStateFlow(false)
val scanFailed: StateFlow = _scanFailed.asStateFlow()
@@ -398,12 +395,8 @@ class ModulesViewModel(
}
/**
- * Rediscovers the installed modules.
- *
- * [refresh] goes through to the shared enumeration and says "do not answer me from the cache".
- * Set by a pull and by the reload after an uninstall, which are the two callers who know the
- * cached list is behind the device; everything else arrives either on a package broadcast,
- * which has already dropped the cache, or on first open, where there is nothing to be behind.
+ * Rediscovers the installed modules. [refresh] says "not from the cache", and is set by a pull
+ * and by the reload after an uninstall — the two callers that know the cache is behind.
*/
fun loadModules(refresh: Boolean = false) {
viewModelScope.launch {
@@ -505,12 +498,10 @@ class ModulesViewModel(
_daemonAvailable.value = usersResult.isSuccess
val users = usersResult.getOrNull() ?: emptyList()
- // Through the repository rather than straight to the daemon, so that concurrent rescans
- // join one enumeration instead of each starting their own. This collector is driven by a
- // package event that is delivered twice on purpose, and the daemon's binder heap is a
- // fixed megabyte that a handful of these in flight together will exhaust — at which point
- // it stops being a performance question and becomes a wrong answer, because a per-user
- // query that fails takes that whole profile out of the list.
+ // Through the repository, so concurrent rescans join one enumeration. The package
+ // event driving this is delivered twice on purpose, and a handful of enumerations at once
+ // exhausts the daemon's binder heap — at which point a failed per-user query takes a whole
+ // profile out of the list.
val scan = ServiceLocator.apps.moduleScanPackages(refresh)
scan.onFailure { e -> logE("modules: installed package list unavailable", e) }
_scanFailed.value = scan.isFailure
@@ -551,10 +542,9 @@ class ModulesViewModel(
)
}
- // Not on a failed scan. [ModuleDetectionCache.flush] retains only the keys whose package
- // is in the set it is handed, so flushing an empty set while anything else in the process
- // has marked the cache dirty writes an empty cache to disk — and the next scan that does
- // succeed then reopens every APK on the device instead of reading a line each.
+ // Not on a failed scan: `flush` keeps only the keys whose package is in the set handed
+ // to it, so flushing the empty set writes an empty cache and makes the next good scan
+ // reopen every APK on the device.
if (scan.isSuccess) detection.flush(packages.mapTo(mutableSetOf()) { it.packageName })
// The one number that explains a slow Modules panel: how many APKs this scan had to open.
// Everything else is a map lookup, so a large figure here on a second run means the cache
diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeScreen.kt
index b77b8b5e2e..99bbaaf40d 100644
--- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeScreen.kt
+++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeScreen.kt
@@ -599,12 +599,9 @@ private fun ScopeSelectMenu(
hasRecommended: Boolean,
includeNewApps: Boolean,
/**
- * Whether the device has more than one user, which is the only case where the switch needs to
- * say whose apps it means.
- *
- * The setting is per user either way. On a single-user device naming the user would raise a
- * distinction the reader cannot act on, so the shorter sentence stays there — the same
- * arrangement the framework row above this uses.
+ * Whether the device has more than one user — the only case where the switch needs to say
+ * whose apps it means. On a single-user device that distinction is one the reader cannot act
+ * on, so the shorter sentence stays, as the framework row above does.
*/
multipleUsers: Boolean,
onUseRecommended: () -> Unit,
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 1fc3c7a07b..71a33712c3 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
@@ -314,12 +314,10 @@ interface IManagerService {
/**
* Whether this copy of a module is given each newly installed app automatically.
*
- * Per user, and {@code userId} names which copy is being asked about - not which apps it
- * would take. A module the owner and a work profile both hold is two copies with two scopes,
- * so it is two answers here; a new app is offered only to the copies in the user it was
- * installed into. This was one flag for the whole device until it was keyed by user, which
- * meant turning it on from a work profile's scope screen turned it on for the owner, and the
- * next app installed anywhere was added to both.
+ * {@code userId} names which copy is being asked about, not which apps it would take. A
+ * module two profiles hold is two copies with two scopes, so it is two answers here, and a new
+ * app is offered only to the copies in the user it arrived in. One flag for the whole device
+ * until it was keyed by user.
*
* @return false for a package the daemon holds no module row for, and for the framework's own
* pseudo-module - so a false here is not evidence that a module exists
@@ -329,9 +327,8 @@ interface IManagerService {
/**
* Sets that flag for one user.
*
- * Changes nothing about the other users' copies, and changes no scope that already exists:
- * it decides what the next install does. Switching it off likewise leaves behind the rows it
- * previously added, as {@link #getModuleScope} describes.
+ * Changes no other user's copy and no scope that already exists: it decides what the next
+ * install does, and switching it off leaves behind the rows it added.
*
* @return whether the daemon stored it, which is not whether the call arrived: no row is
* written for a package that is not a known module, or for the framework's own
@@ -549,28 +546,19 @@ interface IManagerService {
/**
* Which of those users hold this package, by id.
*
- * Asked of the platform per user rather than derived from a package list, so it is cheap and
- * it answers for a package no list the manager holds would carry: {@link
- * #getInstalledPackagesFromAllUsers} filtered to apps with a process does not include a module
- * that ships no components, and neither list says anything about a package the caller has not
- * enumerated. The predicate is the one that call filters each user's rows by, so a user here
- * and a user whose apps the manager draws are the same notion of "holds".
+ * Asked per user rather than derived from a package list, so it answers for a package no
+ * list the manager caches would carry - each is filtered for its own purpose. The predicate is
+ * the one {@link #getInstalledPackagesFromAllUsers} filters each user's rows by.
*
- * Empty for a package no user holds, and empty when the user service cannot be reached -
- * the two are not distinguished, and a caller that offers an action per user it is missing from
- * would offer every user on a device whose daemon has just lost that service. Subtract it from
- * {@link #getUsers}, which is empty under the same failure, and the difference is empty too.
- *
+ * Empty both for a package no user holds and for a user service out of reach. Subtract it
+ * from {@link #getUsers}, which is empty under the same failure, and the difference is too.
*/
int[] getPackageUsers(String packageName);
/**
- * The photo a user has, as a readable descriptor, or null when it has none.
- *
- * A descriptor rather than the bytes: the daemon opens a file the manager cannot reach, and
- * handing over the open file is the whole of what it has to do. Most users have never been
- * given a photo and answer null, which is the ordinary case and not a failure - the caller
- * draws its own avatar for them.
+ * The photo a user has, as a readable descriptor, or null when it has none - which is most
+ * users, and the ordinary case rather than a failure. A descriptor rather than the bytes,
+ * since opening a file the manager cannot reach is the whole of the daemon's part.
*/
@nullable ParcelFileDescriptor getUserIcon(int userId);
@@ -636,17 +624,10 @@ interface IManagerService {
/**
* Installs a package another user already holds into {@code userId}, without an APK.
*
- * Android keeps one APK per package name for the whole device and varies only who has it
- * installed, so putting a module into a work profile is not a copy - it is the platform's own
- * {@code installExistingPackageAsUser}, which flips the install state for that user and
- * nothing else. The module keeps its version, its signature and its uid derivation; only the
- * user's set of installed packages changes.
- *
- * This is what the module list needs to be able to offer at all. A module reaches a
- * profile's apps only from inside that profile, so a module the owner holds and the profile
- * does not can never be scoped there - and the manager runs in one user, so without this call
- * there is nothing in the product that can put it in the other. A profile with no modules is
- * then a profile that can never get one.
+ * Android keeps one APK per package name and varies only who has it installed, so this is
+ * no copy: the package keeps its version, signature and uid derivation, and only the user's
+ * set of installed packages changes. Without it nothing in the product can put a module into
+ * a profile that lacks one, and a module reaches a profile's apps only from inside it.
*
* @return whether the platform reported the install. A user that does not exist, a package no
* user holds, and a device-policy refusal all come back false
diff --git a/zygisk/build.gradle.kts b/zygisk/build.gradle.kts
index b3075666b8..e6763c9b48 100644
--- a/zygisk/build.gradle.kts
+++ b/zygisk/build.gradle.kts
@@ -241,19 +241,13 @@ androidComponents {
// The manager alone, for the edit-build-look loop.
//
// Every root provider's install stages into /data/adb/modules_update and lets the next
- // boot promote the directory, while the daemon resolves the manager APK beside its own -
- // in /data/adb/modules, the live one. So through the tasks above, a one-line change to a
- // Compose file costs a reboot. These three put the APK where the daemon will actually read
- // it and restart the process holding it, which is the whole loop in a few seconds.
+ // boot promote it, while the daemon resolves the manager APK beside its own - in
+ // /data/adb/modules, the live one. So a one-line Compose change costs a reboot. These
+ // three put the APK where the daemon reads it and restart the process holding it.
//
- // Deliberately not solved by teaching the daemon to prefer modules_update. A staged update
- // sits beside a daemon still running from the last boot, so serving a manager out of it
- // pairs a new manager with an old daemon - the exact mismatch PROTOCOL_VERSION exists to
- // refuse, and it would duly refuse it, having gained nothing.
- //
- // The manager only. A daemon, AIDL or native change still needs a full install and a
- // reboot, and nothing here checks that: the handshake does, at runtime, and says which
- // side is the old one.
+ // Not solved by teaching the daemon to prefer modules_update: that pairs a new manager
+ // with the daemon still running from the last boot, which PROTOCOL_VERSION would refuse.
+ // A daemon, AIDL or native change still needs a full install and a reboot.
val pushManagerTask =
tasks.register("pushManagerApk$variantCapped") {
group = "Zygisk Module Installation"
@@ -272,13 +266,10 @@ androidComponents {
group = "Zygisk Module Installation"
description = "Swaps the live ${variant.name} manager APK for the pushed one."
dependsOn(pushManagerTask)
- // Written *through* the existing file rather than moved over it, so the owner and
- // the SELinux label the flash gave it survive. A fresh file would carry the label
- // of whatever created it, and the daemon only reasserts that label at startup -
- // which is the reboot this task exists to avoid.
- // Quoted so that the redirection reaches the shell `su` starts. `adb shell`
- // joins its arguments and hands them to a shell running as `shell`, which would
- // otherwise be the process trying to create the file - and it cannot.
+ // Written *through* the existing file, so its owner and SELinux label survive:
+ // the daemon only reasserts that label at startup, the reboot being avoided.
+ // Quoted so the redirection reaches the shell `su` starts: `adb shell` joins
+ // its arguments and runs them as `shell`, which cannot create that file.
commandLine(
"adb",
"shell",
@@ -289,9 +280,8 @@ androidComponents {
)
}
- // Stopping the host is what unloads the old APK; the category is what brings the manager
- // back rather than the host's own activity, since the redirection in system_server only
- // fires for an intent carrying it. The same line module/action.sh uses.
+ // Stopping the host unloads the old APK; the category brings the manager back rather
+ // than the host's own activity, as module/action.sh does.
tasks.register("restartManager$variantCapped") {
group = "Zygisk Module Installation"
description = "Replaces the live ${variant.name} manager APK and restarts it."
From d891fc2e5f8c307b80c20ceb47627d529c470754 Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Thu, 5 Nov 2026 18:50:34 +0100
Subject: [PATCH 12/20] Reorder the sheet's actions with arrows, not a drag
The drag was not practical. It had to be discovered before it could be
used, since nothing on the sheet says a long press does anything; it is
invisible to a screen reader, which leaves the order unchangeable for
anyone using one; and it contended with the sheet's own drag for the
same movement, which it lost whenever the finger strayed sideways -- the
sheet took the pointer back and ended the gesture as a cancel. That last
one is why the order had to be written on every swap rather than when
the finger lifted.
A toggle in the header turns the rows into a list with a pair of arrows
each. Two buttons say what they do, move exactly one place, and work the
same for everyone. The rows keep their ordinary appearance while being
moved, so what is reordered is what the reader sees; they are narrowed
by the width of the buttons so a long subtitle wraps clear of them, and
made inert, since in this mode a tap belongs to the arrows.
The toggle is offered on every sheet but the framework's, which has one
row. That test is on `isSystemFramework` rather than on how many rows
were collected, because the list is filled below the header: reading its
size there sees the previous composition, and a plain list cannot
provoke the next one.
Each entry is drawn in a Column rather than straight into the Box the
arrows are aligned in -- uninstall brings its own divider, and a Box
would stack the two on top of one another.
---
.../ui/components/PackageActionMenu.kt | 143 +++++++++++-------
manager/src/main/res/values-ar/strings.xml | 3 +
manager/src/main/res/values-de/strings.xml | 3 +
manager/src/main/res/values-es/strings.xml | 3 +
manager/src/main/res/values-fa/strings.xml | 3 +
manager/src/main/res/values-fr/strings.xml | 3 +
manager/src/main/res/values-in/strings.xml | 3 +
manager/src/main/res/values-it/strings.xml | 3 +
manager/src/main/res/values-iw/strings.xml | 3 +
manager/src/main/res/values-ja/strings.xml | 3 +
manager/src/main/res/values-ko/strings.xml | 3 +
manager/src/main/res/values-pl/strings.xml | 3 +
.../src/main/res/values-pt-rBR/strings.xml | 3 +
manager/src/main/res/values-ru/strings.xml | 3 +
manager/src/main/res/values-tr/strings.xml | 3 +
manager/src/main/res/values-uk/strings.xml | 3 +
manager/src/main/res/values-vi/strings.xml | 3 +
.../src/main/res/values-zh-rCN/strings.xml | 3 +
.../src/main/res/values-zh-rTW/strings.xml | 3 +
manager/src/main/res/values/strings.xml | 3 +
20 files changed, 145 insertions(+), 55 deletions(-)
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 3e1fdadeb6..235ad25c05 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
@@ -32,12 +32,18 @@ import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Launch
import androidx.compose.material.icons.rounded.Bolt
+import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Stop
+import androidx.compose.material.icons.rounded.SwapVert
+import androidx.compose.material.icons.rounded.KeyboardArrowDown
+import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Switch
@@ -178,6 +184,11 @@ fun PackageActionSheet(
}
var confirmSoftReboot by remember { mutableStateOf(false) }
+ // Collected rather than emitted where written, so the order can be the reader's. Each keeps
+ // its own condition: an unusable row is absent, not present and inert.
+ val rows = remember { mutableListOf Unit>>() }
+ var reordering by remember { mutableStateOf(false) }
+
// Every user the device has, less the ones already holding this package. Asked of the
// daemon about *this* package rather than filtered from a cached list, since each of those is
// filtered for its own purpose — and the module list's tabs cover only users it draws, while
@@ -365,6 +376,19 @@ LocalizedOverlay {
overflow = TextOverflow.Ellipsis,
)
}
+ // Every sheet but the framework's carries at least three rows -- app info, force
+ // stop, and either uninstall or re-optimize -- so there is always an order to change.
+ // The framework's has one row and no use for this. Deliberately not `rows.size`: the
+ // list is filled below this point, so reading it here sees the previous composition.
+ if (!isSystemFramework) {
+ IconButton(onClick = { reordering = !reordering }) {
+ Icon(
+ if (reordering) Icons.Rounded.Check else Icons.Rounded.SwapVert,
+ contentDescription = stringResource(R.string.action_reorder),
+ tint = if (reordering) colors.primary else colors.onSurfaceVariant,
+ )
+ }
+ }
}
HorizontalDivider(Modifier.padding(horizontal = 24.dp))
@@ -379,9 +403,6 @@ LocalizedOverlay {
)
}
- // Collected rather than emitted where written, so the order can be the reader's. Each
- // keeps its own condition: an unusable row is absent, not present and inert.
- val rows = remember { mutableListOf Unit>>() }
rows.clear()
fun row(id: ActionId, show: Boolean, content: @Composable () -> Unit) {
if (show) rows.add(id to content)
@@ -573,7 +594,11 @@ LocalizedOverlay {
}
}
- OrderedActions(kind = if (isModule) "module" else "app", rows = rows.toList())
+ OrderedActions(
+ kind = if (isModule) "module" else "app",
+ reordering = reordering,
+ rows = rows.toList(),
+ )
Spacer(Modifier.height(24.dp))
}
@@ -581,15 +606,23 @@ LocalizedOverlay {
}
/**
- * The collected rows, in the reader's order, each draggable into another place.
+ * The collected rows, in the reader's order, with a way to change it.
+ *
+ * Arrows behind a toggle, not a drag. A drag has to be discovered before it can be used, it is
+ * invisible to a screen reader, and on this sheet it contends with the sheet's own drag for the
+ * same movement — which it loses, since the sheet takes the pointer back whenever the finger
+ * strays sideways. Two buttons say what they do, move exactly one place, and work the same for
+ * everyone.
*
- * Long press then drag, not a handle behind an edit mode: a plain drag already means "move the
- * sheet" and has to keep working, and `detectDragGesturesAfterLongPress` consumes nothing until
- * the press is held, so the two never contend. Heights are measured because the rows differ —
- * a swap decided on an average would jump two places one way and none the other.
+ * The rows keep their ordinary appearance while being moved, so what is reordered is what the
+ * reader sees. They are only made inert: in this mode a tap belongs to the arrows.
*/
@Composable
-private fun ColumnScope.OrderedActions(kind: String, rows: List Unit>>) {
+private fun ColumnScope.OrderedActions(
+ kind: String,
+ reordering: Boolean,
+ rows: List Unit>>,
+) {
val settings = ServiceLocator.settings
val revision by settings.actionOrderRevision.collectAsStateWithLifecycle()
// Sorted by where the stored order names each id, with anything it does not name kept in
@@ -604,53 +637,53 @@ private fun ColumnScope.OrderedActions(kind: String, rows: List() }
- var dragging by remember { mutableStateOf(null) }
- var offset by remember { mutableFloatStateOf(0f) }
-
- order.forEach { id ->
- val draw = content[id] ?: return@forEach
- Box(
- modifier =
- Modifier.zIndex(if (id == dragging) 1f else 0f)
- .offset { IntOffset(0, if (id == dragging) offset.roundToInt() else 0) }
- .onSizeChanged { heights[id] = it.height }
- .pointerInput(id, order) {
- detectDragGesturesAfterLongPress(
- onDragStart = {
- dragging = id
- offset = 0f
- },
- onDragEnd = {
- dragging = null
- offset = 0f
- },
- onDragCancel = {
- dragging = null
- offset = 0f
- },
- ) { change, amount ->
- change.consume()
- offset += amount.y
- val at = order.indexOf(id)
- // One neighbour at a time: past half the row being crossed they
- // swap and its height comes off the offset, so the dragged row stays
- // under the finger.
- val step = if (offset > 0) 1 else -1
- val next = order.getOrNull(at + step) ?: return@detectDragGesturesAfterLongPress
- val span = heights[next] ?: return@detectDragGesturesAfterLongPress
- if (abs(offset) > span / 2f) {
- order = order.toMutableList().apply { add(at + step, removeAt(at)) }
- offset -= step * span
- // On the swap, not when the finger lifts: the sheet takes the
- // pointer back if the drag strays, ending the gesture as a cancel.
- settings.setActionOrder(kind, order.map { it.name })
- }
- }
- }
- ) {
+ order.forEachIndexed { index, id ->
+ val draw = content[id] ?: return@forEachIndexed
+ if (!reordering) {
draw()
+ return@forEachIndexed
+ }
+ Box(contentAlignment = Alignment.CenterEnd) {
+ // In a Column, because an entry may emit more than the row itself -- uninstall brings
+ // its own divider -- and a Box would stack them on top of one another. Narrowed by
+ // the width of the two buttons, so a long subtitle wraps clear of them rather than
+ // running underneath; the row reflowing is also what says the mode is on.
+ Column(Modifier.padding(end = 96.dp)) { draw() }
+ // The row keeps its own look and is only made inert: this box swallows the tap it
+ // would otherwise take, since here a tap belongs to the arrows.
+ Box(
+ Modifier.matchParentSize()
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ ) {}
+ )
+ Row(
+ modifier = Modifier.padding(end = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = { move(id, -1) }, enabled = index > 0) {
+ Icon(
+ Icons.Rounded.KeyboardArrowUp,
+ contentDescription = stringResource(R.string.action_move_up),
+ )
+ }
+ IconButton(onClick = { move(id, 1) }, enabled = index < order.lastIndex) {
+ Icon(
+ Icons.Rounded.KeyboardArrowDown,
+ contentDescription = stringResource(R.string.action_move_down),
+ )
+ }
+ }
}
}
}
diff --git a/manager/src/main/res/values-ar/strings.xml b/manager/src/main/res/values-ar/strings.xml
index e7c7fba71b..cb1323a33e 100644
--- a/manager/src/main/res/values-ar/strings.xml
+++ b/manager/src/main/res/values-ar/strings.xml
@@ -469,6 +469,9 @@
تثبيت لمستخدم آخر
يضيف هذه الوحدة إلى ملف تعريف لا يملكها، ليتسنّى تحديد نطاقها هناك
يضيف هذا التطبيق إلى ملف تعريف لا يملكه، لتتمكن وحدة في ذلك الملف من الوصول إليه
+ إعادة ترتيب هذه الإجراءات
+ تحريك لأعلى
+ تحريك لأسفل
اختر المستخدم الذي تريد تثبيت هذه الوحدة له.
اختر المستخدم الذي تريد تثبيت هذا التطبيق له.
تم التثبيت لـ %1$s.
diff --git a/manager/src/main/res/values-de/strings.xml b/manager/src/main/res/values-de/strings.xml
index d7123aa987..a7b0ed29b2 100644
--- a/manager/src/main/res/values-de/strings.xml
+++ b/manager/src/main/res/values-de/strings.xml
@@ -425,6 +425,9 @@
Für anderen Nutzer installieren
Fügt dieses Modul einem Profil hinzu, das es noch nicht hat, damit es dort einen Geltungsbereich bekommen kann
Fügt diese App einem Profil hinzu, das sie nicht hat, damit ein Modul in jenem Profil sie erreichen kann
+ Diese Aktionen neu anordnen
+ Nach oben
+ Nach unten
Wähle den Nutzer, für den dieses Modul installiert werden soll.
Wähle den Nutzer, für den diese App installiert werden soll.
Für %1$s installiert.
diff --git a/manager/src/main/res/values-es/strings.xml b/manager/src/main/res/values-es/strings.xml
index 66fcacf791..3a1b156eff 100644
--- a/manager/src/main/res/values-es/strings.xml
+++ b/manager/src/main/res/values-es/strings.xml
@@ -425,6 +425,9 @@
Instalar para otro usuario
Añade este módulo a un perfil que no lo tiene, para poder darle un ámbito allí
Añade esta app a un perfil que no la tiene, para que un módulo de ese perfil pueda alcanzarla
+ Reordenar estas acciones
+ Subir
+ Bajar
Elige el usuario para el que instalar este módulo.
Elige el usuario para el que instalar esta app.
Instalado para %1$s.
diff --git a/manager/src/main/res/values-fa/strings.xml b/manager/src/main/res/values-fa/strings.xml
index f287db67c3..d29ed912d8 100644
--- a/manager/src/main/res/values-fa/strings.xml
+++ b/manager/src/main/res/values-fa/strings.xml
@@ -425,6 +425,9 @@
نصب برای کاربر دیگر
این ماژول را به نمایهای که آن را ندارد اضافه میکند تا بتوان دامنهاش را آنجا تعیین کرد
این برنامه را به نمایهای که آن را ندارد اضافه میکند تا ماژولی در آن نمایه بتواند به آن برسد
+ مرتبسازی دوبارهٔ این کنشها
+ انتقال به بالا
+ انتقال به پایین
کاربری را که میخواهید این ماژول برایش نصب شود انتخاب کنید.
کاربری را که میخواهید این برنامه برایش نصب شود انتخاب کنید.
برای %1$s نصب شد.
diff --git a/manager/src/main/res/values-fr/strings.xml b/manager/src/main/res/values-fr/strings.xml
index 74a4e07977..b46a07df3d 100644
--- a/manager/src/main/res/values-fr/strings.xml
+++ b/manager/src/main/res/values-fr/strings.xml
@@ -425,6 +425,9 @@
Installer pour un autre utilisateur
Ajoute ce module à un profil qui ne l’a pas, afin de lui donner une portée là-bas
Ajoute cette appli à un profil qui ne l’a pas, afin qu’un module de ce profil puisse l’atteindre
+ Réorganiser ces actions
+ Monter
+ Descendre
Choisissez l’utilisateur pour lequel installer ce module.
Choisissez l’utilisateur pour lequel installer cette appli.
Installé pour %1$s.
diff --git a/manager/src/main/res/values-in/strings.xml b/manager/src/main/res/values-in/strings.xml
index 0e10583461..7f55214b8f 100644
--- a/manager/src/main/res/values-in/strings.xml
+++ b/manager/src/main/res/values-in/strings.xml
@@ -418,6 +418,9 @@
Pasang untuk pengguna lain
Menambahkan modul ini ke profil yang belum memilikinya, agar cakupannya bisa diatur di sana
Menambahkan aplikasi ini ke profil yang belum memilikinya, agar modul di profil itu bisa menjangkaunya
+ Atur ulang tindakan ini
+ Naikkan
+ Turunkan
Pilih pengguna untuk memasang modul ini.
Pilih pengguna untuk memasang aplikasi ini.
Terpasang untuk %1$s.
diff --git a/manager/src/main/res/values-it/strings.xml b/manager/src/main/res/values-it/strings.xml
index 497758e999..1eca651d4d 100644
--- a/manager/src/main/res/values-it/strings.xml
+++ b/manager/src/main/res/values-it/strings.xml
@@ -425,6 +425,9 @@
Installa per un altro utente
Aggiunge questo modulo a un profilo che non lo ha, così da poterne definire l’ambito lì
Aggiunge questa app a un profilo che non la ha, così un modulo in quel profilo può raggiungerla
+ Riordina queste azioni
+ Sposta su
+ Sposta giù
Scegli l’utente per cui installare questo modulo.
Scegli l’utente per cui installare questa app.
Installato per %1$s.
diff --git a/manager/src/main/res/values-iw/strings.xml b/manager/src/main/res/values-iw/strings.xml
index 4e28aad805..a8d00ba1fc 100644
--- a/manager/src/main/res/values-iw/strings.xml
+++ b/manager/src/main/res/values-iw/strings.xml
@@ -451,6 +451,9 @@
התקנה עבור משתמש אחר
מוסיף את המודול הזה לפרופיל שאין לו אותו, כדי שאפשר יהיה להגדיר לו טווח שם
מוסיף את האפליקציה הזאת לפרופיל שאין לו אותה, כדי שמודול באותו פרופיל יוכל להגיע אליה
+ שינוי סדר הפעולות האלה
+ העלאה
+ הורדה
בחר את המשתמש שעבורו להתקין את המודול הזה.
בחר את המשתמש שעבורו להתקין את האפליקציה הזאת.
הותקן עבור %1$s.
diff --git a/manager/src/main/res/values-ja/strings.xml b/manager/src/main/res/values-ja/strings.xml
index fadc36ec6f..7e40ee9b57 100644
--- a/manager/src/main/res/values-ja/strings.xml
+++ b/manager/src/main/res/values-ja/strings.xml
@@ -404,6 +404,9 @@
他のユーザーにインストール
このモジュールを持っていないプロファイルに追加し、そこでスコープを設定できるようにします
このアプリを、持っていないプロファイルに追加します。そのプロファイルのモジュールが対象にできるようになります
+ これらの操作を並べ替える
+ 上に移動
+ 下に移動
このモジュールをインストールするユーザーを選択してください。
このアプリをインストールするユーザーを選択してください。
%1$s にインストールしました。
diff --git a/manager/src/main/res/values-ko/strings.xml b/manager/src/main/res/values-ko/strings.xml
index 2c43a7f52a..401e363c01 100644
--- a/manager/src/main/res/values-ko/strings.xml
+++ b/manager/src/main/res/values-ko/strings.xml
@@ -414,6 +414,9 @@
다른 사용자에게 설치
이 모듈이 없는 프로필에 추가하여 거기서 범위를 지정할 수 있게 합니다
이 앱을 가지고 있지 않은 프로필에 추가해, 그 프로필의 모듈이 접근할 수 있게 합니다
+ 이 작업 순서 바꾸기
+ 위로 이동
+ 아래로 이동
이 모듈을 설치할 사용자를 선택하세요.
이 앱을 설치할 사용자를 선택하세요.
%1$s에 설치했습니다.
diff --git a/manager/src/main/res/values-pl/strings.xml b/manager/src/main/res/values-pl/strings.xml
index 53f86a0d20..c2d2f58fca 100644
--- a/manager/src/main/res/values-pl/strings.xml
+++ b/manager/src/main/res/values-pl/strings.xml
@@ -487,6 +487,9 @@
Zainstaluj dla innego użytkownika
Dodaje ten moduł do profilu, który go nie ma, aby można było określić tam jego zakres
Dodaje tę aplikację do profilu, który jej nie ma, aby moduł w tym profilu mógł ją objąć
+ Zmień kolejność tych działań
+ Przenieś w górę
+ Przenieś w dół
Wybierz użytkownika, dla którego zainstalować ten moduł.
Wybierz użytkownika, dla którego zainstalować tę aplikację.
Zainstalowano dla %1$s.
diff --git a/manager/src/main/res/values-pt-rBR/strings.xml b/manager/src/main/res/values-pt-rBR/strings.xml
index ff943b3082..242704f5b9 100644
--- a/manager/src/main/res/values-pt-rBR/strings.xml
+++ b/manager/src/main/res/values-pt-rBR/strings.xml
@@ -425,6 +425,9 @@
Instalar para outro usuário
Adiciona este módulo a um perfil que não o tem, para que possa ter escopo ali
Adiciona este app a um perfil que não o tem, para que um módulo nesse perfil possa alcançá-lo
+ Reordenar estas ações
+ Mover para cima
+ Mover para baixo
Escolha o usuário para instalar este módulo.
Escolha o usuário para instalar este app.
Instalado para %1$s.
diff --git a/manager/src/main/res/values-ru/strings.xml b/manager/src/main/res/values-ru/strings.xml
index b57b59fbab..7b6a61f91f 100644
--- a/manager/src/main/res/values-ru/strings.xml
+++ b/manager/src/main/res/values-ru/strings.xml
@@ -430,6 +430,9 @@
Установить для другого пользователя
Добавляет модуль в профиль, где его нет, чтобы задать там его область действия
Добавляет это приложение в профиль, где его нет, чтобы модуль в том профиле мог до него дотянуться
+ Изменить порядок действий
+ Переместить вверх
+ Переместить вниз
Выберите пользователя, для которого установить модуль.
Выберите пользователя, для которого установить приложение.
Установлено для %1$s.
diff --git a/manager/src/main/res/values-tr/strings.xml b/manager/src/main/res/values-tr/strings.xml
index 26e88837a6..cf6ffe264c 100644
--- a/manager/src/main/res/values-tr/strings.xml
+++ b/manager/src/main/res/values-tr/strings.xml
@@ -425,6 +425,9 @@
Başka kullanıcı için yükle
Bu modülü, onda olmayan bir profile ekler; böylece orada kapsam verilebilir
Bu uygulamayı, ona sahip olmayan bir profile ekler; böylece o profildeki bir modül ona erişebilir
+ Bu eylemleri yeniden sırala
+ Yukarı taşı
+ Aşağı taşı
Bu modülün yükleneceği kullanıcıyı seçin.
Bu uygulamanın yükleneceği kullanıcıyı seçin.
%1$s için yüklendi.
diff --git a/manager/src/main/res/values-uk/strings.xml b/manager/src/main/res/values-uk/strings.xml
index f00d4ea676..72ccfaa7f0 100644
--- a/manager/src/main/res/values-uk/strings.xml
+++ b/manager/src/main/res/values-uk/strings.xml
@@ -467,6 +467,9 @@
Встановити для іншого користувача
Додає модуль до профілю, де його немає, щоб задати там його область дії
Додає цей застосунок до профілю, який його не має, щоб модуль у тому профілі міг до нього дістатися
+ Змінити порядок цих дій
+ Перемістити вгору
+ Перемістити вниз
Виберіть користувача, для якого встановити модуль.
Виберіть користувача, для якого встановити застосунок.
Встановлено для %1$s.
diff --git a/manager/src/main/res/values-vi/strings.xml b/manager/src/main/res/values-vi/strings.xml
index 91d530a921..a96f766729 100644
--- a/manager/src/main/res/values-vi/strings.xml
+++ b/manager/src/main/res/values-vi/strings.xml
@@ -414,6 +414,9 @@
Cài cho người dùng khác
Thêm mô-đun này vào hồ sơ chưa có nó, để có thể đặt phạm vi ở đó
Thêm ứng dụng này vào một hồ sơ chưa có nó, để mô-đun trong hồ sơ đó có thể nhắm tới nó
+ Sắp xếp lại các thao tác này
+ Chuyển lên
+ Chuyển xuống
Chọn người dùng để cài mô-đun này.
Chọn người dùng để cài ứng dụng này.
Đã cài cho %1$s.
diff --git a/manager/src/main/res/values-zh-rCN/strings.xml b/manager/src/main/res/values-zh-rCN/strings.xml
index c121f928b2..301dddfc94 100644
--- a/manager/src/main/res/values-zh-rCN/strings.xml
+++ b/manager/src/main/res/values-zh-rCN/strings.xml
@@ -415,6 +415,9 @@
为其他用户安装
把此模块添加到尚未安装它的用户空间,以便在那里设置作用域
将此应用添加到尚未拥有它的配置文件,使该配置文件中的模块可以作用于它
+ 重新排列这些操作
+ 上移
+ 下移
选择要为其安装此模块的用户。
选择要为其安装此应用的用户。
已为 %1$s 安装。
diff --git a/manager/src/main/res/values-zh-rTW/strings.xml b/manager/src/main/res/values-zh-rTW/strings.xml
index d47c705979..e16710ac29 100644
--- a/manager/src/main/res/values-zh-rTW/strings.xml
+++ b/manager/src/main/res/values-zh-rTW/strings.xml
@@ -415,6 +415,9 @@
為其他使用者安裝
把此模組加入尚未安裝它的使用者空間,以便在那裡設定作用域
將此應用程式加入尚未擁有它的設定檔,使該設定檔中的模組可以作用於它
+ 重新排列這些操作
+ 上移
+ 下移
選擇要為其安裝此模組的使用者。
選擇要為其安裝此應用程式的使用者。
已為 %1$s 安裝。
diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml
index b1c60a26d1..4ae291426e 100644
--- a/manager/src/main/res/values/strings.xml
+++ b/manager/src/main/res/values/strings.xml
@@ -469,6 +469,9 @@
Install for another user
Puts this module into a profile that does not have it, so it can be scoped there
Puts this app into a profile that does not have it, so a module in that profile can reach it
+ Reorder these actions
+ Move up
+ Move down
Choose the user to install this module for.
Choose the user to install this app for.
Installed for %1$s.
From 7c56c55160696c3c7b02d711f4dc3d83267ff8b4 Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Thu, 5 Nov 2026 18:51:56 +0100
Subject: [PATCH 13/20] Bump the protocol again, for the two methods added
after the last bump
`getPackageUsers` and `getUserIcon` were both declared after the commit
that took PROTOCOL_VERSION to 2, and both sit in the middle of the
interface, so the ids below them moved while the number stayed put. Two
builds of this branch therefore both answer 2 and disagree about what
each id means.
That is not theoretical here. The daemon is reused until a real boot and
`restartManagerDebug` replaces the manager alone, so pairing a manager
from one commit of this branch with a daemon from another is the normal
way of working on it -- and the handshake would wave that pair through.
The file's rule is per change, not per branch: bump it in the same commit
as the change, however many have gone before.
---
.../src/main/aidl/org/matrix/vector/ipc/IManagerService.aidl | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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 71a33712c3..78cfb9eddb 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 = 2;
+ const int PROTOCOL_VERSION = 3;
/**
* Which generation of this interface the daemon implements, never below 1.
From 9b2f48f62c1c0395000e93ddbf691806e3895400 Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Thu, 5 Nov 2026 18:53:07 +0100
Subject: [PATCH 14/20] Clear up what the drag left behind
Ten imports the drag needed and the arrows do not -- the gesture
detector, the offset and zIndex modifiers, the height map, both maths
helpers. The compiler does not mind an unused import and neither does
ktfmt, so they would have sat there.
The row list is built afresh each composition rather than remembered and
refilled: its entries close over values that pass computed, so one kept
across passes would hand the next one stale captures.
The box that swallows a tap in reorder mode carries no semantics now. It
exists to make the row inert, and an unlabelled button announced over
every row would be noise to a screen reader -- which is the reader the
arrows were chosen for.
---
.../ui/components/PackageActionMenu.kt | 20 ++++++++-----------
1 file changed, 8 insertions(+), 12 deletions(-)
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 235ad25c05..79be9e62cd 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
@@ -1,7 +1,5 @@
package org.matrix.vector.manager.ui.components
-import kotlin.math.abs
-import kotlin.math.roundToInt
import org.matrix.vector.ui.AppIcon
import org.matrix.vector.ui.SnackbarTone
import org.matrix.vector.ui.SharedAlertDialog
@@ -15,7 +13,6 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
@@ -24,7 +21,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
@@ -55,10 +51,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.layout.onSizeChanged
-import androidx.compose.ui.unit.IntOffset
-import androidx.compose.ui.zIndex
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
@@ -67,6 +59,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
@@ -83,8 +76,6 @@ import androidx.compose.material.icons.rounded.CloudDownload
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.NotificationsOff
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
@@ -186,7 +177,10 @@ fun PackageActionSheet(
// Collected rather than emitted where written, so the order can be the reader's. Each keeps
// its own condition: an unusable row is absent, not present and inert.
- val rows = remember { mutableListOf Unit>>() }
+ //
+ // Built afresh each composition rather than remembered and refilled: the entries close over
+ // values this pass computed, so one kept across passes would hand the next one stale captures.
+ val rows = mutableListOf Unit>>()
var reordering by remember { mutableStateOf(false) }
// Every user the device has, less the ones already holding this package. Asked of the
@@ -403,7 +397,6 @@ LocalizedOverlay {
)
}
- rows.clear()
fun row(id: ActionId, show: Boolean, content: @Composable () -> Unit) {
if (show) rows.add(id to content)
}
@@ -662,6 +655,9 @@ private fun ColumnScope.OrderedActions(
// would otherwise take, since here a tap belongs to the arrows.
Box(
Modifier.matchParentSize()
+ // No semantics of its own: it exists to swallow a touch, and an unlabelled
+ // button announced over every row would be noise to a screen reader.
+ .clearAndSetSemantics {}
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
From 6e650191a0ec129eba8db0a22b3f431957b8dffd Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Thu, 5 Nov 2026 18:54:22 +0100
Subject: [PATCH 15/20] Stop two comments describing a gesture that is gone
The stored order is reached with arrows now, so "until someone drags
something" and "one nobody has dragged" name an interaction this branch
removed. The revision flow also moves above the two functions that write
and read it, where every other backing field in this file sits.
---
.../manager/data/repository/SettingsRepository.kt | 10 +++++-----
.../vector/manager/ui/components/PackageActionMenu.kt | 2 +-
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt
index ee39ad3d0e..644a09812c 100644
--- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt
+++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt
@@ -519,12 +519,16 @@ class SettingsRepository(context: Context) : StoreSettings, NetworkSettings {
_dohEnabled.value = enabled
}
+ /** Bumped on every write, so a sheet already open re-reads the order it is drawing from. */
+ private val _actionOrderRevision = MutableStateFlow(0)
+ val actionOrderRevision: StateFlow = _actionOrderRevision.asStateFlow()
+
/**
* The order the long-press sheet lists its actions in, per kind of package.
*
* Ids rather than indices, so an action added or retired in a later build does not shuffle the
* rest: one the stored order does not name keeps its place in declaration order, and a name the
- * build no longer has is ignored. Empty until someone drags something, which is the usual case.
+ * build no longer has is ignored. Empty until someone moves a row, which is the usual case.
*/
fun actionOrder(kind: String): List =
prefs.getString("action_order_$kind", "").orEmpty().split(',').filter { it.isNotEmpty() }
@@ -534,10 +538,6 @@ class SettingsRepository(context: Context) : StoreSettings, NetworkSettings {
_actionOrderRevision.value++
}
- /** Bumped on every write, so a sheet already open re-reads the order it is drawing from. */
- private val _actionOrderRevision = MutableStateFlow(0)
- val actionOrderRevision: StateFlow = _actionOrderRevision.asStateFlow()
-
private companion object {
/** The Winged Victory's patina. Kept as a literal so this file needs no UI imports. */
const val DEFAULT_SEED_COLOR = 0xFF6ABFCF.toInt()
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 79be9e62cd..037739daac 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
@@ -99,7 +99,7 @@ import org.matrix.vector.ui.theme.Mono
* The actions the long-press sheet can offer, named so an order can outlive them.
*
* Declaration order is the default order. A stored order names ids, so an action added or retired
- * in a later build leaves the rest where they are: one nobody has dragged keeps its place here, and
+ * in a later build leaves the rest where they are: one nobody has moved keeps its place here, and
* a name this build no longer has is ignored when the order is read back.
*/
private enum class ActionId {
From 6fad2daffc664a315a53221d2c8e399d4264d4e2 Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Thu, 5 Nov 2026 20:17:38 +0100
Subject: [PATCH 16/20] Fill the row list in the scope that draws it
Every action appeared twice. The list was made in the function body and
filled inside the sheet's own lambda, and that lambda is its own
recomposition scope: it runs again without the body around it, so the
list it found was the one the last pass had already filled, and each row
was added to it a second time.
Made where it is filled now, so one pass fills one list. The earlier
shape -- remembered, and cleared at the top of the lambda -- worked for
the same reason and was lost when the clear went.
Found on the device. It is invisible to the compiler and to a reading
that takes "fresh each composition" to mean what it says.
---
.../manager/ui/components/PackageActionMenu.kt | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
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 037739daac..52830b58bb 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
@@ -175,12 +175,6 @@ fun PackageActionSheet(
}
var confirmSoftReboot by remember { mutableStateOf(false) }
- // Collected rather than emitted where written, so the order can be the reader's. Each keeps
- // its own condition: an unusable row is absent, not present and inert.
- //
- // Built afresh each composition rather than remembered and refilled: the entries close over
- // values this pass computed, so one kept across passes would hand the next one stale captures.
- val rows = mutableListOf Unit>>()
var reordering by remember { mutableStateOf(false) }
// Every user the device has, less the ones already holding this package. Asked of the
@@ -397,6 +391,13 @@ LocalizedOverlay {
)
}
+ // Collected rather than emitted where written, so the order can be the reader's. Each
+ // keeps its own condition: an unusable row is absent, not present and inert.
+ //
+ // Declared here, in the same scope as the calls that fill it, and not in the function
+ // body above: this lambda is its own recomposition scope and runs without the body
+ // around it, so a list made out there would be filled twice and draw every row twice.
+ val rows = mutableListOf Unit>>()
fun row(id: ActionId, show: Boolean, content: @Composable () -> Unit) {
if (show) rows.add(id to content)
}
From 772705723d83fe96263d1cf725cbc0fbe13bc156 Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Thu, 5 Nov 2026 20:27:54 +0100
Subject: [PATCH 17/20] Let the empty state be pulled, since it is the one that
says to pull
"The list of installed packages could not be read ... pull down to try
again" was drawn by a branch that returns before the PullToRefreshBox,
so there was nothing to pull and no other way to retry. The empty state
is wrapped in one now.
In a lazy list holding one full-height item rather than a plain Box: the
pull is seen through nested scroll, and something that does not scroll
dispatches nothing to see. Over a Box the indicator never appeared
however far the gesture went, which is what the device showed before and
after.
Also here: the statement `moduleIdOf` compiles is closed. It is a native
statement, and the two callers around it leak theirs, which is not a
reason for a third.
---
.../vector/daemon/data/ModuleDatabase.kt | 7 +--
.../ui/screens/modules/ModulesScreen.kt | 44 ++++++++++++++-----
2 files changed, 37 insertions(+), 14 deletions(-)
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 569d37ca81..733b2c3ee9 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
@@ -459,9 +459,10 @@ object ModuleDatabase {
*/
private fun moduleIdOf(db: SQLiteDatabase, packageName: String): Long? =
runCatching {
- db.compileStatement("SELECT mid FROM modules WHERE module_pkg_name = ?")
- .apply { bindString(1, packageName) }
- .simpleQueryForLong()
+ db.compileStatement("SELECT mid FROM modules WHERE module_pkg_name = ?").use {
+ it.bindString(1, packageName)
+ it.simpleQueryForLong()
+ }
}
.getOrNull()
}
diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt
index eabdf349f1..043f390676 100644
--- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt
+++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt
@@ -156,7 +156,6 @@ fun ModulesScreen(
viewModel: ModulesViewModel = viewModel(factory = ModulesViewModelFactory()),
) {
val tabs by viewModel.userModulesTabs.collectAsStateWithLifecycle()
-
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val query by viewModel.query.collectAsStateWithLifecycle()
val filter by viewModel.filter.collectAsStateWithLifecycle()
@@ -353,14 +352,32 @@ fun ModulesScreen(
}
if (tabs.isEmpty() || tabs.all { it.modules.isEmpty() }) {
- // A filter empties the list exactly as a search does, so both count as narrowing.
- // Otherwise picking "Inactive" on a device where everything is on would say "you
- // have no modules installed" over a list the filter had just hidden.
- EmptyState(
- daemonAvailable = daemonAvailable,
- scanFailed = scanFailed,
- filtered = query.isNotBlank() || filter != ModuleFilter.All,
- )
+ // Pullable like the list is, because this is the state that most needs a retry:
+ // a scan the daemon could not answer says to pull, and there would otherwise be
+ // nothing here to pull. It costs the other two states nothing.
+ PullToRefreshBox(
+ isRefreshing = isLoading,
+ onRefresh = { viewModel.loadModules(refresh = true) },
+ ) {
+ // In a lazy list holding one full-height item, not a plain Box: the pull is
+ // seen through nested scroll, and something that does not scroll dispatches
+ // nothing to see. Measured on the device -- over a Box the indicator never
+ // appeared, however far down the gesture went.
+ LazyColumn(modifier = Modifier.fillMaxSize()) {
+ item {
+ // A filter empties the list exactly as a search does, so both count
+ // as narrowing. Otherwise picking "Inactive" on a device where
+ // everything is on would say "you have no modules installed" over a
+ // list the filter had just hidden.
+ EmptyState(
+ daemonAvailable = daemonAvailable,
+ scanFailed = scanFailed,
+ filtered = query.isNotBlank() || filter != ModuleFilter.All,
+ modifier = Modifier.fillParentMaxSize(),
+ )
+ }
+ }
+ }
return@Column
}
@@ -862,8 +879,13 @@ private fun ApiBadge(module: InstalledModule, incompatible: Boolean) {
}
@Composable
-private fun EmptyState(daemonAvailable: Boolean, scanFailed: Boolean, filtered: Boolean) {
- Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
+private fun EmptyState(
+ daemonAvailable: Boolean,
+ scanFailed: Boolean,
+ filtered: Boolean,
+ modifier: Modifier = Modifier.fillMaxSize(),
+) {
+ Box(modifier = modifier.padding(32.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Rounded.Extension,
From 65a8d0eb94ea2a765e9bd53e5828d599258176d3 Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Thu, 5 Nov 2026 20:28:17 +0100
Subject: [PATCH 18/20] Keep the pushed manager where a tmp daemon will look
for it
The daemon launcher prefers /data/local/tmp/daemon.apk when one is there,
and a daemon started that way resolves the manager beside its own class
path -- so it reads /data/local/tmp/manager.apk, not the module's. The
task deleted the pushed copy after writing the module's, which left that
daemon serving the APK from before the task ran, and the change under
test simply did not appear.
It is kept now whenever a tmp daemon.apk is present, and still removed
otherwise, so the ordinary case leaves nothing behind.
---
zygisk/build.gradle.kts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/zygisk/build.gradle.kts b/zygisk/build.gradle.kts
index e6763c9b48..c03d2fa91e 100644
--- a/zygisk/build.gradle.kts
+++ b/zygisk/build.gradle.kts
@@ -270,13 +270,17 @@ androidComponents {
// the daemon only reasserts that label at startup, the reboot being avoided.
// Quoted so the redirection reaches the shell `su` starts: `adb shell` joins
// its arguments and runs them as `shell`, which cannot create that file.
+ //
+ // The pushed copy is left where it is when a daemon is being run from
+ // /data/local/tmp, because that daemon resolves the manager beside its own class
+ // path and would otherwise go on serving the APK from before this task ran.
commandLine(
"adb",
"shell",
"su",
"-c",
"'cat /data/local/tmp/manager.apk > /data/adb/modules/$moduleId/manager.apk && " +
- "rm -f /data/local/tmp/manager.apk'",
+ "{ [ -r /data/local/tmp/daemon.apk ] || rm -f /data/local/tmp/manager.apk; }'",
)
}
From 6c6bc84a940be1aea67884586d056f90e08c8857 Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Thu, 5 Nov 2026 20:29:04 +0100
Subject: [PATCH 19/20] Say "Done" on the control that means done
The reorder toggle kept one description across both states, so a screen
reader announced "Reorder these actions" over the check that ends the
mode -- the one moment the control means the opposite. It reads
`panels_done` there now: the same word the navigation bar's own reorder
ends on, already translated, and paired with the same check icon in
PanelBar.
---
.../vector/manager/ui/components/PackageActionMenu.kt | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
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 52830b58bb..62618ebb55 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
@@ -372,7 +372,14 @@ LocalizedOverlay {
IconButton(onClick = { reordering = !reordering }) {
Icon(
if (reordering) Icons.Rounded.Check else Icons.Rounded.SwapVert,
- contentDescription = stringResource(R.string.action_reorder),
+ // The check means "finished", not "reorder", and a screen reader reads
+ // only this. `panels_done` is the same word the navigation bar's own
+ // reorder ends on, and is already translated.
+ contentDescription =
+ stringResource(
+ if (reordering) UiR.string.panels_done
+ else R.string.action_reorder
+ ),
tint = if (reordering) colors.primary else colors.onSurfaceVariant,
)
}
From 2a8b39c3bd1c66041875ce1c980d473daa09d4fe Mon Sep 17 00:00:00 2001
From: JingMatrix
Date: Thu, 5 Nov 2026 21:14:00 +0100
Subject: [PATCH 20/20] Stop this comment from arguing against PanelBar
It read "arrows, not a drag", and gave three reasons: a drag has to be
discovered, it is invisible to a screen reader, and it contends with the
sheet. Two of those three are just as true of the navigation panels,
which this project reorders by dragging on purpose -- so the comment
argued against a pattern it sits twenty lines from, without saying why
here is different.
Only the third reason is about this sheet, so it leads now, and the
precedent is named rather than contradicted: a drag works on the bar
because the bar does not move under it, and it fails here because the
sheet does. The toggle is that screen's edit mode by another name.
---
.../vector/manager/ui/components/PackageActionMenu.kt | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
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 62618ebb55..a6f1c0ca71 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
@@ -609,11 +609,11 @@ LocalizedOverlay {
/**
* The collected rows, in the reader's order, with a way to change it.
*
- * Arrows behind a toggle, not a drag. A drag has to be discovered before it can be used, it is
- * invisible to a screen reader, and on this sheet it contends with the sheet's own drag for the
- * same movement — which it loses, since the sheet takes the pointer back whenever the finger
- * strays sideways. Two buttons say what they do, move exactly one place, and work the same for
- * everyone.
+ * Arrows behind a toggle, rather than the drag `PanelBar` reorders the navigation panels with. A
+ * drag works there because the bar itself does not move, so the others can be watched stepping
+ * aside. This sheet is draggable, and it wins the gesture: stray sideways and it takes the pointer
+ * back, which ends the reorder as a cancel. The toggle is that screen's edit mode by another name,
+ * and buttons have the side benefit of working for a screen reader, which no drag does.
*
* The rows keep their ordinary appearance while being moved, so what is reordered is what the
* reader sees. They are only made inert: in this mode a tap belongs to the arrows.