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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PackageInfo> {
val method = getInstalledPackagesMethod ?: return emptyList()
): List<PackageInfo>? {
val method = getInstalledPackagesMethod ?: return null
return runCatching {
val result = method.invoke(this, flags, userId)
@Suppress("UNCHECKED_CAST") (result as? ParceledListSlice<PackageInfo>)?.list
}
.onFailure { Log.e(TAG, "Reflection call failed", it.cause ?: it) }
.getOrNull() ?: emptyList()
.getOrNull()
}

fun IPackageManager.getInstalledPackagesFromAllUsers(
Expand All @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeviceUser> = real?.users ?: mutableListOf()

override fun startActivityAsUser(intent: Intent?, userId: Int, noUserSwitch: Boolean): Int =
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -63,6 +65,13 @@ class AppRepository(
*/
private var inFlight: Deferred<List<AppInfo>>? = null

/** The same arrangement for [moduleScanPackages], which asks the daemon a different question. */
@Volatile private var cachedModuleScan: List<PackageInfo>? = null

private val moduleScanLock = Mutex()

private var moduleScanInFlight: Deferred<Result<List<PackageInfo>>>? = null

/**
* Drops the cache so the next read goes back to the daemon.
*
Expand All @@ -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<List<PackageInfo>> {
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<List<PackageInfo>> {
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<AppInfo> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,16 @@ class DaemonClient(private val serviceState: StateFlow<IManagerService?>) {
suspend fun uninstallPackage(packageName: String, userId: Int): Result<Boolean> = 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<Boolean> =
runIpc { it.installExistingPackageAsUser(packageName, userId) }

suspend fun isSepolicyLoaded(): Result<Boolean> = runIpc { it.isSepolicyLoaded }

suspend fun getUsers(): Result<List<org.matrix.vector.ipc.DeviceUser>> = runIpc { it.users
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Int>? = 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
Expand All @@ -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<List<DeviceUser>>(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
Expand Down Expand Up @@ -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 {

Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading