From 6f2e127595f14923be69c40e86a7078600ca46ba Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Fri, 14 Aug 2026 15:50:47 +0200 Subject: [PATCH 1/4] Gate the TV onboarding start destination on login state --- .../onboarding/TvOnboardingNavHost.kt | 2 -- .../onboarding/TvOnboardingViewModel.kt | 10 +++----- .../onboarding/TvOnboardingViewModelTest.kt | 24 +++++++------------ 3 files changed, 11 insertions(+), 25 deletions(-) diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingNavHost.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingNavHost.kt index 2301283a4b4..fbb687e6b9d 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingNavHost.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingNavHost.kt @@ -41,7 +41,6 @@ fun TvOnboardingNavHost( onSignIn = { navController.navigate(TvOnboardingRoutes.SIGN_IN) }, onCreateAccount = { navController.navigate(TvOnboardingRoutes.CREATE_ACCOUNT) }, onContinueWithoutAccount = { - viewModel.completeOnboarding() navController.navigate(TvOnboardingRoutes.HOME) { popUpTo(TvOnboardingRoutes.LANDING) { inclusive = true } } @@ -65,7 +64,6 @@ fun TvOnboardingNavHost( composable(TvOnboardingRoutes.SYNCING) { TvSyncingScreen( onSyncComplete = { - viewModel.completeOnboarding() navController.navigate(TvOnboardingRoutes.HOME) { popUpTo(TvOnboardingRoutes.SYNCING) { inclusive = true } } diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingViewModel.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingViewModel.kt index 6760a3844e3..70244f653b6 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingViewModel.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingViewModel.kt @@ -1,21 +1,17 @@ package au.com.shiftyjelly.pocketcasts.onboarding import androidx.lifecycle.ViewModel -import au.com.shiftyjelly.pocketcasts.preferences.Settings +import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class TvOnboardingViewModel @Inject constructor( - private val settings: Settings, + syncManager: SyncManager, ) : ViewModel() { - val startDestination: String = if (settings.hasCompletedOnboarding()) { + val startDestination: String = if (syncManager.isLoggedIn()) { TvOnboardingRoutes.HOME } else { TvOnboardingRoutes.LANDING } - - fun completeOnboarding() { - settings.setHasDoneInitialOnboarding() - } } diff --git a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingViewModelTest.kt b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingViewModelTest.kt index bd6319e27f7..f3a47b7b220 100644 --- a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingViewModelTest.kt +++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingViewModelTest.kt @@ -1,34 +1,26 @@ package au.com.shiftyjelly.pocketcasts.onboarding -import au.com.shiftyjelly.pocketcasts.preferences.Settings +import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager import org.junit.Assert.assertEquals import org.junit.Test import org.mockito.kotlin.mock -import org.mockito.kotlin.verify import org.mockito.kotlin.whenever class TvOnboardingViewModelTest { - private val settings = mock() + private val syncManager = mock() @Test - fun `start destination is landing when onboarding not completed`() { - whenever(settings.hasCompletedOnboarding()).thenReturn(false) - val viewModel = TvOnboardingViewModel(settings) + fun `start destination is landing when signed out`() { + whenever(syncManager.isLoggedIn()).thenReturn(false) + val viewModel = TvOnboardingViewModel(syncManager) assertEquals(TvOnboardingRoutes.LANDING, viewModel.startDestination) } @Test - fun `start destination is home when onboarding completed`() { - whenever(settings.hasCompletedOnboarding()).thenReturn(true) - val viewModel = TvOnboardingViewModel(settings) + fun `start destination is home when signed in`() { + whenever(syncManager.isLoggedIn()).thenReturn(true) + val viewModel = TvOnboardingViewModel(syncManager) assertEquals(TvOnboardingRoutes.HOME, viewModel.startDestination) } - - @Test - fun `complete onboarding persists to settings`() { - val viewModel = TvOnboardingViewModel(settings) - viewModel.completeOnboarding() - verify(settings).setHasDoneInitialOnboarding() - } } From 54a38f4c9a468141bf53fbdaa54124ed777421dd Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Fri, 14 Aug 2026 15:52:41 +0200 Subject: [PATCH 2/4] Return to the TV welcome screen when signing out --- .../java/au/com/shiftyjelly/pocketcasts/home/TvScaffold.kt | 2 ++ .../pocketcasts/onboarding/TvOnboardingNavHost.kt | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvScaffold.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvScaffold.kt index 996fde364ad..93be96baf5e 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvScaffold.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvScaffold.kt @@ -42,6 +42,7 @@ import au.com.shiftyjelly.pocketcasts.upnext.TvUpNextScreen fun TvScaffold( onLogIn: () -> Unit, onCreateAccount: () -> Unit, + onSignedOut: () -> Unit, modifier: Modifier = Modifier, viewModel: TvScaffoldViewModel = hiltViewModel(), ) { @@ -131,6 +132,7 @@ fun TvScaffold( onLogOut = { isProfileModalVisible = false viewModel.signOut() + onSignedOut() }, ) } diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingNavHost.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingNavHost.kt index fbb687e6b9d..9587efb4378 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingNavHost.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingNavHost.kt @@ -74,6 +74,11 @@ fun TvOnboardingNavHost( TvScaffold( onLogIn = { navController.navigate(TvOnboardingRoutes.SIGN_IN) }, onCreateAccount = { navController.navigate(TvOnboardingRoutes.CREATE_ACCOUNT) }, + onSignedOut = { + navController.navigate(TvOnboardingRoutes.LANDING) { + popUpTo(navController.graph.id) { inclusive = true } + } + }, ) } } From a204675923aca37eca0d387831a56f0173e0289b Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Fri, 14 Aug 2026 16:23:36 +0200 Subject: [PATCH 3/4] Install the RxJava undeliverable-exception handler on TV --- .../main/java/au/com/shiftyjelly/pocketcasts/TvApplication.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/TvApplication.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/TvApplication.kt index 7ab67b2bad2..e86b461f9ef 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/TvApplication.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/TvApplication.kt @@ -5,6 +5,7 @@ import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager import au.com.shiftyjelly.pocketcasts.utils.TimberDebugTree +import au.com.shiftyjelly.pocketcasts.utils.log.RxJavaUncaughtExceptionHandling import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject import kotlinx.coroutines.CoroutineScope @@ -29,6 +30,7 @@ class TvApplication : if (BuildConfig.DEBUG) { Timber.plant(TimberDebugTree()) } + RxJavaUncaughtExceptionHandling.setUp() // setup() subscribes the Up Next queue's sync pipeline itself, so there must be no // separate UpNextQueue.setupBlocking() call on TV. applicationScope.launch { From f7ef2583e41edeca8a501d76036bcaa69d171224 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Fri, 14 Aug 2026 16:28:31 +0200 Subject: [PATCH 4/4] Address PR comments --- .../onboarding/TvOnboardingNavHost.kt | 17 +++++++-------- .../onboarding/TvOnboardingViewModel.kt | 4 +++- .../onboarding/TvOnboardingViewModelTest.kt | 21 ++++++++++++++----- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingNavHost.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingNavHost.kt index 9587efb4378..95957e575f0 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingNavHost.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingNavHost.kt @@ -28,6 +28,11 @@ fun TvOnboardingNavHost( viewModel: TvOnboardingViewModel = hiltViewModel(), ) { val navController = rememberNavController() + val navigateClearingBackStack: (String) -> Unit = { route -> + navController.navigate(route) { + popUpTo(navController.graph.id) { inclusive = true } + } + } val toastHostState = remember { TvToastHostState() } CompositionLocalProvider(LocalTvToastHostState provides toastHostState) { Box(modifier = modifier.fillMaxSize()) { @@ -54,11 +59,7 @@ fun TvOnboardingNavHost( } composable(TvOnboardingRoutes.SIGN_IN) { TvSignInScreen( - onSignInComplete = { - navController.navigate(TvOnboardingRoutes.SYNCING) { - popUpTo(navController.graph.id) { inclusive = true } - } - }, + onSignInComplete = { navigateClearingBackStack(TvOnboardingRoutes.SYNCING) }, ) } composable(TvOnboardingRoutes.SYNCING) { @@ -74,11 +75,7 @@ fun TvOnboardingNavHost( TvScaffold( onLogIn = { navController.navigate(TvOnboardingRoutes.SIGN_IN) }, onCreateAccount = { navController.navigate(TvOnboardingRoutes.CREATE_ACCOUNT) }, - onSignedOut = { - navController.navigate(TvOnboardingRoutes.LANDING) { - popUpTo(navController.graph.id) { inclusive = true } - } - }, + onSignedOut = { navigateClearingBackStack(TvOnboardingRoutes.LANDING) }, ) } } diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingViewModel.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingViewModel.kt index 70244f653b6..966e2b962d7 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingViewModel.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingViewModel.kt @@ -1,6 +1,7 @@ package au.com.shiftyjelly.pocketcasts.onboarding import androidx.lifecycle.ViewModel +import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -8,8 +9,9 @@ import javax.inject.Inject @HiltViewModel class TvOnboardingViewModel @Inject constructor( syncManager: SyncManager, + settings: Settings, ) : ViewModel() { - val startDestination: String = if (syncManager.isLoggedIn()) { + val startDestination: String = if (syncManager.isLoggedIn() && !settings.getFullySignedOut()) { TvOnboardingRoutes.HOME } else { TvOnboardingRoutes.LANDING diff --git a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingViewModelTest.kt b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingViewModelTest.kt index f3a47b7b220..f8328928d29 100644 --- a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingViewModelTest.kt +++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/onboarding/TvOnboardingViewModelTest.kt @@ -1,5 +1,6 @@ package au.com.shiftyjelly.pocketcasts.onboarding +import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager import org.junit.Assert.assertEquals import org.junit.Test @@ -9,18 +10,28 @@ import org.mockito.kotlin.whenever class TvOnboardingViewModelTest { private val syncManager = mock() + private val settings = mock() @Test fun `start destination is landing when signed out`() { whenever(syncManager.isLoggedIn()).thenReturn(false) - val viewModel = TvOnboardingViewModel(syncManager) - assertEquals(TvOnboardingRoutes.LANDING, viewModel.startDestination) + whenever(settings.getFullySignedOut()).thenReturn(true) + assertEquals(TvOnboardingRoutes.LANDING, viewModel().startDestination) } @Test - fun `start destination is home when signed in`() { + fun `start destination is home when signed in and not fully signed out`() { whenever(syncManager.isLoggedIn()).thenReturn(true) - val viewModel = TvOnboardingViewModel(syncManager) - assertEquals(TvOnboardingRoutes.HOME, viewModel.startDestination) + whenever(settings.getFullySignedOut()).thenReturn(false) + assertEquals(TvOnboardingRoutes.HOME, viewModel().startDestination) } + + @Test + fun `start destination is landing when logged in but sign-out is pending`() { + whenever(syncManager.isLoggedIn()).thenReturn(true) + whenever(settings.getFullySignedOut()).thenReturn(true) + assertEquals(TvOnboardingRoutes.LANDING, viewModel().startDestination) + } + + private fun viewModel() = TvOnboardingViewModel(syncManager, settings) }