diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5ed78171..dd6cadb3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "dev.typetype.android" minSdk = 23 targetSdk = 37 - versionCode = 10400 - versionName = "1.4.0" + versionCode = 10500 + versionName = "1.5.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } @@ -100,6 +100,14 @@ android { sourceSets["androidTest"].assets.directories.add("$projectDir/schemas") } +androidComponents.onVariants { variant -> + if (variant.name == "benchmarkRelease") { + variant.sources.manifests.addStaticManifestFile( + "src/benchmarkRelease/AndroidManifest.xml", + ) + } +} + dependencies { implementation(project(":player")) baselineProfile(project(":baseline-profile")) diff --git a/app/src/androidTest/java/dev/typetype/android/AppShellAdaptiveTest.kt b/app/src/androidTest/java/dev/typetype/android/AppShellAdaptiveTest.kt index d076f190..6970ab49 100644 --- a/app/src/androidTest/java/dev/typetype/android/AppShellAdaptiveTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/AppShellAdaptiveTest.kt @@ -1,26 +1,37 @@ package dev.typetype.android import androidx.activity.ComponentActivity +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.requiredWidth import androidx.compose.runtime.MutableState import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import androidx.compose.ui.input.InputMode import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.junit4.StateRestorationTester import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTextInput import androidx.compose.ui.input.key.Key import androidx.compose.ui.test.pressKey import androidx.compose.ui.unit.Density @@ -30,7 +41,11 @@ import androidx.compose.ui.unit.dp import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.test.espresso.Espresso.closeSoftKeyboard import dev.typetype.android.core.ui.navigation.HomeRoute +import dev.typetype.android.core.ui.navigation.LibraryRoute +import dev.typetype.android.core.ui.navigation.SearchRoute +import dev.typetype.android.core.ui.navigation.SubscriptionsRoute import dev.typetype.android.domain.playback.PlaybackQueueController import dev.typetype.android.domain.playback.PlaybackQueueEntry import dev.typetype.android.domain.playback.PlaybackQueueSnapshot @@ -117,6 +132,144 @@ class AppShellAdaptiveTest { composeRule.onNodeWithText("Shorts").assertIsFocused() } + @Test + fun topLevelNavigationDoesNotRestoreSearchOverItsOpeningTab() { + composeRule.setContent { + val navController = rememberNavController() + AppShell( + navController = navController, + playerHostController = PlayerHostController(FakePlaybackQueueController()), + onOpenSearch = { navController.navigate(SearchRoute) }, + onOpenSettings = {}, + onPlayVideo = {}, + onOpenChannel = {}, + onOpenAccounts = {}, + onClosePlayback = {}, + ) { contentModifier -> + NavHost( + navController = navController, + startDestination = HomeRoute, + modifier = contentModifier, + ) { + composable { androidx.compose.material3.Text("Home content") } + composable { + androidx.compose.material3.Text("Subscriptions content") + } + composable { androidx.compose.material3.Text("Library content") } + composable { androidx.compose.material3.Text("Search content") } + } + } + } + + composeRule.onNodeWithContentDescription("Search").performClick() + composeRule.onNodeWithText("Search content").assertIsDisplayed() + composeRule.onNodeWithText("Subscriptions").performClick() + composeRule.onNodeWithText("Subscriptions content").assertIsDisplayed() + + composeRule.onNodeWithContentDescription("Search").performClick() + composeRule.onNodeWithText("Search content").assertIsDisplayed() + composeRule.onNodeWithText("Home").performClick() + + composeRule.onNodeWithText("Home content").assertIsDisplayed() + composeRule.onNodeWithText("Search content").assertDoesNotExist() + } + + @Test + fun closedKeyboardDoesNotTrapSearchNavigationOrBack() { + composeRule.setContent { + val navController = rememberNavController() + AppShell( + navController = navController, + playerHostController = PlayerHostController(FakePlaybackQueueController()), + onOpenSearch = { navController.navigate(SearchRoute) }, + onOpenSettings = {}, + onPlayVideo = {}, + onOpenChannel = {}, + onOpenAccounts = {}, + onClosePlayback = {}, + ) { contentModifier -> + NavHost( + navController = navController, + startDestination = HomeRoute, + modifier = contentModifier, + ) { + composable { androidx.compose.material3.Text("Home content") } + composable { + androidx.compose.material3.Text("Subscriptions content") + } + composable { androidx.compose.material3.Text("Library content") } + composable { + var query by rememberSaveable { mutableStateOf("") } + BasicTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier.testTag(SEARCH_FIELD_TAG), + ) + } + } + } + } + + composeRule.onNodeWithContentDescription("Search").performClick() + composeRule.onNodeWithTag(SEARCH_FIELD_TAG).performTextInput("video") + closeSoftKeyboard() + composeRule.onNodeWithText("Library").performClick() + composeRule.onNodeWithText("Library content").assertIsDisplayed() + + composeRule.onNodeWithContentDescription("Search").performClick() + composeRule.onNodeWithTag(SEARCH_FIELD_TAG).assertTextEquals("") + composeRule.activity.onBackPressedDispatcher.onBackPressed() + composeRule.onNodeWithText("Library content").assertIsDisplayed() + + composeRule.onNodeWithText("Home").performClick() + composeRule.onNodeWithContentDescription("Search").performClick() + closeSoftKeyboard() + composeRule.onNodeWithText("Subscriptions").performClick() + composeRule.onNodeWithText("Subscriptions content").assertIsDisplayed() + composeRule.onNodeWithTag(SEARCH_FIELD_TAG).assertDoesNotExist() + } + + @Test + fun restoredSearchCanLeaveAndReopenWithoutStaleState() { + val restorationTester = StateRestorationTester(composeRule) + restorationTester.setContent { + val navController = rememberNavController() + AppShell( + navController = navController, + playerHostController = PlayerHostController(FakePlaybackQueueController()), + onOpenSearch = { navController.navigate(SearchRoute) }, + onOpenSettings = {}, + onPlayVideo = {}, + onOpenChannel = {}, + onOpenAccounts = {}, + onClosePlayback = {}, + ) { contentModifier -> + NavHost(navController, HomeRoute, contentModifier) { + composable { androidx.compose.material3.Text("Home content") } + composable { androidx.compose.material3.Text("Library content") } + composable { + var query by rememberSaveable { mutableStateOf("") } + BasicTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier.testTag(SEARCH_FIELD_TAG), + ) + } + } + } + } + + composeRule.onNodeWithContentDescription("Search").performClick() + composeRule.onNodeWithTag(SEARCH_FIELD_TAG).performTextInput("video") + restorationTester.emulateSavedInstanceStateRestore() + composeRule.onNodeWithTag(SEARCH_FIELD_TAG).assertTextEquals("video") + + composeRule.onNodeWithText("Library").performClick() + composeRule.onNodeWithText("Library content").assertIsDisplayed() + composeRule.onNodeWithContentDescription("Search").performClick() + composeRule.onNodeWithTag(SEARCH_FIELD_TAG).assertTextEquals("") + } + private fun setShellWidth(width: Dp) { setShellSize(width = width, height = 800.dp) } @@ -172,6 +325,8 @@ class AppShellAdaptiveTest { } } +private const val SEARCH_FIELD_TAG = "search_field" + private class FakePlaybackQueueController : PlaybackQueueController { override val state: StateFlow = MutableStateFlow(PlaybackQueueState()) override fun start(title: String, entries: List, shuffle: Boolean) = Unit diff --git a/app/src/androidTest/java/dev/typetype/android/core/ui/branding/DeArrowBrandingEnvironmentTest.kt b/app/src/androidTest/java/dev/typetype/android/core/ui/branding/DeArrowBrandingEnvironmentTest.kt index 17e64c0f..d91bc96e 100644 --- a/app/src/androidTest/java/dev/typetype/android/core/ui/branding/DeArrowBrandingEnvironmentTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/core/ui/branding/DeArrowBrandingEnvironmentTest.kt @@ -76,6 +76,38 @@ class DeArrowBrandingEnvironmentTest { assertEquals(0, calls.get()) } + @Test + fun deferredEnhancementsKeepOriginalBrandingWithoutLoading() { + val calls = AtomicInteger() + val environment = DeArrowBrandingEnvironment( + enabled = true, + preferences = DeArrowPreferences("dearrow", "dearrow", "accepted"), + loader = { _, _ -> + calls.incrementAndGet() + Result.success(BRANDING) + }, + ) + + composeRule.setContent { + CompositionLocalProvider(LocalDeArrowBranding provides environment) { + val branding = rememberVideoBranding( + VIDEO_URL, + "Original", + "original.jpg", + 120, + loadEnhancements = false, + ) + Text( + text = "${branding.title}|${branding.thumbnailUrl}", + modifier = Modifier.testTag("branding"), + ) + } + } + + composeRule.onNodeWithTag("branding").assertTextEquals("Original|original.jpg") + assertEquals(0, calls.get()) + } + @Test fun videoCardRendersResolvedTitle() { val environment = DeArrowBrandingEnvironment( diff --git a/app/src/androidTest/java/dev/typetype/android/core/ui/error/UserErrorMapperAndroidTest.kt b/app/src/androidTest/java/dev/typetype/android/core/ui/error/UserErrorMapperAndroidTest.kt index cca8b29e..52224f14 100644 --- a/app/src/androidTest/java/dev/typetype/android/core/ui/error/UserErrorMapperAndroidTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/core/ui/error/UserErrorMapperAndroidTest.kt @@ -7,6 +7,7 @@ import dev.typetype.android.R import dev.typetype.android.data.network.ServerError import dev.typetype.android.data.network.ServerResponseException import java.io.IOException +import javax.net.ssl.SSLHandshakeException import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -56,6 +57,14 @@ class UserErrorMapperAndroidTest { ) } + @Test + fun mapsTlsFailureToAnActionableMessage() { + assertEquals( + context.getString(R.string.error_secure_connection_failed), + mapper.message(SSLHandshakeException("certificate rejected"), R.string.setup_server_unreachable), + ) + } + private fun assertMapped(status: Int, expectedResource: Int) { assertEquals( context.getString(expectedResource), diff --git a/app/src/androidTest/java/dev/typetype/android/data/feed/SubscriptionFeedCachePersistenceTest.kt b/app/src/androidTest/java/dev/typetype/android/data/feed/SubscriptionFeedCachePersistenceTest.kt new file mode 100644 index 00000000..1a6d87b4 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/data/feed/SubscriptionFeedCachePersistenceTest.kt @@ -0,0 +1,117 @@ +package dev.typetype.android.data.feed + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.typetype.android.data.account.AccountEntity +import dev.typetype.android.data.database.TypeTypeDatabase +import dev.typetype.android.data.server.ServerEntity +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SubscriptionFeedCachePersistenceTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private var database: TypeTypeDatabase? = null + + @After + fun tearDown() { + database?.close() + context.deleteDatabase(DATABASE_NAME) + } + + @Test + fun cachedFeedSurvivesRecreationAndRemainsScoped() = runBlocking { + openDatabase().also { current -> + seedAccount(current, SERVER_A, ACCOUNT_A) + seedAccount(current, SERVER_A, ACCOUNT_B) + seedAccount(current, SERVER_B, ACCOUNT_A) + current.feedVideoDao().replace( + SERVER_A, + ACCOUNT_A, + SUBSCRIPTIONS, + listOf(feedRow(SERVER_A, ACCOUNT_A, "video-a")), + ) + current.close() + database = null + } + + val recreated = openDatabase() + val restored = recreated.feedVideoDao().get(SERVER_A, ACCOUNT_A, SUBSCRIPTIONS) + + assertEquals(listOf("video-a"), restored.map { it.videoId }) + assertTrue(recreated.feedVideoDao().get(SERVER_A, ACCOUNT_B, SUBSCRIPTIONS).isEmpty()) + assertTrue(recreated.feedVideoDao().get(SERVER_B, ACCOUNT_A, SUBSCRIPTIONS).isEmpty()) + assertTrue(recreated.feedVideoDao().get(SERVER_A, ACCOUNT_A, "home").isEmpty()) + } + + private fun openDatabase(): TypeTypeDatabase = Room.databaseBuilder( + context, + TypeTypeDatabase::class.java, + DATABASE_NAME, + ).build().also { database = it } + + private suspend fun seedAccount( + target: TypeTypeDatabase, + serverId: String, + accountId: String, + ) { + target.serverDao().upsert( + ServerEntity(serverId, "https://$serverId.example/api/", serverId, 1L), + ) + target.accountDao().upsert( + AccountEntity( + serverId = serverId, + accountId = accountId, + publicUsername = accountId, + role = "user", + avatarUrl = null, + avatarType = null, + avatarCode = null, + isGuest = false, + lastUsedAt = 1L, + sessionGeneration = 1L, + ), + ) + } + + private fun feedRow(serverId: String, accountId: String, videoId: String) = FeedVideoEntity( + serverId = serverId, + accountId = accountId, + feed = SUBSCRIPTIONS, + position = 0, + videoUrl = "https://video.example/watch?v=$videoId", + videoId = videoId, + title = "Subscription video", + thumbnailUrl = "https://image.example/$videoId", + uploaderName = "Channel", + uploaderUrl = "https://video.example/channel", + uploaderAvatarUrl = "", + uploaderVerified = false, + durationSeconds = 120, + isLive = false, + viewCount = 10, + uploadedAtMillis = 20, + isShortFormContent = false, + shortDescription = null, + publishedAtMillis = 20, + isPostLive = false, + isLiveContent = false, + requiresMembership = false, + savedAtMillis = 30, + ) + + private companion object { + const val DATABASE_NAME = "subscription-feed-cache-persistence.db" + const val SERVER_A = "server-a" + const val SERVER_B = "server-b" + const val ACCOUNT_A = "account-a" + const val ACCOUNT_B = "account-b" + const val SUBSCRIPTIONS = "subscriptions" + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/data/network/NetworkSecurityConfigAndroidTest.kt b/app/src/androidTest/java/dev/typetype/android/data/network/NetworkSecurityConfigAndroidTest.kt new file mode 100644 index 00000000..843da268 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/data/network/NetworkSecurityConfigAndroidTest.kt @@ -0,0 +1,35 @@ +package dev.typetype.android.data.network + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import dev.typetype.android.R +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.xmlpull.v1.XmlPullParser + +@RunWith(AndroidJUnit4::class) +@SdkSuppress(minSdkVersion = 24) +class NetworkSecurityConfigAndroidTest { + @Test + fun applicationTrustsSystemAndUserCertificateAuthorities() { + val context = ApplicationProvider.getApplicationContext() + val parser = context.resources.getXml(R.xml.network_security_config) + val certificateSources = mutableSetOf() + var cleartextPermitted: String? = null + while (parser.eventType != XmlPullParser.END_DOCUMENT) { + if (parser.eventType == XmlPullParser.START_TAG) { + when (parser.name) { + "base-config" -> cleartextPermitted = parser.getAttributeValue(null, "cleartextTrafficPermitted") + "certificates" -> parser.getAttributeValue(null, "src")?.let(certificateSources::add) + } + } + parser.next() + } + + assertEquals("true", cleartextPermitted) + assertEquals(setOf("system", "user"), certificateSources) + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/data/network/PersistentCookieJarTest.kt b/app/src/androidTest/java/dev/typetype/android/data/network/PersistentCookieJarTest.kt index 94836b51..970c8247 100644 --- a/app/src/androidTest/java/dev/typetype/android/data/network/PersistentCookieJarTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/data/network/PersistentCookieJarTest.kt @@ -50,11 +50,20 @@ class PersistentCookieJarTest { jar.resumeAuthentication(server.id, server.baseUrl) jar.completeAuthentication(server.id, "account-a") - val stored = jar.scoped(server.id, "account-a", server.baseUrl) + val recreatedJar = PersistentCookieJar( + context, + ApiBaseUrlHolder(FakeServerRepository(server)), + accountStore, + ) + val stored = recreatedJar.scoped(server.id, "account-a", server.baseUrl) .loadForRequest(requestUrl) assertEquals(listOf("refresh_token"), stored.map { it.name }) - assertTrue(jar.scoped(server.id, "account-b", server.baseUrl).loadForRequest(requestUrl).isEmpty()) + assertTrue( + recreatedJar.scoped(server.id, "account-b", server.baseUrl) + .loadForRequest(requestUrl) + .isEmpty(), + ) } finally { jar.clear() } diff --git a/app/src/androidTest/java/dev/typetype/android/data/network/UserCertificateHttpsAndroidTest.kt b/app/src/androidTest/java/dev/typetype/android/data/network/UserCertificateHttpsAndroidTest.kt new file mode 100644 index 00000000..f433216e --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/data/network/UserCertificateHttpsAndroidTest.kt @@ -0,0 +1,204 @@ +package dev.typetype.android.data.network + +import android.content.Context +import android.security.KeyChain +import android.util.Base64 +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry +import dev.typetype.android.data.account.AccountScopeStore +import dev.typetype.android.domain.server.Server +import dev.typetype.android.domain.server.ServerRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeNotNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@SdkSuppress(minSdkVersion = 24) +class UserCertificateHttpsAndroidTest { + @Test + fun loginAndRefreshUseAUserInstalledCertificateAuthority() { + val arguments = InstrumentationRegistry.getArguments() + if (arguments.getString(STAGE_ARGUMENT) == INSTALL_STAGE) { + launchCertificateInstaller(arguments.getString(CERTIFICATE_ARGUMENT)) + return + } + val rawBaseUrl = arguments.getString(BASE_URL_ARGUMENT) + assumeNotNull(rawBaseUrl) + val baseUrl = requireNotNull(rawBaseUrl?.toHttpUrlOrNull()) + require(baseUrl.isHttps) + when (arguments.getString(STAGE_ARGUMENT)) { + LOGIN_STAGE -> login(baseUrl) + REFRESH_STAGE -> refresh(baseUrl) + else -> error("Missing HTTPS test stage") + } + } + + private fun launchCertificateInstaller(encodedCertificate: String?) { + assumeNotNull(encodedCertificate) + val context = ApplicationProvider.getApplicationContext() + val intent = KeyChain.createInstallIntent().apply { + putExtra(KeyChain.EXTRA_CERTIFICATE, Base64.decode(encodedCertificate, Base64.DEFAULT)) + putExtra(KeyChain.EXTRA_NAME, "TypeType Android Test CA") + addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } + + private fun login(baseUrl: HttpUrl) { + val context = ApplicationProvider.getApplicationContext() + val server = testServer(baseUrl) + val accountStore = AccountScopeStore(context) + val jar = cookieJar(context, server, accountStore) + jar.clear() + accountStore.clearCurrentAccountId(SERVER_ID) + jar.beginAuthentication(SERVER_ID, server.baseUrl) + + val response = client(jar).newCall( + postRequest( + baseUrl.endpoint("auth/login"), + """{"identifier":"test","password":"test"}""", + ), + ).execute() + response.use { + assertEquals(200, it.code) + assertTrue(it.body.string().contains("login-access-token")) + } + + jar.completeAuthentication(SERVER_ID, ACCOUNT_ID) + accountStore.setCurrentAccountId(SERVER_ID, ACCOUNT_ID) + assertEquals( + listOf("refresh_token"), + jar.scoped(SERVER_ID, ACCOUNT_ID, server.baseUrl) + .loadForRequest(baseUrl.endpoint("auth/refresh")) + .map { it.name }, + ) + } + + private fun refresh(baseUrl: HttpUrl) { + val context = ApplicationProvider.getApplicationContext() + val server = testServer(baseUrl) + val accountStore = AccountScopeStore(context) + val recreatedJar = cookieJar(context, server, accountStore) + try { + val scopedJar = recreatedJar.scoped(SERVER_ID, ACCOUNT_ID, server.baseUrl) + val tokenStore = HttpsTestAccessTokenStore("expired-access-token") + val scope = NetworkRequestScope(SERVER_ID, ACCOUNT_ID, server.baseUrl) + val authenticatedClient = OkHttpClient.Builder() + .cookieJar(scopedJar) + .addInterceptor(ScopedRequestInterceptor(scope) { + tokenStore.getAccessToken(SERVER_ID, ACCOUNT_ID) + }) + .authenticator( + ScopedTokenAuthenticator( + scope = scope, + tokenStore = tokenStore, + refreshClient = client(scopedJar), + json = Json { ignoreUnknownKeys = true }, + lock = Any(), + ), + ) + .build() + val response = authenticatedClient.newCall( + Request.Builder().url(baseUrl.endpoint("protected")).build(), + ).execute() + response.use { + assertEquals(200, it.code) + assertTrue(it.body.string().contains("authenticated")) + } + assertEquals( + "refreshed-access-token", + tokenStore.getAccessToken(SERVER_ID, ACCOUNT_ID), + ) + } finally { + recreatedJar.clear() + accountStore.clearCurrentAccountId(SERVER_ID) + } + } + + private fun cookieJar( + context: Context, + server: Server, + accountStore: AccountScopeStore, + ): PersistentCookieJar = PersistentCookieJar( + context, + ApiBaseUrlHolder(HttpsTestServerRepository(server)), + accountStore, + ) + + private fun testServer(baseUrl: HttpUrl) = Server( + id = SERVER_ID, + baseUrl = baseUrl.toString(), + displayName = "HTTPS certificate test", + addedAt = 1L, + ) + + private fun client(cookieJar: okhttp3.CookieJar) = OkHttpClient.Builder() + .cookieJar(cookieJar) + .build() + + private fun postRequest(url: HttpUrl, body: String) = Request.Builder() + .url(url) + .post(body.toRequestBody(JSON_MEDIA_TYPE)) + .build() + + private fun HttpUrl.endpoint(path: String): HttpUrl = requireNotNull(resolve(path)) + + private companion object { + const val BASE_URL_ARGUMENT = "typetypeHttpsTestUrl" + const val STAGE_ARGUMENT = "typetypeHttpsTestStage" + const val CERTIFICATE_ARGUMENT = "typetypeHttpsTestCertificate" + const val INSTALL_STAGE = "install" + const val LOGIN_STAGE = "login" + const val REFRESH_STAGE = "refresh" + const val SERVER_ID = "user-ca-https-test-server" + const val ACCOUNT_ID = "user-ca-https-test-account" + val JSON_MEDIA_TYPE = "application/json".toMediaType() + } +} + +private class HttpsTestAccessTokenStore(initialToken: String) : ScopedAccessTokenStore { + private var token: String? = initialToken + + override fun getAccessToken(serverId: String, accountId: String): String? = token + + override fun setAccessToken(serverId: String, accountId: String, token: String?) { + this.token = token + } +} + +private class HttpsTestServerRepository(server: Server) : ServerRepository { + private val current = MutableStateFlow(server) + + override fun observeServers(): Flow> = MutableStateFlow(listOfNotNull(current.value)) + + override fun observeCurrentServer(): Flow = current + + override suspend fun getServer(id: String): Server? = current.value?.takeIf { it.id == id } + + override suspend fun addServer(server: Server) { + current.value = server + } + + override suspend fun deleteServer(id: String) { + if (current.value?.id == id) current.value = null + } + + override suspend fun setCurrentServer(id: String) = Unit + + override suspend fun clearCurrentServer() { + current.value = null + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/channel/ChannelScreenStateTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/channel/ChannelScreenStateTest.kt index 89239a8c..30dc22af 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/channel/ChannelScreenStateTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/channel/ChannelScreenStateTest.kt @@ -158,10 +158,13 @@ class ChannelScreenStateTest { TypeTypeTheme { ChannelContentGrid( state = state, + onNavigateBack = {}, onPlayVideo = {}, onOpenPodcast = {}, onOpenPlaylist = {}, onAction = onAction, + searchExpanded = false, + onSearchExpandedChange = {}, menuScope = emptyMenuScope(), ) } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerFullscreenEffectTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerFullscreenEffectTest.kt index 3731f254..a63fc678 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerFullscreenEffectTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerFullscreenEffectTest.kt @@ -8,7 +8,9 @@ import android.view.WindowManager import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.Lifecycle import androidx.test.filters.SdkSuppress import dev.typetype.android.core.ui.util.WindowHelper @@ -25,7 +27,6 @@ class PlayerFullscreenEffectTest { val composeRule = createAndroidComposeRule() private val fullscreen = mutableStateOf(false) - private var disposedFullscreen = true @Before fun showEffectInPortrait() { @@ -41,7 +42,7 @@ class PlayerFullscreenEffectTest { PlayerFullscreenEffect( activity = composeRule.activity, isFullscreen = fullscreen.value, - onFullscreenChange = { disposedFullscreen = it }, + locksLandscape = true, ) } composeRule.waitForIdle() @@ -102,7 +103,6 @@ class PlayerFullscreenEffectTest { composeRule.activity.window.attributes.layoutInDisplayCutoutMode, ) } - assertTrue(disposedFullscreen) } composeRule.waitUntil(SYSTEM_BARS_TIMEOUT_MILLIS) { systemBarsVisible() } } @@ -129,6 +129,13 @@ class PlayerFullscreenEffectTest { composeRule.waitUntil(SYSTEM_BARS_TIMEOUT_MILLIS) { systemBarsVisible() } composeRule.runOnIdle { assertFalse(hasNoLimitsFlag()) + assertEquals( + WindowInsetsControllerCompat.BEHAVIOR_DEFAULT, + WindowCompat.getInsetsController( + composeRule.activity.window, + composeRule.activity.window.decorView, + ).systemBarsBehavior, + ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { assertEquals( WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT, @@ -151,8 +158,12 @@ class PlayerFullscreenEffectTest { return decorView.systemUiVisibility and hiddenFlags == 0 } val insets = ViewCompat.getRootWindowInsets(decorView) ?: return false + val navigationBars = WindowInsetsCompat.Type.navigationBars() + val navigationBarInsets = insets.getInsetsIgnoringVisibility(navigationBars) + val hasNavigationBar = navigationBarInsets.left != 0 || navigationBarInsets.top != 0 || + navigationBarInsets.right != 0 || navigationBarInsets.bottom != 0 return insets.isVisible(WindowInsetsCompat.Type.statusBars()) && - insets.isVisible(WindowInsetsCompat.Type.navigationBars()) + (!hasNavigationBar || insets.isVisible(navigationBars)) } private companion object { diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowTest.kt new file mode 100644 index 00000000..abe9666a --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowTest.kt @@ -0,0 +1,58 @@ +package dev.typetype.android.feature.player + +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import dev.typetype.android.core.ui.theme.TypeTypeTheme +import java.util.concurrent.atomic.AtomicBoolean +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class PlayerInteractionRowTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun commentsActionOpensComments() { + val opened = AtomicBoolean(false) + composeRule.setContent { + TypeTypeTheme { + PlayerInteractionRow( + isFavorited = false, + isInWatchLater = false, + shareUrl = "/watch?v=test", + onToggleFavorite = {}, + onToggleWatchLater = {}, + onAddToPlaylist = {}, + onShowComments = { opened.set(true) }, + onDownload = {}, + ) + } + } + + composeRule.onNodeWithContentDescription("Comments").performClick() + + assertTrue(opened.get()) + } + + @Test + fun commentsActionIsHiddenWhenCommentsAreDisabled() { + composeRule.setContent { + TypeTypeTheme { + PlayerInteractionRow( + isFavorited = false, + isInWatchLater = false, + shareUrl = "/watch?v=test", + onToggleFavorite = {}, + onToggleWatchLater = {}, + onAddToPlaylist = {}, + onShowComments = null, + onDownload = {}, + ) + } + } + + composeRule.onNodeWithContentDescription("Comments").assertDoesNotExist() + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/ShortsPlayerOptionsButtonTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/ShortsPlayerOptionsButtonTest.kt new file mode 100644 index 00000000..bd453222 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/ShortsPlayerOptionsButtonTest.kt @@ -0,0 +1,29 @@ +package dev.typetype.android.feature.player + +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import dev.typetype.android.core.ui.theme.TypeTypeTheme +import java.util.concurrent.atomic.AtomicBoolean +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class ShortsPlayerOptionsButtonTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun shortsExposePlaybackOptions() { + val opened = AtomicBoolean(false) + composeRule.setContent { + TypeTypeTheme { + ShortsPlaybackOptionsButton(onClick = { opened.set(true) }) + } + } + + composeRule.onNodeWithContentDescription("Playback options").performClick() + + assertTrue(opened.get()) + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlaybackDanmakuOptionsTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlaybackDanmakuOptionsTest.kt index 37bae83d..637ef825 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlaybackDanmakuOptionsTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlaybackDanmakuOptionsTest.kt @@ -19,7 +19,7 @@ class PlaybackDanmakuOptionsTest { @Test fun supportedVideoCanToggleBulletComments() { val enabled = AtomicBoolean(false) - show(enabled = false) { enabled.set(it) } + show(enabled = false, onDanmakuChange = { enabled.set(it) }) composeRule.onNode(hasText("Show bullet comments") and hasClickAction()).performClick() @@ -34,9 +34,28 @@ class PlaybackDanmakuOptionsTest { composeRule.onNodeWithText("Bullet comment size").assertIsDisplayed() } + @Test + fun mainPageOpensAudioAndCaptionControls() { + val audioOpened = AtomicBoolean(false) + val captionsOpened = AtomicBoolean(false) + show( + enabled = false, + onOpenAudio = { audioOpened.set(true) }, + onOpenCaptions = { captionsOpened.set(true) }, + ) + + composeRule.onNode(hasText("Audio") and hasClickAction()).performClick() + composeRule.onNode(hasText("Captions") and hasClickAction()).performClick() + + assertTrue(audioOpened.get()) + assertTrue(captionsOpened.get()) + } + private fun show( enabled: Boolean, onDanmakuChange: (Boolean) -> Unit = {}, + onOpenAudio: () -> Unit = {}, + onOpenCaptions: () -> Unit = {}, ) { composeRule.setContent { TypeTypeTheme { @@ -58,8 +77,8 @@ class PlaybackDanmakuOptionsTest { danmakuLoadFailed = false, onOpenCodec = {}, onOpenQuality = {}, - onOpenCaptions = {}, - onOpenAudio = {}, + onOpenCaptions = onOpenCaptions, + onOpenAudio = onOpenAudio, onOpenSpeed = {}, onOpenResize = {}, onAudioOnlyChange = {}, diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/host/PlayerHostMotionLayoutTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/host/PlayerHostMotionLayoutTest.kt new file mode 100644 index 00000000..1cbe902a --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/host/PlayerHostMotionLayoutTest.kt @@ -0,0 +1,182 @@ +package dev.typetype.android.feature.player.host + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.down +import androidx.compose.ui.test.moveTo +import androidx.compose.ui.test.cancel +import androidx.compose.ui.test.swipe +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class PlayerHostMotionLayoutTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun playerFollowsDownwardDragIntoMiniPlayer() { + val observedProgress = mutableStateListOf() + var settledTarget by mutableStateOf(PlayerHostTarget.Expanded) + var requestStamp by mutableIntStateOf(0) + var dragDistancePx = 0f + composeRule.setContent { + val density = LocalDensity.current + dragDistancePx = with(density) { 500.dp.toPx() } + Box(Modifier.requiredWidth(400.dp).requiredHeight(600.dp)) { + PlayerHostMotionLayout( + target = settledTarget, + requestStamp = requestStamp.toLong(), + miniAnchorPx = dragDistancePx, + containerHeightPx = with(density) { 600.dp.toPx() }, + miniHeightPx = with(density) { 64.dp.toPx() }, + dragEnabled = true, + miniContentEnabled = true, + onTargetSettled = { + settledTarget = it + requestStamp += 1 + }, + onProgressChange = observedProgress::add, + miniContent = { Text("Mini content") }, + expandedContent = { Text("Expanded content", modifier = it) }, + ) + } + } + + composeRule.onNodeWithTag(PLAYER_HOST_OVERLAY_TAG).performTouchInput { + swipe(center, Offset(center.x, bottom - 1f), 600L) + } + composeRule.waitUntil(5_000) { settledTarget == PlayerHostTarget.Mini } + composeRule.onNodeWithText("Mini content").assertIsDisplayed() + assertTrue(observedProgress.any { it in 0.1f..0.9f }) + } + + @Test + fun miniPlayerFollowsUpwardDragIntoExpandedPlayer() { + val observedProgress = mutableStateListOf() + var settledTarget by mutableStateOf(PlayerHostTarget.Mini) + var requestStamp by mutableIntStateOf(0) + composeRule.setContent { + val density = LocalDensity.current + Box(Modifier.requiredWidth(400.dp).requiredHeight(600.dp)) { + PlayerHostMotionLayout( + target = settledTarget, + requestStamp = requestStamp.toLong(), + miniAnchorPx = with(density) { 500.dp.toPx() }, + containerHeightPx = with(density) { 600.dp.toPx() }, + miniHeightPx = with(density) { 64.dp.toPx() }, + dragEnabled = true, + miniContentEnabled = true, + onTargetSettled = { + settledTarget = it + requestStamp += 1 + }, + onProgressChange = observedProgress::add, + miniContent = { Text("Mini content") }, + expandedContent = { Text("Expanded content", modifier = it) }, + ) + } + } + + composeRule.onNodeWithText("Mini content").assertIsDisplayed() + observedProgress.clear() + composeRule.onNodeWithText("Mini content").performTouchInput { + swipe(center, Offset(center.x, center.y - 300f), 100L) + } + assertTrue( + "Upward drag did not move the player: $observedProgress", + observedProgress.any { it < 0.99f }, + ) + composeRule.waitUntil(5_000) { settledTarget == PlayerHostTarget.Expanded } + composeRule.onNodeWithText("Expanded content").assertIsDisplayed() + } + + @Test + fun cancelledDragReturnsWithoutSettlingOrRestartingPlaybackState() { + val observedProgress = mutableStateListOf() + val settledTargets = mutableStateListOf() + var miniAnchorPx = 0f + composeRule.setContent { + val density = LocalDensity.current + miniAnchorPx = with(density) { 500.dp.toPx() } + Box(Modifier.requiredWidth(400.dp).requiredHeight(600.dp)) { + PlayerHostMotionLayout( + target = PlayerHostTarget.Expanded, + requestStamp = 7, + miniAnchorPx = miniAnchorPx, + containerHeightPx = with(density) { 600.dp.toPx() }, + miniHeightPx = with(density) { 64.dp.toPx() }, + dragEnabled = true, + miniContentEnabled = true, + onTargetSettled = settledTargets::add, + onProgressChange = observedProgress::add, + miniContent = { Text("Mini content") }, + expandedContent = { Text("Expanded content", modifier = it) }, + ) + } + } + composeRule.waitForIdle() + settledTargets.clear() + observedProgress.clear() + + composeRule.onNodeWithTag(PLAYER_HOST_OVERLAY_TAG).performTouchInput { + down(center) + moveTo(Offset(center.x, center.y + miniAnchorPx * 0.35f), 300L) + cancel() + } + + assertTrue(observedProgress.any { it in 0.1f..0.9f }) + composeRule.waitUntil(5_000) { observedProgress.lastOrNull()?.let { it < 0.01f } == true } + assertTrue(settledTargets.isEmpty()) + composeRule.onNodeWithText("Expanded content").assertIsDisplayed() + } + + @Test + fun disabledDragDoesNotMoveThePlayer() { + val observedProgress = mutableStateListOf() + composeRule.setContent { + val density = LocalDensity.current + Box(Modifier.requiredWidth(400.dp).requiredHeight(600.dp)) { + PlayerHostMotionLayout( + target = PlayerHostTarget.Expanded, + requestStamp = 9, + miniAnchorPx = with(density) { 500.dp.toPx() }, + containerHeightPx = with(density) { 600.dp.toPx() }, + miniHeightPx = with(density) { 64.dp.toPx() }, + dragEnabled = false, + miniContentEnabled = false, + onTargetSettled = {}, + onProgressChange = observedProgress::add, + miniContent = { Text("Mini content") }, + expandedContent = { Text("Expanded content", modifier = it) }, + ) + } + } + composeRule.waitForIdle() + observedProgress.clear() + + composeRule.onNodeWithTag(PLAYER_HOST_OVERLAY_TAG).performTouchInput { + swipe(center, Offset(center.x, bottom - 1f), 200L) + } + + assertTrue(observedProgress.all { it < 0.01f }) + composeRule.onNodeWithText("Expanded content").assertIsDisplayed() + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsInteractionTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsInteractionTest.kt new file mode 100644 index 00000000..fcefc2ba --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsInteractionTest.kt @@ -0,0 +1,171 @@ +package dev.typetype.android.feature.shorts + +import android.content.res.Configuration +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipe +import dev.typetype.android.core.ui.components.LocalAnimatedStatePlayback +import dev.typetype.android.core.ui.theme.TypeTypeTheme +import dev.typetype.android.domain.feed.Video +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test + +class ShortsInteractionTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun horizontalSwipeLeftOpensTheShortChannelWithHapticFeedback() { + val openedChannel = AtomicReference() + val hapticCount = AtomicInteger() + show( + onOpenChannel = openedChannel::set, + hapticFeedback = RecordingHapticFeedback(hapticCount), + ) + + composeRule.onNodeWithTag(SHORTS_PAGER_TAG).performTouchInput { + swipe( + start = center.copy(x = center.x + 180f), + end = center.copy(x = center.x - 180f), + durationMillis = 300, + ) + } + + composeRule.waitUntil { openedChannel.get() == "https://channel" } + assertEquals(1, hapticCount.get()) + } + + @Test + fun portraitTitleLongPressCopiesWithHapticFeedback() { + val copiedTitle = AtomicReference() + val hapticCount = AtomicInteger() + show( + orientation = Configuration.ORIENTATION_PORTRAIT, + onCopyTitle = copiedTitle::set, + hapticFeedback = RecordingHapticFeedback(hapticCount), + ) + + composeRule.onNodeWithText("Short one").performTouchInput { longClick() } + + assertEquals("Short one", copiedTitle.get()) + assertEquals(1, hapticCount.get()) + } + + @Test + fun landscapeTitleDoesNotExposeTheCopyGesture() { + val copiedTitle = AtomicReference() + show( + orientation = Configuration.ORIENTATION_LANDSCAPE, + onCopyTitle = copiedTitle::set, + ) + + composeRule.onNodeWithText("Short one").performTouchInput { longClick() } + + assertNull(copiedTitle.get()) + } + + @Test + fun activePlaybackStartsBeforeUpcomingShortsArePrefetched() { + val activeUrl = AtomicReference() + val upcomingUrls = AtomicReference>() + var playbackReady by mutableStateOf(false) + composeRule.setContent { + CompositionLocalProvider(LocalAnimatedStatePlayback provides false) { + TypeTypeTheme { + ShortsScreen( + state = ShortsState( + videos = listOf(video("one"), video("two"), video("three")), + isLoading = false, + ), + onNavigateBack = {}, + onPlayVideo = {}, + onOpenChannel = {}, + onRefresh = {}, + onLoadMore = {}, + embeddedPlaybackEnabled = true, + playbackReady = playbackReady, + onActiveVideoChanged = { activeUrl.set(it?.url) }, + onUpcomingVideosChanged = { videos -> + upcomingUrls.set(videos.map(Video::url)) + }, + ) + } + } + } + + composeRule.waitUntil { activeUrl.get() == "https://video/one" } + assertNull(upcomingUrls.get()) + composeRule.runOnUiThread { playbackReady = true } + composeRule.waitUntil { + upcomingUrls.get() == listOf("https://video/two", "https://video/three") + } + } + + private fun show( + orientation: Int = Configuration.ORIENTATION_PORTRAIT, + onOpenChannel: (String) -> Unit = {}, + onCopyTitle: (String) -> Unit = {}, + hapticFeedback: HapticFeedback = RecordingHapticFeedback(AtomicInteger()), + ) { + val configuration = Configuration().apply { this.orientation = orientation } + composeRule.setContent { + CompositionLocalProvider( + LocalAnimatedStatePlayback provides false, + LocalConfiguration provides configuration, + LocalHapticFeedback provides hapticFeedback, + ) { + TypeTypeTheme { + ShortsScreen( + state = ShortsState(videos = listOf(video()), isLoading = false), + onNavigateBack = {}, + onPlayVideo = {}, + onOpenChannel = onOpenChannel, + onRefresh = {}, + onLoadMore = {}, + onCopyTitle = onCopyTitle, + ) + } + } + } + } + + private fun video(id: String = "one") = Video( + id = id, + url = "https://video/$id", + title = "Short $id", + thumbnailUrl = "", + uploaderName = "Channel", + uploaderUrl = "https://channel", + uploaderAvatarUrl = "", + uploaderVerified = false, + durationSeconds = 30L, + isLive = false, + viewCount = 1L, + uploadedAtMillis = 1L, + isShortFormContent = true, + shortDescription = null, + ) +} + +private class RecordingHapticFeedback( + private val count: AtomicInteger, +) : HapticFeedback { + override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) { + count.incrementAndGet() + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsLoadingBudgetTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsLoadingBudgetTest.kt new file mode 100644 index 00000000..7d1ce7bf --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsLoadingBudgetTest.kt @@ -0,0 +1,126 @@ +package dev.typetype.android.feature.shorts + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeUp +import dev.typetype.android.core.ui.branding.DeArrowBrandingEnvironment +import dev.typetype.android.core.ui.branding.LocalDeArrowBranding +import dev.typetype.android.core.ui.components.LocalAnimatedStatePlayback +import dev.typetype.android.core.ui.theme.TypeTypeTheme +import dev.typetype.android.domain.branding.DeArrowPreferences +import dev.typetype.android.domain.feed.Video +import java.util.concurrent.atomic.AtomicInteger +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class ShortsLoadingBudgetTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun brandingLoadsOnlyForTheReadyActiveShort() { + val brandingRequests = AtomicInteger() + val environment = DeArrowBrandingEnvironment( + enabled = true, + preferences = DeArrowPreferences("dearrow", "dearrow", "accepted"), + loader = { _, _ -> + brandingRequests.incrementAndGet() + Result.success(null) + }, + ) + var playbackReady by mutableStateOf(false) + composeRule.setContent { + CompositionLocalProvider( + LocalAnimatedStatePlayback provides false, + LocalDeArrowBranding provides environment, + ) { + TypeTypeTheme { + ShortsScreen( + state = ShortsState( + videos = listOf(video("one"), video("two"), video("three")), + isLoading = false, + ), + onNavigateBack = {}, + onPlayVideo = {}, + onOpenChannel = {}, + onRefresh = {}, + onLoadMore = {}, + embeddedPlaybackEnabled = true, + playbackReady = playbackReady, + ) + } + } + } + + composeRule.waitForIdle() + assertEquals(0, brandingRequests.get()) + + composeRule.runOnUiThread { playbackReady = true } + composeRule.waitUntil { brandingRequests.get() == 1 } + + composeRule.onNodeWithTag(SHORTS_PAGER_TAG).performTouchInput { swipeUp() } + composeRule.waitUntil { brandingRequests.get() == 2 } + assertEquals(2, brandingRequests.get()) + } + + @Test + fun swipeDoesNotRecomposeMetadataOnEveryFrame() { + val statsEvaluations = AtomicInteger() + composeRule.setContent { + CompositionLocalProvider(LocalAnimatedStatePlayback provides false) { + TypeTypeTheme { + ShortsScreen( + state = ShortsState( + videos = List(6) { video(it.toString()) }, + isLoading = false, + ), + onNavigateBack = {}, + onPlayVideo = {}, + onOpenChannel = {}, + onRefresh = {}, + onLoadMore = {}, + embeddedPlaybackEnabled = true, + statsForVideo = { + statsEvaluations.incrementAndGet() + ShortsVideoStats(it.viewCount, null) + }, + ) + } + } + } + + composeRule.waitForIdle() + val initialEvaluations = statsEvaluations.get() + repeat(3) { + composeRule.onNodeWithTag(SHORTS_PAGER_TAG).performTouchInput { swipeUp() } + composeRule.waitForIdle() + } + + val swipeEvaluations = statsEvaluations.get() - initialEvaluations + assertTrue("metadata evaluated $swipeEvaluations times", swipeEvaluations <= 10) + } + + private fun video(id: String) = Video( + id = id, + url = "https://video/$id", + title = "Short $id", + thumbnailUrl = "https://image/$id", + uploaderName = "Channel", + uploaderUrl = "https://channel", + uploaderAvatarUrl = "", + uploaderVerified = false, + durationSeconds = 30L, + isLive = false, + viewCount = 1L, + uploadedAtMillis = 1L, + isShortFormContent = true, + shortDescription = null, + ) +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsPlaybackContinuityAndroidTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsPlaybackContinuityAndroidTest.kt index c6b70136..ec38e7dc 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsPlaybackContinuityAndroidTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsPlaybackContinuityAndroidTest.kt @@ -90,6 +90,7 @@ class ShortsPlaybackContinuityAndroidTest { CompositionLocalProvider(LocalAnimatedStatePlayback provides false) { TypeTypeTheme { ShortsScreen( + onNavigateBack = {}, state = ShortsState( videos = videos, isLoading = false, diff --git a/app/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsScreenTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsScreenTest.kt index 17c7e140..94884234 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsScreenTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsScreenTest.kt @@ -22,6 +22,8 @@ import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.awaitCancellation import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -105,7 +107,22 @@ class ShortsScreenTest { } @Test - fun cancelledSwipeKeepsTheSettledShortActive() { + fun embeddedPagerDoesNotFlashPlayButtonsOnAdjacentPages() { + show( + state = ShortsState( + videos = listOf(video("one"), video("two")), + isLoading = false, + ), + embeddedPlaybackEnabled = true, + embeddedPlayback = { video, _ -> Text("Embedded ${video.title}") }, + ) + + composeRule.onNodeWithText("Embedded Short one").assertIsDisplayed() + composeRule.onNodeWithContentDescription("Play Short two").assertDoesNotExist() + } + + @Test + fun cancelledSwipeKeepsTheCurrentShortPlaying() { val activeUrl = AtomicReference() val inactiveEvents = AtomicInteger() val playbackDisposals = AtomicInteger() @@ -130,11 +147,10 @@ class ShortsScreenTest { composeRule.waitUntil { activeUrl.get() == "https://video/one" } composeRule.onNodeWithTag(SHORTS_PAGER_TAG).performTouchInput { - swipe( - start = center, - end = center.copy(y = center.y - 80f), - durationMillis = 300, - ) + down(center) + moveTo(center.copy(y = -center.y * 0.5f), 180L) + moveTo(center, 180L) + up() } composeRule.waitForIdle() @@ -145,7 +161,29 @@ class ShortsScreenTest { } @Test - fun pagerReportsTwoUpcomingShortsForMetadataPrefetch() { + fun horizontalSwipeRightKeepsTheShortOpen() { + val openedChannel = AtomicReference() + + show( + state = ShortsState(videos = listOf(video("one")), isLoading = false), + onOpenChannel = openedChannel::set, + ) + + composeRule.onNodeWithTag(SHORTS_PAGER_TAG).performTouchInput { + swipe( + start = center.copy(x = center.x - 180f), + end = center.copy(x = center.x + 180f), + durationMillis = 300, + ) + } + + composeRule.waitForIdle() + + assertNull(openedChannel.get()) + } + + @Test + fun pagerReportsTwoUpcomingShortsForPlaybackPrefetch() { val upcomingUrls = AtomicReference>() show( @@ -162,7 +200,7 @@ class ShortsScreenTest { } @Test - fun changingPageCancelsThePreviousMetadataPrefetch() { + fun changingPageCancelsThePreviousPlaybackPrefetch() { val prefetchStarted = AtomicBoolean() val prefetchCancelled = AtomicBoolean() @@ -224,12 +262,39 @@ class ShortsScreenTest { assertEquals("one", subscribedVideo.get().id) } + @Test + fun activeShortShowsViewsAndLikes() { + show( + state = ShortsState(videos = listOf(video("one")), isLoading = false), + statsForVideo = { ShortsVideoStats(viewCount = 1_500, likeCount = 42) }, + ) + + composeRule.onNodeWithContentDescription("1.5K views").assertIsDisplayed() + composeRule.onNodeWithContentDescription("42 likes").assertIsDisplayed() + } + + @Test + fun backButtonIsAvailableOverTheShort() { + val navigatedBack = AtomicBoolean() + + show( + state = ShortsState(videos = listOf(video("one")), isLoading = false), + onNavigateBack = { navigatedBack.set(true) }, + ) + + composeRule.onNodeWithContentDescription("Back").performClick() + assertTrue(navigatedBack.get()) + } + private fun show( state: ShortsState, onPlayVideo: (String) -> Unit = {}, + onNavigateBack: () -> Unit = {}, + onOpenChannel: (String) -> Unit = {}, embeddedPlaybackEnabled: Boolean = false, onActiveVideoChanged: (Video?) -> Unit = {}, onUpcomingVideosChanged: suspend (List