diff --git a/manager-ui/src/main/res/values/strings_store.xml b/manager-ui/src/main/res/values/strings_store.xml index bb40e3173..dc1f04c73 100644 --- a/manager-ui/src/main/res/values/strings_store.xml +++ b/manager-ui/src/main/res/values/strings_store.xml @@ -78,6 +78,8 @@ Waiting for the system prompt… Could not install %1$s Could not install %1$s: %2$s + + the external installer did not confirm within %1$d minutes Cancel 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 5e6ca0d5e..06fb12c82 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,9 +1,22 @@ package org.matrix.vector.manager.data.repository +import android.content.BroadcastReceiver +import android.content.ClipData +import android.content.ContentValues import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageInstaller +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.annotation.RequiresApi import java.io.IOException +import java.io.InputStream +import java.io.OutputStream import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive @@ -11,34 +24,42 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import okhttp3.OkHttpClient import okhttp3.Request -import org.matrix.vector.ui.store.InstallStep -import org.matrix.vector.ui.store.ReleaseAsset import org.matrix.vector.manager.ipc.commitForResult import org.matrix.vector.manager.ipc.requestReplaceExisting import org.matrix.vector.manager.logW +import org.matrix.vector.ui.R as UiR +import org.matrix.vector.ui.store.InstallStep +import org.matrix.vector.ui.store.ReleaseAsset /** - * Downloads a release asset straight into a `PackageInstaller` session. + * Downloads a release asset and puts it on the device, by one of two routes. * - * **Straight into**, with no temporary file, and not as an optimisation. Parasitically the - * manager's manifest is never installed, so it has no `ContentProvider` and therefore no - * `FileProvider`; `ACTION_INSTALL_PACKAGE` with a `content://` URI is not available at all. - * `PackageInstaller.Session.openWrite` is the one path that works identically in both modes, and it - * needs no storage permission. + * **From API 29 the hand-off route: the download is staged into `MediaStore.Downloads` and one + * plain `ACTION_VIEW` intent hands the APK to whatever installer answers** — the user's chosen + * default, a resolver the user picks from, or the platform's own. Committing a `PackageInstaller` + * session directly never gives another installer a chance: parasitically the commit is a silent + * install by `com.android.shell` that nothing observes, and standalone it pins the platform's own + * confirmation UI no matter what the user would rather install with. An intent carries a + * `content://` URI, the manager has no `FileProvider` to serve one (its manifest is never + * installed parasitically), and `MediaStore` is the one provider every device already runs whose + * entries can be written without a permission and read by another app through the grant flag + * — which is why the staging below exists rather than a cache file. * - * **The consent story differs sharply between the two modes, and that is why the caller's own - * dialog matters.** Parasitically the manager runs inside `com.android.shell`, which holds - * `android.permission.INSTALL_PACKAGES` — so the commit below installs a third-party APK with no - * system confirmation whatsoever. Standalone, the same code produces the usual - * `REQUEST_INSTALL_PACKAGES` prompt. In the mode most people run, Vector's own confirmation is the - * *only* consent gate there is, so it must name what is about to happen before anything is - * downloaded. See ConfirmInstall, which asks the platform which of the two modes it is in. + * **The session route** — below API 29, or when the hand-off could not start — streams straight + * into a `PackageInstaller` session with no temporary file at all. That is not an + * optimisation either: it is the same storage story as above in reverse, because before Q there is + * no permissionless way to share bytes with another app. * - * The session's package name is pinned to the catalogue entry's, and the platform fails an install - * whose staged APKs are inconsistent with it. A module page therefore cannot install a package - * other than the one it advertises. + * **The consent story differs sharply between the two routes, and that is why the caller's own + * dialog matters.** Parasitically the manager runs inside `com.android.shell`, which holds + * `android.permission.INSTALL_PACKAGES` — so the session route installs a third-party APK with no + * system confirmation whatsoever. The hand-off route delegates the verdict to the chosen installer, + * which may be silent (Shizuku, root) or not; either way Vector's own dialog remains the gate that + * names what is about to happen before anything is downloaded. See ConfirmInstall, which asks the + * platform which of the two modes it is in. */ class ModuleInstaller(private val context: Context, private val client: OkHttpClient) { @@ -53,14 +74,14 @@ class ModuleInstaller(private val context: Context, private val client: OkHttpCl /** * Fetches [asset] and installs it as [packageName]. * - * 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. + * Returns true only when the install is confirmed: the platform reports success on the session + * route, and a package event for [packageName] arrives on the hand-off route within + * [HANDOFF_TIMEOUT_MS]. 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). * - * 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. + * 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. */ suspend fun install(packageName: String, asset: ReleaseAsset): Boolean = withContext(Dispatchers.IO) { @@ -70,94 +91,327 @@ class ModuleInstaller(private val context: Context, private val client: OkHttpCl return@withContext false } - val packageInstaller = context.packageManager.packageInstaller - var sessionId = -1 - var succeeded = false - try { - _state.value = InstallStep.Downloading(packageName, 0, asset.size) - - val params = - PackageInstaller.SessionParams( - PackageInstaller.SessionParams.MODE_FULL_INSTALL - ) - .apply { - setAppPackageName(packageName) - if (asset.size > 0) setSize(asset.size) - requestReplaceExisting() - } - sessionId = packageInstaller.createSession(params) - - packageInstaller.openSession(sessionId).use { session -> - stream(session, packageName, url, asset.size) - _state.value = InstallStep.Installing(packageName) - val result = commit(session, sessionId, packageName) - succeeded = result.first == PackageInstaller.STATUS_SUCCESS - if (!succeeded) { - logW( - "store: install of $packageName failed, status ${result.first}: " + - "${result.second}" - ) - } - _state.value = - if (succeeded) InstallStep.Done(packageName) - else InstallStep.Failed(packageName, result.second) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val handoff = prepareHandoff(packageName, url, asset) + // A hand-off that never started — MediaStore refused, the intent could not be + // fired, the staged APK is not the one the page advertised — is not an install + // failure: the session route still works, and a dead end on screen is worth less + // than the old behaviour. + if (handoff == null) { + logW("store: $packageName could not be handed to an installer") + installThroughSession(packageName, url, asset.size) + } else { + awaitHandoff(packageName, handoff.first, handoff.second) } + } else { + installThroughSession(packageName, url, asset.size) + } + } + + /** + * The first half of the hand-off route: stage the download in `MediaStore.Downloads`, check it + * against the catalogue entry, watch for the verdict, and let the user's installer take it + * from an `ACTION_VIEW` intent. + * + * Null means nothing left this process — no intent fired, so the caller falls back to the + * session route rather than reporting failure. Cancellation propagates instead: a cancelled + * download must not resurrect itself as a session install. + * + * The entry is inserted `IS_PENDING` and only published once complete, so nothing can read a + * half-written APK mid-transfer. The receiver is registered before the intent fires: a + * privileged installer working silently needs seconds at most, and the verdict must not outrun + * its listener. + */ + @RequiresApi(Build.VERSION_CODES.Q) + private suspend fun prepareHandoff( + packageName: String, + url: String, + asset: ReleaseAsset, + ): Pair? { + val uri = + try { + stageInDownloads(packageName, url, asset) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - // The check in stream() cancels by throwing, and a cancelled transfer is not a - // failed install: reporting it as one would put an error on a screen the reader - // has already left, and would race the acknowledge() that cancelled it. - if (e is CancellationException) throw e - logW("store: install of $packageName failed", e) - _state.value = InstallStep.Failed(packageName, e.message) - } finally { - // Without this, a cancelled download leaves a staged session behind — and staged - // sessions accumulate, each holding the bytes written so far. - if (!succeeded && sessionId != -1) { - runCatching { packageInstaller.abandonSession(sessionId) } - } + logW("store: hand-off of $packageName failed before the intent could fire", e) + return null } - succeeded + if (!matchesCatalogueEntry(uri, packageName)) { + logW("store: staged copy of $packageName declares another package; not handing it off") + deleteStagedCopy(uri) + return null + } + val watch = ExternalInstallWatch(context).also { it.register(packageName) } + try { + startInstallIntent(uri) + } catch (e: Exception) { + watch.dispose() + // The entry was published for an intent that never fired, and the session fallback + // stages a copy of its own — so this one goes back out. + deleteStagedCopy(uri) + logW("store: hand-off of $packageName failed before the intent could fire", e) + return null } + return uri to watch + } - private suspend fun stream( - session: PackageInstaller.Session, + /** + * The second half of the hand-off route: wait for the installer's verdict. + * + * A package event naming [packageName] reports done; a run cancelled while the external + * installer is still showing withdraws the staged file with it. A timeout keeps the file + * instead — the installer may simply be waiting for the user, and deleting the source under it + * would turn "not yet confirmed" into "cannot ever be confirmed". + */ + @RequiresApi(Build.VERSION_CODES.Q) + private suspend fun awaitHandoff( + packageName: String, + uri: Uri, + watch: ExternalInstallWatch, + ): Boolean { + var keepStagedFile = false + try { + // The intent already went out; from here on it is the installer's prompt. + _state.value = InstallStep.Confirming(packageName) + val confirmed = withTimeoutOrNull(HANDOFF_TIMEOUT_MS) { watch.await() } != null + if (!confirmed) { + keepStagedFile = true + logW( + "store: external install of $packageName unconfirmed after " + + "${HANDOFF_TIMEOUT_MS / 60_000} min; $uri stays for the installer" + ) + _state.value = + InstallStep.Failed( + packageName, + context.getString( + UiR.string.store_install_unconfirmed, + HANDOFF_TIMEOUT_MS / 60_000, + ), + ) + } else { + _state.value = InstallStep.Done(packageName) + } + return confirmed + } finally { + // Only a timeout leaves the file behind; every other exit — cancellation, confirmed + // install — cleans it up. + if (!keepStagedFile) { + deleteStagedCopy(uri) + } + } + } + + /** + * Streams the download into a pending `MediaStore.Downloads` entry and publishes it. + * + * Progress is reported against whichever length is realer — the response's or the release's — + * exactly as the session route reports against its own. + */ + @RequiresApi(Build.VERSION_CODES.Q) + private suspend fun stageInDownloads( + packageName: String, + url: String, + asset: ReleaseAsset, + ): Uri { + val displayName = suggestedFileName(packageName, asset) + _state.value = InstallStep.Downloading(packageName, 0, asset.size) + + val values = + ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, APK_MIME) + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + val resolver = context.contentResolver + val uri = + resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + ?: throw IOException("MediaStore refused a Downloads entry for $displayName") + + try { + client.newCall(Request.Builder().url(url).build()).execute().use { response -> + if (!response.isSuccessful) throw IOException("HTTP ${response.code} for $url") + val body = response.body + val total = body.contentLength().takeIf { it > 0 } ?: asset.size + val out = + resolver.openOutputStream(uri) + ?: throw IOException("no output stream for $uri") + out.use { transfer(body.byteStream(), packageName, total, it) {} } + } + + values.clear() + values.put(MediaStore.MediaColumns.IS_PENDING, 0) + resolver.update(uri, values, null, null) + } catch (e: Exception) { + // A staging that never reaches its publish leaves a pending row behind, and a pending + // row is invisible in Downloads — nobody but MediaStore's week-long purge would ever + // collect it. The session route abandons its session for exactly this reason, and a + // cancelled transfer takes the row with it for the same one. + deleteStagedCopy(uri) + throw e + } + return uri + } + + /** Publishes [uri] to whatever installer resolves an APK view intent. */ + private fun startInstallIntent(uri: Uri) { + val intent = + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, APK_MIME) + // ClipData carries the grant through a resolver dialog, where the flags alone + // would stop at ResolverActivity. + clipData = ClipData.newRawUri(null, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } + + /** A file name for the staged copy that no provider will reject. */ + private fun suggestedFileName(packageName: String, asset: ReleaseAsset): String { + val original = + asset.name?.substringAfterLast('/')?.takeIf { it.endsWith(".apk", ignoreCase = true) } + return (original ?: "$packageName.apk").replace(ILLEGAL_FILE_CHARS, "_") + } + + /** Withdraws a staged copy; an entry nobody will hand off is worth less than its bytes. */ + private fun deleteStagedCopy(uri: Uri) { + runCatching { context.contentResolver.delete(uri, null, null) } + .onFailure { logW("store: staged copy $uri could not be removed", it) } + } + + /** + * Whether the staged copy still declares the package the page advertised. + * + * The session route gets this for nothing: its package name is pinned on the session and the + * platform fails a mismatch. The hand-off route hands bytes to an intent, so the guarantee is + * checked here or not at all — a repository entry could otherwise serve any package it liked. + * The check is one-way on purpose: an APK that cannot be parsed is still handed off, because + * no installer can install what it cannot parse either, and the prompt the user then sees + * names what was found. Only a name that could be read *and* disagrees stops the hand-off. + */ + private fun matchesCatalogueEntry(uri: Uri, packageName: String): Boolean { + val path = + runCatching { + context.contentResolver + .query(uri, arrayOf(MediaStore.MediaColumns.DATA), null, null, null) + ?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + } + .getOrNull() + if (path == null) return true + val declared = + runCatching { context.packageManager.getPackageArchiveInfo(path, 0)?.packageName } + .getOrNull() + return declared == null || declared == packageName + } + + /** + * The session route, unchanged from when it was the only one: the download goes straight into a + * `PackageInstaller.Session.openWrite` stream and the commit waits for the platform's verdict. + * + * The session's package name is pinned to the catalogue entry's, and the platform fails an + * install whose staged APKs are inconsistent with it. A module page therefore cannot install a + * package other than the one it advertises — a guarantee the hand-off route upholds by + * checking the staged copy before it is handed over. + */ + private suspend fun installThroughSession( packageName: String, url: String, declaredSize: Long, - ) { - client.newCall(Request.Builder().url(url).build()).execute().use { response -> - if (!response.isSuccessful) throw IOException("HTTP ${response.code} for $url") - val body = response.body - val total = body.contentLength().takeIf { it > 0 } ?: declaredSize - - session.openWrite(WRITE_NAME, 0, total).use { out -> - body.byteStream().use { input -> - val buffer = ByteArray(CHUNK_BYTES) - var written = 0L - var reported = 0L - while (true) { - // The read below is blocking, so cancellation is only observed between - // chunks. Checking here is what lets leaving the screen stop the transfer. - currentCoroutineContext().ensureActive() - val read = input.read(buffer) - if (read < 0) break - out.write(buffer, 0, read) - written += read - - // Progress is published per 256 KB, not per chunk: at 64 KB a small module - // would spend more time on binder calls to setStagingProgress and on - // recompositions than on the download itself. - if (written - reported >= PROGRESS_STEP_BYTES || read < buffer.size) { - reported = written - _state.value = InstallStep.Downloading(packageName, written, total) + ): Boolean { + val packageInstaller = context.packageManager.packageInstaller + var sessionId = -1 + var succeeded = false + try { + _state.value = InstallStep.Downloading(packageName, 0, declaredSize) + + val params = + PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + .apply { + setAppPackageName(packageName) + if (declaredSize > 0) setSize(declaredSize) + requestReplaceExisting() + } + sessionId = packageInstaller.createSession(params) + + packageInstaller.openSession(sessionId).use { session -> + client.newCall(Request.Builder().url(url).build()).execute().use { response -> + if (!response.isSuccessful) throw IOException("HTTP ${response.code} for $url") + val body = response.body + val total = body.contentLength().takeIf { it > 0 } ?: declaredSize + session.openWrite(WRITE_NAME, 0, total).use { out -> + transfer(body.byteStream(), packageName, total, out) { written -> if (total > 0) session.setStagingProgress(written.toFloat() / total) } + session.fsync(out) } - out.flush() - session.fsync(out) } + _state.value = InstallStep.Installing(packageName) + val result = commit(session, sessionId, packageName) + succeeded = result.first == PackageInstaller.STATUS_SUCCESS + if (!succeeded) { + logW( + "store: install of $packageName failed, status ${result.first}: " + + "${result.second}" + ) + } + _state.value = + if (succeeded) InstallStep.Done(packageName) + else InstallStep.Failed(packageName, result.second) + } + } catch (e: Exception) { + // The check in transfer() cancels by throwing, and a cancelled transfer is not a + // failed install: reporting it as one would put an error on a screen the reader + // has already left, and would race the acknowledge() that cancelled it. + if (e is CancellationException) throw e + logW("store: install of $packageName failed", e) + _state.value = InstallStep.Failed(packageName, e.message) + } finally { + // Without this, a cancelled download leaves a staged session behind — and staged + // sessions accumulate, each holding the bytes written so far. + if (!succeeded && sessionId != -1) { + runCatching { packageInstaller.abandonSession(sessionId) } } } + return succeeded + } + + /** + * Copies [input] into [out], publishing progress and honouring cancellation between chunks. + * + * The read below is blocking, so cancellation is only observed between chunks. Checking here is + * what lets leaving the screen stop the transfer. + * + * Progress is published per 256 KB, not per chunk: at 64 KB a small module would spend more + * time recomposing than downloading. [onProgress] carries the route-specific part of a tick — + * the session route maps it onto `setStagingProgress`; the hand-off route needs nothing. + */ + private suspend fun transfer( + input: InputStream, + packageName: String, + total: Long, + out: OutputStream, + onProgress: (Long) -> Unit, + ) { + val buffer = ByteArray(CHUNK_BYTES) + var written = 0L + var reported = 0L + input.use { reader -> + while (true) { + currentCoroutineContext().ensureActive() + val read = reader.read(buffer) + if (read < 0) break + out.write(buffer, 0, read) + written += read + + if (written - reported >= PROGRESS_STEP_BYTES || read < buffer.size) { + reported = written + _state.value = InstallStep.Downloading(packageName, written, total) + onProgress(written) + } + } + out.flush() + } } /** @@ -181,6 +435,87 @@ class ModuleInstaller(private val context: Context, private val client: OkHttpCl private companion object { const val WRITE_NAME = "module.apk" const val CHUNK_BYTES = 64 * 1024 - const val PROGRESS_STEP_BYTES = 256L * 1024 + const val PROGRESS_STEP_BYTES = 256L * 1024L + + /** How long an externally handed-off install may stay unconfirmed before giving up. */ + const val HANDOFF_TIMEOUT_MS = 5L * 60_000L + + const val APK_MIME = "application/vnd.android.package-archive" + + val ILLEGAL_FILE_CHARS = Regex("[\\\\/\u0000]") + } +} + +/** + * One registration that turns package broadcasts into a single resumption. + * + * Split from a plain `suspendCancellableCoroutine` block because order matters: the signal has to + * exist *before* the install intent fires, or a privileged installer working silently could deliver + * the verdict to nobody. A `CompletableDeferred` is the signal because the verdict can also beat + * [await] itself — the intent goes out in the caller, the waiting starts a suspension later — and a + * completion is buffered where a continuation attached too late would simply have missed it. + * + * Both events are accepted, and `EXTRA_REPLACING` is how they are told apart: an update announces + * itself as ADDED carrying the flag, then REPLACED follows; taking the flagged ADD would resume + * before the replacement finished. Broadcasts for other packages are ignored — every install on the + * device passes through here otherwise. + */ +private class ExternalInstallWatch(private val context: Context) { + + /** Completed where the verdict lands, whenever that is; later completions are no-ops. */ + private val verdict = CompletableDeferred() + + private var receiver: BroadcastReceiver? = null + + fun register(targetPackage: String) { + check(receiver == null) { "ExternalInstallWatch registered twice" } + val receiver = + object : BroadcastReceiver() { + override fun onReceive(received: Context, intent: Intent) { + if ( + intent.action == Intent.ACTION_PACKAGE_ADDED && + intent.getBooleanExtra(Intent.EXTRA_REPLACING, false) + ) { + return + } + if (intent.data?.schemeSpecificPart != targetPackage) return + verdict.complete(Unit) + } + } + this.receiver = receiver + val filter = + IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REPLACED) + addDataScheme("package") + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // System-sent broadcasts reach a NOT_EXPORTED receiver, but nothing a random app + // forges could resume this — the same bar InstallResult.kt holds its own receiver to. + context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + context.registerReceiver(receiver, filter) + } + } + + /** + * Suspends until the watched package event arrives or the caller is cancelled. Either exit takes + * the registration with it, so the receiver never outlives its one waiter. + */ + suspend fun await() { + try { + verdict.await() + } finally { + dispose() + } + } + + /** + * Removes the registration — the exit for paths that never wait, such as an intent that failed + * to start. + */ + fun dispose() { + runCatching { receiver?.let(context::unregisterReceiver) } + receiver = null } }