From 8c13691e5634ae61eb2b963ef6788d11165bf068 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 7 Sep 2026 10:45:39 +0200 Subject: [PATCH 1/3] 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 b4dd39e5a..3383914f4 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 a710c9d37a489d436c83a932e16445e23f9b42e3 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 7 Sep 2026 10:45:39 +0200 Subject: [PATCH 2/3] 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 96251dffc..6e9fdd94a 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 a0e7964f0..202b3dd63 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 b4c0705f8..e1f9f9e62 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 6e64fc7be..323587da8 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 3496443fe..e746b5ca4 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 932f431a3..cf4bd87d9 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 55a712de7..603987557 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 d3fc8ad5c..cc36b0b73 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 d4c8a0670..e6c572451 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 3a401d36b..0b98c9968 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 3c6c6390e..d1b25f8da 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 e71e14e18..4423169d8 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 385c87583..59be1565f 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 3b75f0c3f..ab213f087 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 533f39735..c81bb5621 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 e8f40a59c..dca8127e0 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 3977bb60a..819805870 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 c0c7f5370..afa46ebb4 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 701a438e6..7c96d9715 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 29cb7052f..c9d63bd09 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 c592b94e7..b7a4eea72 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 5068e3cf5..b93191cdc 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 76a243e73..ecb859b93 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 ec54516e1..71e7a97cd 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 19319ba12..21389a296 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 e5aa30e7e4d346e8f43abc8e7f76e61315e84a00 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 7 Sep 2026 10:46:02 +0200 Subject: [PATCH 3/3] 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 6865d71ba..616dadc0b 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 c7da5e501..73cb09959 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