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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion daemon/src/main/res/drawable/ic_statue_monochrome.xml

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package org.matrix.vector.manager.data.model

/**
* Which copy of the manager, if any, is installed beside the one that is running.
*
* Parasitically the manager is not a package at all — it is loaded into the host from the daemon's
* own APK — so installing it as an app puts a second copy of that code on the device. The two are
* meant to be the same build and nothing on the device keeps them so: an install is a moment, and
* the module behind it is reflashed whenever the framework is updated.
*
* The version code cannot tell them apart when they drift. It is `git rev-list --count
* origin/master`, so a branch build and the official build at the same depth carry the same number,
* and a copy installed from either reports the number the other would. Only the bytes settle it,
* which is why the check that produces this reaches for a digest whenever the numbers agree.
*/
enum class ManagerCopy {

/** No such package on this device. Installing it is the whole of the offer. */
Absent,

/**
* Installed, and nothing has shown it to be a different build.
*
* This is also where "could not tell" lands — a daemon that will not hand over its APK, an
* install whose file could not be read — because a check that did not complete has found no
* difference, and a failed check reported as a wrong build would send the reader to replace a
* copy that is very probably fine. The card then reads exactly as it did before there was
* anything to compare, which is the state this state is deliberately indistinguishable from.
*/
Present,

/**
* Installed, and it is a different build of Vector: another version code, or the same code over
* different bytes.
*/
Diverged;

/**
* Whether the launcher has a Vector icon to open, whichever build is behind it.
*
* A diverged copy is still a way back in — an older or a branch build of the same manager opens
* and talks to the same daemon — so anything asking "can this device reach the manager" must
* count it, and only the offer to install reads the difference.
*/
val installed: Boolean
get() = this != Absent
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
package org.matrix.vector.manager.data.repository

import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller
import java.io.FileInputStream
import java.io.InputStream
import java.security.MessageDigest
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.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.matrix.vector.manager.BuildConfig
import org.matrix.vector.manager.data.model.ManagerCopy
import org.matrix.vector.manager.data.model.versionCodeCompat
import org.matrix.vector.manager.logE
import org.matrix.vector.manager.logW
import org.matrix.vector.manager.ipc.DaemonClient
Expand Down Expand Up @@ -64,6 +71,31 @@ class ManagerInstaller(private val context: Context, private val daemon: DaemonC
private val _state = MutableStateFlow<ManagerInstallStep>(ManagerInstallStep.Idle)
val state: StateFlow<ManagerInstallStep> = _state.asStateFlow()

/**
* The last digest comparison, and the copy it was made against.
*
* Hashing two APKs of twenty-odd megabytes is not something to repeat every time somebody opens
* the status page — which is every arrival at Home as well, since both screens refresh presence
* and each holds its own ViewModel. This installer is the process-wide singleton both reach
* through, so remembering the verdict here answers all of them.
*
* It is keyed on when the installed copy was last written rather than cleared by hand. That
* expires the verdict on exactly the events that could change it — an install, an update, a
* reinstall of the very same bytes — including the ones that happen while this app is not
* running, and it needs no invalidation call at the far end of a code path that might forget.
*/
@Volatile private var comparison: Comparison? = null

/**
* Holds the comparison to one at a time.
*
* Home and the status page each hold their own ViewModel and the first composition refreshes
* presence from both within milliseconds of each other, which is early enough that neither has
* written [comparison] by the time the other looks. Without this they would both go and hash
* eighty megabytes between them for the one answer.
*/
private val comparing = Mutex()

/** Clears a finished result so the button returns to its resting state. */
fun acknowledge() {
_state.value = ManagerInstallStep.Idle
Expand All @@ -85,13 +117,143 @@ class ManagerInstaller(private val context: Context, private val daemon: DaemonC
return removed
}

/** True once `org.matrix.vector.manager` is a package on this device. */
fun isInstalled(): Boolean =
/**
* What can be said about the installed copy without hashing anything.
*
* Whether the package exists is one `getPackageInfo`, and its version code comes back on the
* same object, so both are cheap enough to answer on the main thread, which is where the
* presence refresh asks. Whether two copies wearing the same number hold the same bytes is not,
* so the verdict [refreshInstalledManager] last reached for this very copy is repeated until it
* is asked again, and a copy nothing is yet known about reads as [ManagerCopy.Present] — the
* same "installed, with nothing said against it" a failed comparison gives.
*
* Repeating the verdict is what keeps the row still. Without it every arrival at the screen
* would show a plain check for as long as the digest takes and then flip to a reinstall button.
*/
fun installedManager(): ManagerCopy {
val installed = installedPackage() ?: return ManagerCopy.Absent
// The cheap half of the comparison is redone rather than remembered. It costs one field of
// a `PackageInfo` already in hand, and a divergence the numbers alone can see is the one
// this screen meets most often — an install left behind by an older framework — so it would
// be a shame to show it a check for as long as a digest takes and then take the check away.
if (installed.versionCodeCompat != BuildConfig.VERSION_CODE.toLong()) {
return ManagerCopy.Diverged
}
val known = comparison
return if (known != null && known.installedAt == installed.lastUpdateTime) known.verdict
else ManagerCopy.Present
}

/**
* Compares the installed copy of the manager with the build this one is running.
*
* The version code goes first because it is free, and a disagreement there is already the
* answer. Agreement is not: the code is `git rev-list --count origin/master`, so a build made
* on a branch and the official build at that same depth wear the same number while being
* different binaries entirely. That is the case worth spending a digest on, and the only one —
* the whole point of taking one is to separate two copies the numbers call identical.
*
* What this manager is running is the canonical side. Parasitically its dex comes out of the
* daemon's own module APK, which is the same file `getManagerApk` hands over, so `BuildConfig`
* here describes the copy the daemon would install and the fetch is only needed for its bytes.
*
* **A comparison that could not be made is not a mismatch.** A dead daemon, a refused APK, an
* unreadable install: each leaves [ManagerCopy.Present], because the reader would be sent to
* replace a perfectly good copy on the strength of a check that never ran. Only a completed
* comparison that came out different says so, and only that is remembered — a failure is left
* unremembered on purpose, so that a daemon which comes back is asked again.
*/
suspend fun refreshInstalledManager(): ManagerCopy {
// Installed rather than parasitic, this manager *is* the copy in question: the package it
// would compare itself against is itself, and the daemon's module APK is then a third file
// that is allowed to differ without anything being wrong. Nothing renders the answer in
// that mode either — the card that asks is drawn only parasitically — so the cheap answer
// is the whole answer.
if (!LaunchShortcut.isParasitic(context)) return installedManager()
return comparing.withLock { compare() }
}

/** The comparison itself, off the drawing thread and one at a time; see [comparing]. */
private suspend fun compare(): ManagerCopy =
withContext(Dispatchers.IO) {
val installed = installedPackage() ?: return@withContext ManagerCopy.Absent
if (installed.versionCodeCompat != BuildConfig.VERSION_CODE.toLong()) {
return@withContext ManagerCopy.Diverged
}

val known = comparison
if (known != null && known.installedAt == installed.lastUpdateTime) {
return@withContext known.verdict
}

// The daemon's copy first, dearer though the round trip is. A daemon that is gone
// refuses it at once, and that is much the likeliest reason this comparison cannot be
// made: presence is refreshed whether or not there is a binder, and the row showing the
// answer is drawn disabled without one. Hashing the local copy first would spend twenty
// megabytes of reads, on every arrival at the screen, to arrive at the same nothing.
val ours = canonicalDigest()
if (ours == null) {
logW("actions: the daemon served no manager APK to compare against")
return@withContext ManagerCopy.Present
}
// `sourceDir` is the whole of the installed copy — this installer stages one APK and
// never any splits, so there is nothing else of it to fold in.
val source = installed.applicationInfo?.sourceDir
val theirs = source?.let { path -> sha256 { FileInputStream(path) } }
if (theirs == null) {
logW("actions: the installed manager could not be read, so it cannot be compared")
return@withContext ManagerCopy.Present
}

val verdict =
if (theirs.contentEquals(ours)) ManagerCopy.Present else ManagerCopy.Diverged
comparison = Comparison(installed.lastUpdateTime, verdict)
verdict
}

/** The installed manager as the package manager sees it, or null when there is none. */
private fun installedPackage(): PackageInfo? =
runCatching { context.packageManager.getPackageInfo(BuildConfig.MANAGER_PACKAGE_NAME, 0) }
.getOrNull()

/**
* Digests the APK the daemon would install, and closes the descriptor it came on.
*
* A fresh `getManagerApk` rather than anything [install] holds: that descriptor is read to its
* end and closed by the install itself, and there is no rewinding it.
*/
private suspend fun canonicalDigest(): ByteArray? {
val apk =
withTimeoutOrNull(APK_TIMEOUT_MS) { daemon.getManagerApk().getOrNull() } ?: return null
return try {
sha256 { FileInputStream(apk.fileDescriptor) }
} finally {
runCatching { apk.close() }
}
}

/**
* The SHA-256 of a stream, or null if it could not be read to its end.
*
* A chunk at a time, because the manager APK runs to tens of megabytes and neither side of this
* comparison has any business sitting in this process's heap — parasitically that heap belongs
* to `com.android.shell`, which is not sized for it. The stream is opened inside so that a file
* that cannot be opened is the same null as one that cannot be read, and closed on every path.
*/
private fun sha256(open: () -> InputStream): ByteArray? =
runCatching {
context.packageManager.getPackageInfo(BuildConfig.MANAGER_PACKAGE_NAME, 0)
true
open().use { stream ->
val digest = MessageDigest.getInstance("SHA-256")
val buffer = ByteArray(DIGEST_CHUNK)
while (true) {
val read = stream.read(buffer)
if (read < 0) break
digest.update(buffer, 0, read)
}
digest.digest()
}
}
.getOrDefault(false)
.getOrNull()

/**
* Fetches the flashed manager APK from the daemon and installs it.
Expand Down Expand Up @@ -190,9 +352,20 @@ class ManagerInstaller(private val context: Context, private val daemon: DaemonC
promptFailure = "actions: manager install prompt could not be started",
)

/**
* A completed comparison, and the copy it was made against.
*
* @property installedAt `PackageInfo.lastUpdateTime` of the copy that was compared, which is
* what makes this verdict expire when that copy is replaced rather than outlive it.
*/
private data class Comparison(val installedAt: Long, val verdict: ManagerCopy)

private companion object {
const val WRITE_NAME = "manager.apk"

/** How much of an APK is held at once while it is being hashed. */
const val DIGEST_CHUNK = 64 * 1024

/** What the platform calls it in `EXTRA_STATUS_MESSAGE`; see PackageManagerException. */
const val SIGNATURE_CONFLICT = "INSTALL_FAILED_UPDATE_INCOMPATIBLE"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ enum class AmbienceKind(val key: String, val labelRes: Int) {
/** A carved maze with one wanderer in it. Tap to move it, swipe for a new maze. */
Maze("maze", R.string.ambience_maze),
/**
* Signal traces carrying several pulses at once. Tap to fire one, swipe to re-route.
* Signal traces carrying several pulses at once. Tap to fire one, swipe sideways to re-route
* the board, up and down to change how fast the signals run.
*
* Kept beside [Maze] rather than replaced by it because they are opposites and both are worth
* having: a circuit is a designed path many signals share, a maze is an undesigned one a single
Expand Down Expand Up @@ -88,8 +89,8 @@ interface AmbienceRenderer {
/**
* How fast it moves, as a multiple of its resting speed.
*
* Only meaningful where there is continuous motion — the maze wanderer and the circuit pulses
* move on their own schedule, so they ignore it.
* Only meaningful where there is continuous motion — the maze wanderer walks at the one pace
* that lets a decision be watched being made, so it ignores this.
*/
var speed: Float
get() = 1f
Expand Down
Loading
Loading