App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
@@ -0,0 +1,194 @@
|
||||
package com.ponzischeme89.memby.benchmark
|
||||
|
||||
import android.view.KeyEvent
|
||||
import androidx.benchmark.macro.CompilationMode
|
||||
import androidx.benchmark.macro.ExperimentalMetricApi
|
||||
import androidx.benchmark.macro.MacrobenchmarkScope
|
||||
import androidx.benchmark.macro.StartupMode
|
||||
import androidx.benchmark.macro.TraceSectionMetric
|
||||
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.uiautomator.By
|
||||
import androidx.test.uiautomator.Until
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* How long a viewer waits between pressing Play and seeing a picture.
|
||||
*
|
||||
* This exists because the question could not be settled by hand. Timing a few launches off
|
||||
* the logcat line gave a 70% spread between identical runs of the same file — enough noise
|
||||
* to make a single before-and-after comparison meaningless, and enough to let a real
|
||||
* regression hide. Repeating a launch ten times and reporting the distribution is the only
|
||||
* way to know whether a change to the player's HTTP stack, its buffering or the order of
|
||||
* its startup actually moved anything.
|
||||
*
|
||||
* Two metrics, from the spans in `PlaybackTraceSections`:
|
||||
*
|
||||
* - `Memby.playbackLaunch` — the whole wait, from the player opening to the first frame.
|
||||
* - `Memby.playbackFirstFrame` — `prepare()` to the first frame: opening the file over
|
||||
* HTTP, seeking, and starting a decoder. On a resume this is the overwhelming majority
|
||||
* of the wait, so a change that moves the first number without moving this one has
|
||||
* almost certainly moved something else.
|
||||
*
|
||||
* **This benchmark talks to a real server.** Unlike `HomeBenchmark`, it cannot run against
|
||||
* a blank install: the television must be signed in and the Emby server reachable, because
|
||||
* what is being measured is largely the network and the decoder. That also means the
|
||||
* numbers are only comparable against other runs on *the same TV, server and title* — they
|
||||
* are a regression guard, not an absolute.
|
||||
*
|
||||
* Preconditions, all of which fail the run loudly rather than quietly measuring nothing:
|
||||
*
|
||||
* 1. Memby signed in, on the Home destination.
|
||||
* 2. At least one part-watched item in Continue Watching, so a detail page offers Resume.
|
||||
*
|
||||
* Run it with the variant task, not `connectedCheck` — the latter also runs
|
||||
* `nonMinifiedRelease`, which is the baseline-profile generator's variant, and would
|
||||
* measure everything twice:
|
||||
*
|
||||
* ```
|
||||
* $env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"
|
||||
* .\gradlew.bat :benchmark:connectedBenchmarkReleaseAndroidTest `
|
||||
* -P android.testInstrumentationRunnerArguments.class=com.ponzischeme89.memby.benchmark.PlaybackBenchmark
|
||||
* ```
|
||||
*/
|
||||
@OptIn(ExperimentalMetricApi::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PlaybackBenchmark {
|
||||
|
||||
@get:Rule val benchmarkRule = MacrobenchmarkRule()
|
||||
|
||||
/**
|
||||
* The case this was all built for: picking something up part-way through.
|
||||
*
|
||||
* The browsing is deliberately in `setupBlock` and therefore unmeasured. What is timed
|
||||
* is one keypress on Resume and the wait that follows it, which is the thing a viewer
|
||||
* is actually sitting through.
|
||||
*/
|
||||
@Test
|
||||
fun resumeFromContinueWatching() = benchmarkRule.measureRepeated(
|
||||
packageName = PACKAGE_NAME,
|
||||
metrics = listOf(
|
||||
TraceSectionMetric(PLAYBACK_LAUNCH, TraceSectionMetric.Mode.Sum),
|
||||
TraceSectionMetric(PLAYBACK_FIRST_FRAME, TraceSectionMetric.Mode.Sum),
|
||||
),
|
||||
compilationMode = CompilationMode.Partial(),
|
||||
startupMode = StartupMode.WARM,
|
||||
iterations = ITERATIONS,
|
||||
setupBlock = {
|
||||
pressHome()
|
||||
startActivityAndWait()
|
||||
openResumableDetailPage()
|
||||
},
|
||||
measureBlock = { playAndWaitForFirstFrame() },
|
||||
)
|
||||
|
||||
/**
|
||||
* The same measurement without a seek, as the control. A resume differs from a cold
|
||||
* start in exactly one way that matters — the file is opened part-way through — so the
|
||||
* pair is what says whether a change helped seeking or helped everything.
|
||||
*
|
||||
* The hero's Play button is used because it is the one target on the launcher that is
|
||||
* always exactly one D-pad press away, which keeps this from measuring navigation luck.
|
||||
*/
|
||||
@Test
|
||||
fun coldStartFromHomeHero() = benchmarkRule.measureRepeated(
|
||||
packageName = PACKAGE_NAME,
|
||||
metrics = listOf(
|
||||
TraceSectionMetric(PLAYBACK_LAUNCH, TraceSectionMetric.Mode.Sum),
|
||||
TraceSectionMetric(PLAYBACK_FIRST_FRAME, TraceSectionMetric.Mode.Sum),
|
||||
),
|
||||
compilationMode = CompilationMode.Partial(),
|
||||
startupMode = StartupMode.WARM,
|
||||
iterations = ITERATIONS,
|
||||
setupBlock = {
|
||||
pressHome()
|
||||
startActivityAndWait()
|
||||
device.pressKeyCode(KeyEvent.KEYCODE_DPAD_RIGHT)
|
||||
device.waitForIdle()
|
||||
},
|
||||
measureBlock = { playAndWaitForFirstFrame() },
|
||||
)
|
||||
|
||||
/**
|
||||
* Presses the focused Play button and waits for the picture.
|
||||
*
|
||||
* The end of the wait is the loading overlay going away, which `PlayerActivity` does
|
||||
* from `onRenderedFirstFrame` — the same moment that closes the trace spans. The wait
|
||||
* is only here to stop the capture ending mid-launch; the spans, not this, are what is
|
||||
* reported.
|
||||
*/
|
||||
private fun MacrobenchmarkScope.playAndWaitForFirstFrame() {
|
||||
device.pressKeyCode(KeyEvent.KEYCODE_DPAD_CENTER)
|
||||
val started = device.wait(
|
||||
Until.hasObject(By.res(PACKAGE_NAME, PLAYER_LOADING_ID)),
|
||||
PLAYER_APPEAR_TIMEOUT_MS,
|
||||
)
|
||||
// A launch that reused a prefetched stream can be past the overlay before the first
|
||||
// poll, so its absence is not a failure — only never getting a picture is.
|
||||
if (started != null) {
|
||||
check(
|
||||
device.wait(
|
||||
Until.gone(By.res(PACKAGE_NAME, PLAYER_LOADING_ID)),
|
||||
FIRST_FRAME_TIMEOUT_MS,
|
||||
),
|
||||
) { "No picture within ${FIRST_FRAME_TIMEOUT_MS}ms — is the server reachable?" }
|
||||
}
|
||||
device.waitForIdle()
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the Continue Watching row until a detail page offers Resume, and leaves it
|
||||
* open with that button focused.
|
||||
*
|
||||
* It searches rather than assuming a position because the row reorders itself as the
|
||||
* household watches things — the item that was second yesterday is first today. If
|
||||
* nothing in the row offers a Resume the run is skipped with a message, rather than
|
||||
* silently measuring a cold start and reporting it as a resume.
|
||||
*/
|
||||
private fun MacrobenchmarkScope.openResumableDetailPage() {
|
||||
device.pressKeyCode(KeyEvent.KEYCODE_DPAD_RIGHT)
|
||||
repeat(2) { device.pressKeyCode(KeyEvent.KEYCODE_DPAD_DOWN) }
|
||||
device.waitForIdle()
|
||||
|
||||
repeat(CARDS_TO_TRY) {
|
||||
device.pressKeyCode(KeyEvent.KEYCODE_DPAD_CENTER)
|
||||
if (device.wait(Until.hasObject(By.text(RESUME_LABEL)), DETAIL_TIMEOUT_MS) != null) {
|
||||
device.waitForIdle()
|
||||
return
|
||||
}
|
||||
// Not a resumable item. Close the detail page and try the next card along.
|
||||
device.pressBack()
|
||||
device.wait(Until.gone(By.text(RESUME_LABEL)), DETAIL_TIMEOUT_MS)
|
||||
device.pressKeyCode(KeyEvent.KEYCODE_DPAD_RIGHT)
|
||||
device.waitForIdle()
|
||||
}
|
||||
assumeTrue(
|
||||
"No part-watched item found in Continue Watching — this benchmark needs one.",
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PACKAGE_NAME = "com.ponzischeme89.memby"
|
||||
|
||||
// Kept identical to PlaybackTraceSections in :app. They cannot be imported — the
|
||||
// benchmark is a separate module that does not depend on the app's code — so a
|
||||
// rename there has to be repeated here, and the assertion in
|
||||
// PlaybackTraceNamesTest is what makes that impossible to forget.
|
||||
const val PLAYBACK_LAUNCH = "Memby.playbackLaunch"
|
||||
const val PLAYBACK_FIRST_FRAME = "Memby.playbackFirstFrame"
|
||||
|
||||
const val PLAYER_LOADING_ID = "playback_loading"
|
||||
const val RESUME_LABEL = "Resume"
|
||||
|
||||
/** Ten launches is enough to see the distribution through the run-to-run spread. */
|
||||
const val ITERATIONS = 10
|
||||
const val CARDS_TO_TRY = 6
|
||||
const val PLAYER_APPEAR_TIMEOUT_MS = 3_000L
|
||||
const val DETAIL_TIMEOUT_MS = 5_000L
|
||||
const val FIRST_FRAME_TIMEOUT_MS = 30_000L
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user