diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubModels.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubModels.kt index a13bae7bb..88747478c 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubModels.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubModels.kt @@ -247,30 +247,60 @@ data class GhReleaseAsset( @SerialName("browser_download_url") val downloadUrl: String? = null, ) -/** A successful CI run, as the canary screen renders it. */ -data class CanaryBuild( - val id: Long, - /** - * The build this is, and the key the installer selects by. - * - * CI tags every canary `canary-`, so the number is in the tag and needs no second - * request. It is what [FrameworkRelease.versionCode] holds for the same release, which is how a - * row here can hand the installer a build rather than a URL. - */ - val versionCode: Long, +/** + * A closed issue, as GitHub's issue list reports it. + * + * **This is how the canary screen knows what got fixed, and it has to be.** A commit message only + * names an issue when somebody wrote `Fixes #816` into it; this repository's issues are usually + * linked through the web UI's *Development* panel instead, which closes them on merge and writes + * nothing into the history at all. The link itself is only readable through GraphQL — + * `PullRequest.closingIssuesReferences` — and GraphQL answers 403 to an anonymous caller, which + * this app is by design. So what is asked instead is the question REST will answer without an + * account: which issues closed, and when. + */ +data class ClosedIssue( + val number: Int, val title: String, - val branch: String, - val shortSha: String, - val epochSeconds: Long, + val closedAtEpoch: Long, val htmlUrl: String?, - val artifacts: List, ) +@Serializable +data class GhIssue( + val number: Int, + val title: String = "", + @SerialName("closed_at") val closedAt: String? = null, + /** + * Why it closed: `completed`, `not_planned` or `duplicate`. + * + * Only the first is a fix. Counting the others would tell a reader that eleven issues were + * dealt with since their build when five of them were triage. + */ + @SerialName("state_reason") val stateReason: String? = null, + @SerialName("html_url") val htmlUrl: String? = null, +) { + /** + * Whether this is really a pull request. + * + * The issues endpoint returns both — a pull request *is* an issue to GitHub — and in one page + * of this repository's closed items more than half were pull requests. Told apart by the URL + * rather than by the presence of the `pull_request` object, which would mean decoding a nested + * payload none of whose fields are wanted. + */ + val isPullRequest: Boolean + get() = htmlUrl?.contains("/pull/") == true +} + /** * A published build of the framework, canary or stable, with the zip to flash. * * One type for both channels because the install path is identical — the difference is only which * of them a given reader is allowed to be offered. + * + * One type for the canary list as well, which used to read the same endpoint through a shape of its + * own. Two models over one response meant the canary page fetched what the update page was already + * holding, and could say nothing about a build that this type does not carry — no notes, no commit, + * and so no way to mark the one that is running. */ data class FrameworkRelease( val tag: String, @@ -302,6 +332,15 @@ data class FrameworkRelease( /** The one to offer by default when nothing has been chosen. */ val defaultZip: CanaryArtifact? get() = zips.firstOrNull { it.variant == ZipVariant.Release } ?: zips.firstOrNull() + + /** + * The commit, abbreviated the way git abbreviates it, or null when the release names a branch. + * + * Seven characters because that is what `git rev-parse --short` gives on a repository this + * size, and what the commit rail already prints — the two are read side by side. + */ + val shortSha: String? + get() = commit?.take(7) } /** @@ -321,7 +360,6 @@ data class CanaryArtifact( val id: Long, val name: String, val sizeInBytes: Long, - val expired: Boolean, val downloadUrl: String?, ) { val variant: ZipVariant diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubRepository.kt index ea360eb18..d9f8205dd 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubRepository.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubRepository.kt @@ -705,57 +705,66 @@ class GitHubRepository( .getOrNull() /** - * The canary builds, newest first. - * - * These are **prereleases**, not Actions artifacts, and that is the whole point. GitHub gates an - * artifact download behind an account even for a public repository — `actions/artifacts//zip` - * answers 401 to an anonymous caller, while a release asset answers 206 — so sourcing canaries - * from artifacts would mean asking every would-be tester for an OAuth grant to work around a - * storage decision. CI attaches the same zips to a rolling `canary-` prerelease, - * and this reads that, so nobody signs in to anything. - * - * Filtered to the canary tag rather than taking every prerelease: a hand-cut release candidate - * is also a prerelease, and it is not a nightly. + * The issues that have been closed as done, newest first. + * + * **Asked of the issue tracker rather than derived from the commits, and that is not a + * shortcut.** An issue linked through GitHub's *Development* panel — the usual way here — is + * closed by the merge itself and leaves no trace in any commit message, so reading the history + * would report only the minority that happened to be written up as `Fixes #816`. The link that + * did the closing lives in `PullRequest.closingIssuesReferences`, which exists only in GraphQL, + * and GraphQL answers 403 without an account. This endpoint answers the neighbouring question + * anonymously, and the answer is the one worth showing: not which commit closed what, but what + * has been fixed since the reader's build. + * + * Closed is not fixed: `not_planned` and `duplicate` are also closures, and were a fifth of one + * page here. Only `completed` is counted. + * + * One page, unpaginated. A hundred items reaches back several weeks on this repository, which + * covers the span between any two builds a reader could be choosing between; older than that + * and the number stops mattering because the reader is being told to update, not to test. */ - suspend fun canaryBuilds(freshness: Freshness = Freshness.Revalidate): List = + suspend fun closedIssues(freshness: Freshness = Freshness.Revalidate): List = withContext(Dispatchers.IO) { - val body = releaseListJson(freshness) ?: return@withContext emptyList() - - runCatching { json.decodeFromString>(body) } - .onFailure { e -> logE("update: canary release list unreadable", e) } + val url = "$API/$REPO/issues?state=closed&per_page=100&sort=updated&direction=desc" + val body = + runCatching { get(url, freshness) } + .onFailure { e -> logW("canary: closed issue list unavailable", e) } + .getOrNull() ?: return@withContext emptyList() + + runCatching { json.decodeFromString>(body) } + .onFailure { e -> logE("canary: closed issue list unreadable", e) } .getOrDefault(emptyList()) - .filter { it.prerelease && it.tagName.startsWith(CANARY_TAG_PREFIX) } - .take(CANARY_KEEP) - .map { release -> - CanaryBuild( - id = release.id, - versionCode = release.versionCode() ?: 0, - title = release.name ?: release.tagName, - branch = release.tagName, - shortSha = release.targetCommitish.take(7), - epochSeconds = parseIso8601(release.publishedAt.orEmpty()), - htmlUrl = release.htmlUrl, - artifacts = - release.assets.map { - CanaryArtifact( - id = it.id, - name = it.name, - sizeInBytes = it.size, - expired = false, - downloadUrl = it.downloadUrl, - ) - }, + .filter { !it.isPullRequest && it.stateReason == "completed" } + .mapNotNull { issue -> + val closed = parseIso8601(issue.closedAt ?: return@mapNotNull null) + ClosedIssue( + number = issue.number, + title = issue.title, + closedAtEpoch = closed.takeIf { it > 0 } ?: return@mapNotNull null, + htmlUrl = issue.htmlUrl, ) } + .sortedByDescending { it.closedAtEpoch } } /** * Every published build, both channels, newest first. * - * One fetch for both because they come from the same endpoint, and because deciding which - * channel a reader is on needs to see both: a canary that has aged out of the rolling five is - * still recognisable as a canary by being *newer than the newest stable release*, and that - * comparison is impossible with only one of the two lists in hand. + * The canaries here are **prereleases**, not Actions artifacts, and that is the whole point. + * GitHub gates an artifact download behind an account even for a public repository — + * `actions/artifacts//zip` answers 401 to an anonymous caller, while a release asset answers + * 206 — so sourcing canaries from artifacts would mean asking every would-be tester for an OAuth + * grant to work around a storage decision. CI attaches the same zips to a rolling + * `canary-` prerelease, and this reads those, so nobody signs in to anything. + * + * A canary is recognised by its tag rather than by being a prerelease: a hand-cut release + * candidate is also a prerelease, and it is not a nightly. + * + * One fetch for both channels because they come from the same endpoint, because deciding which + * channel a reader is on needs to see both — a canary that has aged out of the rolling five is + * still recognisable by being *newer than the newest stable release*, and that comparison is + * impossible with only one of the two lists in hand — and because the canary list is this same + * answer filtered, not a second question. */ suspend fun frameworkReleases(freshness: Freshness = Freshness.Revalidate): List = @@ -788,7 +797,6 @@ class GitHubRepository( id = it.id, name = it.name, sizeInBytes = it.size, - expired = false, downloadUrl = it.downloadUrl, ) }, @@ -945,7 +953,14 @@ class GitHubRepository( /** CI keeps five; a few extra are fetched so a stable release among them costs nothing. */ private const val CANARY_FETCH = 12 - private const val CANARY_KEEP = 5 + + /** + * How many canaries CI keeps, which the canary screen states as reassurance. + * + * Read from here rather than written into the sentence, so the promise the screen makes + * and the number the workflow prunes to cannot drift apart silently. + */ + const val CANARY_KEEP = 5 private const val API = "https://api.github.com/repos" private const val API_ROOT = "https://api.github.com" @@ -982,6 +997,7 @@ class GitHubRepository( private val PR_SUFFIX = Regex("""\(#(\d+)\)\s*$""") + private val LAST_PAGE = Regex("""[?&]page=(\d+)>;\s*rel="last"""") private val CO_AUTHOR = diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/CanaryLayout.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/CanaryLayout.kt new file mode 100644 index 000000000..69e925e43 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/CanaryLayout.kt @@ -0,0 +1,216 @@ +package org.matrix.vector.manager.data.repository + +import org.matrix.vector.manager.data.github.ClosedIssue +import org.matrix.vector.manager.data.github.CommunityFeed +import org.matrix.vector.manager.data.github.FrameworkRelease +import org.matrix.vector.manager.data.github.TimelineCommit + +/** + * What the canary list draws, in order. + * + * **A version code is a commit count.** `versionCode` is generated by `git rev-list --count`, and + * `TimelineCommit.globalIndex` counts the same way on the same history, so the commits a build + * carries over the one before it are not an estimate — they are the half-open range between two + * version codes, and the feed the home screen already holds can name them. + * + * That is the whole reason this file exists. Without it a canary row can only say what it is + * called and how big its zip is, which tells a reader nothing about whether it is worth their + * evening. With it the row says what landed, and the page above it says what has been fixed since + * the build they are running. + */ +sealed interface CanaryItem { + + /** One published canary, with the work it brought over the canary below it. */ + data class Build(val span: CanarySpan) : CanaryItem + + /** + * Where the reader's own build sits among the canaries. + * + * The same marker the commit rail draws, for the same reason and from the same numbers: + * everything above it is what installing would actually bring. + */ + data class Installed( + val versionCode: Long, + val commitsAhead: Int, + /** Running something newer than every published canary — a local or branch build. */ + val ahead: Boolean, + ) : CanaryItem +} + +/** + * One canary: the build, and the commit it was cut from. + * + * **The head commit, and nothing else about the history.** A build is one commit — the tip CI + * happened to build — and that commit's subject is what tells a reader what this build is. Counting + * the commits between it and the build below says only how much time passed, which the dates + * already say, and it competed for room with the thing worth reading. + */ +data class CanarySpan( + val release: FrameworkRelease, + /** + * The commit this build was made from, when the feed reaches it. + * + * Matched by SHA rather than by version code. Both would usually work, but `globalIndex` is + * assigned by counting down from a total across a paged fetch, and its own documentation warns + * that a page lost after it was written leaves the numbers below the seam reading high. A + * release names its commit exactly, so there is no reason to rely on the fragile one. + */ + val head: TimelineCommit?, + /** + * The subject to show, from the head commit or, failing that, from the release notes. + * + * CI writes the commit subject as the first bold line of every canary's notes, so a build whose + * commit the feed no longer reaches — older than the window, or a cold cache — still has a + * title rather than a bare number. + */ + val subject: String?, + /** True when this is the build that is running. */ + val installed: Boolean, + /** True when the running build wears this number but was not built from this release. */ + val diverged: Boolean, +) + +/** + * The sentence at the top of the canary screen: what taking one would get this reader. + * + * [fixed] is the recruiting number, and it is a claim about other people's reports rather than + * about our own commit subjects — which is what makes it worth stating. It is scoped by time, not + * by authorship: these are the issues closed as done *since the reader's build was cut*, which is + * the only attribution obtainable without an account. That the sentence says exactly that, and + * claims no more, is deliberate. + */ +data class CanaryOverview( + val installedVersionCode: Long, + val commitsAhead: Int, + /** Issues closed as completed since the running build, newest first. */ + val fixed: List = emptyList(), + /** True when the running build is itself a canary: this reader is already testing. */ + val onCanary: Boolean = false, + /** True when the running build is newer than every published canary. */ + val ahead: Boolean = false, + /** + * True when the running build carries a listed canary's number but was not built from it. + * + * A version code is a commit count, not an identity: a build from another branch, or from a + * working tree with changes in it, reaches the same count and wears the same number. Without + * this the page would tell such a reader they were running the newest canary while the card for + * that canary, two lines below, marked itself "same number, other build". + */ + val diverged: Boolean = false, +) { + val behind: Boolean + get() = commitsAhead > 0 +} + +data class CanaryBoard( + val overview: CanaryOverview = CanaryOverview(0, 0), + val items: List = emptyList(), + /** False until the release list has answered; the screen spins rather than saying "none". */ + val loaded: Boolean = false, +) + +object CanaryLayout { + + /** + * @param attempted whether the release list has been asked for and answered, however it + * answered. A refusal — no network, a rate limit, a daemon that cannot say what is installed + * — leaves the catalogue empty, and without this the screen could not tell that from a fetch + * still in flight, so it span forever on exactly the devices least able to reach GitHub. + */ + fun build( + feed: CommunityFeed, + state: FrameworkUpdateState, + closed: List, + attempted: Boolean, + ): CanaryBoard { + val canaries = state.catalog.filter { it.isCanary }.sortedByDescending { it.versionCode } + val installed = state.installedVersionCode + val loaded = attempted || state.catalog.isNotEmpty() + if (canaries.isEmpty()) return CanaryBoard(loaded = loaded, items = emptyList()) + + val newest = canaries.first().versionCode + val since = state.builtAt(feed) + val overview = + CanaryOverview( + installedVersionCode = installed, + commitsAhead = (newest - installed).coerceAtLeast(0).toInt(), + fixed = + if (since == null) emptyList() + else closed.filter { it.closedAtEpoch > since }, + onCanary = state.onCanary, + ahead = installed > newest, + diverged = + canaries + .firstOrNull { it.versionCode == installed } + ?.let { state.divergesFrom(it) } == true, + ) + + val items = ArrayList(canaries.size + 1) + // Above every canary, because there is no canary it could sit under. A build past the head + // of master is not a position in this list; it is a statement that the list does not + // describe what is running. + if (overview.ahead) { + items += CanaryItem.Installed(installed, overview.commitsAhead, ahead = true) + } + // Nothing to mark when the reader is already on the newest canary: the card for it is + // badged as installed, and a marker under it saying "0 commits newer than yours" is a + // sentence about nothing. + var markerPlaced = overview.ahead || installed <= 0 || !overview.behind + + canaries.forEachIndexed { index, release -> + val head = release.commit?.let { sha -> feed.commits.firstOrNull { it.sha == sha } } + + items += + CanaryItem.Build( + CanarySpan( + release = release, + head = head, + subject = head?.subject ?: release.notesSubject(), + installed = release.versionCode == installed, + diverged = state.divergesFrom(release), + ) + ) + + // Placed under the last canary that is newer than the reader's build, which is where + // the line between "what I could have" and "what I already have" actually falls. + val next = canaries.getOrNull(index + 1) + if (!markerPlaced && (next == null || next.versionCode <= installed)) { + items += CanaryItem.Installed(installed, overview.commitsAhead, ahead = false) + markerPlaced = true + } + } + + return CanaryBoard(overview = overview, items = items, loaded = true) + } +} + +/** + * When the running build was cut, or null when that cannot be established. + * + * The commit at the reader's version code is the exact answer and needs no extra request — the + * version code *is* that commit's position. A published build the feed no longer reaches falls + * back to when its release went out, which is within hours of the same thing. A build that is + * neither in the window nor in the catalogue — someone's own — has no date here, and the screen + * says nothing about what has been fixed rather than guessing a span. + */ +private fun FrameworkUpdateState.builtAt(feed: CommunityFeed): Long? = + feed.commits.firstOrNull { it.globalIndex == installedVersionCode }?.epochSeconds + ?: catalog.firstOrNull { it.versionCode == installedVersionCode }?.epochSeconds + +/** + * The commit subject CI wrote into the release notes, or null. + * + * The workflow opens every canary's body with the subject in bold — `**Keep a module inside the + * users that installed it**` — so this is a copy of the same string the commit carries, published + * alongside the zips. It is the fallback for a build the commit feed cannot reach, and it is why + * such a build still reads as a build rather than as a number. + */ +private fun FrameworkRelease.notesSubject(): String? = + notesMarkdown + ?.lineSequence() + ?.firstOrNull { it.isNotBlank() } + ?.trim() + ?.let { NOTES_SUBJECT.find(it)?.groupValues?.getOrNull(1) } + ?.takeIf { it.isNotBlank() } + +private val NOTES_SUBJECT = Regex("""^\*\*(.+?)\*\*$""") diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/FrameworkUpdateRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/FrameworkUpdateRepository.kt index cf836d1ef..7a3c0c006 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/FrameworkUpdateRepository.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/FrameworkUpdateRepository.kt @@ -23,8 +23,10 @@ import org.matrix.vector.manager.data.model.buildStamp * aged out of the rolling five prereleases and would otherwise look like a release build. It also * correctly classifies a locally built development copy, which is ahead of everything published. * - * A reader on a release build is only ever offered releases. That is the whole point of the - * distinction: a nightly is not something to be nudged towards. + * A reader on a release build is never *offered* a canary. That is the whole point of the + * distinction: a nightly is not something to be nudged towards. It is not a ban on installing one — + * the canary list exists to be acted on, and [FrameworkUpdateState.catalog] keeps both channels so + * a build asked for by name can still be found. Only the unasked-for offer is filtered. */ class FrameworkUpdateRepository(private val github: GitHubRepository) { @@ -45,19 +47,21 @@ class FrameworkUpdateRepository(private val github: GitHubRepository) { releases.any { it.isCanary && it.versionCode == installedVersionCode } || (newestStable != null && installedVersionCode > newestStable.versionCode) - // A canary reader sees whichever is newer; a release reader never sees a canary at all. - val candidates = if (onCanary) releases else releases.filterNot { it.isCanary } - val newest = candidates.maxByOrNull { it.versionCode } + // A canary reader is offered whichever is newer; a release reader is offered no canary. + val newest = releases.filter { onCanary || !it.isCanary }.maxByOrNull { it.versionCode } _state.value = FrameworkUpdateState( installedVersionCode = installedVersionCode, installedCommit = installedCommit, available = newest?.takeIf { it.versionCode > installedVersionCode }, - // Every release on the channel, not only the newest: the same list that answers - // "is there anything newer" also answers "what could I go back to" — a question - // people ask after a build breaks something for them. - history = candidates.sortedByDescending { it.versionCode }, + // Every published build, not only the newest and not only this channel's: the same + // list that answers "is there anything newer" also answers "what could I go back + // to" — a question people ask after a build breaks something for them — and "which + // build was that row on the canary page", which is a question only the other + // channel can answer. + catalog = releases.sortedByDescending { it.versionCode }, + onCanary = onCanary, ) } } @@ -79,9 +83,24 @@ data class FrameworkUpdateState( */ val installedCommit: String? = null, val available: FrameworkRelease? = null, - /** Every release on this channel, newest first — including ones older than the installed one. */ - val history: List = emptyList(), + /** + * Every published build, both channels, newest first — including ones older than the installed + * one, and, for a reader on a release build, the canaries they are not being offered. + * + * Kept whole because a canary the reader picked off the canary page has to be resolvable by + * version code. Filtering it out here is what made that tap land on the newest *release* + * instead: the number named a build the screen had thrown away, so the selection fell through + * to the channel's default and a reader who asked for a nightly was shown the stable release + * they were already running. + */ + val catalog: List = emptyList(), + /** Whether the running build is itself a canary, by the rule the repository documents. */ + val onCanary: Boolean = false, ) { + /** What this reader is offered unasked: their own channel, newest first. */ + val history: List + get() = catalog.filter { onCanary || !it.isCanary } + val hasUpdate: Boolean get() = available != null } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt index f2e664a65..8d146c05e 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt @@ -239,6 +239,7 @@ private fun EntryProviderScope.registerRoutes(navigator: Navigator) { onNavigateBack = { navigator.back() }, onOpenUrl = { url -> navigator.go(Web(url)) }, onInstall = { versionCode -> navigator.go(FrameworkUpdate(versionCode)) }, + onOpenReport = { navigator.go(Troubleshoot) }, ) } entry { route -> diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryScreen.kt index ea16b981b..90cd4d472 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryScreen.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryScreen.kt @@ -1,5 +1,7 @@ package org.matrix.vector.manager.ui.screens.canary +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,54 +16,71 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.automirrored.rounded.OpenInNew +import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.Science +import androidx.compose.material.icons.rounded.SystemUpdateAlt import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.matrix.vector.manager.R -import org.matrix.vector.manager.data.github.CanaryBuild import org.matrix.vector.manager.data.github.GitHubRepository -import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.data.github.TimelineCommit +import org.matrix.vector.manager.data.repository.CanaryItem +import org.matrix.vector.manager.data.repository.CanaryOverview +import org.matrix.vector.manager.data.repository.CanarySpan +import org.matrix.vector.manager.ui.components.InstalledMarkerRow +import org.matrix.vector.manager.ui.components.exactTime import org.matrix.vector.manager.ui.theme.VectorMono /** - * Canary builds: what CI has produced since the last release, and how to try one. + * Canary builds: what has landed since the reader's own build, and how to go and run it. * * **Nobody signs in here, and that is what decides where the zips come from.** GitHub gates artifact * downloads behind an account even on a public repository — `actions/artifacts//zip` answers 401 * to an anonymous caller where a release asset answers 206 — so listing Actions artifacts would mean * asking every would-be tester for an OAuth grant to work around where the zips happen to live, and * would lose the people who cannot reach GitHub's login page at all. CI attaches the same zips to a - * rolling `canary-` prerelease instead, and this lists those. + * rolling `canary-` prerelease, and this lists those. + * + * **This page chooses; it does not install.** It used to do both, badly: each row carried the zip + * names, their sizes and an install button, all of which the build page does better — it states the + * sizes, remembers which variant was last taken, checks the root implementation, shows the download + * and the installer's own output. Duplicating that left no room for the one thing this screen is + * for, which is deciding whether tonight's build is worth an evening. * - * The Actions page is one tap away for anyone who wants the build log or a commit older than the - * five canaries CI keeps, filtered the way the project README filters it. + * So each row answers that instead. A version code is `git rev-list --count`, and the commit feed + * counts the same way, so the commits between two builds are exact — the rows name them, and say + * how many were fixes. That number is the honest argument for testing: not "please help", but "four + * of the nine commits since your build are fixes". */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -69,9 +88,10 @@ fun CanaryScreen( onNavigateBack: () -> Unit, onOpenUrl: (String) -> Unit, onInstall: (Long) -> Unit, + onOpenReport: () -> Unit, + viewModel: CanaryViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), ) { - var builds by remember { mutableStateOf?>(null) } - LaunchedEffect(Unit) { builds = ServiceLocator.github.canaryBuilds() } + val board by viewModel.board.collectAsStateWithLifecycle() Scaffold( topBar = { @@ -86,6 +106,9 @@ fun CanaryScreen( } }, actions = { + // The only route out to GitHub on the page. It used to be three — this, a + // button under the list and a third in the empty state — which is two more + // than a screen has reasons to leave itself. IconButton(onClick = { onOpenUrl(GitHubRepository.CANARY_URL) }) { Icon( Icons.AutoMirrored.Rounded.OpenInNew, @@ -97,161 +120,431 @@ fun CanaryScreen( } ) { padding -> Column(Modifier.padding(padding).fillMaxSize()) { - val list = builds when { - list == null -> + !board.loaded -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } - list.isEmpty() -> CanaryEmpty(onOpenUrl = onOpenUrl) + board.items.isEmpty() -> CanaryEmpty(onOpenUrl = onOpenUrl) else -> LazyColumn(contentPadding = PaddingValues(bottom = 24.dp)) { - items(list, key = { it.id }) { build -> - BuildRow(build = build, onOpenUrl = onOpenUrl, onInstall = onInstall) - HorizontalDivider( - modifier = Modifier.padding(horizontal = 20.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - ) - } - item { - // The raw runs, for anyone who wants the build log or a commit older - // than the five CI keeps. - Row( - modifier = Modifier.fillMaxWidth().padding(20.dp), - horizontalArrangement = Arrangement.Center, - ) { - OutlinedButton( - onClick = { onOpenUrl(GitHubRepository.CANARY_URL) } - ) { - Icon( - Icons.AutoMirrored.Rounded.OpenInNew, - contentDescription = null, - modifier = Modifier.size(18.dp), + item { Preamble(board.overview) } + items( + items = board.items, + key = { item: CanaryItem -> itemKey(item) }, + ) { item -> + when (item) { + is CanaryItem.Build -> + BuildCard( + span = item.span, + onInstall = onInstall, + onOpenUrl = onOpenUrl, + ) + is CanaryItem.Installed -> + InstalledMarkerRow( + versionCode = item.versionCode, + commitsAhead = item.commitsAhead, + aheadOfMaster = item.ahead, + modifier = Modifier.padding(horizontal = 20.dp), ) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.canary_open_actions)) - } } } + item { ReportFoot(onOpenReport = onOpenReport) } } } } } } +private fun itemKey(item: CanaryItem): Any = + when (item) { + is CanaryItem.Build -> item.span.release.tag + is CanaryItem.Installed -> "installed" + } + /** - * Nothing published yet. + * What a canary is, and what taking one would get *this* reader. * - * Says what to do about it rather than only reporting the absence: before CI has pushed its first - * prerelease this is the normal state, not a fault. + * The second half is the part that matters, and it is the part the screen never had. "Try a canary" + * asks for a favour; naming three issues that have been fixed since their build states a reason. + * + * The two halves fail independently, on purpose. The commit count is version-code arithmetic and + * needs nothing but the release list, so it survives a cold cache; the issues come from the tracker + * and simply do not appear when that request fails or when the running build cannot be dated. What + * is never shown is a zero — an empty answer here means "not known", and printing it as "0 issues + * fixed" would turn a missing request into a discouraging fact. */ @Composable -private fun CanaryEmpty(onOpenUrl: (String) -> Unit) { - Column( - modifier = Modifier.fillMaxSize().padding(32.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - Icons.Rounded.Science, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, +private fun Preamble(overview: CanaryOverview) { + val colors = MaterialTheme.colorScheme + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Rounded.Science, + contentDescription = null, + tint = colors.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(10.dp)) + Text( + stringResource(R.string.canary_what_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + } + Spacer(Modifier.height(6.dp)) + Text( + stringResource(R.string.canary_what_body), + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, ) - Spacer(Modifier.height(12.dp)) + + Spacer(Modifier.height(10.dp)) Text( - stringResource(R.string.canary_none), + text = + when { + // Past every canary and not on one: a release cut after the last nightly, which + // is the normal state for a day or two after every release. Ordinary news, and + // must not borrow the sentence written for a build of unknown provenance. + overview.ahead && !overview.onCanary -> + stringResource(R.string.canary_after_release) + // Past every canary while on the canary channel: built locally or from a + // branch. Not a position in this list at all, and worth saying rather than + // leaving the reader to wonder why nothing below is marked as theirs. + overview.ahead -> stringResource(R.string.canary_ahead) + // Wearing the newest canary's number without being it. Saying "you are running + // the newest canary" here would contradict the card below, which marks itself + // "same number, other build" from the same comparison. + overview.diverged -> stringResource(R.string.canary_diverged) + !overview.behind -> stringResource(R.string.canary_current) + else -> + pluralStringResource( + R.plurals.canary_since_commits, + overview.commitsAhead, + overview.commitsAhead, + ) + }, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, + // Caution only for the two that say the running build is not what its number claims. + color = + if (overview.diverged || (overview.ahead && overview.onCanary)) colors.tertiary + else colors.primary, ) - Spacer(Modifier.height(16.dp)) - OutlinedButton(onClick = { onOpenUrl(GitHubRepository.CANARY_URL) }) { - Text(stringResource(R.string.canary_open_actions)) + + // The strongest argument the page has, and the only one that is about the reader's own + // complaints rather than the project's activity. Named rather than counted: someone who + // filed one of these recognises it, and a count never gives them that. + if (overview.fixed.isNotEmpty() && overview.behind) { + Spacer(Modifier.height(12.dp)) + Text( + text = + pluralStringResource( + R.plurals.canary_fixed_since, + overview.fixed.size, + overview.fixed.size, + ), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = colors.onSurface, + ) + Spacer(Modifier.height(4.dp)) + overview.fixed.take(ISSUES_SHOWN).forEach { issue -> + Row(Modifier.fillMaxWidth().padding(top = 3.dp)) { + Text("#${issue.number}", style = VectorMono, color = colors.primary) + Spacer(Modifier.width(8.dp)) + Text( + text = issue.title, + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } + + Spacer(Modifier.height(10.dp)) + // The fear that stops people is not that a nightly might break; it is that they would be + // stuck with it. Saying otherwise costs one line and is the difference between a page that + // asks and a page that reassures. + Text( + stringResource(R.string.canary_keep_body, GitHubRepository.CANARY_KEEP), + style = MaterialTheme.typography.bodySmall, + color = colors.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + HorizontalDivider(color = colors.outlineVariant.copy(alpha = 0.4f)) } } +/** + * One canary: when it was built, what it brought, and one tap to the page that installs it. + * + * The whole card is the target rather than a button on it. There is one thing to do with a build, + * the row is already about that build, and a button would only repeat what the row means while + * shrinking the area that means it. + */ @Composable -private fun BuildRow(build: CanaryBuild, onOpenUrl: (String) -> Unit, onInstall: (Long) -> Unit) { +private fun BuildCard(span: CanarySpan, onInstall: (Long) -> Unit, onOpenUrl: (String) -> Unit) { val colors = MaterialTheme.colorScheme - Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp)) { - Text( - text = build.title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + val release = span.release + + Column( + Modifier.fillMaxWidth() + .clickable { onInstall(release.versionCode) } + .padding(horizontal = 20.dp, vertical = 14.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.canary_build, release.versionCode), + style = VectorMono, + color = colors.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.width(10.dp)) + // Which of these is running, by the same rule the version picker uses: the number + // alone is not enough, because a build made from another branch wears it too. + when { + span.diverged -> + StatusChip(stringResource(R.string.update_same_number), colors.tertiary) + span.installed -> + StatusChip(stringResource(R.string.update_installed), colors.primary) + } + Spacer(Modifier.weight(1f)) + Icon( + Icons.Rounded.SystemUpdateAlt, + contentDescription = null, + tint = colors.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + Spacer(Modifier.height(3.dp)) Row(verticalAlignment = Alignment.CenterVertically) { - Text(build.shortSha, style = VectorMono, color = colors.onSurfaceVariant) - Text(" · ", style = MaterialTheme.typography.labelSmall, color = colors.outlineVariant) Text( - build.branch, + text = exactTime(release.epochSeconds), style = MaterialTheme.typography.labelMedium, color = colors.onSurfaceVariant, ) + // Who wrote it, in place of a count of how many commits went by. The same credit line + // the rail uses, and the same recognition: a contributor's name in the accent colour, + // on the screen the project uses to ask for testers. + span.head?.let { head -> + Text( + text = " · ", + style = MaterialTheme.typography.labelMedium, + color = colors.outlineVariant, + ) + Text( + text = credit(head), + style = MaterialTheme.typography.labelMedium, + fontWeight = if (head.isCommunity) FontWeight.SemiBold else FontWeight.Normal, + color = if (head.isCommunity) colors.primary else colors.onSurfaceVariant, + ) + } } - build.artifacts.filterNot { it.expired }.forEach { artifact -> + span.subject?.let { subject -> Spacer(Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text( - artifact.name, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - formatSize(artifact.sizeInBytes), - style = MaterialTheme.typography.labelSmall, - color = colors.onSurfaceVariant, - ) - } - + // Bottom-aligned, because the pull-request slot is a fixed corner of the card and the + // subject grows upward from it: a title that wraps to three lines still ends level + // with its own number. + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) { + Text( + // Wrapped, never truncated. A commit subject is a sentence written to be read, + // and the half of it that an ellipsis eats is usually the half that says what + // the change actually does. + text = subject, + style = MaterialTheme.typography.bodyLarge, + color = colors.onSurface, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(10.dp)) + PullRequestSlot(number = span.head?.pullRequest, onOpenUrl = onOpenUrl) } } + } + HorizontalDivider( + modifier = Modifier.padding(horizontal = 20.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + ) +} + +/** + * Everyone credited on the commit, written the way the rail writes it. + * + * The same three shapes and the same two strings as `CommitRow`, so a name reads identically + * wherever the reader meets it. + */ +@Composable +private fun credit(commit: TimelineCommit): String = + when (commit.coAuthors.size) { + 0 -> commit.authorLogin + 1 -> + stringResource( + R.string.home_with_coauthor, + commit.authorLogin, + commit.coAuthors.first().login, + ) + else -> + stringResource( + R.string.home_with_coauthors, + commit.authorLogin, + commit.coAuthors.size, + ) + } + +/** + * The bottom-right corner of a card, where the build's pull request lives. + * + * **The space is held whether or not there is a number in it.** The subject beside it wraps into + * whatever room is left, so a slot that appeared and disappeared would re-wrap the titles from one + * card to the next and the column would look ragged for a reason the reader cannot see. + * + * Its width is measured from the widest number the tracker could plausibly reach rather than + * written down as a dp, so it is still correct at a large font scale — where a guessed width clips + * the digits it exists to show. + * + * Tapping it opens the pull request rather than the build, which is the one place on this screen + * where a reader can read the discussion, see the review and answer it. + */ +@Composable +private fun PullRequestSlot(number: Int?, onOpenUrl: (String) -> Unit) { + val colors = MaterialTheme.colorScheme + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + val width = + remember(measurer, density) { + with(density) { measurer.measure(WIDEST_PR, VectorMono).size.width.toDp() } + + PR_CHIP_PADDING * 2 + + PR_CHIP_BORDER * 2 + } - if (build.artifacts.none { !it.expired }) { - Spacer(Modifier.height(6.dp)) + Box(Modifier.width(width), contentAlignment = Alignment.CenterEnd) { + if (number != null) { Text( - stringResource(R.string.canary_expired), - style = MaterialTheme.typography.labelMedium, - color = colors.onSurfaceVariant, + text = "#$number", + style = VectorMono, + color = colors.primary, + maxLines = 1, + modifier = + Modifier.clip(RoundedCornerShape(4.dp)) + .border( + PR_CHIP_BORDER, + colors.primary.copy(alpha = 0.4f), + RoundedCornerShape(4.dp), + ) + .clickable { + onOpenUrl("${GitHubRepository.REPO_URL}/pull/$number") + } + .padding(horizontal = PR_CHIP_PADDING, vertical = 2.dp), ) } + } +} + +@Composable +private fun StatusChip(label: String, color: Color) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier.clip(CircleShape) + .border(1.dp, color.copy(alpha = 0.5f), CircleShape) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) +} - // One action per build rather than one per zip. Choosing between the Release and the Debug - // zip belongs on the installer, which already offers it, states the size of each and - // remembers which was picked last; here it would be a choice made before the reader has - // been told what either one is for. +/** + * The other half of testing. + * + * A canary that misbehaves is only useful to the project if somebody says so, and the reader most + * likely to hit one is on this screen. The debug-build advice sits here rather than on the build + * page because this is where it is still actionable — by the time the variant picker is on screen + * the reader has already decided what they are installing and why. + */ +@Composable +private fun ReportFoot(onOpenReport: () -> Unit) { + val colors = MaterialTheme.colorScheme + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 18.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Rounded.BugReport, + contentDescription = null, + tint = colors.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(10.dp)) + Text( + stringResource(R.string.canary_report_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + } Spacer(Modifier.height(6.dp)) - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - build.htmlUrl?.let { url -> - TextButton(onClick = { onOpenUrl(url) }) { - Text(stringResource(R.string.canary_open_run)) - } - } - Spacer(Modifier.weight(1f)) - if (build.versionCode > 0 && build.artifacts.any { !it.expired }) { - FilledTonalButton(onClick = { onInstall(build.versionCode) }) { - Icon( - Icons.Rounded.Download, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(6.dp)) - Text(stringResource(R.string.canary_install)) - } - } + Text( + stringResource(R.string.canary_report_body), + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + FilledTonalButton(onClick = onOpenReport) { + Text(stringResource(R.string.home_open_issue)) } } } -private fun formatSize(bytes: Long): String = - when { - bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576.0) - bytes >= 1024 -> "%.0f kB".format(bytes / 1024.0) - else -> "$bytes B" +/** + * Nothing published yet. + * + * Says what to do about it rather than only reporting the absence: before CI has pushed its first + * prerelease this is the normal state, not a fault. + */ +@Composable +private fun CanaryEmpty(onOpenUrl: (String) -> Unit) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Rounded.Science, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + Text( + stringResource(R.string.canary_none), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(16.dp)) + OutlinedButton(onClick = { onOpenUrl(GitHubRepository.CANARY_URL) }) { + Text(stringResource(R.string.canary_open_actions)) + } } +} + +/** + * The number the pull-request slot is sized to hold. + * + * Five digits: this repository is in the eight hundreds, and a slot that has to be widened later is + * a slot that re-wraps every subject on the screen when it is. + */ +private const val WIDEST_PR = "#99999" + +private val PR_CHIP_PADDING = 6.dp +private val PR_CHIP_BORDER = 1.dp + +/** + * How many closed issues the header names before it stops. + * + * Three, for the same reason: enough that a reader waiting on one has a fair chance of seeing it, + * short enough that the list still reads as evidence rather than as a changelog. + */ +private const val ISSUES_SHOWN = 3 diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryViewModel.kt new file mode 100644 index 000000000..101a60651 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryViewModel.kt @@ -0,0 +1,91 @@ +package org.matrix.vector.manager.ui.screens.canary + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.matrix.vector.manager.BuildConfig +import org.matrix.vector.manager.data.github.ClosedIssue +import org.matrix.vector.manager.data.github.CommunityFeed +import org.matrix.vector.manager.data.github.GitHubRepository +import org.matrix.vector.manager.data.repository.CanaryBoard +import org.matrix.vector.manager.data.repository.CanaryLayout +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.logW + +/** + * The canary list, joined to the history it comes from. + * + * **Nothing here fetches anything the app was not already holding.** The builds are the release + * list the update page reads, filtered to the canaries; the commits are the feed the home screen + * loads on launch, served from disk. The screen this feeds used to make a request of its own for a + * second view of the first of those, and could still say nothing about the second. + */ +class CanaryViewModel : ViewModel() { + + private val daemon = ServiceLocator.daemon + private val github = ServiceLocator.github + private val updates = ServiceLocator.frameworkUpdates + + private val feed = MutableStateFlow(CommunityFeed()) + private val closed = MutableStateFlow>(emptyList()) + + /** + * True once the release list has answered, however it answered. + * + * Without it an unreachable GitHub is indistinguishable from a fetch in flight, and the screen + * spins forever on the devices least able to reach it. + */ + private val attempted = MutableStateFlow(false) + + val board: StateFlow = + combine(feed, updates.state, closed, attempted) { commits, state, issues, asked -> + CanaryLayout.build(commits, state, issues, asked) + } + // Off the main thread for the same reason the rail is: laying this out is a pass over + // an archive that runs to thousands of commits, once per canary shown. + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), CanaryBoard()) + + init { + viewModelScope.launch { + // The framework's version when the daemon is up, otherwise this manager's own. Both + // are `git rev-list --count origin/master` on the same repository, so either locates a + // build among the canaries correctly — and the fallback matters more here than + // anywhere else, because a reader whose framework is not answering is exactly the + // reader who has come looking for a build that works. + val installed = + daemon + .getXposedVersionCode() + .getOrElse { e -> + logW("canary: framework version unavailable, using the manager's own", e) + 0L + } + .takeIf { it > 0 } ?: BuildConfig.VERSION_CODE.toLong() + updates.refresh(installed, daemon.getFrameworkCommit().getOrNull()) + attempted.value = true + } + viewModelScope.launch { + // The one request this screen adds, and the only way to know what has actually been + // fixed: see `GitHubRepository.closedIssues`. Revalidated rather than forced, so + // coming back to the screen inside the half-hour window costs nothing. + closed.value = github.closedIssues() + } + viewModelScope.launch { + // Disk first, which is where the home screen's launch-time load has already put it, so + // arriving here costs no request and no wait. Only a reader who reached this screen + // before that finished — or on the first run of a fresh install — pays for a fetch. + val cached = github.load(GitHubRepository.Freshness.Cached) + feed.value = cached + if (cached.commits.isEmpty()) { + feed.value = github.load(GitHubRepository.Freshness.Revalidate) + } + } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/update/FrameworkUpdateScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/update/FrameworkUpdateScreen.kt index af212eede..3463de553 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/update/FrameworkUpdateScreen.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/update/FrameworkUpdateScreen.kt @@ -102,7 +102,7 @@ fun FrameworkUpdateScreen( viewModel: FrameworkUpdateViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), ) { // Before the list has loaded, which is the point: the pin is a number, and it is resolved - // against the history whenever that arrives. + // against the catalogue whenever that arrives. LaunchedEffect(openOnVersionCode) { openOnVersionCode?.let(viewModel::select) } val update by viewModel.update.collectAsStateWithLifecycle() val flash by viewModel.flash.collectAsStateWithLifecycle() @@ -110,6 +110,7 @@ fun FrameworkUpdateScreen( val chosenZip by viewModel.chosenZip.collectAsStateWithLifecycle() val root by viewModel.root.collectAsStateWithLifecycle() val selected by viewModel.selected.collectAsStateWithLifecycle() + val history by viewModel.history.collectAsStateWithLifecycle() val direction by viewModel.direction.collectAsStateWithLifecycle() val scope = androidx.compose.runtime.rememberCoroutineScope() var versionsOpen by remember { mutableStateOf(false) } @@ -120,7 +121,7 @@ fun FrameworkUpdateScreen( if (versionsOpen) { VersionsSheet( - history = update.history, + history = history, update = update, selected = selected, onSelect = viewModel::select, @@ -160,7 +161,7 @@ fun FrameworkUpdateScreen( } }, actions = { - if (update.history.size > 1) { + if (history.size > 1) { IconButton(onClick = { versionsOpen = true }) { Icon( Icons.Rounded.History, diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/update/FrameworkUpdateViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/update/FrameworkUpdateViewModel.kt index 8c9c9113b..67664c6a7 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/update/FrameworkUpdateViewModel.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/update/FrameworkUpdateViewModel.kt @@ -81,19 +81,37 @@ class FrameworkUpdateViewModel : ViewModel() { * The release the screen is about. * * Defaults to whatever is worth offering — the update if there is one, otherwise the newest - * known build, which is usually the installed one — and follows an explicit choice once made. - * Held as a version code rather than the object so a refresh that returns fresh instances does - * not silently drop the selection. + * build on this reader's channel, which is usually the installed one — and follows an explicit + * choice once made. Held as a version code rather than the object so a refresh that returns + * fresh instances does not silently drop the selection. + * + * The pin is resolved against the whole catalogue, both channels, while the defaults stay on + * the reader's own. Asking is not the same as being offered: someone on a release build who + * opened the canary list and pressed install on a row has named the build they want, and + * looking that number up in the release-only list finds nothing and quietly hands them the + * stable release instead. */ val selected: StateFlow = combine(update, explicit) { state, pinned -> - val list = state.history - pinned?.let { code -> list.firstOrNull { it.versionCode == code } } + pinned?.let { code -> state.catalog.firstOrNull { it.versionCode == code } } ?: state.available - ?: list.firstOrNull() + ?: state.history.firstOrNull() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + /** + * The builds the version picker lists. + * + * The reader's own channel, unless the page is sitting on a canary — then all of them. A page + * opened from the canary list is a page about prereleases, and a picker that answered it with + * the stable list would offer no way back to the build the reader had just been looking at. + */ + val history: StateFlow> = + combine(update, selected) { state, release -> + if (release?.isCanary == true) state.catalog else state.history + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + /** Where the selected release sits relative to what is running. */ val direction: StateFlow = combine(update, selected) { state, release -> @@ -120,7 +138,7 @@ class FrameworkUpdateViewModel : ViewModel() { * The canary list arrives here naming the build it was showing. It holds no [FrameworkRelease] * — it reads the same prereleases through a different shape — but CI tags every canary * `canary-`, so the number is the one thing both sides already agree on. Pinning - * it before the list has loaded is fine: [selected] resolves the number against the history + * it before the list has loaded is fine: [selected] resolves the number against the catalogue * whenever that arrives. */ fun select(versionCode: Long) { diff --git a/manager/src/main/res/values-ar/strings.xml b/manager/src/main/res/values-ar/strings.xml index 1d354d12d..0487d8f8f 100644 --- a/manager/src/main/res/values-ar/strings.xml +++ b/manager/src/main/res/values-ar/strings.xml @@ -255,10 +255,33 @@ ستُحذف %1$d وحدة وكل ما أعدّته. لا يمكن التراجع عن ذلك. فتح على GitHub - فتح هذا التشغيل - تثبيت - انتهت صلاحية مخرجات هذا الإصدار. لم يُنشر أي إصدار canary بعد. + ما هي نسخة canary + الحالة الراهنة لفرع master، بُنيت وفُحصت بواسطة CI وحدها. تصل الإصلاحات إلى هنا قبل أيام أو أسابيع من وصولها إلى إصدار مستقر. + يُحتفظ بأحدث %1$d منها، وأي صفحة بناء تنقلك بينها أو تعيدك إلى آخر إصدار مستقر. لا شيء هنا طريق باتجاه واحد. + + لم يصل أي إيداع منذ نسختك. + وصل إيداع واحد منذ نسختك. + وصل إيداعان منذ نسختك. + وصلت %1$d إيداعات منذ نسختك. + وصل %1$d إيداعًا منذ نسختك. + وصل %1$d إيداع منذ نسختك. + + + أُصلح منذ نسختك + أُصلح منذ نسختك + أُصلحت مشكلتان منذ نسختك + أُصلحت %1$d مشكلات منذ نسختك + أُصلحت %1$d مشكلة منذ نسختك + أُصلحت %1$d مشكلة منذ نسختك + + أنت تشغّل أحدث نسخة canary. لا شيء لاختباره حتى تنشر CI نسخة جديدة. + نسختك أحدث من كل نسخ canary المنشورة، لذا لم تُبنَ من أي منها. + تحمل نسختك رقم أحدث نسخة canary لكنها لم تُبنَ منها. ثبّتها لتتأكد مما تختبره. + أنت على إصدار مستقر أحدث من كل نسخ canary هنا. ستُبنى نسخة canary التالية فوقه. + canary %1$d + هل وجدت شيئًا؟ + سوء تصرّف نسخة canary هو تحديدًا سبب تشغيلها — والتبليغ هو ما يحوّله إلى إصلاح. خذ ملف ZIP من نوع Debug إن كنت تتعقّب علة: فهو يسجّل أكثر بكثير، وهو ما يحتاجه أي تبليغ. الأكثر إيداعًا الأحدث ما تطلبه الوحدة diff --git a/manager/src/main/res/values-de/strings.xml b/manager/src/main/res/values-de/strings.xml index f99a448ad..151f6f3f3 100644 --- a/manager/src/main/res/values-de/strings.xml +++ b/manager/src/main/res/values-de/strings.xml @@ -223,10 +223,25 @@ Alle %1$d Module und alles, was sie eingerichtet haben, werden entfernt. Das lässt sich nicht rückgängig machen. Auf GitHub öffnen - Diesen Lauf öffnen - Installieren - Die Artefakte dieses Builds sind abgelaufen. Noch keine Canary-Builds veröffentlicht. + Was ein Canary ist + Der aktuelle Stand von master, gebaut und geprüft allein von der CI. Korrekturen landen hier Tage oder Wochen, bevor sie eine Veröffentlichung erreichen. + Die neuesten %1$d bleiben erhalten, und jede Build-Seite bringt dich zwischen ihnen hin und her oder zurück zur letzten Veröffentlichung. Nichts hier ist eine Einbahnstraße. + + Seit deinem Build ist %1$d Commit hinzugekommen. + Seit deinem Build sind %1$d Commits hinzugekommen. + + + Seit deinem Build behoben + Seit deinem Build behoben — %1$d Meldungen + + Du nutzt das neueste Canary. Bis die CI erneut veröffentlicht, gibt es nichts zu testen. + Dein Build ist neuer als jedes veröffentlichte Canary und wurde daher aus keinem davon gebaut. + Dein Build trägt die Nummer des neuesten Canary, wurde aber nicht daraus gebaut. Installiere es, um sicher zu sein, was du testest. + Du nutzt eine Veröffentlichung, die neuer ist als jedes Canary hier. Das nächste Canary baut darauf auf. + Canary %1$d + Etwas gefunden? + Ein Canary, das sich danebenbenimmt, ist genau der Zweck der Sache — erst eine Meldung macht daraus eine Korrektur. Nimm das Debug-ZIP, wenn du einem Fehler nachgehst: es protokolliert weit mehr, und genau das braucht eine Meldung. Meiste Commits Zuletzt aktiv Was das Modul verlangt diff --git a/manager/src/main/res/values-es/strings.xml b/manager/src/main/res/values-es/strings.xml index 50d6467b6..131d2daca 100644 --- a/manager/src/main/res/values-es/strings.xml +++ b/manager/src/main/res/values-es/strings.xml @@ -223,10 +223,25 @@ Se eliminarán los %1$d módulos y todo lo que hayan configurado. No se puede deshacer. Abrir en GitHub - Abrir esta ejecución - Instalar - Los artefactos de esta compilación han caducado. Todavía no se ha publicado ninguna compilación canary. + Qué es una canary + El estado actual de master, compilado y comprobado solo por la CI. Las correcciones llegan aquí días o semanas antes que a una versión estable. + Se conservan las %1$d más recientes, y cualquier página de compilación te lleva de una a otra o de vuelta a la última versión. Nada de esto es un camino sin retorno. + + Ha llegado %1$d commit desde tu compilación. + Han llegado %1$d commits desde tu compilación. + + + Corregido desde tu compilación + Corregido desde tu compilación: %1$d incidencias + + Estás usando la canary más reciente. No hay nada que probar hasta que la CI publique de nuevo. + Tu compilación es más reciente que cualquier canary publicada, así que no se creó a partir de ninguna de ellas. + Tu compilación lleva el número de la canary más reciente, pero no se creó a partir de ella. Instálala para saber con certeza qué estás probando. + Estás en una versión más reciente que cualquier canary de aquí. La próxima canary se construirá sobre ella. + canary %1$d + ¿Has encontrado algo? + Que una canary falle es justamente el motivo de usarla: es el informe lo que la convierte en una corrección. Usa el ZIP Debug si persigues un fallo: registra mucho más, y es lo que hace falta para informar. Más commits Más recientes Lo que el módulo pide diff --git a/manager/src/main/res/values-fa/strings.xml b/manager/src/main/res/values-fa/strings.xml index e629f3443..93ae815f6 100644 --- a/manager/src/main/res/values-fa/strings.xml +++ b/manager/src/main/res/values-fa/strings.xml @@ -223,10 +223,25 @@ هر %1$d ماژول و هر آنچه پیکربندی کرده‌اند حذف می‌شود. این کار برگشت‌پذیر نیست. باز کردن در GitHub - باز کردن این اجرا - نصب - فرآورده‌های این ساخت منقضی شده‌اند. هنوز هیچ ساخت canary منتشر نشده است. + canary چیست + وضعیت کنونی master، که تنها به‌دست CI ساخته و بررسی شده است. رفع‌ اشکال‌ها روزها یا هفته‌ها پیش از رسیدن به یک نسخهٔ پایدار، اینجا می‌آیند. + ‏%1$d مورد تازه‌تر نگه داشته می‌شود و هر صفحهٔ ساخت شما را میان آن‌ها یا به آخرین نسخه بازمی‌گرداند. هیچ‌چیز اینجا یک‌طرفه نیست. + + از زمان ساخت شما %1$d کامیت رسیده است. + از زمان ساخت شما %1$d کامیت رسیده است. + + + از زمان ساخت شما رفع شد + از زمان ساخت شما رفع شد — %1$d مشکل + + تازه‌ترین canary را اجرا می‌کنید. تا انتشار بعدی CI چیزی برای آزمودن نیست. + ساخت شما از هر canary منتشرشده تازه‌تر است، پس از هیچ‌کدام ساخته نشده است. + ساخت شما شمارهٔ تازه‌ترین canary را دارد اما از آن ساخته نشده است. برای اطمینان از آنچه می‌آزمایید، آن را نصب کنید. + روی نسخه‌ای هستید که از همهٔ canaryهای اینجا تازه‌تر است. canary بعدی روی همین ساخته می‌شود. + canary %1$d + چیزی پیدا کردید؟ + بدرفتاری یک canary دقیقاً دلیل اجرای آن است — گزارش است که آن را به یک اصلاح تبدیل می‌کند. اگر دنبال یک اشکال هستید ZIP نسخهٔ Debug را بردارید: بسیار بیشتر ثبت می‌کند و همان چیزی است که گزارش لازم دارد. بیشترین کامیت تازه‌ترین آنچه ماژول می‌خواهد diff --git a/manager/src/main/res/values-fr/strings.xml b/manager/src/main/res/values-fr/strings.xml index a21fb5c17..14b789423 100644 --- a/manager/src/main/res/values-fr/strings.xml +++ b/manager/src/main/res/values-fr/strings.xml @@ -223,10 +223,25 @@ Les %1$d modules et tout ce qu\'ils ont configuré seront supprimés. C\'est irréversible. Ouvrir sur GitHub - Ouvrir cette exécution - Installer - Les artefacts de ce build ont expiré. Aucune version canary publiée pour l\'instant. + Ce qu\'est une canary + L\'état actuel de master, compilé et vérifié par la CI et rien d\'autre. Les correctifs arrivent ici des jours ou des semaines avant d\'atteindre une version stable. + Les %1$d plus récentes sont conservées, et n\'importe quelle page de build vous fait passer de l\'une à l\'autre, ou revenir à la dernière version. Rien ici n\'est sans retour. + + %1$d commit est arrivé depuis votre build. + %1$d commits sont arrivés depuis votre build. + + + Corrigé depuis votre build + Corrigé depuis votre build — %1$d tickets + + Vous utilisez la canary la plus récente. Rien à tester tant que la CI n\'a pas publié à nouveau. + Votre build est plus récent que toutes les canary publiées : il n\'a donc été compilé à partir d\'aucune d\'elles. + Votre build porte le numéro de la canary la plus récente sans en être issu. Installez-la pour savoir exactement ce que vous testez. + Vous êtes sur une version plus récente que toutes les canary listées ici. La prochaine canary sera construite dessus. + canary %1$d + Vous avez trouvé quelque chose ? + Une canary qui déraille, c\'est précisément l\'intérêt d\'en utiliser une — c\'est le rapport qui la transforme en correctif. Prenez le ZIP Debug si vous traquez un bogue : il journalise bien plus, et c\'est ce qu\'un rapport demande. Plus de commits Plus récents Ce que le module demande diff --git a/manager/src/main/res/values-in/strings.xml b/manager/src/main/res/values-in/strings.xml index 8780478fa..f395140aa 100644 --- a/manager/src/main/res/values-in/strings.xml +++ b/manager/src/main/res/values-in/strings.xml @@ -219,10 +219,23 @@ Semua %1$d modul dan segala yang mereka atur akan dihapus. Ini tidak bisa dibatalkan. Buka di GitHub - Buka eksekusi ini - Pasang - Artefak build ini sudah kedaluwarsa. Belum ada build canary yang diterbitkan. + Apa itu canary + Kondisi master saat ini, dibangun dan diperiksa oleh CI dan tidak lebih. Perbaikan tiba di sini berhari-hari atau berminggu-minggu sebelum sampai ke rilis. + %1$d terbaru disimpan, dan halaman build mana pun bisa memindahkan Anda di antaranya atau kembali ke rilis terakhir. Tidak ada yang searah di sini. + + %1$d commit telah masuk sejak build Anda. + + + Diperbaiki sejak build Anda — %1$d masalah + + Anda menjalankan canary terbaru. Tidak ada yang perlu diuji sampai CI menerbitkan lagi. + Build Anda lebih baru daripada semua canary yang diterbitkan, jadi tidak dibangun dari salah satunya. + Build Anda membawa nomor canary terbaru tetapi tidak dibangun darinya. Pasang canary itu agar yakin apa yang Anda uji. + Anda memakai rilis yang lebih baru daripada semua canary di sini. Canary berikutnya akan dibangun di atasnya. + canary %1$d + Menemukan sesuatu? + Canary yang bermasalah justru itulah gunanya dijalankan — laporanlah yang mengubahnya menjadi perbaikan. Ambil ZIP Debug jika Anda sedang memburu bug: log-nya jauh lebih banyak, dan itulah yang dibutuhkan sebuah laporan. Commit terbanyak Paling baru Yang diminta modul diff --git a/manager/src/main/res/values-it/strings.xml b/manager/src/main/res/values-it/strings.xml index aae330f51..8461d27ed 100644 --- a/manager/src/main/res/values-it/strings.xml +++ b/manager/src/main/res/values-it/strings.xml @@ -223,10 +223,25 @@ Tutti i %1$d moduli e tutto ciò che hanno configurato verranno rimossi. Non si può annullare. Apri su GitHub - Apri questa esecuzione - Installa - Gli artefatti di questa build sono scaduti. Nessuna build canary pubblicata finora. + Che cos\'è una canary + Lo stato attuale di master, compilato e verificato dalla CI e da nient\'altro. Le correzioni arrivano qui giorni o settimane prima di raggiungere una versione stabile. + Vengono conservate le %1$d più recenti, e qualsiasi pagina di build ti sposta tra loro o ti riporta all\'ultima versione. Qui nulla è a senso unico. + + È arrivato %1$d commit dopo la tua build. + Sono arrivati %1$d commit dopo la tua build. + + + Risolto dopo la tua build + Risolto dopo la tua build — %1$d segnalazioni + + Stai usando la canary più recente. Non c\'è nulla da provare finché la CI non pubblica di nuovo. + La tua build è più recente di ogni canary pubblicata, quindi non è stata creata da nessuna di esse. + La tua build porta il numero della canary più recente ma non deriva da essa. Installala per sapere con certezza cosa stai provando. + Sei su una versione più recente di ogni canary elencata qui. La prossima canary sarà costruita su di essa. + canary %1$d + Hai trovato qualcosa? + Una canary che si comporta male è esattamente il motivo per cui la si usa: è la segnalazione a trasformarla in una correzione. Prendi lo ZIP Debug se stai cercando un bug: registra molto di più, ed è ciò che serve per una segnalazione. Più commit Più recenti Quello che chiede il modulo diff --git a/manager/src/main/res/values-iw/strings.xml b/manager/src/main/res/values-iw/strings.xml index 951804033..96eb0f692 100644 --- a/manager/src/main/res/values-iw/strings.xml +++ b/manager/src/main/res/values-iw/strings.xml @@ -243,10 +243,29 @@ כל %1$d המודולים וכל מה שהגדירו יימחקו. אי אפשר לבטל את זה. פתיחה ב-GitHub - פתיחת ההרצה הזאת - התקנה - התוצרים של הגרסה הזאת פגו. עדיין לא פורסמו גרסאות canary. + מהי גרסת canary + המצב הנוכחי של master, שנבנה ונבדק על ידי CI בלבד. תיקונים מגיעים לכאן ימים או שבועות לפני שהם מגיעים לגרסה יציבה. + ‏%1$d החדשות ביותר נשמרות, וכל עמוד גרסה מאפשר לעבור ביניהן או לחזור לגרסה היציבה האחרונה. שום דבר כאן אינו חד־כיווני. + + מאז הגרסה שלכם נוסף קומיט אחד. + מאז הגרסה שלכם נוספו שני קומיטים. + מאז הגרסה שלכם נוספו %1$d קומיטים. + מאז הגרסה שלכם נוספו %1$d קומיטים. + + + תוקן מאז הגרסה שלכם + תוקנו מאז הגרסה שלכם — שתי תקלות + תוקנו מאז הגרסה שלכם — %1$d תקלות + תוקנו מאז הגרסה שלכם — %1$d תקלות + + אתם מריצים את גרסת ה־canary החדשה ביותר. עד הפרסום הבא של CI אין מה לבדוק. + הגרסה שלכם חדשה מכל גרסאות ה־canary שפורסמו, ולכן לא נבנתה מאף אחת מהן. + הגרסה שלכם נושאת את מספרה של גרסת ה־canary החדשה ביותר אך לא נבנתה ממנה. התקינו אותה כדי לדעת בוודאות מה אתם בודקים. + אתם על גרסה יציבה חדשה יותר מכל גרסאות ה־canary כאן. גרסת ה־canary הבאה תיבנה על גביה. + canary %1$d + מצאתם משהו? + גרסת canary שמתנהגת לא כשורה היא בדיוק הסיבה להריץ אותה — דיווח הוא מה שהופך אותה לתיקון. קחו את קובץ ה־ZIP מסוג Debug אם אתם מחפשים תקלה: הוא מתעד הרבה יותר, וזה בדיוק מה שדיווח צריך. הכי הרבה קומיטים העדכניים ביותר מה שהמודול מבקש diff --git a/manager/src/main/res/values-ja/strings.xml b/manager/src/main/res/values-ja/strings.xml index bf6d8bdfc..6aedb1541 100644 --- a/manager/src/main/res/values-ja/strings.xml +++ b/manager/src/main/res/values-ja/strings.xml @@ -215,10 +215,23 @@ %1$d 個のモジュールと、それらが設定した内容がすべて削除されます。元には戻せません。 GitHub で開く - この実行を開く - インストール - このビルドの成果物は期限切れです。 canary ビルドはまだ公開されていません。 + canary とは + master の現在の状態で、CI がビルドして確認しただけのものです。修正はリリースに届く数日から数週間前に、まずここへ来ます。 + 最新の %1$d 件が保持され、どのビルドのページからでもそれらの間を行き来したり、最後のリリースへ戻ったりできます。ここに片道の選択はありません。 + + お使いのビルド以降に %1$d 件のコミットが入りました。 + + + お使いのビルド以降に修正 — %1$d 件 + + 最新の canary を使用中です。CI が次を公開するまで試すものはありません。 + お使いのビルドは公開済みのどの canary よりも新しく、そのいずれからもビルドされていません。 + お使いのビルドは最新 canary と同じ番号ですが、そこからビルドされたものではありません。何を試しているのか確かめるには、その canary を導入してください。 + ここにあるどの canary よりも新しいリリースを使用中です。次の canary はこの上に構築されます。 + canary %1$d + 何か見つかりましたか? + canary の不具合こそ、それを使う理由そのものです。報告があって初めて修正になります。バグを追っているなら Debug の ZIP を選んでください。記録される情報がはるかに多く、報告に必要なのはそれです。 コミット数順 最近の順 モジュールが要求したもの diff --git a/manager/src/main/res/values-ko/strings.xml b/manager/src/main/res/values-ko/strings.xml index 74dbb17db..f761eb44c 100644 --- a/manager/src/main/res/values-ko/strings.xml +++ b/manager/src/main/res/values-ko/strings.xml @@ -215,10 +215,23 @@ 모듈 %1$d개와 그 모듈들이 설정한 모든 내용이 삭제됩니다. 되돌릴 수 없습니다. GitHub에서 열기 - 이 실행 열기 - 설치 - 이 빌드의 산출물이 만료되었습니다. 아직 공개된 canary 빌드가 없습니다. + canary란 + master의 현재 상태로, CI가 빌드하고 확인한 것이 전부입니다. 수정은 릴리스에 도달하기 며칠에서 몇 주 전에 이곳에 먼저 도착합니다. + 최신 %1$d개가 보관되며, 어느 빌드 페이지에서든 그 사이를 오가거나 마지막 릴리스로 돌아갈 수 있습니다. 여기에 되돌릴 수 없는 선택은 없습니다. + + 내 빌드 이후 커밋 %1$d개가 들어왔습니다. + + + 내 빌드 이후 수정됨 — %1$d건 + + 최신 canary를 사용 중입니다. CI가 다시 공개할 때까지 시험할 것이 없습니다. + 내 빌드가 공개된 모든 canary보다 최신이므로, 그중 어느 것으로도 빌드되지 않았습니다. + 내 빌드는 최신 canary와 같은 번호이지만 그것으로 빌드되지 않았습니다. 무엇을 시험하는지 확실히 하려면 해당 canary를 설치하세요. + 여기 있는 모든 canary보다 최신인 릴리스를 사용 중입니다. 다음 canary는 그 위에 만들어집니다. + canary %1$d + 무언가 발견하셨나요? + canary가 말썽을 부리는 것이야말로 그것을 쓰는 이유입니다. 신고가 있어야 수정으로 이어집니다. 버그를 쫓는 중이라면 Debug ZIP을 받으세요. 훨씬 많은 기록을 남기며, 신고에 필요한 것이 바로 그것입니다. 커밋 많은 순 최근 순 모듈이 요청한 것 diff --git a/manager/src/main/res/values-pl/strings.xml b/manager/src/main/res/values-pl/strings.xml index fb26ecdd5..13fea0ea0 100644 --- a/manager/src/main/res/values-pl/strings.xml +++ b/manager/src/main/res/values-pl/strings.xml @@ -239,10 +239,29 @@ Wszystkie %1$d modułu i wszystko, co skonfigurowały, zostanie usunięte. Tego nie da się cofnąć. Otwórz na GitHubie - Otwórz to uruchomienie - Zainstaluj - Artefakty tej kompilacji wygasły. Nie opublikowano jeszcze żadnej kompilacji canary. + Czym jest canary + Bieżący stan gałęzi master, zbudowany i sprawdzony wyłącznie przez CI. Poprawki trafiają tutaj dni lub tygodnie przed tym, jak dotrą do wydania. + Zachowywane są %1$d najnowsze, a każda strona kompilacji pozwala przechodzić między nimi lub wrócić do ostatniego wydania. Nic tutaj nie jest drogą w jedną stronę. + + Od Twojej kompilacji pojawił się %1$d commit. + Od Twojej kompilacji pojawiły się %1$d commity. + Od Twojej kompilacji pojawiło się %1$d commitów. + Od Twojej kompilacji pojawiło się %1$d commitów. + + + Naprawione od Twojej kompilacji + Naprawione od Twojej kompilacji — %1$d zgłoszenia + Naprawione od Twojej kompilacji — %1$d zgłoszeń + Naprawione od Twojej kompilacji — %1$d zgłoszeń + + Używasz najnowszej kompilacji canary. Do kolejnej publikacji CI nie ma czego testować. + Twoja kompilacja jest nowsza niż wszystkie opublikowane canary, więc nie powstała z żadnej z nich. + Twoja kompilacja nosi numer najnowszej canary, ale nie z niej powstała. Zainstaluj ją, aby mieć pewność, co testujesz. + Masz wydanie nowsze niż wszystkie canary z tej listy. Następna canary powstanie na jego podstawie. + canary %1$d + Znalazłeś coś? + Canary, która zawodzi, to właśnie powód, by ją uruchamiać — dopiero zgłoszenie zmienia to w poprawkę. Jeśli tropisz błąd, weź archiwum Debug: zapisuje znacznie więcej, a tego właśnie wymaga zgłoszenie. Najwięcej commitów Najnowsi To, o co prosi moduł diff --git a/manager/src/main/res/values-pt-rBR/strings.xml b/manager/src/main/res/values-pt-rBR/strings.xml index c8322745c..a2bf0761e 100644 --- a/manager/src/main/res/values-pt-rBR/strings.xml +++ b/manager/src/main/res/values-pt-rBR/strings.xml @@ -223,10 +223,25 @@ Todos os %1$d módulos e tudo que eles configuraram serão removidos. Isso não pode ser desfeito. Abrir no GitHub - Abrir esta execução - Instalar - Os artefatos desta versão expiraram. Nenhuma versão canary publicada ainda. + O que é uma canary + O estado atual do master, compilado e verificado apenas pela CI. As correções chegam aqui dias ou semanas antes de alcançarem uma versão estável. + As %1$d mais recentes são mantidas, e qualquer página de versão leva você de uma para outra ou de volta à última versão. Nada aqui é sem volta. + + %1$d commit chegou desde a sua versão. + %1$d commits chegaram desde a sua versão. + + + Corrigido desde a sua versão + Corrigido desde a sua versão — %1$d relatos + + Você está usando a canary mais recente. Não há nada para testar até a CI publicar de novo. + Sua versão é mais recente que todas as canary publicadas, portanto não foi criada a partir de nenhuma delas. + Sua versão carrega o número da canary mais recente, mas não foi criada a partir dela. Instale-a para ter certeza do que está testando. + Você está em uma versão mais recente que todas as canary daqui. A próxima canary será construída sobre ela. + canary %1$d + Encontrou algo? + Uma canary que se comporta mal é justamente o motivo de usá-la — é o relato que a transforma em correção. Pegue o ZIP Debug se estiver atrás de um bug: ele registra muito mais, e é o que um relato precisa. Mais commits Mais recentes O que o módulo pede diff --git a/manager/src/main/res/values-ru/strings.xml b/manager/src/main/res/values-ru/strings.xml index 62d6d2176..21ac520a7 100644 --- a/manager/src/main/res/values-ru/strings.xml +++ b/manager/src/main/res/values-ru/strings.xml @@ -220,10 +220,29 @@ Все %1$d модулей и все их настройки будут удалены. Это необратимо. Открыть на GitHub - Открыть эту сборку - Установить - Артефакты этой сборки устарели. Canary-сборки пока не публиковались. + Что такое canary + Текущее состояние master, собранное и проверенное только CI. Исправления попадают сюда за дни или недели до того, как дойдут до релиза. + Хранятся %1$d самых свежих, и с любой страницы сборки можно перейти между ними или вернуться к последнему релизу. Здесь нет пути в один конец. + + С момента вашей сборки добавился %1$d коммит. + С момента вашей сборки добавилось %1$d коммита. + С момента вашей сборки добавилось %1$d коммитов. + С момента вашей сборки добавилось %1$d коммитов. + + + Исправлено с момента вашей сборки + Исправлено с момента вашей сборки — %1$d проблемы + Исправлено с момента вашей сборки — %1$d проблем + Исправлено с момента вашей сборки — %1$d проблем + + У вас самая свежая canary-сборка. Пока CI не опубликует следующую, тестировать нечего. + Ваша сборка новее всех опубликованных canary, то есть собрана не из них. + Ваша сборка носит номер свежайшей canary, но собрана не из неё. Установите её, чтобы точно знать, что вы тестируете. + У вас релиз новее всех canary в этом списке. Следующая canary будет собрана поверх него. + canary %1$d + Что-то нашли? + Сбой в canary — именно то, ради чего её и запускают: отчёт превращает его в исправление. Если вы ищете ошибку, берите Debug-архив: он пишет намного больше, и это как раз то, что нужно для отчёта. По числу коммитов По давности То, что просит модуль diff --git a/manager/src/main/res/values-tr/strings.xml b/manager/src/main/res/values-tr/strings.xml index 2cae69d9d..a3fdcff07 100644 --- a/manager/src/main/res/values-tr/strings.xml +++ b/manager/src/main/res/values-tr/strings.xml @@ -223,10 +223,25 @@ %1$d modülün tamamı ve yapılandırdıkları her şey kaldırılacak. Bu geri alınamaz. GitHub\'da aç - Bu çalıştırmayı aç - Kur - Bu derlemenin çıktıları süresi dolmuş. Henüz yayımlanmış canary sürümü yok. + Canary nedir + master dalının şu anki hâli; yalnızca CI tarafından derlenip denetlenir. Düzeltmeler buraya, bir sürüme ulaşmasından günler ya da haftalar önce iner. + En yeni %1$d tanesi saklanır ve herhangi bir yapı sayfası sizi bunlar arasında ya da son sürüme geri götürür. Burada hiçbir şey tek yönlü değildir. + + Sizin yapınızdan bu yana %1$d commit geldi. + Sizin yapınızdan bu yana %1$d commit geldi. + + + Sizin yapınızdan bu yana düzeltildi + Sizin yapınızdan bu yana düzeltildi — %1$d sorun + + En yeni canary sürümünü kullanıyorsunuz. CI yeniden yayımlayana kadar test edilecek bir şey yok. + Yapınız yayımlanmış her canary sürümünden yeni; dolayısıyla hiçbirinden derlenmemiş. + Yapınız en yeni canary sürümünün numarasını taşıyor ama ondan derlenmemiş. Ne test ettiğinizden emin olmak için onu kurun. + Buradaki her canary sürümünden yeni bir sürümdesiniz. Sonraki canary bunun üzerine kurulacak. + canary %1$d + Bir şey mi buldunuz? + Hatalı davranan bir canary, onu çalıştırmanın asıl sebebidir — bildirim, onu bir düzeltmeye dönüştüren şeydir. Bir hatanın peşindeyseniz Debug ZIP dosyasını alın: çok daha fazlasını günlüğe yazar ve bir bildirimin ihtiyacı olan da budur. En çok commit En yeni Modülün istedikleri diff --git a/manager/src/main/res/values-uk/strings.xml b/manager/src/main/res/values-uk/strings.xml index 23a041384..e048b19ea 100644 --- a/manager/src/main/res/values-uk/strings.xml +++ b/manager/src/main/res/values-uk/strings.xml @@ -239,10 +239,29 @@ Усі %1$d модуля й усе, що вони налаштували, буде вилучено. Скасувати це не можна. Відкрити на GitHub - Відкрити цей запуск - Встановити - Артефакти цієї збірки застаріли. Збірок canary ще не опубліковано. + Що таке canary + Поточний стан master, зібраний і перевірений лише CI. Виправлення потрапляють сюди за дні або тижні до того, як дійдуть до випуску. + Зберігаються %1$d найсвіжіших, і з будь-якої сторінки збірки можна перейти між ними або повернутися до останнього випуску. Тут немає дороги в один бік. + + Від вашої збірки з\'явився %1$d коміт. + Від вашої збірки з\'явилося %1$d коміти. + Від вашої збірки з\'явилося %1$d комітів. + Від вашої збірки з\'явилося %1$d комітів. + + + Виправлено від вашої збірки + Виправлено від вашої збірки — %1$d проблеми + Виправлено від вашої збірки — %1$d проблем + Виправлено від вашої збірки — %1$d проблем + + У вас найсвіжіша збірка canary. Поки CI не опублікує наступну, тестувати нічого. + Ваша збірка новіша за всі опубліковані canary, тобто зібрана не з них. + Ваша збірка має номер найсвіжішої canary, але зібрана не з неї. Встановіть її, щоб точно знати, що ви тестуєте. + У вас випуск, новіший за всі canary в цьому списку. Наступну canary буде зібрано поверх нього. + canary %1$d + Щось знайшли? + Збій у canary — саме те, заради чого її й запускають: звіт перетворює його на виправлення. Якщо ви шукаєте помилку, беріть Debug-архів: він пише значно більше, і саме це потрібно для звіту. Найбільше комітів Найновіші Те, що просить модуль diff --git a/manager/src/main/res/values-vi/strings.xml b/manager/src/main/res/values-vi/strings.xml index 84e64465e..06ea53c70 100644 --- a/manager/src/main/res/values-vi/strings.xml +++ b/manager/src/main/res/values-vi/strings.xml @@ -215,10 +215,23 @@ Toàn bộ %1$d mô-đun và mọi thứ chúng đã cấu hình sẽ bị xoá. Không thể hoàn tác. Mở trên GitHub - Mở lần chạy này - Cài đặt - Sản phẩm của bản dựng này đã hết hạn. Chưa có bản canary nào được phát hành. + Bản canary là gì + Trạng thái hiện tại của master, được dựng và kiểm tra bởi CI chứ không gì khác. Các bản sửa lỗi đến đây trước hàng ngày hoặc hàng tuần so với một bản phát hành. + %1$d bản mới nhất được giữ lại, và bất kỳ trang bản dựng nào cũng đưa bạn qua lại giữa chúng hoặc trở về bản phát hành gần nhất. Ở đây không có gì là một chiều. + + Đã có %1$d commit kể từ bản dựng của bạn. + + + Đã sửa kể từ bản dựng của bạn — %1$d vấn đề + + Bạn đang chạy bản canary mới nhất. Không có gì để thử cho tới khi CI phát hành tiếp. + Bản dựng của bạn mới hơn mọi bản canary đã phát hành, nên nó không được dựng từ bản nào trong số đó. + Bản dựng của bạn mang số hiệu của bản canary mới nhất nhưng không được dựng từ nó. Hãy cài bản đó để chắc chắn bạn đang thử gì. + Bạn đang dùng một bản phát hành mới hơn mọi bản canary ở đây. Bản canary kế tiếp sẽ được dựng trên nó. + canary %1$d + Phát hiện điều gì chăng? + Một bản canary trục trặc chính là lý do để chạy nó — báo cáo mới là thứ biến nó thành bản sửa lỗi. Hãy lấy tệp ZIP Debug nếu bạn đang truy một lỗi: nó ghi log nhiều hơn hẳn, và đó là thứ một báo cáo cần. Nhiều commit nhất Gần đây nhất Những gì mô-đun yêu cầu diff --git a/manager/src/main/res/values-zh-rCN/strings.xml b/manager/src/main/res/values-zh-rCN/strings.xml index 5db2bb30a..ef4246918 100644 --- a/manager/src/main/res/values-zh-rCN/strings.xml +++ b/manager/src/main/res/values-zh-rCN/strings.xml @@ -215,10 +215,23 @@ 这 %1$d 个模块及其全部配置都将被移除,且无法撤销。 在 GitHub 中打开 - 查看此次构建 - 安装 - 此次构建的产物已过期。 尚未发布任何 Canary 版本。 + 什么是 Canary + master 分支的当前状态,只经过 CI 构建与检查。修复会先出现在这里,比进入正式版早数天甚至数周。 + 最新的 %1$d 个会被保留,任何构建页面都能带你在它们之间切换,或退回上一个正式版。这里没有单向的选择。 + + 自你的版本以来已有 %1$d 次提交。 + + + 自你的版本以来已修复 %1$d 个问题 + + 你正在运行最新的 Canary。在 CI 再次发布之前没有可测试的内容。 + 你的版本比所有已发布的 Canary 都新,因此并非由其中任何一个构建而来。 + 你的版本与最新 Canary 编号相同,但并非由它构建。安装它才能确定你测试的到底是什么。 + 你使用的正式版比这里所有 Canary 都新。下一个 Canary 将在它之上构建。 + canary %1$d + 发现问题了吗? + Canary 出问题正是运行它的意义所在——是反馈让它变成修复。如果你正在追查缺陷,请选择 Debug 压缩包:它记录的信息多得多,也正是反馈所需要的。 提交最多 最近提交 模块请求的应用 diff --git a/manager/src/main/res/values-zh-rTW/strings.xml b/manager/src/main/res/values-zh-rTW/strings.xml index 5908e4238..a49872eb5 100644 --- a/manager/src/main/res/values-zh-rTW/strings.xml +++ b/manager/src/main/res/values-zh-rTW/strings.xml @@ -215,10 +215,23 @@ 這 %1$d 個模組及其全部設定都將被移除,且無法復原。 在 GitHub 開啟 - 檢視這次建置 - 安裝 - 這次建置的產物已過期。 尚未發行任何 Canary 版本。 + 什麼是 Canary + master 分支的目前狀態,只經過 CI 建置與檢查。修正會先出現在這裡,比進入正式版早數天甚至數週。 + 最新的 %1$d 個會保留下來,任何建置頁面都能帶你在它們之間切換,或退回上一個正式版。這裡沒有單向的選擇。 + + 自你的版本以來已有 %1$d 次提交。 + + + 自你的版本以來已修正 %1$d 個問題 + + 你正在執行最新的 Canary。在 CI 再次發行之前沒有可測試的內容。 + 你的版本比所有已發行的 Canary 都新,因此並非由其中任何一個建置而來。 + 你的版本與最新 Canary 編號相同,但並非由它建置。安裝它才能確定你測試的究竟是什麼。 + 你使用的正式版比這裡所有 Canary 都新。下一個 Canary 將在它之上建置。 + canary %1$d + 發現問題了嗎? + Canary 出問題正是執行它的意義所在——是回報讓它變成修正。如果你正在追查缺陷,請選擇 Debug 壓縮檔:它記錄的資訊多得多,也正是回報所需要的。 提交最多 最近提交 模組要求的應用程式 diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml index 229338fab..f247b4dcf 100644 --- a/manager/src/main/res/values/strings.xml +++ b/manager/src/main/res/values/strings.xml @@ -362,11 +362,32 @@ The module and everything it has configured will be removed. This cannot be undone. All %1$d modules and everything they have configured will be removed. This cannot be undone. + Open on GitHub - Open this run - Install - The artifacts of this build have expired. No canary builds published yet. + What a canary is + The current state of master, built and checked by CI and nothing else. Fixes land here days or weeks before they reach a release. + + The newest %1$d are kept, and any build page can move you between them or back to the last release. Nothing here is a one-way door. + + %1$d commit has landed since your build. + %1$d commits have landed since your build. + + + + Fixed since your build + Fixed since your build — %1$d issues + + You are running the newest canary. Nothing to test until CI publishes again. + Your build is newer than every published canary, so it was not built from any of them. + + Your build carries the newest canary\'s number but was not built from it. Install it to be sure what you are testing. + + You are on a release newer than every canary here. The next canary will be built on top of it. + + canary %1$d + Found something? + A canary that misbehaves is the whole point of running one — a report is what turns it into a fix. Take the Debug zip if you are chasing a bug: it logs far more, and it is what a report needs. Most commits Most recent What the module asks for