Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions .github/workflows/android-beta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ jobs:

- name: Verify and prepare beta APK
run: |
export PATH="$JAVA_HOME/bin:$PATH"
mkdir -p artifacts
apk="$(find app/build/outputs/apk/release -name '*.apk' | head -n 1)"
test -n "$apk"
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ jobs:

- name: Prepare APK artifact
run: |
export PATH="$JAVA_HOME/bin:$PATH"
mkdir -p artifacts
apk="$(find app/build/outputs/apk/release -name '*.apk' | head -n 1)"
test -n "$apk"
Expand Down
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 = 10822
versionName = "1.8.1-beta.1"
versionCode = 10823
versionName = "1.8.1-beta.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
resValue("string", "app_name", "TypeType")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.typetype.android.R
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeTrue
import org.junit.Rule
import org.junit.Test

Expand All @@ -24,6 +25,10 @@ class PlayerInteractionRowComposeTest {

@Test
fun tabletShowsLabelsAndLargerTouchTargets() {
assumeTrue(
"tablet interaction row needs a 600dp wide device",
composeRule.activity.resources.configuration.smallestScreenWidthDp >= 600,
)
var commentClicks = 0
setActions(720.dp) { commentClicks += 1 }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class CommentBodyTest {

@Test
fun longCommentCollapsesAndExpandsWithReadMore() {
val suffix = " more comment detail".repeat(60)
val suffix = " more comment detail".repeat(30)
val comment = Comment(
id = "comment",
text = "Long comment starts here$suffix",
Expand All @@ -47,7 +47,9 @@ class CommentBodyTest {
}

composeRule.onNodeWithText("Read more").assertExists().performClick()
composeRule.waitForIdle()
composeRule.onNodeWithText("Show less").assertExists().performClick()
composeRule.waitForIdle()
composeRule.onNodeWithText("Read more").assertExists()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import java.lang.reflect.Proxy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
Expand Down Expand Up @@ -164,6 +165,10 @@ class PlayerControlsLayoutTest {

@Test
fun tabletControlsHaveLargerTargetsWithoutOverlapping() {
assumeTrue(
"tablet player controls need a 600dp wide device",
composeRule.activity.resources.configuration.smallestScreenWidthDp >= 600,
)
setControls(720.dp, 405.dp, 720)
assertControlsDoNotOverlap()
composeRule.onNodeWithTag(PLAYER_CENTER_CONTROLS_TAG)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToIndex
import androidx.compose.ui.test.performScrollToNode
import dev.typetype.android.core.ui.theme.TypeTypeTheme
import dev.typetype.android.domain.preferences.AccentColor
import dev.typetype.android.domain.preferences.AppPreferences
Expand Down Expand Up @@ -109,8 +110,8 @@ class AppearanceScreenTest {
}
}

composeRule.onAllNodes(hasScrollAction())[0].performScrollToIndex(11)
composeRule.onNode(isSelectable() and hasText("Red"))
.assertIsEnabled()
val accentCell = isSelectable() and hasText("Red")
composeRule.onAllNodes(hasScrollAction())[0].performScrollToNode(accentCell)
composeRule.onNode(accentCell).assertIsEnabled()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ class ImportDataScreenTest {
fun restoreRequiresASelectedArchive() {
setScreen(ImportDataState())

composeRule.onNode(hasScrollAction())
.performScrollToNode(hasText("Restore backup"))
composeRule.onNodeWithText("Restore backup").assertIsNotEnabled()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ class PlayerSettingsCaptionStyleTest {
}
}

composeRule.onNode(hasScrollAction())
.performScrollToNode(hasText("Show advanced player settings"))
composeRule.onNodeWithText("Show advanced player settings").performClick()
composeRule.onNode(hasScrollAction())
.performScrollToNode(hasText("Text size"))
composeRule.onNodeWithText("Text size").performClick()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ class PlayerSettingsDanmakuTest {
}
}

composeRule.onNode(hasScrollAction())
.performScrollToNode(hasText("Show advanced player settings"))
composeRule.onNodeWithText("Show advanced player settings").performClick()
composeRule.onNode(hasScrollAction())
.performScrollToNode(hasText("Show bullet comments"))
composeRule.onNodeWithText("Show bullet comments")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ class PlaybackBackgroundTransitionTest {
}

private fun waitForMainThread(condition: () -> Boolean): Boolean {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10)
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30)
while (System.nanoTime() < deadline) {
if (readOnMainThread(condition)) return true
Thread.sleep(50)
Expand Down
9 changes: 8 additions & 1 deletion app/src/main/java/dev/typetype/android/AppNavHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import dev.typetype.android.core.ui.navigation.PrivacySettingsRoute
import dev.typetype.android.core.ui.navigation.PublicPlaylistRoute
import dev.typetype.android.core.ui.navigation.ProfileSettingsRoute
import dev.typetype.android.core.ui.share.LocalServerBaseUrl
import dev.typetype.android.feature.player.LocalPlaybackPrewarm
import dev.typetype.android.feature.player.PlaybackPrewarm
import dev.typetype.android.core.ui.navigation.AppearanceRoute
import dev.typetype.android.core.ui.navigation.ContentSettingsRoute
import dev.typetype.android.core.ui.navigation.HomeRoute
Expand Down Expand Up @@ -61,7 +63,11 @@ import dev.typetype.android.feature.subscriptions.SubscriptionsRoute as Subscrip
import kotlinx.coroutines.flow.collectLatest

@Composable
fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) {
fun AppNavHost(
startRoute: Any,
mainViewModel: MainViewModel,
playbackPrewarm: PlaybackPrewarm? = null,
) {
val navController: NavHostController = rememberNavController()
val playerHostController = remember { mainViewModel.playerHostController }
val currentServer by mainViewModel.currentServer.collectAsStateWithLifecycle()
Expand Down Expand Up @@ -133,6 +139,7 @@ fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) {
CompositionLocalProvider(
LocalServerBaseUrl provides currentServer?.baseUrl,
LocalDeArrowBranding provides deArrowEnvironment,
LocalPlaybackPrewarm provides playbackPrewarm,
) {
AppShell(
navController = navController,
Expand Down
10 changes: 9 additions & 1 deletion app/src/main/java/dev/typetype/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import dev.typetype.android.domain.navigation.resolveIncomingVideoUrl
import dev.typetype.android.domain.navigation.resolveSharedVideoUrl
import dev.typetype.android.domain.session.ActiveSessionRepository
import dev.typetype.android.feature.player.components.PIP_ACTION_AUDIO_ONLY
import dev.typetype.android.feature.player.PlaybackPrewarm
import dev.typetype.android.feature.player.components.PIP_ACTION_PLAY_PAUSE
import dev.typetype.android.feature.player.components.PictureInPictureActionStateOwner
import dev.typetype.android.feature.player.components.updatePictureInPicturePlaybackAction
Expand All @@ -56,6 +57,9 @@ class MainActivity : ComponentActivity(), PictureInPictureActionStateOwner {
@Inject
lateinit var oidcCallbackRelay: OidcCallbackRelay

@Inject
lateinit var playbackPrewarm: PlaybackPrewarm

private val viewModel: MainViewModel by viewModels()
private var activityReportingJob: Job? = null
private var pipAudioOnlyAvailable = false
Expand Down Expand Up @@ -105,7 +109,11 @@ class MainActivity : ComponentActivity(), PictureInPictureActionStateOwner {
report = pendingCrashReport,
onContinue = viewModel::continueAfterCrash,
)
startRoute != null -> AppNavHost(startRoute = startRoute, mainViewModel = viewModel)
startRoute != null -> AppNavHost(
startRoute = startRoute,
mainViewModel = viewModel,
playbackPrewarm = playbackPrewarm,
)
else -> FullScreenLoader()
}
}
Expand Down
11 changes: 9 additions & 2 deletions app/src/main/java/dev/typetype/android/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import dev.typetype.android.domain.subscriptions.SubscriptionsRepository
import dev.typetype.android.domain.usersettings.UserSettings
import dev.typetype.android.domain.usersettings.UserSettingsRepository
import dev.typetype.android.feature.player.host.PlayerHostController
import dev.typetype.android.data.notifications.LocalNotificationScheduler
import dev.typetype.android.services.push.PushRegistrationManager
import javax.inject.Inject
import kotlinx.coroutines.channels.Channel
Expand Down Expand Up @@ -65,6 +66,7 @@ class MainViewModel @Inject constructor(
private val libraryRepository: LibraryRepository,
private val activeAccountScope: ActiveAccountScope,
private val pushRegistrationManager: PushRegistrationManager,
private val localNotificationScheduler: LocalNotificationScheduler,
private val startupLandingStore: StartupLandingStore,
private val playbackResumeRepository: PlaybackResumeRepository,
private val playbackQueueRepository: PlaybackQueueRepository,
Expand Down Expand Up @@ -184,10 +186,15 @@ class MainViewModel @Inject constructor(
launch { profileRepository.refresh() }
launch { subscriptionsRepository.refresh() }
launch { libraryRepository.resumePendingWrites() }
launch { pushRegistrationManager.reconcileRegistration() }
launch { reconcileNotificationDelivery() }
}
}

private suspend fun reconcileNotificationDelivery() {
pushRegistrationManager.reconcileRegistration()
localNotificationScheduler.sync()
}

private companion object {
const val STARTUP_TIMEOUT_MS = 4_000L
const val SESSION_VALIDATION_TIMEOUT_MS = 6_000L
Expand Down Expand Up @@ -224,7 +231,7 @@ class MainViewModel @Inject constructor(
launch { profileRepository.refresh() }
launch { subscriptionsRepository.refresh() }
launch { libraryRepository.resumePendingWrites() }
launch { pushRegistrationManager.reconcileRegistration() }
launch { reconcileNotificationDelivery() }
launch {
restorePlaybackUnlessExternalRequestArrives()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import dev.typetype.android.data.imports.RemotePortabilityRepository
import dev.typetype.android.data.imports.RoomYoutubeTakeoutImportRepository
import dev.typetype.android.data.library.RoomVideoMetaRepository
import dev.typetype.android.data.notifications.RemoteNotificationsRepository
import dev.typetype.android.feature.player.PlaybackPrewarm
import dev.typetype.android.feature.player.PlaybackPrewarmLauncher
import dev.typetype.android.data.network.NetworkAvailabilityObserver
import dev.typetype.android.data.network.PlaybackNetworkMonitor
import dev.typetype.android.data.network.PlaybackNetworkObserver
Expand Down Expand Up @@ -179,6 +181,10 @@ abstract class RepositoryModule {
@Singleton
abstract fun bindPushRepository(impl: RemotePushRepository): PushRepository

@Binds
@Singleton
internal abstract fun bindPlaybackPrewarm(impl: PlaybackPrewarmLauncher): PlaybackPrewarm

@Binds
@Singleton
internal abstract fun bindStreamRepository(impl: StreamRepositoryImpl): StreamRepository
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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
Expand All @@ -41,6 +42,9 @@ import dev.typetype.android.domain.feed.Video
import dev.typetype.android.domain.feed.VideoAvailability
import dev.typetype.android.domain.feed.availabilityAt
import dev.typetype.android.domain.feed.releaseTimeMillis
import dev.typetype.android.feature.player.CARD_PREWARM_DELAY_MILLIS
import dev.typetype.android.feature.player.LocalPlaybackPrewarm
import kotlinx.coroutines.delay

@OptIn(ExperimentalFoundationApi::class)
@Composable
Expand All @@ -55,6 +59,14 @@ fun RelatedVideoCard(
var menuVisible by remember { mutableStateOf(false) }
var availabilityVisible by remember { mutableStateOf(false) }
val serverBaseUrl = LocalServerBaseUrl.current
val playbackPrewarm = LocalPlaybackPrewarm.current

LaunchedEffect(video.url, playbackPrewarm) {
if (playbackPrewarm == null) return@LaunchedEffect
delay(CARD_PREWARM_DELAY_MILLIS)
playbackPrewarm.prewarm(video.url, video.isLive)
}

val availability = video.availabilityAt(System.currentTimeMillis())
val branding = rememberVideoBranding(
sourceUrl = video.url,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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
Expand All @@ -42,6 +43,7 @@ import androidx.compose.ui.platform.LocalContext
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import kotlinx.coroutines.delay
import dev.typetype.android.R
import dev.typetype.android.core.ui.branding.rememberVideoBranding
import dev.typetype.android.core.ui.share.LocalServerBaseUrl
Expand All @@ -50,6 +52,8 @@ import dev.typetype.android.domain.feed.Video
import dev.typetype.android.domain.feed.VideoAvailability
import dev.typetype.android.domain.feed.availabilityAt
import dev.typetype.android.domain.feed.releaseTimeMillis
import dev.typetype.android.feature.player.CARD_PREWARM_DELAY_MILLIS
import dev.typetype.android.feature.player.LocalPlaybackPrewarm

@OptIn(ExperimentalFoundationApi::class)
@Composable
Expand All @@ -73,6 +77,13 @@ fun VideoCard(
durationSeconds = video.durationSeconds,
)
val openDescription = stringResource(R.string.video_open_accessibility, branding.title)
val playbackPrewarm = LocalPlaybackPrewarm.current

LaunchedEffect(video.url, playbackPrewarm) {
if (playbackPrewarm == null) return@LaunchedEffect
delay(CARD_PREWARM_DELAY_MILLIS)
playbackPrewarm.prewarm(video.url, video.isLive)
}

Column(
modifier = modifier
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
package dev.typetype.android.core.ui.share

import androidx.compose.runtime.compositionLocalOf
import dev.typetype.android.core.url.percentEncode
import dev.typetype.android.domain.navigation.resolveIncomingVideoUrl
import dev.typetype.android.domain.navigation.toPublicWatchParameter
import java.net.URI
import java.net.URLEncoder
import java.nio.charset.StandardCharsets

val LocalServerBaseUrl = compositionLocalOf<String?> { null }

Expand All @@ -16,7 +15,7 @@ fun buildShareUrl(serverBaseUrl: String?, videoUrl: String): String {
.removeSuffix("/api")
.trimEnd('/')
if (origin.isBlank()) return videoUrl
val encoded = URLEncoder.encode(toPublicWatchParameter(videoUrl), StandardCharsets.UTF_8.toString())
val encoded = percentEncode(toPublicWatchParameter(videoUrl))
return "$origin/watch?v=$encoded"
}

Expand Down Expand Up @@ -77,7 +76,7 @@ fun buildImageUrl(serverBaseUrl: String?, imageUrl: String): String {
if (!source.startsWith("http://") && !source.startsWith("https://")) return source
if (!needsImageProxy(source)) return source

val encoded = URLEncoder.encode(source, StandardCharsets.UTF_8.toString())
val encoded = percentEncode(source)
return "$base/proxy?url=$encoded"
}

Expand Down
22 changes: 22 additions & 0 deletions app/src/main/java/dev/typetype/android/core/url/PercentEncoding.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package dev.typetype.android.core.url

private const val HEX = "0123456789ABCDEF"

internal fun percentEncode(value: String): String {
val bytes = value.toByteArray(Charsets.UTF_8)
val encoded = StringBuilder(bytes.size)
for (byte in bytes) {
val code = byte.toInt() and 0xff
val character = code.toChar()
if (character in 'A'..'Z' || character in 'a'..'z' || character in '0'..'9' ||
character == '-' || character == '_' || character == '.' || character == '~'
) {
encoded.append(character)
} else {
encoded.append('%')
.append(HEX[(code shr 4) and 0x0f])
.append(HEX[code and 0x0f])
}
}
return encoded.toString()
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ interface TypeTypeMediaApi {
suspend fun createSabrPlayback(
@Path("videoId") videoId: String,
@Body body: SabrPlaybackRequest,
@Query("prewarm") prewarm: Boolean? = null,
): Response<SabrPlaybackResponse>

@POST("sabr/playback/{sessionId}/seek")
Expand Down
Loading
Loading