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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ android {
applicationId = "dev.typetype.android"
minSdk = 23
targetSdk = 37
versionCode = 10826
versionName = "1.9.0-beta.3"
versionCode = 10901
versionName = "1.9.1-beta.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
resValue("string", "app_name", "TypeType")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package dev.typetype.android.core.ui.components

import android.graphics.Bitmap
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.unit.dp
import coil3.request.ImageRequest
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test

class RetryingImageTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()

@Test
fun failedImageRecoversWithoutChangingItsUrlOrLeavingTheScreen() {
val file = File.createTempFile("retry-image", ".png", rule.activity.cacheDir)
file.delete()
val errors = AtomicInteger()
val loaded = AtomicBoolean()
val request = ImageRequest.Builder(rule.activity).data(file).build()
try {
rule.setContent {
RetryingImage(
request = request,
contentDescription = "Test image",
contentScale = ContentScale.Crop,
modifier = Modifier.size(48.dp),
onLoaded = { success ->
if (success) {
loaded.set(true)
} else {
errors.incrementAndGet()
val bitmap = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888)
file.outputStream().use {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
}
bitmap.recycle()
}
},
)
}
rule.waitUntil(10_000) { loaded.get() }
assertEquals(1, errors.get())
} finally {
file.delete()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package dev.typetype.android.feature.player

import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test

class PlayerPlaceholderLayerTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()

@Test
fun errorArtworkDisappearsWhenCollapsedAndReturnsWhenExpanded() {
val progress = mutableFloatStateOf(0f)
rule.setContent {
Box(Modifier.size(120.dp).background(Color.Blue).testTag("viewport")) {
PlayerPlaceholderLayer({ progress.floatValue }) {
Box(Modifier.size(120.dp).background(Color.Red))
}
}
}
assertCenter(Color.Red)
rule.runOnIdle { progress.floatValue = 1f }
assertCenter(Color.Blue)
rule.runOnIdle { progress.floatValue = 0f }
assertCenter(Color.Red)
}

private fun assertCenter(expected: Color) {
rule.waitForIdle()
val pixels = rule.onNodeWithTag("viewport").captureToImage().toPixelMap()
assertEquals(expected, pixels[pixels.width / 2, pixels.height / 2])
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package dev.typetype.android.services

import android.content.ComponentName
import android.content.Intent
import android.net.Uri
import android.os.SystemClock
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import androidx.test.platform.app.InstrumentationRegistry
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import kotlin.math.abs
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test

@UnstableApi
class PublicVodSeekMeasurementTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()

@Test
fun measurePublicVodSeekReadinessWithPinnedTracks() {
assumeTrue(InstrumentationRegistry.getArguments().getString("publicVodSeekMeasurement") == "true")
val context = instrumentation.targetContext
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://www.youtube.com/watch?v=tXH5EjM_96c"))
.setPackage(context.packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
val controller = MediaController.Builder(
context,
SessionToken(context, ComponentName(context, PlaybackService::class.java)),
).buildAsync().get(10, TimeUnit.SECONDS)
try {
val deadline = SystemClock.elapsedRealtime() + 40_000L
while (SystemClock.elapsedRealtime() < deadline && read { controller.currentTracks.groups.isEmpty() }) {
Thread.sleep(50L)
}
val tracks = read {
controller.currentTracks.groups.flatMap { group ->
(0 until group.length).filter(group::isTrackSelected).map { index ->
val format = group.getTrackFormat(index)
"${format.id}:${format.sampleMimeType}"
}
}
}
println("Selected public VOD tracks: $tracks")
assertTrue("Expected video itag 299, got $tracks", tracks.any { it.startsWith("299:") })
assertTrue("Expected audio itag 140, got $tracks", tracks.any { it.startsWith("140:") })
for (target in listOf(999_108L, 225_191L)) {
val started = SystemClock.elapsedRealtime()
instrumentation.runOnMainSync {
controller.play()
controller.seekTo(target)
}
var ready = false
while (SystemClock.elapsedRealtime() - started < 45_000L) {
ready = read {
controller.playerError == null && controller.isPlaying &&
controller.playbackState == Player.STATE_READY &&
abs(controller.currentPosition - target) < 4_000L
}
if (ready) break
Thread.sleep(25L)
}
println("Android controller ready: targetMs=$target delayMs=${SystemClock.elapsedRealtime() - started}")
assertTrue("Seek did not recover: $target", ready)
}
} finally {
instrumentation.runOnMainSync { controller.release() }
}
}

private fun <T> read(block: () -> T): T {
val result = AtomicReference<Result<T>>()
instrumentation.runOnMainSync { result.set(runCatching(block)) }
return requireNotNull(result.get()).getOrThrow()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
Expand All @@ -23,7 +22,6 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import dev.typetype.android.core.ui.share.LocalServerBaseUrl
Expand All @@ -40,8 +38,7 @@ fun ChannelAvatar(
) {
val serverBaseUrl = LocalServerBaseUrl.current
val context = LocalPlatformContext.current
var attempt by remember(avatarUrl) { mutableIntStateOf(0) }
var loaded by remember(avatarUrl, attempt) { mutableStateOf(false) }
var loaded by remember(serverBaseUrl, avatarUrl) { mutableStateOf(false) }
val hasSource = avatarUrl.isNotBlank()
var missingExpired by remember(avatarUrl) { mutableStateOf(false) }

Expand Down Expand Up @@ -73,16 +70,13 @@ fun ChannelAvatar(
)
}
if (hasSource) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(avatarRetryUrl(buildImageUrl(serverBaseUrl, avatarUrl), attempt))
RetryingImage(
request = ImageRequest.Builder(context)
.data(buildImageUrl(serverBaseUrl, avatarUrl))
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
onSuccess = { loaded = true },
onError = {
if (attempt < MAX_AVATAR_RETRIES) attempt += 1
},
onLoaded = { loaded = it },
modifier = Modifier.fillMaxSize(),
)
}
Expand All @@ -102,5 +96,4 @@ internal fun avatarInitial(name: String): String =
name.trim().firstOrNull()?.uppercase() ?: "?"

private const val AVATAR_RETRY_PARAM = "_tt_avatar_retry"
private const val MAX_AVATAR_RETRIES = 2
private const val MISSING_AVATAR_GRACE_MILLIS = 1_500L
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package dev.typetype.android.core.ui.components

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import kotlinx.coroutines.delay

@Composable
internal fun RetryingImage(
request: ImageRequest,
contentDescription: String?,
contentScale: ContentScale,
modifier: Modifier,
onLoaded: (Boolean) -> Unit,
) {
var attempt by remember(request.data) { mutableIntStateOf(0) }
var failed by remember(request.data, attempt) { mutableStateOf(false) }
LaunchedEffect(request.data, attempt, failed) {
if (failed) {
delay(imageRetryDelayMillis(attempt))
attempt += 1
}
}
key(request.data, attempt) {
AsyncImage(
model = request,
contentDescription = contentDescription,
contentScale = contentScale,
onSuccess = { onLoaded(true) },
onError = {
onLoaded(false)
failed = true
},
modifier = modifier,
)
}
}

internal fun imageRetryDelayMillis(attempt: Int): Long =
1_000L shl attempt.coerceIn(0, 5)
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import coil3.request.crossfade
Expand All @@ -28,22 +27,21 @@ fun SkeletonImage(
) {
val serverBaseUrl = LocalServerBaseUrl.current
val context = LocalPlatformContext.current
var loaded by remember(imageUrl) { mutableStateOf(false) }
var loaded by remember(serverBaseUrl, imageUrl) { mutableStateOf(false) }
val hasSource = imageUrl.isNotBlank()
Box(modifier = modifier.clip(shape)) {
if (hasSource && !loaded) {
TypeTypeSkeleton(modifier = Modifier.matchParentSize(), shape = shape)
}
if (hasSource) {
AsyncImage(
model = ImageRequest.Builder(context)
RetryingImage(
request = ImageRequest.Builder(context)
.data(buildImageUrl(serverBaseUrl, imageUrl))
.apply { if (crossfadeMillis > 0) crossfade(crossfadeMillis) }
.build(),
contentDescription = contentDescription,
contentScale = contentScale,
onSuccess = { loaded = true },
onError = { loaded = false },
onLoaded = { loaded = it },
modifier = Modifier.matchParentSize(),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,13 +182,17 @@ private fun SabrPlaybackWindowTrackDto.requireTrack(
sabrContractMismatch("SABR returned an empty $expectedKind window")
}
var previousEndMs = -1L
val resolvedSegments = segments.map { segment ->
val resolvedSegments = segments.mapIndexed { index, segment ->
if (
segment.startMs < 0L || segment.durationMs <= 0L ||
segment.startMs < previousEndMs &&
previousEndMs - segment.startMs > TIMELINE_ROUNDING_TOLERANCE_MS
) {
sabrContractMismatch("SABR returned an invalid $expectedKind timeline")
sabrContractMismatch(
"SABR returned an invalid $expectedKind timeline: index=$index " +
"startMs=${segment.startMs} durationMs=${segment.durationMs} " +
"previousEndMs=$previousEndMs",
)
}
val resolvedUrl = requireMediaUrl(
baseUrl,
Expand Down
Loading