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..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,7 +357,10 @@ 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 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()
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..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
@@ -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,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)),
- -- `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, 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))
);
"""
@@ -69,6 +67,21 @@ class Database(context: Context? = FakeContext()) :
"""
.trimIndent())
+ db.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS auto_include (
+ -- 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),
+ 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 +209,30 @@ 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())
+ // 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)
+ 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..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
@@ -63,35 +63,45 @@ object ModuleDatabase {
return result
}
- fun getIncludeNewApps(packageName: String): Boolean {
+ /**
+ * Whether this copy of the module takes every newly installed app.
+ *
+ * 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
- 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], 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()
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 +419,50 @@ object ModuleDatabase {
return res
}
- fun setIncludeNewApps(packageName: String, enabled: Boolean): Boolean {
+ /**
+ * Sets it for one user.
+ *
+ * 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
- 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, 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. `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 {
+ db.compileStatement("SELECT mid FROM modules WHERE module_pkg_name = ?").use {
+ it.bindString(1, packageName)
+ it.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 96251dffc9..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
@@ -61,6 +61,12 @@ object ManagerService : IManagerService.Stub() {
*/
private const val UNINSTALL_TIMEOUT_SECONDS = 60L
+ /**
+ * `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
+
private var managerPid = -1
private var pendingManager = false
@@ -314,6 +320,34 @@ object ManagerService : IManagerService.Stub() {
activityManager?.forceStopPackage(packageName, userId)
}
+ /**
+ * Puts a package an existing user already holds into another user.
+ *
+ * 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
+ 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)
+ }
+ // 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")
+ }
+ status == INSTALL_SUCCEEDED
+ }
+ .onFailure { Log.e(TAG, "Failed to install $packageName for user $userId", it) }
+ .getOrDefault(false)
+ }
+
override fun softReboot() = VectorDaemon.softReboot()
/**
@@ -422,6 +456,37 @@ object ManagerService : IManagerService.Stub() {
} ?: emptyList()
}
+ /**
+ * 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 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)
+ val users = userManager?.getRealUsers() ?: return IntArray(0)
+ 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: 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
+ 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 {
@@ -522,10 +587,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/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..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
@@ -197,18 +197,23 @@ private val getInstalledPackagesMethod: Method? by lazy {
?.apply { isAccessible = true }
}
-/** Reflectively calls getInstalledPackages and casts to ParceledListSlice. */
+/**
+ * Reflectively calls getInstalledPackages and casts to ParceledListSlice.
+ *
+ * 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,
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(
@@ -216,15 +221,24 @@ 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()
+ // `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")
- 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
+ // 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
- val infos = getInstalledPackagesReflect(flagParam, user.id)
+ for (user in users) {
+ // 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}")
if (infos.isEmpty()) continue
val validUserApps =
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..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
@@ -257,8 +257,17 @@ 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 getUserIcon(userId: Int): android.os.ParcelFileDescriptor? =
+ real?.getUserIcon(userId)
+
+ 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.
@@ -300,11 +309,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/data/repository/AppRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt
index 6865d71bac..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
@@ -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,68 @@ 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 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 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) {
+ cachedModuleScan?.let {
+ return Result.success(it)
+ }
+ }
+ val job =
+ moduleScanLock.withLock {
+ 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: 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 }
+ }
+ 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/data/repository/SettingsRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt
index 86a99dce1d..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,6 +519,25 @@ 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 moves a row, 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++
+ }
+
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/ipc/DaemonClient.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt
index b4c0705f89..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
@@ -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
@@ -264,8 +266,29 @@ class DaemonClient(private val serviceState: StateFlow) {
suspend fun uninstallPackage(packageName: String, userId: Int): Result = runIpc { it.uninstallPackage(packageName, userId)
}
+ /**
+ * 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) }
+
suspend fun isSepolicyLoaded(): Result = runIpc { it.isSepolicyLoaded }
+ /**
+ * 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. Decoded here so no descriptor escapes. */
+ suspend fun getUserIcon(userId: Int): Result = runIpc { service ->
+ service.getUserIcon(userId)?.use { BitmapFactory.decodeFileDescriptor(it.fileDescriptor) }
+ }
+
suspend fun getUsers(): Result> = runIpc { it.users
}
@@ -333,19 +356,26 @@ 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, not a user whose apps are being asked about.
+ */
+ 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/components/PackageActionMenu.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionMenu.kt
index 6e64fc7bea..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
@@ -9,23 +9,37 @@ 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.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.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
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
@@ -37,13 +51,21 @@ 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
+import androidx.compose.ui.semantics.clearAndSetSemantics
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
@@ -57,6 +79,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
@@ -71,6 +95,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 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 {
+ Open,
+ AppInfo,
+ SoftReboot,
+ ForceStop,
+ Reoptimize,
+ InstallOtherUser,
+ Uninstall,
+}
+
/** What a long press did, and how it went. */
data class PackageActionResult(
val messageRes: Int,
@@ -134,6 +175,37 @@ fun PackageActionSheet(
}
var confirmSoftReboot by remember { mutableStateOf(false) }
+ 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
+ // 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
+ // 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())
+ // 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)
+ .onFailure { e -> logW("actions: holders of $packageName failed", e) }
+ .getOrDefault(emptyList())
+ .toSet()
+ installTargets = users.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
@@ -179,10 +251,23 @@ 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)
+ // Never shorter than the stop it opens at.
+ //
+ // 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.
+ (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) {
@@ -190,7 +275,72 @@ fun PackageActionSheet(
scope.launch(Dispatchers.Main) { onResult(block()) }
}
- ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
+ 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.
+ 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(
+ if (isModule) R.string.action_install_other_user_prompt
+ else R.string.action_install_other_user_prompt_app
+ )
+ )
+ Spacer(Modifier.height(8.dp))
+ // A row rather than a text button: a profile is recognised by its picture
+ // before its name.
+ installTargets.forEach { target ->
+ 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),
+ ) {
+ UserAvatar(userId = target.id, name = label)
+ Spacer(Modifier.width(16.dp))
+ Text(text = label, modifier = Modifier.weight(1f))
+ }
+ }
+ }
+ },
+ confirmButton = {},
+ dismissButton = {
+ TextButton(onClick = { choosingUser = false }) {
+ Text(stringResource(UiR.string.store_cancel))
+ }
+ },
+ )
+ }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ modifier = Modifier.heightIn(min = minSheetHeight),
+ sheetState = sheetState,
+ ) {
LocalizedOverlay {
Row(
@@ -214,6 +364,26 @@ 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,
+ // 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,
+ )
+ }
+ }
}
HorizontalDivider(Modifier.padding(horizontal = 24.dp))
@@ -228,11 +398,22 @@ 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)
+ }
+
// 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 =
@@ -260,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 =
@@ -288,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),
@@ -303,7 +487,9 @@ LocalizedOverlay {
) {
confirmSoftReboot = true
}
- } else {
+ }
+
+ row(ActionId.ForceStop, !isSystemFramework) {
ActionDrawerItem(
icon = Icons.Rounded.Stop,
title = stringResource(R.string.action_force_stop),
@@ -330,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),
@@ -363,7 +549,26 @@ LocalizedOverlay {
}
}
- if (isModule) {
+ // 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),
+ // 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
+ else R.string.action_install_other_user_summary_app
+ ),
+ tint = colors.primary,
+ ) {
+ choosingUser = true
+ }
+ }
+
+ row(ActionId.Uninstall, isModule) {
HorizontalDivider(Modifier.padding(horizontal = 24.dp, vertical = 4.dp))
ActionDrawerItem(
icon = Icons.Rounded.Delete,
@@ -390,11 +595,103 @@ LocalizedOverlay {
}
}
+ OrderedActions(
+ kind = if (isModule) "module" else "app",
+ reordering = reordering,
+ rows = rows.toList(),
+ )
+
Spacer(Modifier.height(24.dp))
}
}
}
+/**
+ * The collected rows, in the reader's order, with a way to change it.
+ *
+ * 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.
+ */
+@Composable
+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
+ // 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)
+ }
+ )
+ }
+
+ fun move(id: ActionId, by: Int) {
+ val at = order.indexOf(id)
+ val to = at + by
+ if (at < 0 || to !in order.indices) return
+ order = order.toMutableList().apply { add(to, removeAt(at)) }
+ settings.setActionOrder(kind, order.map { it.name })
+ }
+
+ val content = rows.toMap()
+ 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()
+ // 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,
+ ) {}
+ )
+ 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),
+ )
+ }
+ }
+ }
+ }
+}
+
/**
* One setting, in the same shape — the shared [ActionDrawerItem] disc layout — as the actions it
* sits among.
@@ -589,3 +886,50 @@ private fun ModuleUpdateSection(
)
}
}
+
+/**
+ * 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) {
+ 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/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..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
@@ -163,6 +163,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()
@@ -351,13 +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,
- 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
}
@@ -379,7 +399,9 @@ fun ModulesScreen(
// that catches it does not fire for every route in.
PullToRefreshBox(
isRefreshing = isLoading,
- onRefresh = { viewModel.loadModules() },
+ // 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
// Sections only make sense when the order is by state. Under any other sort the
@@ -857,8 +879,13 @@ private fun ApiBadge(module: InstalledModule, incompatible: Boolean) {
}
@Composable
-private fun EmptyState(daemonAvailable: 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,
@@ -872,6 +899,9 @@ private fun EmptyState(daemonAvailable: Boolean, filtered: Boolean) {
stringResource(
when {
!daemonAvailable -> R.string.modules_no_daemon
+ // 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 c7da5e5013..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
@@ -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
@@ -119,6 +118,16 @@ 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 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()
+
private val _query = MutableStateFlow("")
val query: StateFlow = _query.asStateFlow()
@@ -367,7 +376,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))
}
@@ -383,10 +394,14 @@ class ModulesViewModel(
}
}
- fun loadModules() {
+ /**
+ * 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 {
_isLoading.update { true }
- val tabs = withContext(Dispatchers.IO) { discover() }
+ val tabs = withContext(Dispatchers.IO) { discover(refresh) }
_discovered.update { tabs }
_isLoading.update { false }
loadFacts(tabs)
@@ -475,7 +490,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)
@@ -483,18 +498,14 @@ 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
-
- val packages =
- daemonClient
- .getInstalledPackagesFromAllUsers(flags, filterNoProcess = false)
- .getOrElse { e ->
- logE("modules: installed package list unavailable, showing no modules", e)
- emptyList()
- }
+ // 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
+ 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
@@ -531,7 +542,10 @@ class ModulesViewModel(
)
}
- detection.flush(packages.mapNotNull { it.packageName }.toSet())
+ // 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
// key is wrong rather than that the device is slow.
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..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
@@ -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,12 @@ fun ScopeScreen(
private fun ScopeSelectMenu(
hasRecommended: Boolean,
includeNewApps: Boolean,
+ /**
+ * 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,
onSelectAll: () -> Unit,
onSelectNone: () -> Unit,
@@ -648,7 +655,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 932f431a3d..cb1323a33e 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 نشطة
لا وحدة تطابق هذا البحث أو هذه التصفية.
نسخ الوحدات احتياطيًا
@@ -177,6 +178,7 @@
وحدات أخرى
إضافة التطبيقات الجديدة تلقائيًا
التطبيقات التي تُثبَّت من الآن تنضم إلى نطاق هذه الوحدة.
+ التطبيقات التي تُثبَّت لهذا المستخدم من الآن تنضم إلى نطاق هذه الوحدة. لكل مستخدم إعداده الخاص.
نسخ هذا النطاق احتياطيًا
استعادة هذا النطاق
حسب اسم الحزمة
@@ -464,4 +466,14 @@
ليس لـ 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..a7b0ed29b2 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
@@ -153,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
@@ -420,4 +422,14 @@
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
+ 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.
+ 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..3a1b156eff 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
@@ -153,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
@@ -420,4 +422,14 @@
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í
+ 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.
+ 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..d29ed912d8 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 فعال
ماژولی با این جستجو یا پالایه همخوان نیست.
پشتیبانگیری از ماژولها
@@ -153,6 +154,7 @@
ماژولهای دیگر
افزودن خودکار برنامههای جدید
برنامههایی که از این پس نصب شوند به دامنهٔ این ماژول افزوده میشوند.
+ برنامههایی که از این پس برای این کاربر نصب شوند به دامنهٔ این ماژول افزوده میشوند. هر کاربر جداگانه تنظیم میشود.
پشتیبانگیری از این دامنه
بازگردانی این دامنه
بر پایهٔ نام بسته
@@ -420,4 +422,14 @@
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..b46a07df3d 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
@@ -153,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
@@ -420,4 +422,14 @@
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
+ 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.
+ 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..7f55214b8f 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
@@ -151,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
@@ -413,4 +415,14 @@
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
+ 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.
+ 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..1eca651d4d 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
@@ -153,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
@@ -420,4 +422,14 @@
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ì
+ 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.
+ 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..a8d00ba1fc 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 פעילים
אין מודול שתואם לחיפוש הזה או לסינון הזה.
גיבוי המודולים
@@ -169,6 +170,7 @@
מודולים אחרים
הוספת אפליקציות חדשות אוטומטית
אפליקציות שיותקנו מעכשיו יצטרפו לטווח של המודול הזה.
+ אפליקציות שיותקנו למשתמש הזה מעכשיו יצטרפו לטווח של המודול הזה. כל משתמש מוגדר בנפרד.
גיבוי התחום הזה
שחזור התחום הזה
לפי שם החבילה
@@ -446,4 +448,14 @@
ל-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..7e40ee9b57 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 個が有効
その検索や絞り込みに一致するモジュールはありません。
モジュールをバックアップ
@@ -147,6 +148,7 @@
ほかのモジュール
新しいアプリを自動で追加
今後インストールされるアプリは、このモジュールの適用範囲に含まれます。
+ 今後このユーザーにインストールされるアプリは、このモジュールの適用範囲に含まれます。設定はユーザーごとに別です。
この適用範囲をバックアップ
この適用範囲を復元
パッケージ名順
@@ -399,4 +401,14 @@
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..401e363c01 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개 활성
그 검색어나 필터와 일치하는 모듈이 없습니다.
모듈 백업
@@ -147,6 +148,7 @@
다른 모듈
새 앱 자동 추가
지금부터 설치되는 앱이 이 모듈의 범위에 포함됩니다.
+ 지금부터 이 사용자에 설치되는 앱이 이 모듈의 범위에 포함됩니다. 설정은 사용자별로 따로입니다.
이 적용 범위 백업
이 적용 범위 복원
패키지 이름순
@@ -409,4 +411,14 @@
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..c2d2f58fca 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ć
@@ -245,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
@@ -482,4 +484,14 @@
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
+ 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.
+ 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..242704f5b9 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
@@ -153,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
@@ -420,4 +422,14 @@
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
+ 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.
+ 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..7b6a61f91f 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 активны
Ни один модуль не подходит под этот запрос или фильтр.
Сохранить модули
@@ -152,6 +153,7 @@
Другие модули
Добавлять новые приложения автоматически
Приложения, установленные с этого момента, попадут в область действия модуля.
+ Приложения, установленные для этого пользователя с этого момента, попадут в область действия модуля. Для каждого пользователя задаётся отдельно.
Сохранить эту область
Восстановить эту область
По имени пакета
@@ -425,4 +427,14 @@
У 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..cf6ffe264c 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
@@ -153,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
@@ -420,4 +422,14 @@
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 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.
+ %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..72ccfaa7f0 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 активні
Жоден модуль не відповідає цьому запиту чи фільтру.
Створити копію модулів
@@ -165,6 +166,7 @@
Інші модулі
Додавати нові застосунки автоматично
Застосунки, встановлені відтепер, потраплять в область дії цього модуля.
+ Застосунки, встановлені відтепер для цього користувача, потраплять в область дії цього модуля. Для кожного користувача налаштовується окремо.
Зберегти цю область дії
Відновити цю область дії
За назвою пакета
@@ -462,4 +464,14 @@
У 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..a96f766729 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
@@ -147,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
@@ -409,4 +411,14 @@
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 ở đó
+ 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.
+ 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..301dddfc94 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 个已启用
没有符合该搜索或筛选条件的模块。
备份模块
@@ -147,6 +148,7 @@
其他模块
自动加入新安装的应用
此后安装的应用会自动加入此模块的作用域。
+ 此后为该用户安装的应用会自动加入此模块的作用域。每个用户分别设置。
备份此作用域
恢复此作用域
按包名
@@ -410,4 +412,14 @@
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..e16710ac29 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 個已啟用
沒有符合該搜尋或篩選條件的模組。
備份模組
@@ -147,6 +148,7 @@
其他模組
自動加入新安裝的應用程式
此後安裝的應用程式會自動加入此模組的作用域。
+ 此後為該使用者安裝的應用程式會自動加入此模組的作用域。每位使用者分別設定。
備份此範圍
還原此範圍
依套件名稱
@@ -410,4 +412,14 @@
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..4ae291426e 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
@@ -243,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
@@ -464,4 +466,14 @@
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
+ 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.
+ 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..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 = 1;
+ const int PROTOCOL_VERSION = 3;
/**
* Which generation of this interface the daemon implements, never below 1.
@@ -312,21 +312,29 @@ 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.
+ *
+ * {@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
*/
- boolean getIncludeNewApps(String packageName);
+ boolean getIncludeNewApps(String packageName, int userId);
/**
- * Sets that flag.
+ * Sets that flag for one user.
+ *
+ * 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
- * 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
@@ -535,6 +543,25 @@ interface IManagerService {
*/
List getUsers();
+ /**
+ * Which of those users hold this package, by id.
+ *
+ * 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 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 - 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);
+
// ---- things done to the device on the manager's behalf ------------------------------------------
/**
@@ -594,6 +621,19 @@ 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 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
+ */
+ boolean installExistingPackageAsUser(String packageName, int userId);
+
/**
* Clears an app's ART profiles and forces a profile-guided recompile.
*
diff --git a/zygisk/build.gradle.kts b/zygisk/build.gradle.kts
index a105a84c59..c03d2fa91e 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,69 @@ 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 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.
+ //
+ // 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"
+ 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, 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.
+ //
+ // 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 && " +
+ "{ [ -r /data/local/tmp/daemon.apk ] || rm -f /data/local/tmp/manager.apk; }'",
+ )
+ }
+
+ // 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."
+ 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")