Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Restrict independent watch functionality to Plus users #994

Merged
merged 13 commits into from
May 26, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ class SubscriptionManagerImpl @Inject constructor(
if (cachedStatus != null) {
accept(Optional.of(cachedStatus))
} else {
accept(Optional.of(SubscriptionStatus.Free()))
accept(Optional.of(null))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made this change so that a user who is not logged in does not have SubscriptionStatus.Free. This matters because when a Plus user logs in, there is a brief period of time where the user is signed in, but we haven't gotten their subscription status from the subscription API call yet. Before this change, the user would be signed into a plus account, but the app would report their subscription status as free. With this change, we now know that we don't have their subscription status yet because it will be null.

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,29 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.navArgument
import androidx.wear.compose.material.rememberSwipeToDismissBoxState
import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
import androidx.wear.compose.navigation.rememberSwipeDismissableNavHostState
import au.com.shiftyjelly.pocketcasts.models.to.SignInState
import au.com.shiftyjelly.pocketcasts.models.to.SubscriptionStatus
import au.com.shiftyjelly.pocketcasts.ui.theme.Theme
import au.com.shiftyjelly.pocketcasts.wear.theme.WearAppTheme
import au.com.shiftyjelly.pocketcasts.wear.ui.FilesScreen
import au.com.shiftyjelly.pocketcasts.wear.ui.FiltersScreen
import au.com.shiftyjelly.pocketcasts.wear.ui.LoggingInScreen
import au.com.shiftyjelly.pocketcasts.wear.ui.SettingsScreen
import au.com.shiftyjelly.pocketcasts.wear.ui.WatchListScreen
import au.com.shiftyjelly.pocketcasts.wear.ui.authentication.RequirePlusScreen
import au.com.shiftyjelly.pocketcasts.wear.ui.authentication.authenticationNavGraph
import au.com.shiftyjelly.pocketcasts.wear.ui.authentication.authenticationSubGraph
import au.com.shiftyjelly.pocketcasts.wear.ui.component.NowPlayingPager
Expand All @@ -45,6 +51,7 @@ import com.google.android.horologist.compose.navscaffold.composable
import com.google.android.horologist.compose.navscaffold.scrollable
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject

@AndroidEntryPoint
Expand All @@ -57,11 +64,16 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val state by viewModel.state.collectAsState()
WearAppTheme(theme.activeTheme) {

val state by viewModel.state.collectAsState()

WearApp(
signInConfirmationAction = state.signInConfirmationAction,
onSignInConfirmationActionHandled = viewModel::onSignInConfirmationActionHandled,
signInState = state.signInState,
subscriptionStatus = state.subscriptionStatus,
showLoggingInScreen = state.showLoggingInScreen,
onLoggingInScreenShown = viewModel::onSignInConfirmationActionHandled,
signOut = viewModel::signOut,
)
}
}
Expand All @@ -70,26 +82,48 @@ class MainActivity : ComponentActivity() {

@Composable
fun WearApp(
signInConfirmationAction: SignInConfirmationAction?,
onSignInConfirmationActionHandled: () -> Unit,
signInState: SignInState?,
subscriptionStatus: SubscriptionStatus?,
showLoggingInScreen: Boolean,
onLoggingInScreenShown: () -> Unit,
signOut: () -> Unit,
) {

val navController = rememberSwipeDismissableNavController()
val swipeToDismissState = rememberSwipeToDismissBoxState()
val navState = rememberSwipeDismissableNavHostState(swipeToDismissState)

handleSignInConfirmation(
signInConfirmationAction = signInConfirmationAction,
onSignInConfirmationActionHandled = onSignInConfirmationActionHandled,
navController = navController
)
if (showLoggingInScreen) {
navController.navigate(LoggingInScreen.routeWithDelay)
onLoggingInScreenShown()
}

val userCanAccessWatch = when (subscriptionStatus) {
is SubscriptionStatus.Free,
null -> false
is SubscriptionStatus.Plus -> true
}

var waitingForSignIn by remember { mutableStateOf(false) }
if (!userCanAccessWatch) {
waitingForSignIn = true
}

val startDestination = if (userCanAccessWatch) WatchListScreen.route else RequirePlusScreen.route

WearNavScaffold(
navController = navController,
startDestination = WatchListScreen.route,
startDestination = startDestination,
state = navState,
) {

scrollable(RequirePlusScreen.route) {
RequirePlusScreen(
columnState = it.columnState,
onContinueToLogin = { navController.navigate(authenticationSubGraph) },
)
}

scrollable(
route = WatchListScreen.route,
) {
Expand Down Expand Up @@ -241,24 +275,48 @@ fun WearApp(
)
}

authenticationNavGraph(navController)
val popToStartDestination: () -> Unit = {
when (startDestination) {
WatchListScreen.route -> {
val popped = navController.popBackStack(
route = WatchListScreen.route,
inclusive = false,
)
if (popped) {
navController
.currentBackStackEntry
?.savedStateHandle
?.set(WatchListScreen.scrollToTop, true)
}
}

composable(LoggingInScreen.routeWithDelay) {
LoggingInScreen(
onClose = { navController.popBackStack() },
// Because this login is not triggered by the user, make sure that the
// logging in screen is shown for enough time for the user to understand
// what is happening.
withMinimumDelay = true,
)
}
RequirePlusScreen.route -> {
navController.popBackStack(
route = RequirePlusScreen.route,
inclusive = false,
)
}

composable(LoggingInScreen.route) {
LoggingInScreen(
onClose = { WatchListScreen.popToTop(navController) },
)
else -> throw IllegalStateException("Unexpected start destination $startDestination")
}
}

authenticationNavGraph(
navController = navController,
onEmailSignInSuccess = {
navController.navigate(LoggingInScreen.route)
},
googleSignInSuccessScreen = { googleSignInAccount ->
LoggingInScreen(
avatarUrl = googleSignInAccount?.photoUrl?.toString(),
name = googleSignInAccount?.givenName,
onClose = {}
)
}
)

loggingInScreens(onClose = { popToStartDestination() })

composable(PCVolumeScreen.route) {
it.timeTextMode = NavScaffoldViewModel.TimeTextMode.Off

Expand All @@ -271,41 +329,61 @@ fun WearApp(
)
}
}
}

private fun handleSignInConfirmation(
signInConfirmationAction: SignInConfirmationAction?,
onSignInConfirmationActionHandled: () -> Unit,
navController: NavController,
) {

val signInNotificationShowing = navController.currentDestination?.route == LoggingInScreen.routeWithDelay

when (signInConfirmationAction) {
// We cannot use the subscription status contained in the SignInState object because the subscription status
// gets updated to the correct value a bit after the user signs in—there is a delay in getting the updated and
// correct subscription status. For example, immediately after a user signs in with a plus account, their
// sign in state does not report that it is a Plus subscription until after the subscription call completes.
// This has to happen after the WearNavScaffold so that the new start destination has been processed,
// otherwise the new start destination will replace any navigation we do here to the LoggingInScreen.
val previousSubscriptionStatus = remember { mutableStateOf<SubscriptionStatus?>(null) }
if (previousSubscriptionStatus.value != subscriptionStatus &&
signInState is SignInState.SignedIn
) {

is SignInConfirmationAction.Show -> {
if (!signInNotificationShowing) {
navController.navigate(LoggingInScreen.routeWithDelay)
when (subscriptionStatus) {
null, is SubscriptionStatus.Free -> {
// This gets the user back to the start destination if they logged in as free
signOut()
navController.popBackStack(startDestination, inclusive = false)
}
}

SignInConfirmationAction.Hide -> {
if (signInNotificationShowing) {
navController.popBackStack()
is SubscriptionStatus.Plus -> {
if (waitingForSignIn) {
navController.navigate(LoggingInScreen.route)
}
}
}
}
previousSubscriptionStatus.value = subscriptionStatus
}

null -> { /* do nothing */ }
private fun NavGraphBuilder.loggingInScreens(
onClose: () -> Unit,
) {
composable(LoggingInScreen.route) {
Timber.i("navigating to logging in screen")
LoggingInScreen(onClose = onClose)
}

onSignInConfirmationActionHandled()
composable(LoggingInScreen.routeWithDelay) {
LoggingInScreen(
onClose = onClose,
// Because this login is not triggered by the user, make sure that the
// logging in screen is shown for enough time for the user to understand
// what is happening.
withMinimumDelay = true,
)
}
}

@Preview(device = Devices.WEAR_OS_SMALL_ROUND, showSystemUi = true)
@Composable
fun DefaultPreview() {
WearApp(
signInConfirmationAction = null,
onSignInConfirmationActionHandled = {},
signInState = null,
subscriptionStatus = null,
showLoggingInScreen = false,
onLoggingInScreenShown = {},
signOut = {},
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,36 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import au.com.shiftyjelly.pocketcasts.account.watchsync.WatchSync
import au.com.shiftyjelly.pocketcasts.account.watchsync.WatchSyncAuthData
import au.com.shiftyjelly.pocketcasts.models.to.SignInState
import au.com.shiftyjelly.pocketcasts.models.to.SubscriptionStatus
import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager
import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager
import au.com.shiftyjelly.pocketcasts.repositories.subscription.SubscriptionManager
import au.com.shiftyjelly.pocketcasts.repositories.sync.LoginResult
import au.com.shiftyjelly.pocketcasts.repositories.user.UserManager
import com.google.android.horologist.auth.data.tokenshare.TokenBundleRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.reactive.asFlow
import javax.inject.Inject

@HiltViewModel
class WearMainActivityViewModel @Inject constructor(
private val playbackManager: PlaybackManager,
private val podcastManager: PodcastManager,
tokenBundleRepository: TokenBundleRepository<WatchSyncAuthData?>,
subscriptionManager: SubscriptionManager,
private val userManager: UserManager,
watchSync: WatchSync,
) : ViewModel() {

data class State(
val signInConfirmationAction: SignInConfirmationAction? = null,
val showLoggingInScreen: Boolean = false,
val signInState: SignInState? = null,
val subscriptionStatus: SubscriptionStatus? = null,
)

private val _state = MutableStateFlow(State())
Expand All @@ -31,18 +44,39 @@ class WearMainActivityViewModel @Inject constructor(
tokenBundleRepository.flow
.collect { watchSyncAuthData ->
watchSync.processAuthDataChange(watchSyncAuthData) {
onLoginResult(it)
onLoginFromPhoneResult(it)
}
}
}

viewModelScope.launch {
userManager
.getSignInState()
.asFlow()
.collect { signInState ->
_state.update { it.copy(signInState = signInState) }
}
}

viewModelScope.launch {
subscriptionManager
.observeSubscriptionStatus()
.asFlow()
.collect { subscriptionStatus ->
_state.update { it.copy(subscriptionStatus = subscriptionStatus.get()) }
}
}
}

private fun onLoginResult(loginResult: LoginResult) {
private fun onLoginFromPhoneResult(loginResult: LoginResult) {
when (loginResult) {
is LoginResult.Failed -> { /* do nothing */ }
is LoginResult.Success -> {
viewModelScope.launch {
podcastManager.refreshPodcastsAfterSignIn()
}
_state.update {
it.copy(signInConfirmationAction = SignInConfirmationAction.Show)
it.copy(showLoggingInScreen = true)
}
}
}
Expand All @@ -52,11 +86,10 @@ class WearMainActivityViewModel @Inject constructor(
* This should be invoked when the UI has handled showing or hiding the sign in confirmation.
*/
fun onSignInConfirmationActionHandled() {
_state.update { it.copy(signInConfirmationAction = null) }
_state.update { it.copy(showLoggingInScreen = false) }
}
}

sealed class SignInConfirmationAction {
object Show : SignInConfirmationAction()
object Hide : SignInConfirmationAction()
fun signOut() {
userManager.signOut(playbackManager, wasInitiatedByUser = false)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
Expand All @@ -34,7 +33,6 @@ import au.com.shiftyjelly.pocketcasts.compose.images.GravatarProfileImage
import au.com.shiftyjelly.pocketcasts.compose.images.ProfileImage
import au.com.shiftyjelly.pocketcasts.ui.theme.Theme
import au.com.shiftyjelly.pocketcasts.wear.theme.WearAppTheme
import au.com.shiftyjelly.pocketcasts.wear.ui.LoggingInScreenViewModel.State.RefreshComplete.email
import au.com.shiftyjelly.pocketcasts.wear.ui.component.LoadingSpinner
import au.com.shiftyjelly.pocketcasts.localization.R as LR

Expand All @@ -43,6 +41,9 @@ object LoggingInScreen {
const val routeWithDelay = "loggingInScreenWithDelay"
}

/**
* This screen assumes that a refresh has been triggered from somewhere else.
*/
@Composable
fun LoggingInScreen(
avatarUrl: String? = null,
Expand Down
Loading