diff --git a/.github/nightly-notes/2026-10-24-fix-history-chunks-shorts-filter-rotation-youtube-app.md b/.github/nightly-notes/2026-10-24-fix-history-chunks-shorts-filter-rotation-youtube-app.md new file mode 100644 index 0000000000..7bd81d2790 --- /dev/null +++ b/.github/nightly-notes/2026-10-24-fix-history-chunks-shorts-filter-rotation-youtube-app.md @@ -0,0 +1,5 @@ +## Fixed +- History no longer crashes after a large Google Takeout import on Android 10 and older (#1424) +- The Shorts search filter shows Shorts again instead of an empty page (#1425) +- Turning the phone upside down during a fullscreen video no longer flips the app upside down (#1426) +- Open in YouTube now opens the YouTube app even when Flow opens your YouTube links (#1420) diff --git a/app/src/main/java/io/github/aedev/flow/data/local/dao/VideoDao.kt b/app/src/main/java/io/github/aedev/flow/data/local/dao/VideoDao.kt index 9530955faf..e1a640ba5c 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/dao/VideoDao.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/dao/VideoDao.kt @@ -122,8 +122,18 @@ interface VideoDao { suspend fun getVideo(id: String): VideoEntity? @Query("SELECT * FROM videos WHERE id IN (:ids)") - suspend fun getVideosByIds(ids: List): List + suspend fun videosWithIds(ids: List): List + + suspend fun getVideosByIds(ids: List): List = chunkedLookup(ids) { videosWithIds(it) } @Query("SELECT * FROM videos") suspend fun getAllVideos(): List } + +/** SQLite before Android 11 binds at most 999 variables per statement, so one IN list stays well under it. */ +internal const val MAX_IDS_PER_QUERY = 500 + +internal suspend fun chunkedLookup( + ids: List, + query: suspend (List) -> List, +): List = ids.distinct().chunked(MAX_IDS_PER_QUERY).flatMap { query(it) } diff --git a/app/src/main/java/io/github/aedev/flow/data/paging/SearchPagingSource.kt b/app/src/main/java/io/github/aedev/flow/data/paging/SearchPagingSource.kt index c088723e6f..ccf87c958e 100644 --- a/app/src/main/java/io/github/aedev/flow/data/paging/SearchPagingSource.kt +++ b/app/src/main/java/io/github/aedev/flow/data/paging/SearchPagingSource.kt @@ -2,6 +2,7 @@ package io.github.aedev.flow.data.paging import androidx.paging.PagingSource import androidx.paging.PagingState +import io.github.aedev.flow.data.local.ContentType import io.github.aedev.flow.data.local.SearchFilter import io.github.aedev.flow.data.local.WatchState import io.github.aedev.flow.data.model.DistinctKeyTracker @@ -52,7 +53,12 @@ class SearchPagingSource( val page = loadPage(query, filter.toSearchParams(), continuation) if (params.key == null && requests == 0) onHeader(page.header) requests++ - val items = page.toResultItems(shortsEnabled).withoutHidden(hidden) + val items = + if (filter.contentType == ContentType.SHORTS) { + page.toShortsResults(shortsEnabled) + } else { + page.toResultItems(shortsEnabled) + }.withoutHidden(hidden) results += loadedItemKeys.filter(items.watchFiltered()) { it.identityKey() } continuation = page.continuation @@ -123,6 +129,32 @@ internal fun SearchResultsPage.toResultItems(shortsEnabled: Boolean): List { + if (!shortsEnabled) return emptyList() + return sections + .flatMap { section -> + when (section) { + is SearchSection.Result -> { + listOfNotNull(section.item.videoOrNull()) + } + + is SearchSection.Strip -> { + section.shelf + .takeIf { it.style == FeedShelfStyle.Grid } + ?.items + .orEmpty() + .mapNotNull { it.videoOrNull() } + } + } + }.map { SearchResultItem.VideoResult(it) } +} + /** * Drops everything a blocked creator put in the results, the way the home feed already drops them * before ranking: their own card, their videos, and their videos inside a strip. A video marked not diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/player/content/PlayerErrorPanel.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/player/content/PlayerErrorPanel.kt index 2d1fd703b3..fff604345e 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/player/content/PlayerErrorPanel.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/player/content/PlayerErrorPanel.kt @@ -1,8 +1,6 @@ package io.github.aedev.flow.ui.screens.player.content import android.content.Context -import android.content.Intent -import android.net.Uri import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -34,6 +32,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import io.github.aedev.flow.R import io.github.aedev.flow.player.error.PlayerDiagnostics +import io.github.aedev.flow.utils.openInYouTube private val ButtonPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp) @@ -121,14 +120,7 @@ internal fun PlayerErrorPanel( } // Row 2: Open in YouTube (full width) OutlinedButton( - onClick = { - val intent = - Intent( - Intent.ACTION_VIEW, - Uri.parse("https://www.youtube.com/watch?v=$videoId"), - ) - context.startActivity(intent) - }, + onClick = { openInYouTube(context, videoId) }, shape = MaterialTheme.shapes.small, contentPadding = ButtonPadding, modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/player/effects/PlayerWindowEffects.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/player/effects/PlayerWindowEffects.kt index a6a5cec3c9..85cb508ea1 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/player/effects/PlayerWindowEffects.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/player/effects/PlayerWindowEffects.kt @@ -215,12 +215,7 @@ internal fun OrientationListenerEffect( if (!autoRotateOn) return - val newOrientation = - when { - orientation in 60..120 || orientation in 240..300 -> 1 - orientation in 0..30 || orientation in 330..359 || orientation in 150..210 -> 0 - else -> physicalOrientation - } + val newOrientation = heldOrientation(orientation, physicalOrientation) if (newOrientation != physicalOrientation) { physicalOrientation = newOrientation @@ -252,3 +247,18 @@ internal fun OrientationListenerEffect( } } } + +/** + * How the phone is held, from [OrientationEventListener] degrees: 0 upright, 1 on either side, and + * [previous] in between. Upside down also keeps [previous]: counted as portrait, it left fullscreen + * and pinned normal portrait while the phone was inverted, so the picture showed upside down (#1426). + */ +internal fun heldOrientation( + degrees: Int, + previous: Int, +): Int = + when (degrees) { + in 60..120, in 240..300 -> 1 + in 0..30, in 330..359 -> 0 + else -> previous + } diff --git a/app/src/main/java/io/github/aedev/flow/utils/ShareVideo.kt b/app/src/main/java/io/github/aedev/flow/utils/ShareVideo.kt index a250cfb4d5..0c769fccfd 100644 --- a/app/src/main/java/io/github/aedev/flow/utils/ShareVideo.kt +++ b/app/src/main/java/io/github/aedev/flow/utils/ShareVideo.kt @@ -1,10 +1,12 @@ package io.github.aedev.flow.utils +import android.content.ActivityNotFoundException import android.content.ClipData import android.content.ComponentName import android.content.Context import android.content.Intent import android.net.Uri +import android.widget.Toast import io.github.aedev.flow.MainActivity import io.github.aedev.flow.R @@ -19,6 +21,32 @@ fun youtubeWatchUrl( return if (positionSeconds == null) watchUrl else "$watchUrl&t=${positionSeconds}s" } +/** + * Opens the video in the YouTube app. A bare link comes back to Flow wherever Flow is the YouTube + * link handler (#1420); without the app, the chooser offers every other app that can open it. + */ +fun openInYouTube( + context: Context, + videoId: String, +) { + val view = Intent(Intent.ACTION_VIEW, Uri.parse(youtubeWatchUrl(videoId))) + try { + context.startActivity(Intent(view).setPackage(YOUTUBE_PACKAGE)) + } catch (_: ActivityNotFoundException) { + val chooser = + Intent.createChooser(view, context.getString(R.string.ui_open_in_youtube)).apply { + putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(ComponentName(context, MainActivity::class.java))) + } + try { + context.startActivity(chooser) + } catch (_: ActivityNotFoundException) { + Toast.makeText(context, context.getString(R.string.link_cannot_open), Toast.LENGTH_SHORT).show() + } + } +} + +private const val YOUTUBE_PACKAGE = "com.google.android.youtube" + fun youtubeShortsUrl(videoId: String): String = "https://youtube.com/shorts/$videoId" fun youtubeMusicWatchUrl(videoId: String): String = "https://music.youtube.com/watch?v=$videoId" diff --git a/app/src/test/java/io/github/aedev/flow/data/local/dao/VideoLookupChunkingTest.kt b/app/src/test/java/io/github/aedev/flow/data/local/dao/VideoLookupChunkingTest.kt new file mode 100644 index 0000000000..7b48e7c9ac --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/data/local/dao/VideoLookupChunkingTest.kt @@ -0,0 +1,42 @@ +package io.github.aedev.flow.data.local.dao + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** Guards #1424: a History lookup after a large import passed SQLite's 999-variable limit. */ +class VideoLookupChunkingTest { + @Test + fun `a long id list is split into queries that stay under the variable limit`() = + runTest { + val ids = (1..2_500).map { "video$it" } + val calls = mutableListOf>() + + val rows = chunkedLookup(ids) { chunk -> chunk.also { calls += it } } + + assertThat(calls).hasSize(5) + assertThat(calls.map { it.size }.max()).isAtMost(MAX_IDS_PER_QUERY) + assertThat(rows).containsExactlyElementsIn(ids).inOrder() + } + + @Test + fun `a repeated id is asked for once`() = + runTest { + val calls = mutableListOf>() + + chunkedLookup(listOf("a", "b", "a", "b", "c")) { chunk -> chunk.also { calls += it } } + + assertThat(calls).containsExactly(listOf("a", "b", "c")) + } + + @Test + fun `no ids sends no query`() = + runTest { + var called = false + + val rows = chunkedLookup(emptyList()) { chunk -> chunk.also { called = true } } + + assertThat(called).isFalse() + assertThat(rows).isEmpty() + } +} diff --git a/app/src/test/java/io/github/aedev/flow/data/paging/SearchPagingSourceTest.kt b/app/src/test/java/io/github/aedev/flow/data/paging/SearchPagingSourceTest.kt index 355e137de9..e548add8c1 100644 --- a/app/src/test/java/io/github/aedev/flow/data/paging/SearchPagingSourceTest.kt +++ b/app/src/test/java/io/github/aedev/flow/data/paging/SearchPagingSourceTest.kt @@ -10,9 +10,15 @@ import io.github.aedev.flow.data.local.UploadDate import io.github.aedev.flow.data.local.WatchState import io.github.aedev.flow.data.recommendation.FeedExclusions import io.github.aedev.flow.innertube.pages.SearchFixture +import io.github.aedev.flow.innertube.pages.renderer.FeedShelfStyle +import io.github.aedev.flow.innertube.pages.renderer.videoOrNull import io.github.aedev.flow.innertube.pages.search.SearchHeader +import io.github.aedev.flow.innertube.pages.search.SearchSection import io.github.aedev.flow.innertube.pages.search.toSearchResultsPage import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject import org.junit.Test class SearchPagingSourceTest { @@ -154,6 +160,36 @@ class SearchPagingSourceTest { assertThat(loader.calls.single().second).isEqualTo("EgIQCQ%3D%3D") } + @Test + fun `the shorts filter lays its shorts strips out as single shorts`() = + runTest { + val page = source(RecordingLoader(listOf(SearchFixture.TYPE_SHORTS)), SearchFilter(contentType = ContentType.SHORTS)).loadPage() + + assertThat(page.data).isNotEmpty() + assertThat(page.data.all { it is SearchResultItem.VideoResult && it.video.isShort }).isTrue() + assertThat(page.data.videoIds()).containsAtLeastElementsIn(SearchFixture(SearchFixture.TYPE_SHORTS).stripShortIds()) + } + + @Test + fun `a later shorts filter page keeps its shorts although its strips reuse the first page's ids`() = + runTest { + val first = SearchFixture(SearchFixture.TYPE_SHORTS) + val ids = first.stripShortIds() + val second = Json.parseToJsonElement(ids.fold(first.toString()) { text, id -> text.replace(id, id.reversed()) }).jsonObject + val pages = mutableListOf(first, second) + val paging = + SearchPagingSource( + query = "sam sulek", + filter = SearchFilter(contentType = ContentType.SHORTS), + loadPage = SearchPageLoader { _, _, _ -> pages.removeAt(0).toSearchResultsPage() }, + ) + + val one = paging.loadPage() + val two = paging.loadPage(one.nextKey) + + assertThat(two.data.videoIds()).containsAtLeastElementsIn(ids.map { it.reversed() }) + } + @Test fun `builds one token for a combination of filters`() = runTest { @@ -396,6 +432,16 @@ class SearchPagingSourceTest { assertThat(source(RecordingLoader(listOf(SearchFixture.ALL_SAM_SULEK))).getRefreshKey(mockState())).isNull() } + private fun List.videoIds() = filterIsInstance().map { it.video.id } + + private fun JsonObject.stripShortIds() = + toSearchResultsPage() + .sections + .filterIsInstance() + .filter { it.shelf.style == FeedShelfStyle.Grid } + .flatMap { strip -> strip.shelf.items.mapNotNull { it.videoOrNull()?.id } } + .distinct() + private fun mockState() = androidx.paging.PagingState(emptyList(), null, PAGING_CONFIG, 0) private companion object { diff --git a/app/src/test/java/io/github/aedev/flow/ui/screens/player/effects/HeldOrientationTest.kt b/app/src/test/java/io/github/aedev/flow/ui/screens/player/effects/HeldOrientationTest.kt new file mode 100644 index 0000000000..3399c6aa17 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/ui/screens/player/effects/HeldOrientationTest.kt @@ -0,0 +1,33 @@ +package io.github.aedev.flow.ui.screens.player.effects + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class HeldOrientationTest { + @Test + fun `upright is portrait and either side is landscape`() { + assertThat(heldOrientation(0, previous = 1)).isEqualTo(0) + assertThat(heldOrientation(345, previous = 1)).isEqualTo(0) + assertThat(heldOrientation(90, previous = 0)).isEqualTo(1) + assertThat(heldOrientation(270, previous = 0)).isEqualTo(1) + } + + @Test + fun `upside down keeps fullscreen landscape`() { + assertThat(heldOrientation(180, previous = 1)).isEqualTo(1) + assertThat(heldOrientation(150, previous = 1)).isEqualTo(1) + assertThat(heldOrientation(210, previous = 1)).isEqualTo(1) + } + + @Test + fun `upside down from portrait stays portrait`() { + assertThat(heldOrientation(180, previous = 0)).isEqualTo(0) + } + + @Test + fun `angles between the bands keep the last reading`() { + assertThat(heldOrientation(31, previous = 1)).isEqualTo(1) + assertThat(heldOrientation(45, previous = 0)).isEqualTo(0) + assertThat(heldOrientation(149, previous = -1)).isEqualTo(-1) + } +}