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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -270,6 +270,15 @@ object ConfigCache {
}

val newScopes = mutableMapOf<ProcessScope, MutableList<Module>>()

// A module can reach the same process by more than one route: self rows in two users each
// propagate into the other's, and the scope derived below can name a process a row named as
// well. Twice in the list is twice loaded, so every insertion goes through here.
fun addToScope(processName: String, uid: Int, module: Module) {
val modules = newScopes.getOrPut(ProcessScope(processName, uid)) { mutableListOf() }
if (modules.none { it === module }) modules.add(module)
}

ModuleDatabase.enabledScopeRows().forEach { scopeRow ->
val appPkg = scopeRow.appPackage
val modPkg = scopeRow.modulePackage
Expand All@@ -278,7 +287,7 @@ object ConfigCache {
val module = newModules[modPkg] ?: return@forEach

if (appPkg == "system") {
newScopes.getOrPut(ProcessScope("system_server", 1000)) { mutableListOf() }.add(module)
addToScope("system_server", 1000, module)
return@forEach
}

Expand All@@ -291,22 +300,40 @@ object ConfigCache {
val appUid = pkgInfo.applicationInfo!!.uid

for (processName in processNames) {
val processScope = ProcessScope(processName, appUid)
newScopes.getOrPut(processScope) { mutableListOf() }.add(module)
addToScope(processName, appUid, module)

if (modPkg == appPkg) {
val appId = appUid % PER_USER_RANGE
userManager?.getRealUsers()?.forEach { user ->
val moduleUid = user.id * PER_USER_RANGE + appId
if (moduleUid != appUid) {
val moduleSelf = ProcessScope(processName, moduleUid)
newScopes.getOrPut(moduleSelf) { mutableListOf() }.add(module)
}
if (moduleUid != appUid) addToScope(processName, moduleUid, module)
}
}
}
}

// A legacy module reports being active by hooking a method in its own app, so it has to be
// in its own scope before it can say anything at all. The manager used to add that row on
// every save and hide it again on read; #796 dropped both halves, and every legacy module
// has reported itself inactive since (#816).
//
// Derived here rather than stored, so a configuration written by those builds needs no
// repair and nothing that replaces the scope table can drop it again. Legacy is the
// loader's own verdict, so a module built against API 101 keeps its own process to itself.
newModules.values
.filter { it.file?.legacy == true }
.forEach { module ->
userManager?.getRealUsers()?.forEach { user ->
val pkgInfo =
packageManager?.getPackageInfoWithComponents(
module.packageName, MATCH_ALL_FLAGS, user.id) ?: return@forEach
val moduleUid = pkgInfo.applicationInfo?.uid ?: return@forEach
pkgInfo.fetchProcesses().forEach { processName ->
addToScope(processName, moduleUid, module)
}
}
}

// --- ATOMIC STATE SWAP ---
//
// Against the *current* state, not against the copy taken at the top of this function. A
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,14 @@ data class AppInfo(
val isSystemApp: Boolean,
val isGame: Boolean,
val isSelectedInScope: Boolean,
/**
* In the scope without anyone having put it there, and not removable.
*
* Nothing in the scope table says so — the daemon derives this target while it rebuilds its
* configuration — so it is stamped on the row by the screen that knows the rule, exactly as
* [isSelectedInScope] and [isRecommended] are.
*/
val isImplicitInScope: Boolean = false,
val isRecommended: Boolean,
/** When the package was last installed or updated, for the "recently updated" sort. */
val lastUpdateTime: Long,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -427,16 +427,26 @@ fun ScopeScreen(
items(apps, key = { "${it.packageName}:${it.userId}" }) { app ->
AppRow(
app = app,
enabled = !state.recommended.staticScope,
enabled = !state.recommended.staticScope && !app.isImplicitInScope,
origin =
when {
app.isImplicitInScope -> ScopeOrigin.Derived
state.recommended.staticScope && app.isRecommended ->
ScopeOrigin.Locked
app.isRecommended -> ScopeOrigin.Requested
else -> ScopeOrigin.Chosen
},
sharedNote =
app.packageName == SYSTEM_FRAMEWORK_PACKAGE && state.multipleUsers,
// The framework's note only on a device that has more than one user:
// someone editing a work profile module's scope has no other way to know
// that this target is not scoped to their profile, but on a single-user
// phone it is a sentence about a distinction that does not exist.
note =
when {
app.isImplicitInScope -> R.string.scope_self_hook
app.packageName == SYSTEM_FRAMEWORK_PACKAGE &&
state.multipleUsers -> R.string.scope_framework_shared
else -> null
},
onToggle = { checked ->
haptics.performHapticFeedback(
if (checked) HapticFeedbackType.ToggleOn
Expand DownExpand Up@@ -808,6 +818,13 @@ private enum class ScopeOrigin {
Requested,
/** Nothing asked for it; it is in the scope because someone ticked it. */
Chosen,
/**
* The framework put it there, and no row in the scope table records it.
*
* A legacy module's own app: the daemon derives that target every time it rebuilds its
* configuration, so the tick is neither the user's nor the module's to give.
*/
Derived,
}

@Composable
Expand All@@ -818,21 +835,32 @@ private fun ScopeOrigin.color(): Color =
ScopeOrigin.Locked -> MaterialTheme.colorScheme.outline
ScopeOrigin.Requested -> MaterialTheme.colorScheme.primary
ScopeOrigin.Chosen -> Color.Transparent
// The same outline as Locked, and for the same reason: it is a tick nobody on this screen
// owns. The caption below it says which of the two kinds of "not yours" this one is.
ScopeOrigin.Derived -> MaterialTheme.colorScheme.outline
}

private fun ScopeOrigin.labelRes(): Int =
when (this) {
ScopeOrigin.Locked -> R.string.scope_origin_locked
ScopeOrigin.Requested -> R.string.scope_recommended
ScopeOrigin.Chosen -> R.string.scope_origin_chosen
ScopeOrigin.Derived -> R.string.scope_origin_derived
}

@Composable
private fun AppRow(
app: AppInfo,
enabled: Boolean,
origin: ScopeOrigin,
sharedNote: Boolean,
/**
* A sentence under the package name, for a row whose behaviour a label cannot carry.
*
* One slot rather than one flag per case: the two rows that have something to explain — the
* framework, and a legacy module's own app — are never the same row, and a boolean apiece
* would grow with every one that follows.
*/
note: Int?,
onToggle: (Boolean) -> Unit,
onAction: (PackageActionResult) -> Unit,
) {
Expand DownExpand Up@@ -879,13 +907,12 @@ private fun AppRow(
color = ring,
)
}
// The one row on this screen that is not an app, and the one row offered
// identically to every user. Someone editing a work profile module's scope has no
// other way to know that this target is not scoped to their profile — but on a
// single-user device that is a sentence about a distinction that does not exist.
if (sharedNote) {
// Why this row does not behave like the rest: the framework is one process shared
// by every user, and a legacy module's own app is in the scope without anyone
// having put it there. Both are things a checkbox cannot say.
if (note != null) {
Text(
text = stringResource(R.string.scope_framework_shared),
text = stringResource(note),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,6 +58,16 @@ data class ScopeUiState(
* about a distinction that does not exist, so it is not shown.
*/
val multipleUsers: Boolean = false,
/**
* Whether the module is loaded into its own process whatever the scope table says.
*
* A legacy module reports being active by hooking a method in its own app, so it has to be in
* its own scope before it can say anything at all, and the daemon derives that one target
* rather than storing it. Nothing comes back from `getModuleScope` to say so — so without
* this, the one row the module certainly hooks is the one row shown unticked, and with the
* module filter at its default it is not shown at all.
*/
val selfHooked: Boolean = false,
)

class ScopeViewModel(
Expand DownExpand Up@@ -225,6 +235,14 @@ class ScopeViewModel(
// the other few hundred apps beneath uncheckable checkboxes offered a choice that
// does not exist. This one stays absolute: there is no choice to preserve.
val locked = view.state.recommended.staticScope
// The row the daemon hooks whether or not the table names it. Matched on the user
// as well as the package: the same module in a work profile is another copy with
// its own row, and only the copy this screen is editing is the one being loaded
// into the process in front of it.
fun implicit(app: AppInfo) =
view.state.selfHooked &&
app.packageName == modulePackageName &&
app.userId == userId
filters.apps
.asSequence()
.filter { app -> !locked || app.packageName in recommended }
Expand All@@ -236,7 +254,12 @@ class ScopeViewModel(
// An app already in the scope is never filtered away. Otherwise a default
// filter can hide a target the user deliberately chose, and the list then
// disagrees with what the module is actually hooking.
val chosen = ScopeTarget(app.packageName, app.userId) in filters.draft
// Derived counts as chosen throughout, so the module's own row survives
// every filter — including the module filter, which is off by default and
// would otherwise hide the row this whole exemption exists to show.
val chosen =
implicit(app) ||
ScopeTarget(app.packageName, app.userId) in filters.draft
if (filters.recommendedOnly) {
// Answers one question — what does this module want, and what have I
// given it — and the other filters have no say in it. Chrome is a
Expand All@@ -262,7 +285,9 @@ class ScopeViewModel(
.map { app ->
app.copy(
isSelectedInScope =
ScopeTarget(app.packageName, app.userId) in filters.draft,
implicit(app) ||
ScopeTarget(app.packageName, app.userId) in filters.draft,
isImplicitInScope = implicit(app),
isRecommended = app.packageName in recommended,
)
}
Expand DownExpand Up@@ -297,8 +322,14 @@ class ScopeViewModel(
// cannot be found by scrolling, so it has to lead the group it is being
// picked from — and once it is in the scope it is a member like any other,
// with no claim to sit above targets that are already in force.
// A derived row is in force by definition: it is not waiting on an apply,
// and grouping it with the newly ticked would promise a write that will
// never happen.
val (inForce, newlyTicked) =
chosen.partition { ScopeTarget(it.packageName, it.userId) in filters.saved }
chosen.partition {
it.isImplicitInScope ||
ScopeTarget(it.packageName, it.userId) in filters.saved
}
inForce + frameworkFirst(newlyTicked) + frameworkFirst(rest)
}
}
Expand DownExpand Up@@ -422,13 +453,15 @@ class ScopeViewModel(
}
.getOrNull()
}
val recommended =
// One inspection, two answers: what the module asks to hook, and which generation of
// module it is. Both come out of the same pass over the APK, and opening it is the
// expensive part.
val manifest =
info?.let {
withContext(Dispatchers.IO) {
val manifest = ModuleDetection.inspect(it, packageManager)
RecommendedScope(manifest.scope, manifest.staticScope)
}
} ?: RecommendedScope.NONE
withContext(Dispatchers.IO) { ModuleDetection.inspect(it, packageManager) }
}
val recommended =
manifest?.let { RecommendedScope(it.scope, it.staticScope) } ?: RecommendedScope.NONE

_uiState.value =
ScopeUiState(
Expand All@@ -440,6 +473,11 @@ class ScopeViewModel(
recommended = recommended,
loading = false,
multipleUsers = userCount > 1,
// The manager's own reading of the APK, not the daemon's. The daemon settles
// this while it loads the module and never tells anyone — and it only holds an
// answer for a module that is enabled, which is precisely not the state a
// module is in while its scope is being chosen for the first time.
selfHooked = manifest?.isLegacy == true,
)
}
}
Expand DownExpand Up@@ -468,21 +506,33 @@ class ScopeViewModel(

/** Local only. Nothing reaches the daemon until [apply]. */
fun toggle(app: AppInfo, selected: Boolean) {
// A derived row is not the scope table's to give or to take away. Writing a row of our own
// for it would neither add the target — it is already there — nor let it be removed, and
// unticking it would draw an empty box beside a process the module is still loaded into.
if (app.isImplicitInScope) return
val target = ScopeTarget(app.packageName, app.userId)
draftScope.value =
if (selected) draftScope.value + target else draftScope.value - target
}

// Both skip the derived row for the reason [toggle] gives: it is shown among the visible rows
// but it is not one of the ones being written, and either of these sweeping it up would report
// a change to a row whose tick nothing here decides.
fun selectAllVisible() {
draftScope.value =
draftScope.value +
filteredApps.value.map { ScopeTarget(it.packageName, it.userId) }
filteredApps.value
.filterNot { it.isImplicitInScope }
.map { ScopeTarget(it.packageName, it.userId) }
}

fun clearAllVisible() {
draftScope.value =
draftScope.value -
filteredApps.value.map { ScopeTarget(it.packageName, it.userId) }.toSet()
filteredApps.value
.filterNot { it.isImplicitInScope }
.map { ScopeTarget(it.packageName, it.userId) }
.toSet()
}

/** Replace the draft with exactly what the module asked for. */
Expand Down
2 changes: 2 additions & 0 deletions manager/src/main/res/values-ar/strings.xml
Original file line numberDiff line numberDiff line change
Expand Up@@ -256,6 +256,7 @@
<string name="modules_scope_framework">يربط إطار النظام</string>
<string name="scope_origin_locked">ثابت من الوحدة</string>
<string name="scope_origin_chosen">اختيارك</string>
<string name="scope_origin_derived">دائمًا في النطاق</string>
<string name="modules_selection_clear">إلغاء التحديد</string>
<plurals name="modules_selected">
<item quantity="zero">لم يُحدَّد شيء</item>
Expand DownExpand Up@@ -332,6 +333,7 @@
<string name="store_mute_updates_summary">لن تُحتسب قديمة بعد الآن، لا هنا ولا في قائمة الوحدات</string>
<string name="store_options">خيارات</string>
<string name="scope_framework_shared">عملية واحدة يتشاركها جميع المستخدمين، لذا يؤثر هذا في الجهاز كله</string>
<string name="scope_self_hook">تُحمَّل هذه الوحدة داخل تطبيقها الخاص، وبهذا تُبلغ أنها فعّالة</string>
<string name="update_no_output">لم يُخرج برنامج التثبيت أي شيء.</string>
<string name="update_variant_release">نهائي</string>
<string name="update_variant_debug">تنقيح</string>
Expand Down
2 changes: 2 additions & 0 deletions manager/src/main/res/values-de/strings.xml
Original file line numberDiff line numberDiff line change
Expand Up@@ -220,6 +220,7 @@
<string name="modules_scope_framework">Hookt das System-Framework</string>
<string name="scope_origin_locked">Vom Modul festgelegt</string>
<string name="scope_origin_chosen">Deine Wahl</string>
<string name="scope_origin_derived">Immer im Geltungsbereich</string>
<string name="modules_selection_clear">Auswahl aufheben</string>
<plurals name="modules_selected">
<item quantity="one">%1$d ausgewählt</item>
Expand DownExpand Up@@ -288,6 +289,7 @@
<string name="store_mute_updates_summary">Es gilt hier und in der Modulliste nicht mehr als veraltet</string>
<string name="store_options">Optionen</string>
<string name="scope_framework_shared">Ein Prozess, den sich alle Benutzer teilen — das betrifft das ganze Gerät</string>
<string name="scope_self_hook">Dieses Modul wird in seine eigene App geladen — so meldet es, dass es aktiv ist</string>
<string name="update_no_output">Das Installationsprogramm hat nichts ausgegeben.</string>
<string name="update_variant_release">Release</string>
<string name="update_variant_debug">Debug</string>
Expand Down
Loading
Loading