From b463d68e0479b0d850a2188df2fbb024384b35cb Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:20:07 +0300 Subject: [PATCH 1/3] fix(crash): name the wrapped cause in the issue title --- .../flow/ui/screens/crash/CrashSummary.kt | 18 ++++++++-- .../flow/ui/screens/crash/CrashSummaryTest.kt | 34 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/crash/CrashSummary.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/crash/CrashSummary.kt index 3527da779..00642df4a 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/crash/CrashSummary.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/crash/CrashSummary.kt @@ -12,6 +12,7 @@ private const val UNKNOWN_STEPS = "Not known. Flow crashed during normal use; th private const val EXPECTED = "Flow keeps running." private const val TOP_FRAMES = 6 private val ReportTime: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS") +private val WrappedCause = Regex("""([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+(?:Exception|Error))(?::.*)?""", RegexOption.DOT_MATCHES_ALL) /** The facts a person needs from a crash report, read back from the text the crash handler saved. */ data class CrashSummary( @@ -23,7 +24,20 @@ data class CrashSummary( val device: String?, val time: LocalDateTime?, val topFrames: List, -) +) { + /** + * The class a title names. Android rethrows some failures as `RuntimeException(cause)`, whose + * message is the cause, and the cause is what tells two such crashes apart. + */ + val headline: String + get() = + message + ?.let(WrappedCause::matchEntire) + ?.groupValues + ?.get(1) + ?.substringAfterLast('.') + ?: exception +} fun parseCrashSummary(report: String): CrashSummary { val lines = report.lines().map { it.trim() } @@ -61,7 +75,7 @@ fun parseCrashSummary(report: String): CrashSummary { * happened can still send it in one tap and edit only what they know (#1189). */ fun CrashSummary.issueUrl(): String { - val title = "[Bug]: $exception" + (location?.let { " in $it" } ?: "") + val title = "[Bug]: $headline" + (location?.let { " in $it" } ?: "") val error = exception + (message?.let { ": $it" } ?: "") val log = buildString { diff --git a/app/src/test/java/io/github/aedev/flow/ui/screens/crash/CrashSummaryTest.kt b/app/src/test/java/io/github/aedev/flow/ui/screens/crash/CrashSummaryTest.kt index d971e4b23..c2f87e270 100644 --- a/app/src/test/java/io/github/aedev/flow/ui/screens/crash/CrashSummaryTest.kt +++ b/app/src/test/java/io/github/aedev/flow/ui/screens/crash/CrashSummaryTest.kt @@ -68,4 +68,38 @@ class CrashSummaryTest { assertThat(summary.exception).isEqualTo("Crash") assertThat(summary.location).isNull() } + + @Test + fun `a wrapped cause names the title, as with a saved state too large to send (1338)`() { + val wrapped = + FlowCrashReportFormatter.build( + FlowCrashReportSnapshot( + timestampMs = 0, + threadName = "main", + threadId = 2, + exceptionClass = "java.lang.RuntimeException", + exceptionMessage = "android.os.TransactionTooLargeException: data parcel size 1052744 bytes", + stackTrace = + """ + java.lang.RuntimeException: android.os.TransactionTooLargeException: data parcel size 1052744 bytes + at android.app.servertransaction.PendingTransactionActions${'$'}StopInfo.run(PendingTransactionActions.java:146) + """.trimIndent(), + deviceInfo = " Model: Pixel 8\n Manufacturer: Google", + memoryInfo = "", + breadcrumbs = emptyList(), + ), + ) + val summary = parseCrashSummary(wrapped) + + assertThat(summary.exception).isEqualTo("RuntimeException") + assertThat(summary.headline).isEqualTo("TransactionTooLargeException") + assertThat( + summary.issueUrl(), + ).contains("title=%5BBug%5D%3A%20TransactionTooLargeException%20in%20PendingTransactionActions.java%3A146") + } + + @Test + fun `a plain message keeps the exception's own name`() { + assertThat(parseCrashSummary(report).headline).isEqualTo("IllegalArgumentException") + } } From a586c12d899adb75b08ff127927f25d487c102b2 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:39:30 +0300 Subject: [PATCH 2/3] fix(navigation): return to a page already in the back stack instead of stacking it again --- .../java/io/github/aedev/flow/ui/AppHooks.kt | 2 +- .../github/aedev/flow/ui/ChannelNavigation.kt | 15 +---- .../github/aedev/flow/ui/MediaNavigation.kt | 55 +++++++++++-------- .../aedev/flow/ui/MediaNavigationTest.kt | 10 ---- .../io/github/aedev/flow/ui/PageOpenTest.kt | 21 +++++++ 5 files changed, 56 insertions(+), 47 deletions(-) create mode 100644 app/src/test/java/io/github/aedev/flow/ui/PageOpenTest.kt diff --git a/app/src/main/java/io/github/aedev/flow/ui/AppHooks.kt b/app/src/main/java/io/github/aedev/flow/ui/AppHooks.kt index 72dbf565f..ff0fb250f 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/AppHooks.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/AppHooks.kt @@ -43,7 +43,7 @@ fun HandlePendingRoute( val tab = flowTabForRoute(route) when { tab != null -> navController.navigateToTab(tab, startRoute) - navController.currentBackStackEntry?.destination?.route != route -> navController.navigate(route) + else -> navController.openPage(route) } onConsumed() } diff --git a/app/src/main/java/io/github/aedev/flow/ui/ChannelNavigation.kt b/app/src/main/java/io/github/aedev/flow/ui/ChannelNavigation.kt index aae214e0d..031bcb7f4 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/ChannelNavigation.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/ChannelNavigation.kt @@ -1,20 +1,7 @@ package io.github.aedev.flow.ui import androidx.navigation.NavHostController -import java.net.URLDecoder internal fun NavHostController.navigateToYoutubeChannel(channelIdOrHandle: String) { - val targetUrl = youtubeChannelUrl(channelIdOrHandle) ?: return - val currentUrl = currentBackStackEntry - ?.takeIf { it.destination.route == "channel?url={channelUrl}" } - ?.arguments - ?.getString("channelUrl") - ?.let { encodedUrl -> - runCatching { URLDecoder.decode(encodedUrl, Charsets.UTF_8.name()) } - .getOrDefault(encodedUrl) - } - ?.let(::youtubeChannelUrl) - - if (currentUrl == targetUrl) return - youtubeChannelRoute(targetUrl)?.let(::navigate) + youtubeChannelRoute(channelIdOrHandle)?.let(::openPage) } diff --git a/app/src/main/java/io/github/aedev/flow/ui/MediaNavigation.kt b/app/src/main/java/io/github/aedev/flow/ui/MediaNavigation.kt index 196b476bd..0d7378767 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/MediaNavigation.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/MediaNavigation.kt @@ -1,15 +1,14 @@ package io.github.aedev.flow.ui import android.net.Uri +import androidx.navigation.NavController import androidx.navigation.NavHostController import io.github.aedev.flow.data.shorts.queue.ShortsQueueSource import io.github.aedev.flow.ui.components.layout.navigation.MediaNavigator import io.github.aedev.flow.utils.YouTubeLink internal const val MUSIC_ARTIST_ROUTE_PATTERN = "artist/{channelId}" -internal const val MUSIC_ARTIST_ROUTE_ARG = "channelId" internal const val MUSIC_PLAYLIST_ROUTE_PATTERN = "musicPlaylist/{playlistId}" -internal const val MUSIC_PLAYLIST_ROUTE_ARG = "playlistId" internal const val MUSIC_PLAYER_ROUTE_PATTERN = "musicPlayer/{videoId}?list={list}" internal const val MUSIC_PLAYER_ROUTE_ARG = "videoId" internal const val MUSIC_PLAYER_LIST_ARG = "list" @@ -30,13 +29,33 @@ internal fun musicPlayerRoute( internal fun musicCollectionRoute(collectionId: String): String? = collectionId.trim().takeIf(String::isNotEmpty)?.let { "musicPlaylist/${Uri.encode(it)}" } -/** True when the page on top is already [pattern] for [id], so opening it again would stack a duplicate. */ -internal fun isOpenMediaPage( - currentPattern: String?, - currentId: String?, - pattern: String, - id: String, -): Boolean = currentPattern == pattern && currentId == id.trim() +internal enum class PageOpen { PUSH, STAY, RETURN } + +/** A page already on the back stack is returned to, never stacked again; the one on top stays. */ +internal fun pageOpen( + existing: T?, + current: T?, +): PageOpen = + when { + existing == null -> PageOpen.PUSH + existing == current -> PageOpen.STAY + else -> PageOpen.RETURN + } + +/** + * Opens [route], or returns to it when it is already on the back stack. Each stacked page keeps its + * state in the activity's saved bundle, and a long loop between two channels grew that bundle past + * what Android can hand over when the app stops (#1338). + */ +internal fun NavController.openPage(route: String) { + // The public lookup for the exact, argument-filled match that popBackStack(route) pops to. + val existing = runCatching { getBackStackEntry(route) }.getOrNull() + when (pageOpen(existing, currentBackStackEntry)) { + PageOpen.PUSH -> navigate(route) + PageOpen.STAY -> Unit + PageOpen.RETURN -> popBackStack(route, inclusive = false) + } +} /** * The shell's [MediaNavigator]. [beforeNavigate] moves an expanded player out of the way, so a page @@ -54,8 +73,7 @@ internal class FlowMediaNavigator( navController.navigateToYoutubeChannel(channelId) } - override fun openArtist(artistId: String) = - open(MUSIC_ARTIST_ROUTE_PATTERN, MUSIC_ARTIST_ROUTE_ARG, artistId, musicArtistRoute(artistId)) + override fun openArtist(artistId: String) = open(musicArtistRoute(artistId)) override fun openAlbum(albumId: String) = openCollection(albumId) @@ -89,7 +107,7 @@ internal class FlowMediaNavigator( destination is LinkDestination.Page -> { beforeNavigate() - navController.navigate(destination.route) + navController.openPage(destination.route) } } return true @@ -113,18 +131,11 @@ internal class FlowMediaNavigator( private fun isShortsOnTop() = navController.currentBackStackEntry?.destination?.route == SHORTS_ROUTE_PATTERN - private fun openCollection(id: String) = open(MUSIC_PLAYLIST_ROUTE_PATTERN, MUSIC_PLAYLIST_ROUTE_ARG, id, musicCollectionRoute(id)) + private fun openCollection(id: String) = open(musicCollectionRoute(id)) - private fun open( - pattern: String, - argName: String, - id: String, - route: String?, - ) { + private fun open(route: String?) { if (route == null) return beforeNavigate() - val current = navController.currentBackStackEntry - if (isOpenMediaPage(current?.destination?.route, current?.arguments?.getString(argName), pattern, id)) return - navController.navigate(route) + navController.openPage(route) } } diff --git a/app/src/test/java/io/github/aedev/flow/ui/MediaNavigationTest.kt b/app/src/test/java/io/github/aedev/flow/ui/MediaNavigationTest.kt index 9500d1283..09392b613 100644 --- a/app/src/test/java/io/github/aedev/flow/ui/MediaNavigationTest.kt +++ b/app/src/test/java/io/github/aedev/flow/ui/MediaNavigationTest.kt @@ -3,9 +3,7 @@ package io.github.aedev.flow.ui import android.app.Application import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.Config @@ -20,12 +18,4 @@ class MediaNavigationTest { assertNull(musicArtistRoute(" ")) assertNull(musicCollectionRoute("")) } - - @Test - fun theSamePageIsNotOpenedTwice() { - assertTrue(isOpenMediaPage(MUSIC_ARTIST_ROUTE_PATTERN, "UCabc", MUSIC_ARTIST_ROUTE_PATTERN, " UCabc")) - assertFalse(isOpenMediaPage(MUSIC_ARTIST_ROUTE_PATTERN, "UCabc", MUSIC_ARTIST_ROUTE_PATTERN, "UCdef")) - assertFalse(isOpenMediaPage(MUSIC_PLAYLIST_ROUTE_PATTERN, "UCabc", MUSIC_ARTIST_ROUTE_PATTERN, "UCabc")) - assertFalse(isOpenMediaPage(null, null, MUSIC_PLAYLIST_ROUTE_PATTERN, "VLabc")) - } } diff --git a/app/src/test/java/io/github/aedev/flow/ui/PageOpenTest.kt b/app/src/test/java/io/github/aedev/flow/ui/PageOpenTest.kt new file mode 100644 index 000000000..22a2a018c --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/ui/PageOpenTest.kt @@ -0,0 +1,21 @@ +package io.github.aedev.flow.ui + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class PageOpenTest { + @Test + fun `a page not on the back stack is pushed`() { + assertThat(pageOpen(existing = null, current = "home")).isEqualTo(PageOpen.PUSH) + } + + @Test + fun `the page on top stays where it is`() { + assertThat(pageOpen(existing = "channel A", current = "channel A")).isEqualTo(PageOpen.STAY) + } + + @Test + fun `a page deeper in the stack is returned to instead of stacked again (1338)`() { + assertThat(pageOpen(existing = "channel A", current = "channel B")).isEqualTo(PageOpen.RETURN) + } +} From 3bbbe83284a31a56392a28782c68c00255e31886 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:39:30 +0300 Subject: [PATCH 3/3] docs(nightly): note the crash fix for long channel browsing --- .github/nightly-notes/2026-10-22-fix-saved-state-size-1338.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .github/nightly-notes/2026-10-22-fix-saved-state-size-1338.md diff --git a/.github/nightly-notes/2026-10-22-fix-saved-state-size-1338.md b/.github/nightly-notes/2026-10-22-fix-saved-state-size-1338.md new file mode 100644 index 000000000..6e72ca331 --- /dev/null +++ b/.github/nightly-notes/2026-10-22-fix-saved-state-size-1338.md @@ -0,0 +1,3 @@ +## Fixed +- Moving back and forth between channel pages no longer builds up saved state that can crash Flow when you leave the app (#1338) +- Opening a channel, artist or album that is already open further back returns to it instead of opening it again