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..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 @@ -137,10 +137,29 @@ 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) + /** + * 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 @@ -152,6 +171,35 @@ 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 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 + * 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. + */ +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 +212,30 @@ 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 + + /** + * 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. * + * 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 +252,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/ManagerInstaller.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ManagerInstaller.kt index 2a153d7ba..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 @@ -1,27 +1,21 @@ 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 +import org.matrix.vector.manager.ipc.requestReplaceExisting /** Where installing the manager as an app has got to. */ sealed interface ManagerInstallStep { @@ -132,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) @@ -179,85 +176,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..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 @@ -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,13 @@ 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 +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 { @@ -80,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) { @@ -102,6 +101,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) @@ -182,82 +182,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/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/ipc/InstallResult.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/InstallResult.kt new file mode 100644 index 000000000..e5618a9e9 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/InstallResult.kt @@ -0,0 +1,128 @@ +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 + +/** + * 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. + * + * 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" + +/** `PackageManager.INSTALL_REPLACE_EXISTING`, `@hide` like the field it belongs in. */ +private const val INSTALL_REPLACE_EXISTING = 0x00000002 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..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, @@ -657,6 +668,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..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 @@ -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() })) } @@ -1372,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, @@ -1420,6 +1431,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..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 @@ -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) }, ) } @@ -466,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 33a4d3cb8..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 @@ -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,12 +47,25 @@ 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) + + /** 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( @@ -110,17 +125,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 +156,7 @@ class RepoDetailsViewModel( latest = latestFor(module, channel), fetch = fetch, channel = channel, + storeInstall = preferences.storeInstall, ) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), RepoDetailsState()) @@ -156,9 +185,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/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/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 { 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" 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