From 17fe9efe1d17b3dd9a411d13da4b9df1f0ef6530 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 31 Jul 2026 07:31:40 +0200 Subject: [PATCH 1/6] Register the install-result receiver ourselves, and name it with a UUID --- .../data/repository/ManagerInstaller.kt | 91 +++-------------- .../data/repository/ModuleInstaller.kt | 85 ++-------------- .../vector/manager/ipc/InstallResult.kt | 97 +++++++++++++++++++ 3 files changed, 119 insertions(+), 154 deletions(-) create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ipc/InstallResult.kt diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ManagerInstaller.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ManagerInstaller.kt index 2a153d7ba..b91f532aa 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ManagerInstaller.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ManagerInstaller.kt @@ -1,27 +1,20 @@ package org.matrix.vector.manager.data.repository -import android.app.PendingIntent -import android.content.BroadcastReceiver import android.content.Context -import android.content.Intent -import android.content.IntentFilter import android.content.pm.PackageInstaller -import android.os.Build import android.util.Log -import androidx.core.content.ContextCompat -import androidx.core.content.IntentCompat import java.io.FileInputStream import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.matrix.vector.manager.BuildConfig import org.matrix.vector.manager.Constants import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.ipc.commitForResult /** Where installing the manager as an app has got to. */ sealed interface ManagerInstallStep { @@ -179,85 +172,25 @@ class ManagerInstaller(private val context: Context, private val daemon: DaemonC /** * Commits the session and waits for the platform's verdict. * - * Registered at runtime rather than declared, because parasitically nothing in this app's - * manifest exists and a declared receiver would never fire. `STATUS_PENDING_USER_ACTION` is not - * terminal — it means the system is asking, and the real status follows the answer. It should - * not arise here: the host holds `INSTALL_PACKAGES`, so the commit is silent. It is handled - * anyway, because the same code runs from a manager that is already installed and updating - * itself, where the prompt is exactly what the platform will do. + * `STATUS_PENDING_USER_ACTION` should not arise here — the host holds `INSTALL_PACKAGES`, so + * the commit is silent — but it is handled anyway, because the same code runs from a manager + * that is already installed and updating itself, where the prompt is exactly what the platform + * will do. + * + * @see commitForResult */ private suspend fun commit( session: PackageInstaller.Session, sessionId: Int, - ): Pair = suspendCancellableCoroutine { continuation -> - val action = "$RESULT_ACTION.$sessionId" - val receiver = - object : BroadcastReceiver() { - override fun onReceive(received: Context, intent: Intent) { - val status = - intent.getIntExtra( - PackageInstaller.EXTRA_STATUS, - PackageInstaller.STATUS_FAILURE, - ) - if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) { - IntentCompat.getParcelableExtra( - intent, - Intent.EXTRA_INTENT, - Intent::class.java, - ) - ?.let { confirm -> - runCatching { - context.startActivity( - confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) - } - .onFailure { e -> - Log.e( - Constants.TAG, - "actions: manager install prompt could not be started", - e, - ) - } - } - return - } - runCatching { context.unregisterReceiver(this) } - if (continuation.isActive) { - continuation.resumeWith( - Result.success( - status to - intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) - ) - ) - } - } - } - - ContextCompat.registerReceiver( - context, - receiver, - IntentFilter(action), - ContextCompat.RECEIVER_NOT_EXPORTED, + ): Pair = + context.commitForResult( + session, + sessionId, + promptFailure = "actions: manager install prompt could not be started", ) - continuation.invokeOnCancellation { runCatching { context.unregisterReceiver(receiver) } } - - val flags = - PendingIntent.FLAG_UPDATE_CURRENT or - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE - else 0 - val pending = - PendingIntent.getBroadcast( - context, - sessionId, - Intent(action).setPackage(context.packageName), - flags, - ) - session.commit(pending.intentSender) - } private companion object { const val WRITE_NAME = "manager.apk" - const val RESULT_ACTION = "org.matrix.vector.manager.INSTALL_MANAGER_RESULT" /** What the platform calls it in `EXTRA_STATUS_MESSAGE`; see PackageManagerException. */ const val SIGNATURE_CONFLICT = "INSTALL_FAILED_UPDATE_INCOMPATIBLE" diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt index b98e79458..0e313490c 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt @@ -1,15 +1,8 @@ package org.matrix.vector.manager.data.repository -import android.app.PendingIntent -import android.content.BroadcastReceiver import android.content.Context -import android.content.Intent -import android.content.IntentFilter import android.content.pm.PackageInstaller -import android.os.Build import android.util.Log -import androidx.core.content.ContextCompat -import androidx.core.content.IntentCompat import java.io.IOException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -18,12 +11,12 @@ import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import org.matrix.vector.manager.Constants import org.matrix.vector.manager.data.model.ReleaseAsset +import org.matrix.vector.manager.ipc.commitForResult /** Where an install has got to. One at a time, because a user installs one module at a time. */ sealed interface InstallStep { @@ -182,82 +175,24 @@ class ModuleInstaller(private val context: Context, private val client: OkHttpCl /** * Commits the session and waits for the platform's verdict. * - * The result arrives as a broadcast, and the receiver is registered at runtime rather than - * declared: parasitically nothing in the manifest exists, so a declared receiver would simply - * never fire. `STATUS_PENDING_USER_ACTION` is not terminal — it means the system is asking the - * user, and the real status follows once they answer. + * @see commitForResult */ private suspend fun commit( session: PackageInstaller.Session, sessionId: Int, packageName: String, - ): Pair = suspendCancellableCoroutine { continuation -> - val action = "$RESULT_ACTION.$sessionId" - val receiver = - object : BroadcastReceiver() { - override fun onReceive(received: Context, intent: Intent) { - val status = - intent.getIntExtra( - PackageInstaller.EXTRA_STATUS, - PackageInstaller.STATUS_FAILURE, - ) - if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) { - _state.value = InstallStep.Confirming(packageName) - IntentCompat.getParcelableExtra(intent, Intent.EXTRA_INTENT, Intent::class.java) - ?.let { confirm -> - runCatching { - context.startActivity( - confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) - } - .onFailure { e -> - Log.e( - Constants.TAG, - "store: install prompt for $packageName could not be started", - e, - ) - } - } - return - } - runCatching { context.unregisterReceiver(this) } - if (continuation.isActive) { - continuation.resumeWith( - Result.success( - status to - intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) - ) - ) - } - } - } - - ContextCompat.registerReceiver( - context, - receiver, - IntentFilter(action), - ContextCompat.RECEIVER_NOT_EXPORTED, - ) - continuation.invokeOnCancellation { runCatching { context.unregisterReceiver(receiver) } } - - val flags = - PendingIntent.FLAG_UPDATE_CURRENT or - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE - else 0 - val pending = - PendingIntent.getBroadcast( - context, - sessionId, - Intent(action).setPackage(context.packageName), - flags, - ) - session.commit(pending.intentSender) - } + ): Pair = + context.commitForResult( + session, + sessionId, + promptFailure = "store: install prompt for $packageName could not be started", + ) { + _state.value = InstallStep.Confirming(packageName) + } private companion object { const val WRITE_NAME = "module.apk" const val CHUNK_BYTES = 64 * 1024 const val PROGRESS_STEP_BYTES = 256L * 1024 - const val RESULT_ACTION = "org.matrix.vector.manager.INSTALL_RESULT" } } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ipc/InstallResult.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/InstallResult.kt new file mode 100644 index 000000000..6a9d3e8f5 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/InstallResult.kt @@ -0,0 +1,97 @@ +package org.matrix.vector.manager.ipc + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageInstaller +import android.os.Build +import android.util.Log +import androidx.core.content.IntentCompat +import java.util.UUID +import kotlinx.coroutines.suspendCancellableCoroutine +import org.matrix.vector.manager.Constants + +/** + * Commits [session] and suspends until the platform says what became of it. + * + * The verdict arrives as a broadcast, and the receiver is registered here rather than declared: + * parasitically the manager's manifest is never installed, so a declared receiver would never fire. + * `STATUS_PENDING_USER_ACTION` is not terminal — it means the system is asking the user, and the + * real status follows their answer. [onPrompt] is the caller's chance to say so on screen, and + * [promptFailure] is what to log if the prompt cannot be started. + * + * **The UUID in the action is what keeps the verdict ours, and below API 33 nothing else can.** A + * registered receiver has no exported flag before then, so anything installed can broadcast to one + * whose action it knows. `ContextCompat.registerReceiver` only appears to answer that: below 33 it + * stands in for the missing flag by demanding `.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION` + * of this process — a signature permission declared by a manifest that parasitically was never + * installed, looked up under the host's package name — so it threw instead of registering, and + * every install on API 27..32 failed before it began. Requiring a permission of the *sender* is no + * better: a `PendingIntent` broadcast is sent as whoever created it, so that is this process, and + * no permission is held both under the host and standalone. + * + * A forged verdict is worth ruling out rather than merely tidy. A fake `STATUS_SUCCESS` reports an + * install that never happened and skips the caller's `abandonSession`; a fake + * `STATUS_PENDING_USER_ACTION` hands us an arbitrary intent to start, and parasitically we would + * start it as `com.android.shell`. The session id is not a secret to lean on either — the platform + * announces every new session to every app in the user, and asks no permission to listen. + */ +suspend fun Context.commitForResult( + session: PackageInstaller.Session, + sessionId: Int, + promptFailure: String, + onPrompt: () -> Unit = {}, +): Pair = suspendCancellableCoroutine { continuation -> + val action = "$RESULT_ACTION.$sessionId.${UUID.randomUUID()}" + val receiver = + object : BroadcastReceiver() { + override fun onReceive(received: Context, intent: Intent) { + if (intent.action != action) return + val status = + intent.getIntExtra( + PackageInstaller.EXTRA_STATUS, + PackageInstaller.STATUS_FAILURE, + ) + if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) { + onPrompt() + IntentCompat.getParcelableExtra(intent, Intent.EXTRA_INTENT, Intent::class.java) + ?.let { confirm -> + runCatching { + startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + .onFailure { Log.e(Constants.TAG, promptFailure, it) } + } + return + } + runCatching { unregisterReceiver(this) } + if (continuation.isActive) { + continuation.resumeWith( + Result.success( + status to intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + ) + ) + } + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(receiver, IntentFilter(action), Context.RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(receiver, IntentFilter(action)) + } + continuation.invokeOnCancellation { runCatching { unregisterReceiver(receiver) } } + + val flags = + PendingIntent.FLAG_UPDATE_CURRENT or + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0 + // The package restriction names the host parasitically, and has to: the receiver belongs to + // this process, so a broadcast confined to the manager's own package would reach nobody. + val pending = + PendingIntent.getBroadcast(this, sessionId, Intent(action).setPackage(packageName), flags) + session.commit(pending.intentSender) +} + +/** Only ever a prefix; the session id and a UUID follow. */ +private const val RESULT_ACTION = "org.matrix.vector.manager.INSTALL_RESULT" From d5d8b46f212f59c76df19688ebf48e9803a67f83 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 31 Jul 2026 09:44:33 +0200 Subject: [PATCH 2/6] Ask for a replace, which our uid does not get for free Installing a module the device already has fails the moment it is an update: INSTALL_FAILED_ALREADY_EXISTS, "Attempt to re-install top.cbug.adbx without first uninstalling", from a session the platform logged as installFlags 0x500030 -- ALL_WHITELIST_RESTRICTED_PERMISSIONS|ALLOW_DOWNGRADE|FROM_ADB| INTERNAL, and no REPLACE_EXISTING. PackageInstallerService.createSessionInternal sets INSTALL_REPLACE_EXISTING for every ordinary caller and takes a separate branch for SHELL_UID and ROOT_UID that only adds INSTALL_FROM_ADB. Parasitically we are uid 2000, so we take that branch and MODE_FULL_INSTALL is all we ever asked for; PackageManagerService then reaches the new-package path for a package already in mPackages and throws. pm install compensates in its own argument parsing -- replaceExisting starts true there and -r is accepted and ignored -- but a caller of the framework API gets no such help. That branch is unchanged from API 27, our minimum, through AOSP main, so a store update has never worked parasitically on any release, and neither has updating an installed manager from the host. Standalone both already worked, because the platform sets the flag for an ordinary uid; the same call is therefore a no-op there, which is why failing to make it is worth only a warning. installFlags is @hide but greylisted, so reflection on it is permitted in both modes. The daemon did exactly this while it still installed the manager itself, until #1103 removed that code. --- .../data/repository/ManagerInstaller.kt | 4 +++ .../data/repository/ModuleInstaller.kt | 2 ++ .../vector/manager/ipc/InstallResult.kt | 31 +++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ManagerInstaller.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ManagerInstaller.kt index b91f532aa..d5b1e37da 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ManagerInstaller.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ManagerInstaller.kt @@ -15,6 +15,7 @@ import org.matrix.vector.manager.BuildConfig import org.matrix.vector.manager.Constants import org.matrix.vector.manager.ipc.DaemonClient import org.matrix.vector.manager.ipc.commitForResult +import org.matrix.vector.manager.ipc.requestReplaceExisting /** Where installing the manager as an app has got to. */ sealed interface ManagerInstallStep { @@ -125,6 +126,9 @@ class ManagerInstaller(private val context: Context, private val daemon: DaemonC // with it. A daemon serving something else cannot install it as Vector. setAppPackageName(BuildConfig.MANAGER_PACKAGE_NAME) if (size > 0) setSize(size) + // Updating an installed manager from the host is a replace, and + // parasitically the platform does not make it one for us. + requestReplaceExisting() } sessionId = packageInstaller.createSession(params) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt index 0e313490c..c63f54bd3 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt @@ -17,6 +17,7 @@ import okhttp3.Request import org.matrix.vector.manager.Constants import org.matrix.vector.manager.data.model.ReleaseAsset import org.matrix.vector.manager.ipc.commitForResult +import org.matrix.vector.manager.ipc.requestReplaceExisting /** Where an install has got to. One at a time, because a user installs one module at a time. */ sealed interface InstallStep { @@ -95,6 +96,7 @@ class ModuleInstaller(private val context: Context, private val client: OkHttpCl .apply { setAppPackageName(packageName) if (asset.size > 0) setSize(asset.size) + requestReplaceExisting() } sessionId = packageInstaller.createSession(params) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ipc/InstallResult.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/InstallResult.kt index 6a9d3e8f5..e5618a9e9 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ipc/InstallResult.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/InstallResult.kt @@ -13,6 +13,34 @@ import java.util.UUID import kotlinx.coroutines.suspendCancellableCoroutine import org.matrix.vector.manager.Constants +/** + * Asks for an install that replaces whatever copy of the package is already on the device. + * + * `MODE_FULL_INSTALL` does not say that, and parasitically nothing else does either. + * `PackageInstallerService.createSessionInternal` sets `INSTALL_REPLACE_EXISTING` itself for every + * ordinary caller, and takes a separate branch for `SHELL_UID` and `ROOT_UID` which adds + * `INSTALL_FROM_ADB` and leaves the rest of the flags as they came. `pm install` sets the flag in + * its own argument parsing, which is why an adb install still replaces and why `-r` is accepted and + * ignored; a caller of the framework API gets no such help. Under the host the manager *is* that + * uid, so `PackageManagerService` treats a module the device already has as a first install and + * fails it with `INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install without first + * uninstalling`. That branch reads the same from API 27, this app's minimum, to AOSP main, so + * updating a module through the store has never worked parasitically on any release, and neither + * has updating an installed manager from the host. + * + * Standalone this changes nothing, because the platform has already set the flag by the time a + * session exists — which is why failing to set it is worth no more than a warning. The field is + * `@hide` but greylisted (`@UnsupportedAppUsage` carrying no `maxTargetSdk`), so the reflection is + * permitted in both modes rather than only under the platform-signed host. + */ +fun PackageInstaller.SessionParams.requestReplaceExisting() { + runCatching { + val flags = PackageInstaller.SessionParams::class.java.getDeclaredField("installFlags") + flags.setInt(this, flags.getInt(this) or INSTALL_REPLACE_EXISTING) + } + .onFailure { Log.w(Constants.TAG, "ipc: install session could not request a replace", it) } +} + /** * Commits [session] and suspends until the platform says what became of it. * @@ -95,3 +123,6 @@ suspend fun Context.commitForResult( /** Only ever a prefix; the session id and a UUID follow. */ private const val RESULT_ACTION = "org.matrix.vector.manager.INSTALL_RESULT" + +/** `PackageManager.INSTALL_REPLACE_EXISTING`, `@hide` like the field it belongs in. */ +private const val INSTALL_REPLACE_EXISTING = 0x00000002 From 0170f18ea98484838681e2d896c8ba4e228bcc9c Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 31 Jul 2026 10:02:59 +0200 Subject: [PATCH 3/6] Stop offering an update that installing cannot satisfy Installing top.cbug.adbx from the store leaves it in "Modules to update" for ever, reading "1.1.1 -> 1.1.1" (#823). Its newest release is tagged 4115-1.1.1 around an APK that is versionCode 4, and the offer compares a code read out of the tag against the code read out of PackageInfo, so 4115 > 4 stays true however many times it is installed. Reading the manifest out of the newest APK of every catalogue entry says how common that is, and how it fails: of the 745 entries publishing a single APK, 4 state a code the APK does not have and 15 state a different name, and 8 end up unsatisfiable -- one through the code half of the tag, six through the name half, com.coderstory.flyme through both (tagged 469-8.8 around 468/4.2.4.Final). It is also not something the two numbers can be made to answer. 31 modules have kept a single tag code across every release they ever published, so only the name clause can see their updates; 17 publish one versionName under several codes, so only the code clause can see theirs. Any rule over (code, name) is wrong for one of those three sets. So the Store now remembers what it installed: the release, and the version the device reported once it was on. An offer whose release matches that note, on a device still reporting that version, is not an update. Both halves are load-bearing -- the release, because a genuinely new one must ask again; the device version, because a module replaced from anywhere else must too. That version comes from the same all-users read the offer is compared against, which is what readInstalled() is for: a local getPackageInfo answers for user 0 while installedVersions keeps the highest version across every user, and on a device with a work profile the two differ -- a note that could never match, on exactly the row it was meant to settle. So the note is written where an install is known to have landed and that read is already being made -- the store page and the update queue -- and read in StoreEntry.upgradable, the one place that decides, so the header count, the updates filter, the row badge, the Modules badge and the update sheet all change together. RepoDetailsState computes its own answer deliberately and is given the same note, or the page would contradict the list that led to it. Callers pass the release the asset came from rather than the newest one, so installing an older release on purpose does not silence the newest. --- .../vector/manager/data/model/RepoModels.kt | 43 +++++++++++++++++ .../data/repository/ModuleInstaller.kt | 5 ++ .../data/repository/ModuleUpdateQueue.kt | 36 +++++++++++++-- .../manager/data/repository/RepoRepository.kt | 17 +++++++ .../data/repository/SettingsRepository.kt | 46 +++++++++++++++++++ .../vector/manager/di/ServiceLocator.kt | 6 ++- .../ui/components/PackageActionMenu.kt | 1 + .../ui/screens/modules/ModulesScreen.kt | 20 ++++---- .../ui/screens/repo/RepoDetailsScreen.kt | 15 +++--- .../ui/screens/repo/RepoDetailsViewModel.kt | 41 +++++++++++++++-- .../manager/ui/screens/repo/RepoViewModel.kt | 15 ++++-- 11 files changed, 215 insertions(+), 30 deletions(-) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt index 6b2afbc09..cb653b562 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt @@ -137,6 +137,10 @@ data class ReleaseAsset( */ data class RepoVersion(val versionCode: Long, val versionName: String) { + /** The tag this was read from, which is also how [StoreInstall] writes one back down. */ + val tag: String + get() = "$versionCode-$versionName" + fun upgradableOver(installedCode: Long, installedName: String): Boolean = versionCode > installedCode || (versionCode == installedCode && installedName.replace(' ', '_') != versionName) @@ -152,6 +156,34 @@ data class RepoVersion(val versionCode: Long, val versionName: String) { } } +/** + * A release this manager installed, and what the device said the module was afterwards. + * + * Two versions, because they are not the same kind of fact and for some modules not the same + * number: [release] is what a tag claimed, [installed] is what the APK inside it turned out to be. + * + * That difference is the whole reason this is recorded. The comparison above believes the tag, and + * for most modules it may — of the 745 catalogue entries whose newest release carries a single APK, + * 741 state the code their APK really has. For the rest the offer cannot be satisfied by installing + * it: `top.cbug.adbx` is tagged `4115-1.1.1` around an APK that is versionCode 4, so `4115 > 4` + * stays true however many times it is installed, and the update sheet reads `1.1.1 → 1.1.1` for + * ever. Seven others are stuck the same way, six of them through the name half of the tag rather + * than the code. Nothing in the two numbers distinguishes that from a rebuild the reader does want + * — 17 modules publish the same versionName under several codes, and 31 keep one code across every + * release they have ever published, so the code signal is dead for those and the name signal is + * dead for these. Rather than parse the tag harder, the Store remembers what it installed and + * answers "you already have this one". + * + * [installed] is what makes the record expire on its own: it is checked against what the device + * reports now, so a module replaced from anywhere else stops matching and the offer comes back. + */ +data class StoreInstall(val release: RepoVersion, val installed: RepoVersion) { + + /** Whether this note says [latest] is already here, as [current]. */ + fun satisfies(latest: RepoVersion?, current: RepoVersion?): Boolean = + release == latest && installed == current +} + /** * One row of the Store: a catalogue entry, plus what this device has to say about it. * @@ -164,10 +196,20 @@ data class StoreEntry( val installed: RepoVersion?, /** The reader asked not to be told about this one again. */ val updatesMuted: Boolean = false, + /** What this manager last installed here, if this manager is what installed it. */ + val storeInstall: StoreInstall? = null, ) { + + /** The newest release is one we installed, and the device still reports what it left behind. */ + private val alreadyInstalled: Boolean + get() = storeInstall?.satisfies(latest, installed) == true + /** * There is a newer version *and* the reader wants to hear about it. * + * A release this manager itself installed is not a newer version, whatever the two numbers say; + * see [StoreInstall]. + * * Muting is folded in here rather than at each place that reads this, because every list and * count that mentions updates reads it — the Store's header count, its updates filter, its row * badge, and the set the Modules screen badges from — and a mute that only some of them @@ -184,6 +226,7 @@ data class StoreEntry( !updatesMuted && installed != null && latest != null && + !alreadyInstalled && latest.upgradableOver(installed.versionCode, installed.versionName) } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt index c63f54bd3..5f2887a13 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt @@ -74,6 +74,11 @@ class ModuleInstaller(private val context: Context, private val client: OkHttpCl * Returns true only when the platform reports the package installed. There is no resume: a * dropped connection costs the whole transfer, which is an acceptable trade for module APKs * (tens to a few hundred kilobytes) in exchange for never touching the filesystem. + * + * What became of it is recorded by the caller rather than here — see RepoRepository.readInstalled + * and SettingsRepository.noteStoreInstall — because the version to record has to be read the way + * the Store reads it, across every user, and this class talks to the platform rather than to the + * daemon. */ suspend fun install(packageName: String, asset: ReleaseAsset): Boolean = withContext(Dispatchers.IO) { diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleUpdateQueue.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleUpdateQueue.kt index b4be903d0..c8fd1e3a4 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleUpdateQueue.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleUpdateQueue.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.matrix.vector.manager.data.model.ReleaseAsset +import org.matrix.vector.manager.data.model.RepoVersion +import org.matrix.vector.manager.data.model.StoreInstall /** * Several module updates, installed one after another. @@ -27,11 +29,22 @@ class ModuleUpdateQueue( private val installer: ModuleInstaller, private val store: RepoRepository, private val modules: ModuleRepository, + private val settings: SettingsRepository, private val scope: CoroutineScope, ) { - /** One module to update, resolved before the run starts so nothing is looked up mid-flight. */ - data class Item(val packageName: String, val title: String, val asset: ReleaseAsset) + /** + * One module to update, resolved before the run starts so nothing is looked up mid-flight. + * + * [release] is the version of the release [asset] came from, carried so that the installer can + * record what it put on the device. See ModuleInstaller.install. + */ + data class Item( + val packageName: String, + val title: String, + val asset: ReleaseAsset, + val release: RepoVersion?, + ) data class State( val queued: List = emptyList(), @@ -82,7 +95,8 @@ class ModuleUpdateQueue( // Once, at the end, rather than after each install: every version read comes from // one daemon call over every installed package, and paying that four times to // watch four badges settle a second earlier each is not a trade worth making. - store.refreshInstalled() + // Awaited, because the notes below are written from that same read. + note(items, store.readInstalled()) // Told rather than overheard. A replaced package does broadcast, and the manager // does listen, but this is the one install path the app performed itself: there is // no reason for the list to wait on a delivery the system owns. @@ -90,6 +104,22 @@ class ModuleUpdateQueue( } } + /** + * Records what landed, so the Store stops offering a release it has already installed. + * + * Only the items that succeeded, and only against what the device reports now — which is why + * [installed] is passed in rather than read here. See [StoreInstall]. + */ + private fun note(items: List, installed: Map) { + val landed = _state.value.done + for (item in items) { + if (item.packageName !in landed) continue + val release = item.release ?: continue + val version = installed[item.packageName] ?: continue + settings.noteStoreInstall(item.packageName, StoreInstall(release, version)) + } + } + /** * Clears the run, finished or not. * diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt index 23f13b048..40f023e4c 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt @@ -158,6 +158,23 @@ class RepoRepository( scope.launch { loadInstalled() } } + /** + * The same read, awaited and handed back, for a caller that has to act on what it finds. + * + * Which is how an install records what it produced: the note that suppresses a satisfied offer + * is compared against [installedVersions], so it has to be written from that same reading. A + * local `getPackageInfo` would answer for user 0 while this map answers with the highest version + * across every user, and on a device with a work profile the two differ — leaving a note that can + * never match and a row that nags for ever. + * + * Returns the last known map when the daemon cannot be reached, which is the safe direction: a + * note written from a stale version simply fails to match, and the offer stays. + */ + suspend fun readInstalled(): Map { + loadInstalled() + return _installed.value + } + private suspend fun loadInstalled() { val packages = daemon 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 b8477a075..995d000fd 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 @@ -5,6 +5,8 @@ import android.content.SharedPreferences import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import org.matrix.vector.manager.data.model.RepoVersion +import org.matrix.vector.manager.data.model.StoreInstall /** * The manager's own preferences: how it looks, what it shows, and what it has been told to stop @@ -197,6 +199,50 @@ class SettingsRepository(context: Context) { _mutedUpdates.value = next } + /** + * Which catalogue release the Store put on this device, per package. See [StoreInstall]. + * + * Here rather than in the daemon for the reason the mute above is: the daemon has never heard + * of the catalogue, and this is a fact about what *this* app did rather than about the module. + * It has to survive a process death for the same reason too — parasitically the process is the + * shell's, and it is killed constantly, so an in-memory note would forget by the next visit and + * the offer it silenced would be back. + * + * A string set, like the mute, rather than a serialised map: three fields per row, joined by + * newlines, which no package name or tag contains. A row that no longer parses is dropped, + * which is the right answer for a note whose only job is to suppress an offer — the worst a + * lost row can do is offer an update again. Rows are never pruned either, for the same reason: + * one is a few dozen bytes, a device carries tens of modules, and a note left behind by a + * module that has since been uninstalled says nothing until that module is back at that exact + * version. + */ + private val _storeInstalls = MutableStateFlow(readStoreInstalls()) + val storeInstalls: StateFlow> = _storeInstalls.asStateFlow() + + /** Records what the Store installed for [packageName], replacing any earlier note of it. */ + fun noteStoreInstall(packageName: String, install: StoreInstall) { + val next = _storeInstalls.value + (packageName to install) + val rows = next.mapTo(HashSet()) { (name, noted) -> encode(name, noted) } + prefs.edit().putStringSet("store_installs", rows).apply() + _storeInstalls.value = next + } + + private fun encode(packageName: String, install: StoreInstall): String = + "$packageName\n${install.release.tag}\n${install.installed.tag}" + + private fun readStoreInstalls(): Map = + prefs + .getStringSet("store_installs", emptySet()) + .orEmpty() + .mapNotNull { row -> + val parts = row.split('\n') + if (parts.size != 3) return@mapNotNull null + val release = RepoVersion.parse(parts[1]) ?: return@mapNotNull null + val installed = RepoVersion.parse(parts[2]) ?: return@mapNotNull null + parts[0] to StoreInstall(release, installed) + } + .toMap() + /** Which living surface the status header draws. See AmbienceKind. */ private val _headerAmbience = MutableStateFlow(prefs.getString("header_ambience", DEFAULT_AMBIENCE) ?: DEFAULT_AMBIENCE) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/di/ServiceLocator.kt b/manager/src/main/kotlin/org/matrix/vector/manager/di/ServiceLocator.kt index 2b3a0bf12..d22f39587 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/di/ServiceLocator.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/di/ServiceLocator.kt @@ -135,7 +135,8 @@ object ServiceLocator { store.installedVersions, settings.updateChannel, settings.mutedUpdates, - ) { catalog, installed, channelPreference, muted -> + settings.storeInstalls, + ) { catalog, installed, channelPreference, muted, storeInstalls -> val channel = StoreChannel.of(channelPreference) catalog.modules .filter { it.name in installed } @@ -146,6 +147,7 @@ object ServiceLocator { latest = module.latestOn(channel), installed = installed[module.name], updatesMuted = module.name in muted, + storeInstall = storeInstalls[module.name], ) } } @@ -186,7 +188,7 @@ object ServiceLocator { } /** Sequential module updates, outliving the sheet that started them. */ - val moduleUpdates: ModuleUpdateQueue by lazy { ModuleUpdateQueue(installer, store, modules, appScope) } + val moduleUpdates: ModuleUpdateQueue by lazy { ModuleUpdateQueue(installer, store, modules, settings, appScope) } val frameworkInstaller: FrameworkInstaller by lazy { FrameworkInstaller(context, http, daemon) } 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 7fa03357b..7cffeeb0b 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 @@ -657,6 +657,7 @@ private fun ModuleUpdateSection( packageName = packageName, title = entry.module.title, asset = asset, + release = release?.version, ) ) ) 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 bf4383307..40cebbefa 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 @@ -98,6 +98,7 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.rememberModalBottomSheetState import org.matrix.vector.manager.data.model.ReleaseAsset +import org.matrix.vector.manager.data.model.RepoVersion import org.matrix.vector.manager.data.model.StoreEntry import org.matrix.vector.manager.data.repository.ModuleUpdateQueue import org.matrix.vector.manager.ui.components.SheetHeading @@ -1315,20 +1316,20 @@ private fun ModuleUpdatesSheet( // One APK is installable from here; several is a choice this sheet has no room to make, so // those keep their row, uncheckable, pointing at the store page that does. - data class Row(val entry: StoreEntry, val asset: ReleaseAsset?, val muted: Boolean) + data class Row( + val entry: StoreEntry, + val release: RepoVersion?, + val asset: ReleaseAsset?, + val muted: Boolean, + ) val rows = remember(entries, upgradable, mutedUpgradable, channel) { (upgradable + mutedUpgradable).mapNotNull { name -> val entry = entries[name] ?: return@mapNotNull null - val apks = - entry.module - .releasesOn(channel) - .firstOrNull() - ?.releaseAssets - .orEmpty() - .filter { it.isApk } - Row(entry, apks.singleOrNull(), name in mutedUpgradable) + val release = entry.module.releasesOn(channel).firstOrNull() + val apks = release?.releaseAssets.orEmpty().filter { it.isApk } + Row(entry, release?.version, apks.singleOrNull(), name in mutedUpgradable) } .sortedWith(compareBy({ it.muted }, { it.entry.module.title.lowercase() })) } @@ -1420,6 +1421,7 @@ private fun ModuleUpdatesSheet( packageName = it.entry.module.name, title = it.entry.module.title, asset = it.asset!!, + release = it.release, ) } ) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsScreen.kt index 4e7ab0572..9898e70d8 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsScreen.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsScreen.kt @@ -154,7 +154,9 @@ fun RepoDetailsScreen(packageName: String, onNavigateBack: () -> Unit) { var choosing by remember { mutableStateOf(null) } var optionsOpen by remember { mutableStateOf(false) } - var confirming by remember { mutableStateOf(null) } + // The release travels with the asset: what is installed is recorded against the release it came + // from, and picking an older release from the list must not silence the newest one. + var confirming by remember { mutableStateOf?>(null) } Scaffold( topBar = { @@ -209,7 +211,8 @@ fun RepoDetailsScreen(packageName: String, onNavigateBack: () -> Unit) { // One file is the overwhelmingly common case, and asking which of one is // noise. More than one and the choice is the user's — some modules ship a // variant per architecture. - if (assets.size == 1) confirming = assets.first() else choosing = release + if (assets.size == 1) confirming = release to assets.first() + else choosing = release }, onAcknowledge = viewModel::acknowledgeInstall, ) @@ -306,7 +309,7 @@ fun RepoDetailsScreen(packageName: String, onNavigateBack: () -> Unit) { onOpenUrl = openUrl, onInstall = { release -> val assets = release.apks - if (assets.size == 1) confirming = assets.first() + if (assets.size == 1) confirming = release to assets.first() else choosing = release }, ) @@ -347,12 +350,12 @@ fun RepoDetailsScreen(packageName: String, onNavigateBack: () -> Unit) { onDismiss = { choosing = null }, onPick = { asset -> choosing = null - confirming = asset + confirming = release to asset }, ) } - confirming?.let { asset -> + confirming?.let { (release, asset) -> ConfirmInstall( module = state.module, packageName = packageName, @@ -360,7 +363,7 @@ fun RepoDetailsScreen(packageName: String, onNavigateBack: () -> Unit) { onDismiss = { confirming = null }, onConfirm = { confirming = null - viewModel.install(asset) + viewModel.install(asset, release.version) }, ) } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsViewModel.kt index 33a4d3cb8..0045ea0e6 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsViewModel.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsViewModel.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -17,6 +18,7 @@ import org.matrix.vector.manager.data.model.OnlineModule import org.matrix.vector.manager.data.model.Release import org.matrix.vector.manager.data.model.ReleaseAsset import org.matrix.vector.manager.data.model.RepoVersion +import org.matrix.vector.manager.data.model.StoreInstall import org.matrix.vector.manager.data.repository.InstallStep import org.matrix.vector.manager.data.repository.ModuleInstaller import org.matrix.vector.manager.data.repository.RepoRepository @@ -45,11 +47,20 @@ data class RepoDetailsState( val latest: RepoVersion? = null, val fetch: DetailFetch = DetailFetch.Loading, val channel: StoreChannel = StoreChannel.Stable, + /** What the Store last installed for this module, if the Store is what installed it. */ + val storeInstall: StoreInstall? = null, ) { + /** + * As `StoreEntry.upgradable`, minus the mute: this page is a module the reader went looking for. + * + * The note is honoured here as well, and has to be. It is the one thing that keeps this badge + * from disagreeing with the list that led to it — see [StoreInstall]. + */ val upgradable: Boolean get() = installed != null && latest != null && + storeInstall?.satisfies(latest, installed) != true && latest.upgradableOver(installed.versionCode, installed.versionName) } @@ -110,17 +121,30 @@ class RepoDetailsViewModel( fun setUpdatesMuted(muted: Boolean) = settings.setUpdatesMuted(packageName, muted) + /** + * The two preferences this page reads, as one value. + * + * Paired rather than passed separately because `combine` takes five flows and this page already + * watches five things of its own. + */ + private data class Preferences(val channel: StoreChannel, val storeInstall: StoreInstall?) + + private fun preferences(): Flow = + combine(settings.updateChannel, settings.storeInstalls) { channelPreference, installs -> + Preferences(StoreChannel.of(channelPreference), installs[packageName]) + } + val state: StateFlow = combine( repository.catalog, _detail, _fetch, repository.installedVersions, - settings.updateChannel, - ) { catalog, detail, fetch, installed, channelPreference -> + preferences(), + ) { catalog, detail, fetch, installed, preferences -> val seed = catalog.modules.firstOrNull { it.name == packageName } val module = detail ?: seed - val channel = StoreChannel.of(channelPreference) + val channel = preferences.channel RepoDetailsState( module = module, releases = releasesFor(module, channel), @@ -128,6 +152,7 @@ class RepoDetailsViewModel( latest = latestFor(module, channel), fetch = fetch, channel = channel, + storeInstall = preferences.storeInstall, ) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), RepoDetailsState()) @@ -156,9 +181,15 @@ class RepoDetailsViewModel( * change of mind. The installer's state is a single shared flow, so coming back re-attaches to * the progress that kept running. */ - fun install(asset: ReleaseAsset) { + fun install(asset: ReleaseAsset, release: RepoVersion?) { backgroundScope.launch { - if (installer.install(packageName, asset)) repository.refreshInstalled() + if (!installer.install(packageName, asset)) return@launch + // The version has to come from this read rather than from the platform directly: it is + // the one the offer is compared against. See RepoRepository.readInstalled. + val installed = repository.readInstalled()[packageName] + if (release != null && installed != null) { + settings.noteStoreInstall(packageName, StoreInstall(release, installed)) + } } } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoViewModel.kt index 75626a732..71100bef9 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoViewModel.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoViewModel.kt @@ -20,6 +20,7 @@ import org.matrix.vector.manager.data.model.Release import org.matrix.vector.manager.data.model.RepoVersion import org.matrix.vector.manager.data.model.StoreCatalog import org.matrix.vector.manager.data.model.StoreEntry +import org.matrix.vector.manager.data.model.StoreInstall import org.matrix.vector.manager.data.repository.RepoRepository import org.matrix.vector.manager.data.repository.SettingsRepository @@ -148,12 +149,14 @@ class RepoViewModel( * and it walks 809 entries. */ private val allEntries: StateFlow> = - combine(repository.catalog, repository.installedVersions, channel, settings.mutedUpdates) { - catalog, - installed, + combine( + repository.catalog, + repository.installedVersions, channel, - muted -> - catalog.modules.map { entryFor(it, installed, channel, muted) } + settings.mutedUpdates, + settings.storeInstalls, + ) { catalog, installed, channel, muted, storeInstalls -> + catalog.modules.map { entryFor(it, installed, channel, muted, storeInstalls) } } .flowOn(Dispatchers.Default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) @@ -231,12 +234,14 @@ class RepoViewModel( installed: Map, channel: StoreChannel, muted: Set, + storeInstalls: Map, ): StoreEntry = StoreEntry( module = module, latest = module.latestOn(channel), installed = installed[module.name], updatesMuted = module.name in muted, + storeInstall = storeInstalls[module.name], ) private fun StoreEntry.matches(query: String): Boolean { From 9c04cfd952505cd9c3c75346e12aa4227689c461 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 31 Jul 2026 10:29:17 +0200 Subject: [PATCH 4/6] Let the in-app browser have the network the process already has Every page in the in-app browser fails with Chromium's own error page, net::ERR_CACHE_MISS, on a device whose networking is fine -- the same process fetches the catalogue and downloads module APKs over OkHttp while it happens (#823). AwSettings sets mBlockNetworkLoads from context.checkSelfPermission(INTERNET) in its constructor, and a blocked load is not a refused socket: net_helpers rewrites every request to LOAD_ONLY_FROM_CACHE, and HttpCache::Transaction then answers a cache it was never allowed to fill with ERR_CACHE_MISS. The check is by uid, and parasitically the uid is 2000: AOSP's packages/Shell did not request INTERNET until android-12, and PermissionManagerService.checkUidPermission reads the grants of the package owning the uid, consulting platform.xml's assign-permission uid="shell" only when no package owns it. So on Android 11 and below the honest answer is DENIED and WebScreen could never load a page. The reporter's own bundle settles that it is denied on that device rather than merely absent from AOSP: /proc/7696/status lists gid 3003 exactly once, and the zygisk module contributes exactly one (module.cpp:241-281, logged "Manager app detected. Granting internet permissions." for that pid), so the shell package's own PermissionsState contributed none. Nothing between PMS and /proc dedupes. That injected gid, with setAllowNetworkingForProcess, is why sockets and DNS work for OkHttp -- but it does not change what the platform answers, and the answer is all AwSettings reads. setBlockNetworkLoads(false) is no way round it either: it throws SecurityException while the permission is missing. So the context a WebView is constructed with answers that one question, and only where the platform says no -- not gated on the SDK level, because an OEM that strips the permission from its own shell package needs the same treatment. Nothing else in the manager sees it, and a real network failure still arrives as a real network error rather than as a cache miss. Both WebViews are built through it now, which also collapses the two copies of the night-mode configuration context into the one place that already had to exist. The README pane escaped the bug by accident -- it serves its own subresources from shouldInterceptRequest, which never reaches the net stack -- and anything it does not intercept was failing silently. Unverified on hardware: nothing in the bundle shows a chromium load succeeding in that process, so whether its own sockets are satisfied once the flag is off wants a device on Android 11 or below. --- .../manager/ui/screens/repo/StoreHtml.kt | 18 ++---- .../manager/ui/screens/web/WebScreen.kt | 17 +---- .../manager/ui/screens/web/WebViewContext.kt | 63 +++++++++++++++++++ 3 files changed, 70 insertions(+), 28 deletions(-) create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/web/WebViewContext.kt diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/StoreHtml.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/StoreHtml.kt index 83023f316..055841316 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/StoreHtml.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/StoreHtml.kt @@ -1,7 +1,5 @@ package org.matrix.vector.manager.ui.screens.repo -import android.content.Context -import android.content.res.Configuration import android.annotation.SuppressLint import android.view.MotionEvent import android.view.ViewConfiguration @@ -28,6 +26,7 @@ import java.io.ByteArrayInputStream import okhttp3.OkHttpClient import okhttp3.Request import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ui.screens.web.forWebView /** * Repository-supplied HTML — a README, or a release's notes — rendered inside Vector. @@ -91,8 +90,9 @@ private fun HtmlPane( // A WebView reads prefers-color-scheme from the configuration of the context it was built // with. The stylesheet below covers our own markup, but a README using with a - // dark-mode picks its image from this. - val themedContext = remember(dark) { context.forNightMode(dark) } + // dark-mode picks its image from this. The same context is what decides whether the + // pane may fetch anything, which parasitically it otherwise may not — see [forWebView]. + val themedContext = remember(dark) { context.forWebView(dark) } val webView = remember(themedContext) { @@ -215,16 +215,6 @@ private val TRANSPARENT_GIF = 0x3B, // trailer ) -private fun Context.forNightMode(dark: Boolean): Context { - val configuration = - Configuration(resources.configuration).apply { - uiMode = - (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or - if (dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO - } - return createConfigurationContext(configuration) -} - private fun Color.isDark(): Boolean = (0.299f * red + 0.587f * green + 0.114f * blue) < 0.5f private fun Color.css(): String = "#%06X".format(toArgb() and 0xFFFFFF) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/web/WebScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/web/WebScreen.kt index a22f9f711..6460b3a98 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/web/WebScreen.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/web/WebScreen.kt @@ -2,7 +2,6 @@ package org.matrix.vector.manager.ui.screens.web import android.content.ActivityNotFoundException import android.content.Intent -import android.content.res.Configuration import android.graphics.Bitmap import android.net.Uri import android.view.ViewGroup @@ -93,19 +92,9 @@ fun WebScreen(url: String, onNavigateBack: () -> Unit) { var secure by remember { mutableStateOf(url.startsWith("https")) } var barVisible by remember { mutableStateOf(true) } - // The night bit is read from the context the WebView is constructed with, so forcing it here is - // what makes the page follow Vector's own theme rather than the system's. - val themedContext = - remember(dark) { - val config = - Configuration(context.resources.configuration).apply { - uiMode = - (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or - if (dark) Configuration.UI_MODE_NIGHT_YES - else Configuration.UI_MODE_NIGHT_NO - } - context.createConfigurationContext(config) - } + // Both the page's colour scheme and whether it may reach the network at all are decided by the + // context this is built with, and cannot be changed afterwards. See [forWebView]. + val themedContext = remember(dark) { context.forWebView(dark) } val webView = remember(themedContext) { diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/web/WebViewContext.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/web/WebViewContext.kt new file mode 100644 index 000000000..472dd7aeb --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/web/WebViewContext.kt @@ -0,0 +1,63 @@ +package org.matrix.vector.manager.ui.screens.web + +import android.content.Context +import android.content.ContextWrapper +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.os.Process + +/** + * The context a `WebView` has to be built with, because both of these are read once, at + * construction, and cannot be set afterwards. + * + * **The theme.** A `WebView` resolves `prefers-color-scheme` through the configuration of the + * context it was constructed with, so the activity's own context renders a black GitHub page under + * a white app bar whenever the app's theme disagrees with the system's. The night bit is forced to + * match the Compose theme instead. + * + * **Whether it may use the network at all.** `AwSettings` sets `mBlockNetworkLoads` from + * `context.checkSelfPermission(INTERNET)` in its constructor, and a blocked load is implemented as + * `LOAD_ONLY_FROM_CACHE`, so an empty cache makes every page fail as Chromium's own + * `net::ERR_CACHE_MISS` error page — on a device whose networking is perfectly fine. + * + * That check is by uid, and parasitically our uid is 2000. AOSP's `packages/Shell` did not request + * `INTERNET` until Android 12, and `PermissionManagerService.checkUidPermission` reads the grants + * of the package that owns the uid — consulting `platform.xml`'s `assign-permission … uid="shell"` + * only when *no* package owns it, which is never true here. So below Android 12 the platform's + * honest answer for the host is DENIED, and the in-app browser could not load a single page. + * + * The process does have networking: the zygisk module adds `GID_INET` to the manager's fork and + * makes `Zygote` set `setAllowNetworkingForProcess`, which is why OkHttp fetches the catalogue and + * downloads module APKs in this same process. None of that changes what the platform *answers*, and + * the answer is all `AwSettings` looks at. `setBlockNetworkLoads(false)` is not a way round it + * either — it throws `SecurityException` while the permission is missing. + * + * So this context answers the one question, and only where the platform says no: not gated on the + * SDK level, because an OEM that strips the permission from its own shell package needs the same + * treatment. Nothing outside the two `WebView`s is affected, and a genuine network failure still + * arrives as a genuine network error rather than as a cache miss. + */ +internal fun Context.forWebView(dark: Boolean): Context { + val configuration = + Configuration(resources.configuration).apply { + uiMode = + (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or + if (dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO + } + val themed = createConfigurationContext(configuration) + if (themed.checkSelfPermission(INTERNET) == PackageManager.PERMISSION_GRANTED) return themed + + return object : ContextWrapper(themed) { + override fun checkSelfPermission(permission: String): Int = + if (permission == INTERNET) PackageManager.PERMISSION_GRANTED + else super.checkSelfPermission(permission) + + // Older WebView builds ask this instead, about this process. A question about any other + // uid is somebody else's business and is passed through. + override fun checkPermission(permission: String, pid: Int, uid: Int): Int = + if (permission == INTERNET && uid == Process.myUid()) PackageManager.PERMISSION_GRANTED + else super.checkPermission(permission, pid, uid) + } +} + +private const val INTERNET = "android.permission.INTERNET" From 4208461eda509fbf957832fffe25b1a5448556ef Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 31 Jul 2026 11:28:50 +0200 Subject: [PATCH 5/6] Never word an offer as an update from a version to itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The update sheet puts the installed and the offered version name either side of an arrow with no check that they differ, so a module whose tag names the version already on the device reads "1.1.1 -> 1.1.1"; the store badge, the store page's install bar and the module sheet's action row say "Update to 1.1.1" over "From 1.1.1". Eight catalogue entries are in that state today, and the note in the previous commit only settles the row once the reader has installed through the store — before that, and for a module installed by hand, it is still there and still saying something the app cannot mean. Two different things arrive at it and neither number tells them apart: a rebuild of one versionName under a higher code (17 modules do this), and a tag whose code is simply not the APK's (8). The wording therefore has to be true of both, and what is certain in both is where the reader ends up — on this version name again. So "Reinstall 1.1.1", with the size where the pair used to be, and "Same version" beneath it in the module sheet, whose title already names the version. RepoVersion.sameVersionAs applies the same ' ' -> '_' normalisation upgradableOver does, because a git tag cannot carry a space. StoreEntry.sameVersion deliberately does not feed upgradable: whether to offer at all is a different question from what to call it, and a rebuild is worth offering. All eighteen locales are filled in here rather than left to the next Crowdin round, because every one of them is complete today and lint is entitled to say so. Each takes its own file's existing verb and word order -- store_reinstall and store_badge_update -- so nothing new is coined; translators can still revise them through Crowdin as usual. --- .../vector/manager/data/model/RepoModels.kt | 25 +++++++++++++++++++ .../ui/components/PackageActionMenu.kt | 13 +++++++++- .../ui/screens/modules/ModulesScreen.kt | 10 ++++++++ .../ui/screens/repo/RepoDetailsScreen.kt | 3 ++- .../ui/screens/repo/RepoDetailsViewModel.kt | 4 +++ .../manager/ui/screens/repo/RepoScreen.kt | 3 ++- manager/src/main/res/values-ar/strings.xml | 2 ++ .../src/main/res/values-ar/strings_store.xml | 1 + manager/src/main/res/values-de/strings.xml | 2 ++ .../src/main/res/values-de/strings_store.xml | 1 + manager/src/main/res/values-es/strings.xml | 2 ++ .../src/main/res/values-es/strings_store.xml | 1 + manager/src/main/res/values-fa/strings.xml | 2 ++ .../src/main/res/values-fa/strings_store.xml | 1 + manager/src/main/res/values-fr/strings.xml | 2 ++ .../src/main/res/values-fr/strings_store.xml | 1 + manager/src/main/res/values-in/strings.xml | 2 ++ .../src/main/res/values-in/strings_store.xml | 1 + manager/src/main/res/values-it/strings.xml | 2 ++ .../src/main/res/values-it/strings_store.xml | 1 + manager/src/main/res/values-iw/strings.xml | 2 ++ .../src/main/res/values-iw/strings_store.xml | 1 + manager/src/main/res/values-ja/strings.xml | 2 ++ .../src/main/res/values-ja/strings_store.xml | 1 + manager/src/main/res/values-ko/strings.xml | 2 ++ .../src/main/res/values-ko/strings_store.xml | 1 + manager/src/main/res/values-pl/strings.xml | 2 ++ .../src/main/res/values-pl/strings_store.xml | 1 + .../src/main/res/values-pt-rBR/strings.xml | 2 ++ .../main/res/values-pt-rBR/strings_store.xml | 1 + manager/src/main/res/values-ru/strings.xml | 2 ++ .../src/main/res/values-ru/strings_store.xml | 1 + manager/src/main/res/values-tr/strings.xml | 2 ++ .../src/main/res/values-tr/strings_store.xml | 1 + manager/src/main/res/values-uk/strings.xml | 2 ++ .../src/main/res/values-uk/strings_store.xml | 1 + manager/src/main/res/values-vi/strings.xml | 2 ++ .../src/main/res/values-vi/strings_store.xml | 1 + .../src/main/res/values-zh-rCN/strings.xml | 2 ++ .../main/res/values-zh-rCN/strings_store.xml | 1 + .../src/main/res/values-zh-rTW/strings.xml | 2 ++ .../main/res/values-zh-rTW/strings_store.xml | 1 + manager/src/main/res/values/strings.xml | 2 ++ manager/src/main/res/values/strings_store.xml | 1 + 44 files changed, 112 insertions(+), 3 deletions(-) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt index cb653b562..ab625de4f 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt @@ -145,6 +145,21 @@ data class RepoVersion(val versionCode: Long, val versionName: String) { versionCode > installedCode || (versionCode == installedCode && installedName.replace(' ', '_') != versionName) + /** + * Whether installing this would leave the reader on the version they already have, by name. + * + * Which is all the offer can be worded as when it is true. Two different things reach here — a + * rebuild of the same version under a higher code, and a tag whose code is simply not the APK's + * — and nothing in either number tells them apart, so the wording has to be true of both. What + * is certain in both is where the reader ends up: on this version name again. + * + * The underscores are the same normalisation [upgradableOver] applies, and for the same reason: + * a git tag cannot carry a space, so an author whose versionName has one writes it with an + * underscore. + */ + fun sameVersionAs(installed: RepoVersion?): Boolean = + installed != null && installed.versionName.replace(' ', '_') == versionName + companion object { fun parse(raw: String?): RepoVersion? { val text = raw?.takeIf { it.isNotBlank() } ?: return null @@ -204,6 +219,16 @@ data class StoreEntry( private val alreadyInstalled: Boolean get() = storeInstall?.satisfies(latest, installed) == true + /** + * The offer would not change which version this device says it has. See [sameVersionAs]. + * + * Read by everything that *words* an offer, because `1.1.1 → 1.1.1` is a sentence the app cannot + * mean. [upgradable] deliberately does not consult it: whether to offer at all is a different + * question from what to call it, and a rebuild is worth offering. + */ + val sameVersion: Boolean + get() = latest?.sameVersionAs(installed) == true + /** * There is a newer version *and* the reader wants to hear about it. * 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 7cffeeb0b..a3367fe8e 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 @@ -587,7 +587,11 @@ private fun ModuleUpdateSection( ActionRow( icon = Icons.Rounded.ArrowCircleUp, title = - stringResource(R.string.action_update_to, entry.latest?.versionName.orEmpty()), + stringResource( + if (entry.sameVersion) R.string.store_badge_reinstall + else R.string.action_update_to, + entry.latest?.versionName.orEmpty(), + ), subtitle = when { busy -> stringResource(R.string.action_update_running) @@ -596,6 +600,13 @@ private fun ModuleUpdateSection( // them needs the names and sizes the store page already lays out. Sending the // reader there is better than picking one on their behalf. apks.size > 1 -> stringResource(R.string.action_update_choose) + // The title already names the version; saying "from 1.1.1" under "Reinstall + // 1.1.1" would only invite the reader to look for the difference. + entry.sameVersion -> + stringResource( + R.string.action_reinstall_same, + Formatter.formatShortFileSize(context, apks.first().size), + ) else -> stringResource( R.string.action_update_from, 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 40cebbefa..385c42d8b 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 @@ -1373,6 +1373,16 @@ private fun ModuleUpdatesSheet( when { row.asset == null -> stringResource(R.string.action_update_choose) + // "1.1.1 → 1.1.1" is not a thing to say to anyone. + row.entry.sameVersion -> + stringResource( + R.string.modules_update_reinstall, + row.entry.latest?.versionName.orEmpty(), + Formatter.formatShortFileSize( + context, + row.asset.size, + ), + ) else -> stringResource( R.string.modules_update_versions, diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsScreen.kt index 9898e70d8..32c3f43ca 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsScreen.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsScreen.kt @@ -469,7 +469,8 @@ private fun InstallBar( when { state.upgradable -> stringResource( - R.string.store_badge_update, + if (state.sameVersion) R.string.store_badge_reinstall + else R.string.store_badge_update, state.latest?.versionName.orEmpty(), ) state.installed != null -> stringResource(R.string.store_reinstall) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsViewModel.kt index 0045ea0e6..ae78e335b 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsViewModel.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsViewModel.kt @@ -62,6 +62,10 @@ data class RepoDetailsState( latest != null && storeInstall?.satisfies(latest, installed) != true && latest.upgradableOver(installed.versionCode, installed.versionName) + + /** As `StoreEntry.sameVersion`: what the bar may call the offer, not whether to make it. */ + val sameVersion: Boolean + get() = latest?.sameVersionAs(installed) == true } class RepoDetailsViewModel( diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoScreen.kt index 95c92f09d..143f34c59 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoScreen.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoScreen.kt @@ -396,7 +396,8 @@ private fun StoreRow(entry: StoreEntry, onClick: () -> Unit) { icon = Icons.Rounded.Upgrade, text = stringResource( - R.string.store_badge_update, + if (entry.sameVersion) R.string.store_badge_reinstall + else R.string.store_badge_update, entry.latest?.versionName.orEmpty(), ), tint = colors.primary, diff --git a/manager/src/main/res/values-ar/strings.xml b/manager/src/main/res/values-ar/strings.xml index fd9ac4739..b47782c6a 100644 --- a/manager/src/main/res/values-ar/strings.xml +++ b/manager/src/main/res/values-ar/strings.xml @@ -358,6 +358,7 @@ لا يمكن التحقق من تحديثات هذه الوحدة التحديث إلى %1$s من %1$s · %2$s + الإصدار نفسه · %1$s جارٍ التثبيت… هذا الإصدار لا يحتوي على APK للتثبيت عدة إصدارات — اختر واحدًا في المتجر @@ -366,6 +367,7 @@ جارٍ تحديث %1$s — %2$d من %3$d وحدات للتحديث %1$s ← %2$s · %3$s + إعادة تثبيت %1$s · %2$s مُتجاهَلة تحديث %1$d diff --git a/manager/src/main/res/values-ar/strings_store.xml b/manager/src/main/res/values-ar/strings_store.xml index e3c09ad56..729db590a 100644 --- a/manager/src/main/res/values-ar/strings_store.xml +++ b/manager/src/main/res/values-ar/strings_store.xml @@ -30,6 +30,7 @@ يُعرض الفهرس المحفوظ التحديث إلى %1$s + إعادة تثبيت %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 f7cea24d2..f3677c410 100644 --- a/manager/src/main/res/values-de/strings.xml +++ b/manager/src/main/res/values-de/strings.xml @@ -314,6 +314,7 @@ Updates für dieses Modul können nicht geprüft werden Auf %1$s aktualisieren Von %1$s · %2$s + Gleiche Version · %1$s Wird installiert… Diese Version enthält kein installierbares APK Mehrere Builds — im Store auswählen @@ -322,6 +323,7 @@ %1$s wird aktualisiert — %2$d von %3$d Module zum Aktualisieren %1$s → %2$s · %3$s + %1$s neu installieren · %2$s Ignoriert %1$d aktualisieren diff --git a/manager/src/main/res/values-de/strings_store.xml b/manager/src/main/res/values-de/strings_store.xml index f0f32e537..269682d34 100644 --- a/manager/src/main/res/values-de/strings_store.xml +++ b/manager/src/main/res/values-de/strings_store.xml @@ -22,6 +22,7 @@ Gespeicherter Katalog Update auf %1$s + %1$s neu installieren Installiert Vorabversion Aktualisiert %1$s diff --git a/manager/src/main/res/values-es/strings.xml b/manager/src/main/res/values-es/strings.xml index 40960850b..812e04d8b 100644 --- a/manager/src/main/res/values-es/strings.xml +++ b/manager/src/main/res/values-es/strings.xml @@ -314,6 +314,7 @@ No se pueden comprobar actualizaciones de este módulo Actualizar a %1$s Desde %1$s · %2$s + Misma versión · %1$s Instalando… Esta versión no incluye ningún APK Varias compilaciones: elige una en la tienda @@ -322,6 +323,7 @@ Actualizando %1$s: %2$d de %3$d Módulos para actualizar %1$s → %2$s · %3$s + Reinstalar %1$s · %2$s Ignorado Actualizar %1$d diff --git a/manager/src/main/res/values-es/strings_store.xml b/manager/src/main/res/values-es/strings_store.xml index ea178e0b1..5318a0311 100644 --- a/manager/src/main/res/values-es/strings_store.xml +++ b/manager/src/main/res/values-es/strings_store.xml @@ -22,6 +22,7 @@ Mostrando el catálogo guardado Actualizar a %1$s + Reinstalar %1$s Instalado Preversión Actualizado el %1$s diff --git a/manager/src/main/res/values-fa/strings.xml b/manager/src/main/res/values-fa/strings.xml index 14a8c66f7..13e65d158 100644 --- a/manager/src/main/res/values-fa/strings.xml +++ b/manager/src/main/res/values-fa/strings.xml @@ -314,6 +314,7 @@ به‌روزرسانی‌های این ماژول بررسی‌شدنی نیست به‌روزرسانی به %1$s از %1$s · %2$s + همان نسخه · %1$s در حال نصب… این انتشار APK قابل نصبی ندارد چند ساخت — یکی را در فروشگاه انتخاب کنید @@ -322,6 +323,7 @@ به‌روزرسانی %1$s — %2$d از %3$d ماژول‌های قابل به‌روزرسانی %1$s ← %2$s · %3$s + نصب دوباره %1$s · %2$s نادیده‌گرفته به‌روزرسانی %1$d diff --git a/manager/src/main/res/values-fa/strings_store.xml b/manager/src/main/res/values-fa/strings_store.xml index b95b41113..cc199e608 100644 --- a/manager/src/main/res/values-fa/strings_store.xml +++ b/manager/src/main/res/values-fa/strings_store.xml @@ -22,6 +22,7 @@ فهرست ذخیره‌شده نمایش داده می‌شود به‌روزرسانی به %1$s + نصب دوباره %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 d96cbbf54..5fc531816 100644 --- a/manager/src/main/res/values-fr/strings.xml +++ b/manager/src/main/res/values-fr/strings.xml @@ -314,6 +314,7 @@ Les mises à jour de ce module ne peuvent pas être vérifiées Mettre à jour vers %1$s Depuis %1$s · %2$s + Même version · %1$s Installation… Cette version ne contient aucun APK Plusieurs versions — à choisir dans le dépôt @@ -322,6 +323,7 @@ Mise à jour de %1$s — %2$d sur %3$d Modules à mettre à jour %1$s → %2$s · %3$s + Réinstaller %1$s · %2$s Ignoré Mettre à jour %1$d diff --git a/manager/src/main/res/values-fr/strings_store.xml b/manager/src/main/res/values-fr/strings_store.xml index bf418766c..b948ff095 100644 --- a/manager/src/main/res/values-fr/strings_store.xml +++ b/manager/src/main/res/values-fr/strings_store.xml @@ -22,6 +22,7 @@ Catalogue enregistré Mettre à jour vers %1$s + Réinstaller %1$s Installé Préversion Mis à jour le %1$s diff --git a/manager/src/main/res/values-in/strings.xml b/manager/src/main/res/values-in/strings.xml index 8bdbb738a..95c9178d4 100644 --- a/manager/src/main/res/values-in/strings.xml +++ b/manager/src/main/res/values-in/strings.xml @@ -307,6 +307,7 @@ Pembaruan modul ini tidak dapat diperiksa Perbarui ke %1$s Dari %1$s · %2$s + Versi sama · %1$s Memasang… Rilis ini tidak memuat APK Beberapa build — pilih satu di toko @@ -315,6 +316,7 @@ Memperbarui %1$s — %2$d dari %3$d Modul untuk diperbarui %1$s → %2$s · %3$s + Pasang ulang %1$s · %2$s Diabaikan Perbarui %1$d diff --git a/manager/src/main/res/values-in/strings_store.xml b/manager/src/main/res/values-in/strings_store.xml index e4491ef8a..38c2145c0 100644 --- a/manager/src/main/res/values-in/strings_store.xml +++ b/manager/src/main/res/values-in/strings_store.xml @@ -20,6 +20,7 @@ Menampilkan katalog tersimpan Perbarui ke %1$s + Pasang ulang %1$s Terpasang Pra-rilis Diperbarui %1$s diff --git a/manager/src/main/res/values-it/strings.xml b/manager/src/main/res/values-it/strings.xml index 93e565746..12bfff8ea 100644 --- a/manager/src/main/res/values-it/strings.xml +++ b/manager/src/main/res/values-it/strings.xml @@ -314,6 +314,7 @@ Non è possibile controllare gli aggiornamenti di questo modulo Aggiorna alla %1$s Da %1$s · %2$s + Stessa versione · %1$s Installazione… Questa versione non contiene APK Più build — scegline una nel repository @@ -322,6 +323,7 @@ Aggiornamento di %1$s — %2$d di %3$d Moduli da aggiornare %1$s → %2$s · %3$s + Reinstalla %1$s · %2$s Ignorato Aggiorna %1$d diff --git a/manager/src/main/res/values-it/strings_store.xml b/manager/src/main/res/values-it/strings_store.xml index 5357965d4..b3125ceee 100644 --- a/manager/src/main/res/values-it/strings_store.xml +++ b/manager/src/main/res/values-it/strings_store.xml @@ -22,6 +22,7 @@ Catalogo salvato Aggiorna a %1$s + Reinstalla %1$s Installato Preversione Aggiornato il %1$s diff --git a/manager/src/main/res/values-iw/strings.xml b/manager/src/main/res/values-iw/strings.xml index 77211ef52..efefb30e7 100644 --- a/manager/src/main/res/values-iw/strings.xml +++ b/manager/src/main/res/values-iw/strings.xml @@ -340,6 +340,7 @@ לא ניתן לבדוק עדכונים למודול הזה עדכון ל-%1$s מ-%1$s · %2$s + אותה גרסה · %1$s מתקין… למהדורה הזו אין APK להתקנה כמה גרסאות — בחר אחת בחנות @@ -348,6 +349,7 @@ מעדכן את %1$s — %2$d מתוך %3$d מודולים לעדכון %1$s ← %2$s · %3$s + התקנה מחדש של %1$s · %2$s מתעלמים עדכון %1$d diff --git a/manager/src/main/res/values-iw/strings_store.xml b/manager/src/main/res/values-iw/strings_store.xml index 58767bfab..b29908d6f 100644 --- a/manager/src/main/res/values-iw/strings_store.xml +++ b/manager/src/main/res/values-iw/strings_store.xml @@ -26,6 +26,7 @@ מוצג הקטלוג השמור עדכון ל-%1$s + התקנה מחדש של %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 69debcc27..67831f710 100644 --- a/manager/src/main/res/values-ja/strings.xml +++ b/manager/src/main/res/values-ja/strings.xml @@ -303,6 +303,7 @@ このモジュールの更新は確認できません %1$s に更新 %1$s から · %2$s + 同じバージョン · %1$s インストール中… このリリースにインストールできる APK がありません 複数のビルドがあります — ストアで選択 @@ -311,6 +312,7 @@ %1$s を更新中 — %3$d 件中 %2$d 件目 更新するモジュール %1$s → %2$s · %3$s + %1$s を再インストール · %2$s 無視中 %1$d 件を更新 diff --git a/manager/src/main/res/values-ja/strings_store.xml b/manager/src/main/res/values-ja/strings_store.xml index 2641d867e..10b750663 100644 --- a/manager/src/main/res/values-ja/strings_store.xml +++ b/manager/src/main/res/values-ja/strings_store.xml @@ -20,6 +20,7 @@ 保存済みのカタログを表示しています %1$s に更新 + %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 950099d6b..917729c42 100644 --- a/manager/src/main/res/values-ko/strings.xml +++ b/manager/src/main/res/values-ko/strings.xml @@ -303,6 +303,7 @@ 이 모듈의 업데이트는 확인할 수 없습니다 %1$s(으)로 업데이트 %1$s에서 · %2$s + 같은 버전 · %1$s 설치 중… 이 릴리스에는 설치할 APK가 없습니다 빌드가 여러 개입니다 — 스토어에서 선택하세요 @@ -311,6 +312,7 @@ %1$s 업데이트 중 — %3$d개 중 %2$d개 업데이트할 모듈 %1$s → %2$s · %3$s + %1$s 다시 설치 · %2$s 무시함 %1$d개 업데이트 diff --git a/manager/src/main/res/values-ko/strings_store.xml b/manager/src/main/res/values-ko/strings_store.xml index 63d8c1461..510b72166 100644 --- a/manager/src/main/res/values-ko/strings_store.xml +++ b/manager/src/main/res/values-ko/strings_store.xml @@ -20,6 +20,7 @@ 저장된 카탈로그를 표시합니다 %1$s(으)로 업데이트 + %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 d0679bc83..d5f0d545f 100644 --- a/manager/src/main/res/values-pl/strings.xml +++ b/manager/src/main/res/values-pl/strings.xml @@ -336,6 +336,7 @@ Nie można sprawdzić aktualizacji tego modułu Aktualizuj do %1$s Z %1$s · %2$s + Ta sama wersja · %1$s Instalowanie… To wydanie nie zawiera pliku APK Kilka wydań — wybierz w repozytorium @@ -344,6 +345,7 @@ Aktualizowanie %1$s — %2$d z %3$d Moduły do aktualizacji %1$s → %2$s · %3$s + Zainstaluj ponownie %1$s · %2$s Ignorowany Aktualizuj %1$d diff --git a/manager/src/main/res/values-pl/strings_store.xml b/manager/src/main/res/values-pl/strings_store.xml index cedf8b016..f2b467296 100644 --- a/manager/src/main/res/values-pl/strings_store.xml +++ b/manager/src/main/res/values-pl/strings_store.xml @@ -26,6 +26,7 @@ Pokazano zapisany katalog Aktualizuj do %1$s + Zainstaluj ponownie %1$s Zainstalowany Wydanie wstępne Zaktualizowano %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 e8fcdc7fc..5cd95814c 100644 --- a/manager/src/main/res/values-pt-rBR/strings.xml +++ b/manager/src/main/res/values-pt-rBR/strings.xml @@ -314,6 +314,7 @@ Não é possível verificar atualizações deste módulo Atualizar para %1$s De %1$s · %2$s + Mesma versão · %1$s Instalando… Esta versão não traz nenhum APK Várias builds — escolha uma na loja @@ -322,6 +323,7 @@ Atualizando %1$s — %2$d de %3$d Módulos para atualizar %1$s → %2$s · %3$s + Reinstalar %1$s · %2$s Ignorado Atualizar %1$d diff --git a/manager/src/main/res/values-pt-rBR/strings_store.xml b/manager/src/main/res/values-pt-rBR/strings_store.xml index 78a19cff6..90065ab46 100644 --- a/manager/src/main/res/values-pt-rBR/strings_store.xml +++ b/manager/src/main/res/values-pt-rBR/strings_store.xml @@ -22,6 +22,7 @@ Mostrando o catálogo salvo Atualizar para %1$s + Reinstalar %1$s Instalado Pré-lançamento Atualizado em %1$s diff --git a/manager/src/main/res/values-ru/strings.xml b/manager/src/main/res/values-ru/strings.xml index 5d5cd214b..7fd52d530 100644 --- a/manager/src/main/res/values-ru/strings.xml +++ b/manager/src/main/res/values-ru/strings.xml @@ -317,6 +317,7 @@ Обновления этого модуля проверить нельзя Обновить до %1$s С %1$s · %2$s + Та же версия · %1$s Установка… В этом выпуске нет APK для установки Несколько сборок — выберите в репозитории @@ -325,6 +326,7 @@ Обновление %1$s — %2$d из %3$d Модули для обновления %1$s → %2$s · %3$s + Переустановить %1$s · %2$s Игнорируется Обновить %1$d diff --git a/manager/src/main/res/values-ru/strings_store.xml b/manager/src/main/res/values-ru/strings_store.xml index b73b9a820..7b27dfaa3 100644 --- a/manager/src/main/res/values-ru/strings_store.xml +++ b/manager/src/main/res/values-ru/strings_store.xml @@ -23,6 +23,7 @@ всё установленное актуально Показан сохранённый каталог Обновить до %1$s + Переустановить %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 a6c345f6f..903c01f4b 100644 --- a/manager/src/main/res/values-tr/strings.xml +++ b/manager/src/main/res/values-tr/strings.xml @@ -314,6 +314,7 @@ Bu modülün güncellemeleri denetlenemez %1$s sürümüne güncelle %1$s sürümünden · %2$s + Aynı sürüm · %1$s Yükleniyor… Bu sürümde yüklenecek APK yok Birden çok yapı — depodan seçin @@ -322,6 +323,7 @@ %1$s güncelleniyor — %3$d modülden %2$d. Güncellenecek modüller %1$s → %2$s · %3$s + %1$s sürümünü yeniden kur · %2$s Yok sayılıyor %1$d modülü güncelle diff --git a/manager/src/main/res/values-tr/strings_store.xml b/manager/src/main/res/values-tr/strings_store.xml index bde6c8e9a..d865292c4 100644 --- a/manager/src/main/res/values-tr/strings_store.xml +++ b/manager/src/main/res/values-tr/strings_store.xml @@ -22,6 +22,7 @@ Kayıtlı katalog gösteriliyor %1$s sürümüne güncelle + %1$s sürümünü yeniden kur Kurulu Ön sürüm %1$s tarihinde güncellendi diff --git a/manager/src/main/res/values-uk/strings.xml b/manager/src/main/res/values-uk/strings.xml index 62a505a90..4ebe137f1 100644 --- a/manager/src/main/res/values-uk/strings.xml +++ b/manager/src/main/res/values-uk/strings.xml @@ -336,6 +336,7 @@ Оновлення цього модуля перевірити не можна Оновити до %1$s З %1$s · %2$s + Та сама версія · %1$s Встановлення… У цьому випуску немає APK для встановлення Кілька збірок — виберіть у сховищі @@ -344,6 +345,7 @@ Оновлення %1$s — %2$d з %3$d Модулі для оновлення %1$s → %2$s · %3$s + Перевстановити %1$s · %2$s Ігнорується Оновити %1$d diff --git a/manager/src/main/res/values-uk/strings_store.xml b/manager/src/main/res/values-uk/strings_store.xml index 3f193118c..f1d29f34a 100644 --- a/manager/src/main/res/values-uk/strings_store.xml +++ b/manager/src/main/res/values-uk/strings_store.xml @@ -26,6 +26,7 @@ Показано збережений каталог Оновити до %1$s + Перевстановити %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 a166a5853..0c07b043c 100644 --- a/manager/src/main/res/values-vi/strings.xml +++ b/manager/src/main/res/values-vi/strings.xml @@ -303,6 +303,7 @@ Không thể kiểm tra bản cập nhật cho mô-đun này Cập nhật lên %1$s Từ %1$s · %2$s + Cùng phiên bản · %1$s Đang cài đặt… Bản phát hành này không có APK Có nhiều bản dựng — chọn trong kho @@ -311,6 +312,7 @@ Đang cập nhật %1$s — %2$d trên %3$d Mô-đun cần cập nhật %1$s → %2$s · %3$s + Cài lại %1$s · %2$s Đang bỏ qua Cập nhật %1$d diff --git a/manager/src/main/res/values-vi/strings_store.xml b/manager/src/main/res/values-vi/strings_store.xml index 4f4a99e99..697fd843c 100644 --- a/manager/src/main/res/values-vi/strings_store.xml +++ b/manager/src/main/res/values-vi/strings_store.xml @@ -20,6 +20,7 @@ Đang hiện danh mục đã lưu Cập nhật lên %1$s + Cài lại %1$s Đã cài Phát hành thử Cập nhật %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 a3adf7306..c3feeca22 100644 --- a/manager/src/main/res/values-zh-rCN/strings.xml +++ b/manager/src/main/res/values-zh-rCN/strings.xml @@ -304,6 +304,7 @@ 无法检查此模块的更新 更新到 %1$s 当前 %1$s · %2$s + 同一版本 · %1$s 正在安装… 此版本没有可安装的 APK 有多个构建 — 请在仓库中选择 @@ -312,6 +313,7 @@ 正在更新 %1$s — 第 %2$d 个,共 %3$d 个 待更新的模块 %1$s → %2$s · %3$s + 重新安装 %1$s · %2$s 已忽略 更新 %1$d 个 diff --git a/manager/src/main/res/values-zh-rCN/strings_store.xml b/manager/src/main/res/values-zh-rCN/strings_store.xml index 1b876575f..49b06d761 100644 --- a/manager/src/main/res/values-zh-rCN/strings_store.xml +++ b/manager/src/main/res/values-zh-rCN/strings_store.xml @@ -20,6 +20,7 @@ 正在显示已保存的目录 可更新至 %1$s + 重新安装 %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 23862e712..e9a88a35e 100644 --- a/manager/src/main/res/values-zh-rTW/strings.xml +++ b/manager/src/main/res/values-zh-rTW/strings.xml @@ -304,6 +304,7 @@ 無法檢查此模組的更新 更新到 %1$s 目前 %1$s · %2$s + 同一版本 · %1$s 正在安裝… 此版本沒有可安裝的 APK 有多個建置 — 請在倉庫中選擇 @@ -312,6 +313,7 @@ 正在更新 %1$s — 第 %2$d 個,共 %3$d 個 待更新的模組 %1$s → %2$s · %3$s + 重新安裝 %1$s · %2$s 已忽略 更新 %1$d 個 diff --git a/manager/src/main/res/values-zh-rTW/strings_store.xml b/manager/src/main/res/values-zh-rTW/strings_store.xml index 7307197ef..54d5ac3e4 100644 --- a/manager/src/main/res/values-zh-rTW/strings_store.xml +++ b/manager/src/main/res/values-zh-rTW/strings_store.xml @@ -20,6 +20,7 @@ 正在顯示已儲存的目錄 可更新至 %1$s + 重新安裝 %1$s 已安裝 預先發行 更新於 %1$s diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml index 9a2c00eb8..99f03f5f7 100644 --- a/manager/src/main/res/values/strings.xml +++ b/manager/src/main/res/values/strings.xml @@ -175,6 +175,7 @@ Updating %1$s — %2$d of %3$d Modules to update %1$s → %2$s · %3$s + Reinstall %1$s · %2$s Ignored Update %1$d %1$d of %2$d active @@ -273,6 +274,7 @@ Updates for this module cannot be checked Update to %1$s From %1$s · %2$s + Same version · %1$s Installing… This release has no APK to install Several builds — choose one in the store diff --git a/manager/src/main/res/values/strings_store.xml b/manager/src/main/res/values/strings_store.xml index d2c41b90c..d65385c2f 100644 --- a/manager/src/main/res/values/strings_store.xml +++ b/manager/src/main/res/values/strings_store.xml @@ -32,6 +32,7 @@ Update to %1$s + Reinstall %1$s Installed Prerelease From 78c18f94aaaf1072bcaa08bf8cd983f4aeb28b98 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 31 Jul 2026 17:33:26 +0200 Subject: [PATCH 6/6] Say why the Store records an install, not which module made it The note's KDoc argued from one catalogue entry and a survey taken on one day: a package name, its tag, the count of entries whose tag agrees with their APK. None of that is what the code answers to. A tag is written by hand and the manifest is built, nothing keeps them in step, and a module that is fine today can be tagged wrongly tomorrow -- so the numbers dated the comment without supporting it, and the module named in it will read as the reason the whole mechanism exists. Stated generally instead: where a tag disagrees with the manifest the offer cannot be satisfied by taking it, and no rule over (code, name) can settle it, because a module that never changes its code is only seen through the name clause and one that reuses a name only through the code clause. Recording what was installed is what remains once neither number can be believed. --- .../vector/manager/data/model/RepoModels.kt | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt index ab625de4f..bcac22d03 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt @@ -174,20 +174,21 @@ data class RepoVersion(val versionCode: Long, val versionName: String) { /** * A release this manager installed, and what the device said the module was afterwards. * - * Two versions, because they are not the same kind of fact and for some modules not the same - * number: [release] is what a tag claimed, [installed] is what the APK inside it turned out to be. + * Two versions, because they are not the same kind of fact and need not be the same number: + * [release] is what a tag claimed, [installed] is what the APK inside it turned out to be. * * That difference is the whole reason this is recorded. The comparison above believes the tag, and - * for most modules it may — of the 745 catalogue entries whose newest release carries a single APK, - * 741 state the code their APK really has. For the rest the offer cannot be satisfied by installing - * it: `top.cbug.adbx` is tagged `4115-1.1.1` around an APK that is versionCode 4, so `4115 > 4` - * stays true however many times it is installed, and the update sheet reads `1.1.1 → 1.1.1` for - * ever. Seven others are stuck the same way, six of them through the name half of the tag rather - * than the code. Nothing in the two numbers distinguishes that from a rebuild the reader does want - * — 17 modules publish the same versionName under several codes, and 31 keep one code across every - * release they have ever published, so the code signal is dead for those and the name signal is - * dead for these. Rather than parse the tag harder, the Store remembers what it installed and - * answers "you already have this one". + * nothing obliges an author to tag a release with the version their manifest actually states. Where + * the two disagree the offer cannot be satisfied by taking it: installing leaves the device on a + * version the tag still claims to beat, so the row asks again, and again, for ever. + * + * Nor can it be settled by reading the two numbers harder, because both halves of the comparison + * are load-bearing for someone: a module that never changes its tag code is only ever seen to + * update through the name clause, and one that reuses a versionName across several codes only + * through the code clause. Any rule over `(code, name)` is wrong for one of them. + * + * So the Store stops inferring and records instead. An offer it has already installed, on a device + * still reporting what that install produced, is one the reader has taken. * * [installed] is what makes the record expire on its own: it is checked against what the device * reports now, so a module replaced from anywhere else stops matching and the offer comes back.