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
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>): List<VideoEntity>
suspend fun videosWithIds(ids: List<String>): List<VideoEntity>

suspend fun getVideosByIds(ids: List<String>): List<VideoEntity> = chunkedLookup(ids) { videosWithIds(it) }

@Query("SELECT * FROM videos")
suspend fun getAllVideos(): List<VideoEntity>
}

/** 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 <T> chunkedLookup(
ids: List<String>,
query: suspend (List<String>) -> List<T>,
): List<T> = ids.distinct().chunked(MAX_IDS_PER_QUERY).flatMap { query(it) }
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -123,6 +129,32 @@ internal fun SearchResultsPage.toResultItems(shortsEnabled: Boolean): List<Searc
return items
}

/**
* The Shorts filter's results arrive as Shorts strips (#1425), but the Shorts grid lays out single
* Shorts, so every Short becomes a result of its own and anything that is not a Short is left out.
* Keyed by video, a later page's Shorts also survive de-duplication: its strips reuse the first
* page's position-based ids.
*/
internal fun SearchResultsPage.toShortsResults(shortsEnabled: Boolean): List<SearchResultItem> {
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
28 changes: 28 additions & 0 deletions app/src/main/java/io/github/aedev/flow/utils/ShareVideo.kt
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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<List<String>>()

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<List<String>>()

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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -396,6 +432,16 @@ class SearchPagingSourceTest {
assertThat(source(RecordingLoader(listOf(SearchFixture.ALL_SAM_SULEK))).getRefreshKey(mockState())).isNull()
}

private fun List<SearchResultItem>.videoIds() = filterIsInstance<SearchResultItem.VideoResult>().map { it.video.id }

private fun JsonObject.stripShortIds() =
toSearchResultsPage()
.sections
.filterIsInstance<SearchSection.Strip>()
.filter { it.shelf.style == FeedShelfStyle.Grid }
.flatMap { strip -> strip.shelf.items.mapNotNull { it.videoOrNull()?.id } }
.distinct()

private fun mockState() = androidx.paging.PagingState<String, SearchResultItem>(emptyList(), null, PAGING_CONFIG, 0)

private companion object {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading