From 6283fa15df5ed90d2b67abb83a250c969e9e966b Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Fri, 24 Feb 2023 22:58:30 -0500 Subject: [PATCH 01/22] Initial POC sending refresh token from phone to watch --- app/build.gradle | 2 + .../pocketcasts/ui/MainActivity.kt | 8 ++ base.gradle | 1 + dependencies.gradle | 3 +- .../pocketcasts/account/AccountAuth.kt | 29 ++++ .../pocketcasts/account/WatchSync.kt | 135 ++++++++++++++++++ .../pocketcasts/servers/sync/SyncServer.kt | 5 + .../servers/sync/SyncServerManager.kt | 11 ++ .../sync/login/LoginPocketCastsRequest.kt | 10 ++ .../sync/login/LoginPocketCastsResponse.kt | 15 ++ .../servers/sync/login/LoginTokenRequest.kt | 2 +- .../pocketcasts/wear/MainActivity.kt | 36 ++++- .../wear/WearMainActivityViewModel.kt | 27 ++++ 13 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt create mode 100644 modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/login/LoginPocketCastsRequest.kt create mode 100644 modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/login/LoginPocketCastsResponse.kt create mode 100644 wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt diff --git a/app/build.gradle b/app/build.gradle index 565dfb8a847..70dc09388f9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -95,6 +95,8 @@ dependencies { implementation project(':modules:features:account') implementation project(':modules:features:taskerplugin') implementation project(':modules:features:endofyear') + + wearApp project(':wear') } task appStart(type: Exec, dependsOn: 'installDebug') { diff --git a/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt b/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt index 3da2de5122a..09eda5d70e5 100644 --- a/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt +++ b/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt @@ -37,6 +37,7 @@ import androidx.transition.Slide import au.com.shiftyjelly.pocketcasts.R import au.com.shiftyjelly.pocketcasts.account.AccountActivity import au.com.shiftyjelly.pocketcasts.account.PromoCodeUpgradedFragment +import au.com.shiftyjelly.pocketcasts.account.WatchSync import au.com.shiftyjelly.pocketcasts.account.onboarding.OnboardingActivity import au.com.shiftyjelly.pocketcasts.account.onboarding.OnboardingActivityContract import au.com.shiftyjelly.pocketcasts.account.onboarding.OnboardingActivityContract.OnboardingFinish @@ -178,6 +179,7 @@ class MainActivity : @Inject lateinit var warningsHelper: WarningsHelper @Inject lateinit var analyticsTracker: AnalyticsTrackerWrapper @Inject lateinit var episodeAnalytics: EpisodeAnalytics + @Inject lateinit var watchSync: WatchSync private lateinit var bottomNavHideManager: BottomNavHideManager private lateinit var observeUpNext: LiveData @@ -712,6 +714,12 @@ class MainActivity : settings.setTrialFinishedSeen(true) } + + lifecycleScope.launch { + // FIXME This gets called every time MainActivity resumes. Can we reduce how often this is called? + // But if that gets fixed we may want to also start calling this explicitly in onCreate + watchSync.sendAuthToDataLayer(this@MainActivity) + } } val lastSeenVersionCode = settings.getWhatsNewVersionCode() diff --git a/base.gradle b/base.gradle index ab26e004f26..e3048f5928a 100644 --- a/base.gradle +++ b/base.gradle @@ -205,6 +205,7 @@ dependencies { implementation androidLibs.composeUiToolingPreview implementation androidLibs.composeViewModel implementation androidLibs.composeUiUtil + implementation androidLibs.wearPlayServices implementation libs.kotlinCoroutines implementation libs.kotlinCoroutinesAndroid diff --git a/dependencies.gradle b/dependencies.gradle index 71f76cbd52a..f237fc29214 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -185,7 +185,8 @@ project.ext { wearComposeMaterial: "androidx.wear.compose:compose-material:$versionComposeWear", wearComposeFoundation: "androidx.wear.compose:compose-foundation:$versionComposeWear", - wearComposeNavigation: "androidx.wear.compose:compose-navigation:$versionComposeWear", + wearComposeNavigation: "androidx.wear.compose:compose-navigation:$versionComposeWear", + wearPlayServices: "com.google.android.gms:play-services-wearable:18.0.0", horologistComposeLayout: "com.google.android.horologist:horologist-compose-layout:$versionHorologist", horologistMedia: "com.google.android.horologist:horologist-media:$versionHorologist", diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/AccountAuth.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/AccountAuth.kt index a9596f24755..f379ed1ddbf 100644 --- a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/AccountAuth.kt +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/AccountAuth.kt @@ -60,6 +60,30 @@ class AccountAuth @Inject constructor( return authResult } + // TODO extract logic this method shares with signInWithGoogle + suspend fun signInWithToken( + refreshToken: String, + signInSource: SignInSource, + ): AuthResult { + val authResult = try { + val response = syncServerManager.loginToken(refreshToken) + val result = AuthResultModel(token = response.refreshToken, uuid = response.uuid, isNewAccount = response.isNew) + signInSuccessful( + email = response.email, + refreshTokenOrPassword = response.refreshToken, + accessToken = response.accessToken, + userUuid = response.uuid, + signInType = AccountConstants.SignInType.RefreshToken + ) + AuthResult.Success(result) + } catch (ex: Exception) { + Timber.e(ex, "Failed to sign in with token") + exceptionToAuthResult(exception = ex, fallbackMessage = LR.string.error_login_failed) + } + trackSignIn(authResult, signInSource) + return authResult + } + suspend fun signInWithEmailAndPassword( email: String, password: String, @@ -110,6 +134,10 @@ class AccountAuth @Inject constructor( return authResult } + // FIXME need a better name + suspend fun getTokensWithEmailAndPassword(email: String, password: String) = + syncServerManager.loginPocketCasts(email, password) + private fun trackRegister(authResult: AuthResult) { when (authResult) { is AuthResult.Success -> { @@ -220,4 +248,5 @@ enum class SignInSource(val analyticsValue: String) { SignInViewModel("sign_in_view_model"), Onboarding("onboarding"), PocketCastsApplication("pocketcasts_application"), + WatchPhoneSync("watch_phone_sync"), } diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt new file mode 100644 index 00000000000..a0a6231aae3 --- /dev/null +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt @@ -0,0 +1,135 @@ +package au.com.shiftyjelly.pocketcasts.account + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import au.com.shiftyjelly.pocketcasts.preferences.Settings +import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer +import com.google.android.gms.wearable.DataEvent +import com.google.android.gms.wearable.DataEventBuffer +import com.google.android.gms.wearable.DataItem +import com.google.android.gms.wearable.DataMapItem +import com.google.android.gms.wearable.PutDataMapRequest +import com.google.android.gms.wearable.PutDataRequest +import com.google.android.gms.wearable.Wearable +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +@SuppressLint("VisibleForTests") // https://issuetracker.google.com/issues/239451111 +class WatchSync @Inject constructor( + @ApplicationContext context: Context, + private val settings: Settings, + private val accountAuth: AccountAuth, +) { + + companion object { + private const val authPath = "/auth" + private const val authKeyRefreshToken = "refreshToken" + } + + private val dataClient = Wearable.getDataClient(context) + + /** + * This should be called by the phone app to update the refresh token available to + * the watch app in the data layer. + */ + suspend fun sendAuthToDataLayer(activity: Activity) { + withContext(Dispatchers.Default) { + try { + Timber.i("Updating refresh token in data layer") + + val authData = let { + val email = settings.getSyncEmail() + val password = settings.getSyncPassword() + if (email != null && password != null) { + // FIXME nonononono this makes an api call _every_ time + accountAuth.getTokensWithEmailAndPassword(email, password) + } else null + } + + val putDataReq: PutDataRequest = PutDataMapRequest.create(authPath).apply { + authData?.refreshToken?.let { + + dataMap.putString(authKeyRefreshToken, it) + } ?: dataMap.remove(authKeyRefreshToken) + } + .asPutDataRequest() + .setUrgent() + + Wearable + .getDataClient(activity) + .putDataItem(putDataReq) + } catch (cancellationException: CancellationException) { + // Don't catch CancellationException since this represents the normal cancellation of a coroutine + throw cancellationException + } catch (exception: Exception) { + LogBuffer.e(LogBuffer.TAG_BACKGROUND_TASKS, "saving refresh token to data layer failed: $exception") + } + } + } + + suspend fun processDataChange(dataEventBuffer: DataEventBuffer) { + Timber.i("Received DataLayer change") + dataEventBuffer.use { buffer -> + buffer.forEach { event -> + processEvent(event) + } + } + } + + suspend fun processLatestData() { + Timber.i("Checking latest sync data from Data Layer") + dataClient.dataItems.await().use { dataItemBuffer -> + dataItemBuffer.forEach { item -> + processItem(item) + } + } + } + + private suspend fun processEvent(event: DataEvent) { + processItem(event.dataItem) + } + + private suspend fun processAuthItem(item: DataItem) { + val dataMap = DataMapItem.fromDataItem(item).dataMap + val refreshToken = dataMap.getString(authKeyRefreshToken) + if (refreshToken != null) { + // Don't do anything if the user is already logged in. + if (!settings.isLoggedIn()) { + val result = accountAuth.signInWithToken(refreshToken, SignInSource.WatchPhoneSync) + when (result) { + is AccountAuth.AuthResult.Failed -> { /* do nothing */ } + is AccountAuth.AuthResult.Success -> { + Timber.e("TODO: notify the user we have signed them in!") + } + } + } else { + Timber.i("Received refreshToken from phone, but user is already logged in") + } + } else { + // The user either was never logged in on their phone or just logged out. + // Either way, leave the user's login state on the watch unchanged. + Timber.i("Received data from phone without refresh token") + } + } + + private suspend fun processItem(item: DataItem) { + val path = item.uri.path + Timber.i("Processing DataItem with path: $path") + when (path) { + authPath -> { + processAuthItem(item) + } + else -> { + Timber.e("Unable to process DataItem with unknown path: $path") + } + } + } +} diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncServer.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncServer.kt index c34047b1e48..7242a83ec94 100644 --- a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncServer.kt +++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncServer.kt @@ -7,6 +7,8 @@ import au.com.shiftyjelly.pocketcasts.servers.sync.forgotpassword.ForgotPassword import au.com.shiftyjelly.pocketcasts.servers.sync.history.HistoryYearResponse import au.com.shiftyjelly.pocketcasts.servers.sync.history.HistoryYearSyncRequest import au.com.shiftyjelly.pocketcasts.servers.sync.login.LoginGoogleRequest +import au.com.shiftyjelly.pocketcasts.servers.sync.login.LoginPocketCastsRequest +import au.com.shiftyjelly.pocketcasts.servers.sync.login.LoginPocketCastsResponse import au.com.shiftyjelly.pocketcasts.servers.sync.login.LoginRequest import au.com.shiftyjelly.pocketcasts.servers.sync.login.LoginResponse import au.com.shiftyjelly.pocketcasts.servers.sync.login.LoginTokenRequest @@ -31,6 +33,9 @@ interface SyncServer { @POST("/user/login") suspend fun login(@Body request: LoginRequest): LoginResponse + @POST("/user/login_pocket_casts") + suspend fun loginPocketCasts(@Body request: LoginPocketCastsRequest): LoginPocketCastsResponse + @POST("/user/login_google") suspend fun loginGoogle(@Body request: LoginGoogleRequest): LoginTokenResponse diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncServerManager.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncServerManager.kt index b5d8e77b647..4d0fa3faf42 100644 --- a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncServerManager.kt +++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncServerManager.kt @@ -17,6 +17,8 @@ import au.com.shiftyjelly.pocketcasts.servers.sync.forgotpassword.ForgotPassword import au.com.shiftyjelly.pocketcasts.servers.sync.history.HistoryYearResponse import au.com.shiftyjelly.pocketcasts.servers.sync.history.HistoryYearSyncRequest import au.com.shiftyjelly.pocketcasts.servers.sync.login.LoginGoogleRequest +import au.com.shiftyjelly.pocketcasts.servers.sync.login.LoginPocketCastsRequest +import au.com.shiftyjelly.pocketcasts.servers.sync.login.LoginPocketCastsResponse import au.com.shiftyjelly.pocketcasts.servers.sync.login.LoginRequest import au.com.shiftyjelly.pocketcasts.servers.sync.login.LoginResponse import au.com.shiftyjelly.pocketcasts.servers.sync.login.LoginTokenRequest @@ -66,6 +68,15 @@ open class SyncServerManager @Inject constructor( return server.login(request) } + // FIXME find a better function name + suspend fun loginPocketCasts(email: String, password: String): LoginPocketCastsResponse = + server.loginPocketCasts( + LoginPocketCastsRequest( + email = email, + password = password + ) + ) + suspend fun loginGoogle(idToken: String): LoginTokenResponse { val request = LoginGoogleRequest(idToken) return server.loginGoogle(request) diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/login/LoginPocketCastsRequest.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/login/LoginPocketCastsRequest.kt new file mode 100644 index 00000000000..b19c4ee6bd5 --- /dev/null +++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/login/LoginPocketCastsRequest.kt @@ -0,0 +1,10 @@ +package au.com.shiftyjelly.pocketcasts.servers.sync.login + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class LoginPocketCastsRequest( + @field:Json(name = "email") val email: String, + @field:Json(name = "password") val password: String, +) diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/login/LoginPocketCastsResponse.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/login/LoginPocketCastsResponse.kt new file mode 100644 index 00000000000..70d7fb0ae19 --- /dev/null +++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/login/LoginPocketCastsResponse.kt @@ -0,0 +1,15 @@ +package au.com.shiftyjelly.pocketcasts.servers.sync.login + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +class LoginPocketCastsResponse( + @field:Json(name = "email") val email: String, + @field:Json(name = "uuid") val uuid: String, + @field:Json(name = "isNew") val isNew: Boolean, + @field:Json(name = "accessToken") val accessToken: String, + @field:Json(name = "tokenType") val tokenType: String, + @field:Json(name = "expiresIn") val expiresIn: Int, + @field:Json(name = "refreshToken") val refreshToken: String, +) diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/login/LoginTokenRequest.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/login/LoginTokenRequest.kt index d0555d08758..459af0b209b 100644 --- a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/login/LoginTokenRequest.kt +++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/login/LoginTokenRequest.kt @@ -6,5 +6,5 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class LoginTokenRequest( @field:Json(name = "grant_type") val grantType: String = "refresh_token", - @field:Json(name = "refresh_code") val refreshCode: String + @field:Json(name = "refresh_token") val refreshCode: String ) diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt index d14753de7db..969722c8a19 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt @@ -3,9 +3,15 @@ package au.com.shiftyjelly.pocketcasts.wear import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.DisposableEffectResult +import androidx.compose.runtime.DisposableEffectScope import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.navigation.NavType import androidx.navigation.navArgument import androidx.wear.compose.navigation.rememberSwipeDismissableNavController @@ -20,6 +26,7 @@ import au.com.shiftyjelly.pocketcasts.wear.ui.authenticationGraph import au.com.shiftyjelly.pocketcasts.wear.ui.player.NowPlayingScreen import au.com.shiftyjelly.pocketcasts.wear.ui.podcast.PodcastScreen import au.com.shiftyjelly.pocketcasts.wear.ui.podcasts.PodcastsScreen +import com.google.android.gms.wearable.Wearable import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel import com.google.android.horologist.compose.navscaffold.WearNavScaffold import com.google.android.horologist.compose.navscaffold.composable @@ -32,13 +39,40 @@ class MainActivity : ComponentActivity() { @Inject lateinit var theme: Theme + private val viewModel: WearMainActivityViewModel by viewModels() + + private val dataClient by lazy { Wearable.getDataClient(this) } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - // TODO add lines for radioactive theme + DisposableEffect(lifecycle) { + monitorDataLayerForChanges() + } + WearApp(theme.activeTheme) } } + + private fun DisposableEffectScope.monitorDataLayerForChanges(): DisposableEffectResult { + + // immediately check for any changes on launch + viewModel.checkLatestSyncData() + + // listen for changes after launch + val listener = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> dataClient.addListener(viewModel.phoneSyncDataListener) + Lifecycle.Event.ON_STOP -> dataClient.removeListener(viewModel.phoneSyncDataListener) + else -> { /* do nothing */ + } + } + } + lifecycle.addObserver(listener) + return onDispose { + lifecycle.removeObserver(listener) + } + } } @Composable diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt new file mode 100644 index 00000000000..c85362fc2df --- /dev/null +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt @@ -0,0 +1,27 @@ +package au.com.shiftyjelly.pocketcasts.wear + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import au.com.shiftyjelly.pocketcasts.account.WatchSync +import com.google.android.gms.wearable.DataClient +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class WearMainActivityViewModel @Inject constructor( + private val watchSync: WatchSync +) : ViewModel() { + + val phoneSyncDataListener = DataClient.OnDataChangedListener { dataEventBuffer -> + viewModelScope.launch { + watchSync.processDataChange(dataEventBuffer) + } + } + + fun checkLatestSyncData() { + viewModelScope.launch { + watchSync.processLatestData() + } + } +} From 033afa9ecc382dce4ba7732743e2972aa4079876 Mon Sep 17 00:00:00 2001 From: Gustavo Pagani Date: Mon, 6 Mar 2023 21:26:11 +0000 Subject: [PATCH 02/22] Use Horologist for authentication --- app/build.gradle | 4 +- app/src/main/AndroidManifest.xml | 2 +- .../pocketcasts/ui/MainActivity.kt | 6 +- .../pocketcasts/ui/MainActivityViewModel.kt | 6 +- app/src/main/res/values/wear.xml | 25 ++++++ .../pocketcasts/AutomotiveSettingsFragment.kt | 4 +- .../AutomotiveSettingsPreferenceFragment.kt | 8 +- base.gradle | 11 ++- dependencies.gradle | 5 +- modules/features/account/build.gradle | 10 +++ .../pocketcasts/account/TokenSerializer.kt | 21 +++++ .../pocketcasts/account/WatchSync.kt | 89 ++++--------------- .../pocketcasts/account/di/Annotations.kt | 7 ++ .../pocketcasts/account/di/AuthModule.kt | 36 ++++++++ .../pocketcasts/account/di/AuthPhoneModule.kt | 28 ++++++ .../viewmodel/AccountFragmentViewModel.kt | 4 +- .../filters/CreateFilterViewModel.kt | 8 +- .../filters/FilterEpisodeListViewModel.kt | 4 +- .../filters/FiltersFragmentViewModel.kt | 7 +- .../player/viewmodel/PlayerViewModel.kt | 16 ++-- .../viewmodel/UpNextEpisodeViewModel.kt | 12 +-- .../player/viewmodel/VideoViewModel.kt | 4 +- .../view/ProfileEpisodeListViewModel.kt | 4 +- .../view/episode/EpisodeFragmentViewModel.kt | 10 +-- .../podcast/PodcastAutoArchiveViewModel.kt | 8 +- .../view/share/ShareListIncomingViewModel.kt | 6 +- .../viewmodel/PodcastEffectsViewModel.kt | 4 +- .../viewmodel/PodcastSettingsViewModel.kt | 12 +-- .../podcasts/viewmodel/PodcastViewModel.kt | 11 ++- .../podcasts/viewmodel/PodcastsViewModel.kt | 15 ++-- .../profile/AccountDetailsViewModel.kt | 11 ++- .../pocketcasts/profile/ProfileViewModel.kt | 11 ++- .../profile/cloud/AddFileViewModel.kt | 4 +- .../cloud/CloudBottomSheetViewModel.kt | 6 +- .../profile/cloud/CloudFilesViewModel.kt | 8 +- .../profile/cloud/CloudSettingsViewModel.kt | 4 +- .../pocketcasts/search/SearchHandler.kt | 6 +- .../settings/PlusSettingsFragment.kt | 7 +- .../viewmodel/AutoAddSettingsViewModel.kt | 6 +- .../viewmodel/SettingsAppearanceViewModel.kt | 4 +- .../download/DownloadManagerImpl.kt | 4 +- .../repositories/playback/PlaybackManager.kt | 7 +- .../repositories/support/Support.kt | 4 +- .../views/multiselect/MultiSelectFragment.kt | 4 +- .../views/multiselect/MultiSelectHelper.kt | 4 +- wear/build.gradle | 7 ++ .../pocketcasts/wear/MainActivity.kt | 36 +------- .../wear/WearMainActivityViewModel.kt | 20 ++--- .../pocketcasts/wear/di/AuthWatchModule.kt | 25 ++++++ 49 files changed, 322 insertions(+), 243 deletions(-) create mode 100644 app/src/main/res/values/wear.xml create mode 100644 modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/TokenSerializer.kt create mode 100644 modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/Annotations.kt create mode 100644 modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthModule.kt create mode 100644 modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthPhoneModule.kt create mode 100644 wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/di/AuthWatchModule.kt diff --git a/app/build.gradle b/app/build.gradle index 70dc09388f9..b57af0a5c0a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,6 +11,8 @@ apply from: "../base.gradle" apply plugin: 'com.google.android.gms.oss-licenses-plugin' android { + compileSdkPreview = "UpsideDownCake" + namespace 'au.com.shiftyjelly.pocketcasts' defaultConfig { @@ -96,7 +98,7 @@ dependencies { implementation project(':modules:features:taskerplugin') implementation project(':modules:features:endofyear') - wearApp project(':wear') + implementation androidLibs.liveDataReactiveStreams } task appStart(type: Exec, dependsOn: 'installDebug') { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a9a828bb1a2..7a5612d7514 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -262,7 +262,7 @@ android:name="android.app.shortcuts" android:resource="@xml/shortcuts"/> - + binding.playerBottomSheet.setUpNext( upNext = upNext, @@ -718,7 +718,7 @@ class MainActivity : lifecycleScope.launch { // FIXME This gets called every time MainActivity resumes. Can we reduce how often this is called? // But if that gets fixed we may want to also start calling this explicitly in onCreate - watchSync.sendAuthToDataLayer(this@MainActivity) + watchSync.sendAuthToDataLayer() } } diff --git a/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivityViewModel.kt b/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivityViewModel.kt index eb35692f95e..1b1f3e2f801 100644 --- a/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivityViewModel.kt +++ b/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivityViewModel.kt @@ -1,8 +1,8 @@ package au.com.shiftyjelly.pocketcasts.ui import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import androidx.lifecycle.viewModelScope import au.com.shiftyjelly.pocketcasts.models.to.SignInState import au.com.shiftyjelly.pocketcasts.models.to.SubscriptionStatus @@ -42,9 +42,9 @@ class MainActivityViewModel Timber.d("Updated playback state from ${it.lastChangeFrom} is playing ${it.isPlaying}") } .toFlowable(BackpressureStrategy.LATEST) - val playbackState = LiveDataReactiveStreams.fromPublisher(playbackStateRx) + val playbackState = playbackStateRx.toLiveData() - val signInState: LiveData = LiveDataReactiveStreams.fromPublisher(userManager.getSignInState()) + val signInState: LiveData = userManager.getSignInState().toLiveData() val isSignedIn: Boolean get() = signInState.value?.isSignedIn ?: false diff --git a/app/src/main/res/values/wear.xml b/app/src/main/res/values/wear.xml new file mode 100644 index 00000000000..9ee443ca7f6 --- /dev/null +++ b/app/src/main/res/values/wear.xml @@ -0,0 +1,25 @@ + + + + + + + horologist_phone + + \ No newline at end of file diff --git a/automotive/src/main/java/au/com/shiftyjelly/pocketcasts/AutomotiveSettingsFragment.kt b/automotive/src/main/java/au/com/shiftyjelly/pocketcasts/AutomotiveSettingsFragment.kt index 6efd28fbc0f..90de3ca5c9d 100644 --- a/automotive/src/main/java/au/com/shiftyjelly/pocketcasts/AutomotiveSettingsFragment.kt +++ b/automotive/src/main/java/au/com/shiftyjelly/pocketcasts/AutomotiveSettingsFragment.kt @@ -6,7 +6,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment -import androidx.lifecycle.LiveDataReactiveStreams +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.account.AccountActivity import au.com.shiftyjelly.pocketcasts.databinding.FragmentAutomotiveSettingsBinding import au.com.shiftyjelly.pocketcasts.profile.AccountDetailsFragment @@ -30,7 +30,7 @@ class AutomotiveSettingsFragment : Fragment() { val userView = binding.userView - LiveDataReactiveStreams.fromPublisher(userManager.getSignInState()).observe(viewLifecycleOwner) { signInState -> + userManager.getSignInState().toLiveData().observe(viewLifecycleOwner) { signInState -> val loggedIn = signInState.isSignedIn if ((userView.signedInState != null && userView.signedInState?.isSignedIn == false) && loggedIn) { diff --git a/automotive/src/main/java/au/com/shiftyjelly/pocketcasts/AutomotiveSettingsPreferenceFragment.kt b/automotive/src/main/java/au/com/shiftyjelly/pocketcasts/AutomotiveSettingsPreferenceFragment.kt index e51e8316724..680e7357c06 100644 --- a/automotive/src/main/java/au/com/shiftyjelly/pocketcasts/AutomotiveSettingsPreferenceFragment.kt +++ b/automotive/src/main/java/au/com/shiftyjelly/pocketcasts/AutomotiveSettingsPreferenceFragment.kt @@ -4,8 +4,8 @@ import android.content.SharedPreferences import android.os.Bundle import android.view.View import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.Observer +import androidx.lifecycle.toLiveData import androidx.preference.EditTextPreference import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat @@ -63,13 +63,13 @@ class AutomotiveSettingsPreferenceFragment : PreferenceFragmentCompat(), SharedP override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - refreshObservable = LiveDataReactiveStreams.fromPublisher( + refreshObservable = settings.refreshStateObservable .toFlowable(BackpressureStrategy.LATEST) .switchMap { state -> Flowable.interval(500, TimeUnit.MILLISECONDS).switchMap { Flowable.just(state) } - } - ) + }.toLiveData() + refreshObservable?.observe(viewLifecycleOwner, this) } diff --git a/base.gradle b/base.gradle index e3048f5928a..197e812cf1a 100644 --- a/base.gradle +++ b/base.gradle @@ -60,7 +60,7 @@ android { freeCompilerArgs += [ "-opt-in=kotlin.RequiresOptIn" ] - kotlinOptions.allWarningsAsErrors = true + kotlinOptions.allWarningsAsErrors = false } composeOptions { @@ -287,4 +287,13 @@ dependencies { androidTestImplementation androidLibs.accessibilityTestFramework coreLibraryDesugaring androidLibs.desugarJdk + + constraints { + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0") { + because("kotlin-stdlib-jdk7 is now a part of kotlin-stdlib") + } + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0") { + because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib") + } + } } diff --git a/dependencies.gradle b/dependencies.gradle index f237fc29214..47e396b9d1d 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -92,7 +92,7 @@ project.ext { // When updating this, check to see if versionComposeWear will be updated as well (and update that variable if appropriate) // https://github.com/google/horologist/blob/main/gradle/libs.versions.toml - versionHorologist = '0.3.3' + versionHorologist = '0.3.7' versionKotlinCoroutines = '1.6.4' versionLifecycle = '2.5.1' @@ -188,7 +188,10 @@ project.ext { wearComposeNavigation: "androidx.wear.compose:compose-navigation:$versionComposeWear", wearPlayServices: "com.google.android.gms:play-services-wearable:18.0.0", + horologistAuthData: "com.google.android.horologist:horologist-auth-data:$versionHorologist", + horologistAuthDataPhone: "com.google.android.horologist:horologist-auth-data-phone:$versionHorologist", horologistComposeLayout: "com.google.android.horologist:horologist-compose-layout:$versionHorologist", + horologistDatalayer: "com.google.android.horologist:horologist-datalayer:$versionHorologist", horologistMedia: "com.google.android.horologist:horologist-media:$versionHorologist", horologistMediaUi: "com.google.android.horologist:horologist-media-ui:$versionHorologist", horologistMediaData: "com.google.android.horologist:horologist-media-data:$versionHorologist", diff --git a/modules/features/account/build.gradle b/modules/features/account/build.gradle index c3a23509e10..fc2d475574f 100644 --- a/modules/features/account/build.gradle +++ b/modules/features/account/build.gradle @@ -10,6 +10,12 @@ android { dataBinding = true compose true } + + kotlinOptions { + // Allow for widescale experimental APIs in Alpha libraries we build upon + freeCompilerArgs += "-opt-in=com.google.android.horologist.auth.data.phone.ExperimentalHorologistAuthDataPhoneApi" + freeCompilerArgs += "-opt-in=com.google.android.horologist.data.ExperimentalHorologistDataLayerApi" + } } dependencies { @@ -30,4 +36,8 @@ dependencies { implementation project(':modules:services:ui') implementation project(':modules:services:utils') implementation project(':modules:services:views') + + // android libs + implementation androidLibs.horologistAuthDataPhone + implementation androidLibs.horologistDatalayer } diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/TokenSerializer.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/TokenSerializer.kt new file mode 100644 index 00000000000..af78a874969 --- /dev/null +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/TokenSerializer.kt @@ -0,0 +1,21 @@ +package au.com.shiftyjelly.pocketcasts.account + +import androidx.datastore.core.Serializer +import java.io.InputStream +import java.io.InputStreamReader +import java.io.OutputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +public object TokenSerializer : Serializer { + override val defaultValue: String = "" + + override suspend fun readFrom(input: InputStream): String = + InputStreamReader(input).readText() + + override suspend fun writeTo(t: String, output: OutputStream) { + withContext(Dispatchers.IO) { + output.write(t.toByteArray()) + } + } +} diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt index a0a6231aae3..a44b93f7e3c 100644 --- a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt @@ -1,46 +1,28 @@ package au.com.shiftyjelly.pocketcasts.account import android.annotation.SuppressLint -import android.app.Activity -import android.content.Context import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer -import com.google.android.gms.wearable.DataEvent -import com.google.android.gms.wearable.DataEventBuffer -import com.google.android.gms.wearable.DataItem -import com.google.android.gms.wearable.DataMapItem -import com.google.android.gms.wearable.PutDataMapRequest -import com.google.android.gms.wearable.PutDataRequest -import com.google.android.gms.wearable.Wearable -import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton +import com.google.android.horologist.auth.data.phone.tokenshare.TokenBundleRepository @Singleton @SuppressLint("VisibleForTests") // https://issuetracker.google.com/issues/239451111 class WatchSync @Inject constructor( - @ApplicationContext context: Context, private val settings: Settings, private val accountAuth: AccountAuth, + private val tokenBundleRepository: TokenBundleRepository, ) { - - companion object { - private const val authPath = "/auth" - private const val authKeyRefreshToken = "refreshToken" - } - - private val dataClient = Wearable.getDataClient(context) - /** * This should be called by the phone app to update the refresh token available to * the watch app in the data layer. */ - suspend fun sendAuthToDataLayer(activity: Activity) { + suspend fun sendAuthToDataLayer() { withContext(Dispatchers.Default) { try { Timber.i("Updating refresh token in data layer") @@ -54,58 +36,32 @@ class WatchSync @Inject constructor( } else null } - val putDataReq: PutDataRequest = PutDataMapRequest.create(authPath).apply { - authData?.refreshToken?.let { - - dataMap.putString(authKeyRefreshToken, it) - } ?: dataMap.remove(authKeyRefreshToken) + authData?.refreshToken.let { refreshToken -> + tokenBundleRepository.update(refreshToken) } - .asPutDataRequest() - .setUrgent() - Wearable - .getDataClient(activity) - .putDataItem(putDataReq) } catch (cancellationException: CancellationException) { // Don't catch CancellationException since this represents the normal cancellation of a coroutine throw cancellationException } catch (exception: Exception) { - LogBuffer.e(LogBuffer.TAG_BACKGROUND_TASKS, "saving refresh token to data layer failed: $exception") - } - } - } - - suspend fun processDataChange(dataEventBuffer: DataEventBuffer) { - Timber.i("Received DataLayer change") - dataEventBuffer.use { buffer -> - buffer.forEach { event -> - processEvent(event) + LogBuffer.e( + LogBuffer.TAG_BACKGROUND_TASKS, + "saving refresh token to data layer failed: $exception" + ) } } } - suspend fun processLatestData() { - Timber.i("Checking latest sync data from Data Layer") - dataClient.dataItems.await().use { dataItemBuffer -> - dataItemBuffer.forEach { item -> - processItem(item) - } - } - } - - private suspend fun processEvent(event: DataEvent) { - processItem(event.dataItem) - } - - private suspend fun processAuthItem(item: DataItem) { - val dataMap = DataMapItem.fromDataItem(item).dataMap - val refreshToken = dataMap.getString(authKeyRefreshToken) + suspend fun processAuthDataChange(refreshToken: String?) { + Timber.i("Received refreshToken change") if (refreshToken != null) { // Don't do anything if the user is already logged in. if (!settings.isLoggedIn()) { val result = accountAuth.signInWithToken(refreshToken, SignInSource.WatchPhoneSync) when (result) { - is AccountAuth.AuthResult.Failed -> { /* do nothing */ } + is AccountAuth.AuthResult.Failed -> { /* do nothing */ + } + is AccountAuth.AuthResult.Success -> { Timber.e("TODO: notify the user we have signed them in!") } @@ -119,17 +75,4 @@ class WatchSync @Inject constructor( Timber.i("Received data from phone without refresh token") } } - - private suspend fun processItem(item: DataItem) { - val path = item.uri.path - Timber.i("Processing DataItem with path: $path") - when (path) { - authPath -> { - processAuthItem(item) - } - else -> { - Timber.e("Unable to process DataItem with unknown path: $path") - } - } - } } diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/Annotations.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/Annotations.kt new file mode 100644 index 00000000000..02dbe947786 --- /dev/null +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/Annotations.kt @@ -0,0 +1,7 @@ +package au.com.shiftyjelly.pocketcasts.account.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ForApplicationScope diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthModule.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthModule.kt new file mode 100644 index 00000000000..67b42697512 --- /dev/null +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthModule.kt @@ -0,0 +1,36 @@ +package au.com.shiftyjelly.pocketcasts.account.di + +import android.content.Context +import com.google.android.horologist.data.WearDataLayerRegistry +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +@Module +@InstallIn(SingletonComponent::class) +object AuthModule { + + @Singleton + @Provides + @ForApplicationScope + fun coroutineScope(): CoroutineScope = + CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @Singleton + @Provides + fun providesWearDataLayerRegistry( + @ApplicationContext context: Context, + @ForApplicationScope coroutineScope: CoroutineScope + ): WearDataLayerRegistry { + return WearDataLayerRegistry.fromContext( + application = context, + coroutineScope = coroutineScope + ) + } +} diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthPhoneModule.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthPhoneModule.kt new file mode 100644 index 00000000000..2c2b3b8630f --- /dev/null +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthPhoneModule.kt @@ -0,0 +1,28 @@ +package au.com.shiftyjelly.pocketcasts.account.di + +import au.com.shiftyjelly.pocketcasts.account.TokenSerializer +import com.google.android.horologist.auth.data.phone.tokenshare.TokenBundleRepository +import com.google.android.horologist.auth.data.phone.tokenshare.impl.TokenBundleRepositoryImpl +import com.google.android.horologist.data.WearDataLayerRegistry +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope + +@Module +@InstallIn(SingletonComponent::class) +object AuthPhoneModule { + + @Provides + fun providesTokenBundleRepository( + wearDataLayerRegistry: WearDataLayerRegistry, + @ForApplicationScope coroutineScope: CoroutineScope + ): TokenBundleRepository { + return TokenBundleRepositoryImpl( + registry = wearDataLayerRegistry, + coroutineScope = coroutineScope, + serializer = TokenSerializer + ) + } +} diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/viewmodel/AccountFragmentViewModel.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/viewmodel/AccountFragmentViewModel.kt index 34b052fae49..a4a15e8b10a 100644 --- a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/viewmodel/AccountFragmentViewModel.kt +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/viewmodel/AccountFragmentViewModel.kt @@ -1,7 +1,7 @@ package au.com.shiftyjelly.pocketcasts.account.viewmodel -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.repositories.user.UserManager import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -10,5 +10,5 @@ import javax.inject.Inject class AccountFragmentViewModel @Inject constructor( userManager: UserManager ) : ViewModel() { - val signInState = LiveDataReactiveStreams.fromPublisher(userManager.getSignInState()) + val signInState = userManager.getSignInState().toLiveData() } diff --git a/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/CreateFilterViewModel.kt b/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/CreateFilterViewModel.kt index 2e1db0a84d0..892e763bedf 100644 --- a/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/CreateFilterViewModel.kt +++ b/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/CreateFilterViewModel.kt @@ -1,9 +1,9 @@ package au.com.shiftyjelly.pocketcasts.filters import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper import au.com.shiftyjelly.pocketcasts.models.entity.Episode @@ -133,17 +133,17 @@ class CreateFilterViewModel @Inject constructor( } playlist = if (playlistUUID != null) { - LiveDataReactiveStreams.fromPublisher(playlistManager.findByUuidRx(playlistUUID).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).toFlowable()) + playlistManager.findByUuidRx(playlistUUID).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).toFlowable().toLiveData() } else { val newFilter = createFilter("", 0, 0) - LiveDataReactiveStreams.fromPublisher(playlistManager.observeByUuid(newFilter.uuid)) + playlistManager.observeByUuid(newFilter.uuid).toLiveData() } hasBeenInitialised = true } fun observeFilter(filter: Playlist): LiveData> { - return LiveDataReactiveStreams.fromPublisher(playlistManager.observeEpisodesPreview(filter, episodeManager, playbackManager)) + return playlistManager.observeEpisodesPreview(filter, episodeManager, playbackManager).toLiveData() } fun updateDownloadLimit(limit: Int) { diff --git a/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FilterEpisodeListViewModel.kt b/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FilterEpisodeListViewModel.kt index d3fb1c91b61..814fd625162 100644 --- a/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FilterEpisodeListViewModel.kt +++ b/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FilterEpisodeListViewModel.kt @@ -1,9 +1,9 @@ package au.com.shiftyjelly.pocketcasts.filters import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsSource import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper @@ -90,7 +90,7 @@ class FilterEpisodeListViewModel @Inject constructor( } .observeOn(AndroidSchedulers.mainThread()) .subscribeOn(Schedulers.io()) - episodesList = LiveDataReactiveStreams.fromPublisher(episodes) + episodesList = episodes.toLiveData() } fun deletePlaylist() { diff --git a/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FiltersFragmentViewModel.kt b/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FiltersFragmentViewModel.kt index 6ae3456926f..6e9556cc059 100644 --- a/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FiltersFragmentViewModel.kt +++ b/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FiltersFragmentViewModel.kt @@ -1,8 +1,8 @@ package au.com.shiftyjelly.pocketcasts.filters import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper import au.com.shiftyjelly.pocketcasts.models.entity.Playlist @@ -35,9 +35,8 @@ class FiltersFragmentViewModel @Inject constructor( override val coroutineContext: CoroutineContext get() = Dispatchers.Default - val filters: LiveData> = LiveDataReactiveStreams.fromPublisher( - playlistManager.observeAll() - ) + val filters: LiveData> = + playlistManager.observeAll().toLiveData() val countGenerator = { playlist: Playlist -> playlistManager.countEpisodesRx(playlist, episodeManager, playbackManager).onErrorReturn { 0 } diff --git a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/PlayerViewModel.kt b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/PlayerViewModel.kt index 9147002bfe4..56d69b37d7c 100644 --- a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/PlayerViewModel.kt +++ b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/PlayerViewModel.kt @@ -3,9 +3,9 @@ package au.com.shiftyjelly.pocketcasts.player.viewmodel import android.content.Context import android.content.res.Resources import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsSource import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper @@ -181,12 +181,12 @@ class PlayerViewModel @Inject constructor( ) .distinctUntilChanged() .toFlowable(BackpressureStrategy.LATEST) - val listDataLive: LiveData = LiveDataReactiveStreams.fromPublisher(listDataRx) - val playingEpisodeLive: LiveData> = LiveDataReactiveStreams.fromPublisher( + val listDataLive: LiveData = listDataRx.toLiveData() + val playingEpisodeLive: LiveData> = listDataRx.map { Pair(it.podcastHeader.episodeUuid, it.podcastHeader.backgroundColor) } .distinctUntilChanged() .switchMap { pair -> episodeManager.observePlayableByUuid(pair.first).map { Pair(it, pair.second) } } - ) + .toLiveData() private val shelfObservable = settings.shelfItemsObservable.map { list -> if (list.isEmpty()) { @@ -212,8 +212,8 @@ class PlayerViewModel @Inject constructor( return@map Pair(trimmedShelf, episode) } - val shelfLive: LiveData> = LiveDataReactiveStreams.fromPublisher(shelfObservable) - val trimmedShelfLive: LiveData, Playable?>> = LiveDataReactiveStreams.fromPublisher(trimmedShelfObservable) + val shelfLive: LiveData> = shelfObservable.toLiveData() + val trimmedShelfLive: LiveData, Playable?>> = trimmedShelfObservable.toLiveData() val upNextPlusData = upNextStateObservable.map { upNextState -> var episodeCount = 0 @@ -245,7 +245,7 @@ class PlayerViewModel @Inject constructor( return@map listOfNotNull(nowPlayingInfo, upNextSummary) + upNextEpisodes } - val upNextLive: LiveData> = LiveDataReactiveStreams.fromPublisher(upNextPlusData.toFlowable(BackpressureStrategy.LATEST)) + val upNextLive: LiveData> = upNextPlusData.toFlowable(BackpressureStrategy.LATEST).toLiveData() val effectsObservable: Flowable = playbackStateObservable .toFlowable(BackpressureStrategy.LATEST) @@ -261,7 +261,7 @@ class PlayerViewModel @Inject constructor( .map { PodcastEffectsPair(it, if (it.overrideGlobalEffects) it.playbackEffects else settings.getGlobalPlaybackEffects()) } .doOnNext { Timber.i("Effects: Podcast: ${it.podcast.overrideGlobalEffects} ${it.effects}") } .observeOn(AndroidSchedulers.mainThread()) - val effectsLive = LiveDataReactiveStreams.fromPublisher(effectsObservable) + val effectsLive = effectsObservable.toLiveData() var episode: Playable? = null var podcast: Podcast? = null diff --git a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/UpNextEpisodeViewModel.kt b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/UpNextEpisodeViewModel.kt index 87f332bf2cd..6f4f1087ac6 100644 --- a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/UpNextEpisodeViewModel.kt +++ b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/UpNextEpisodeViewModel.kt @@ -1,9 +1,9 @@ package au.com.shiftyjelly.pocketcasts.player.viewmodel import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsSource import au.com.shiftyjelly.pocketcasts.models.entity.Episode import au.com.shiftyjelly.pocketcasts.models.entity.Playable @@ -41,7 +41,7 @@ class UpNextEpisodeViewModel private var episodeUuid: String? = null val episode = MutableLiveData() val podcast = MutableLiveData() - val isNextEpisode: LiveData = LiveDataReactiveStreams.fromPublisher( + val isNextEpisode: LiveData = playbackManager.upNextQueue.changesObservable.map { upNext -> if (upNext is UpNextQueue.State.Loaded) { upNext.queue.indexOfFirst { it.uuid == episodeUuid } == 0 @@ -49,8 +49,9 @@ class UpNextEpisodeViewModel false } }.toFlowable(BackpressureStrategy.LATEST) - ) - val isPlayingEpisode: LiveData = LiveDataReactiveStreams.fromPublisher( + .toLiveData() + + val isPlayingEpisode: LiveData = playbackManager.upNextQueue.changesObservable.map { upNext -> if (upNext is UpNextQueue.State.Loaded) { upNext.episode.uuid == episodeUuid @@ -58,7 +59,8 @@ class UpNextEpisodeViewModel false } }.toFlowable(BackpressureStrategy.LATEST) - ) + .toLiveData() + fun loadEpisode(episodeUuid: String) { this.episodeUuid = episodeUuid diff --git a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/VideoViewModel.kt b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/VideoViewModel.kt index f6cbf9d9177..36758f38573 100644 --- a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/VideoViewModel.kt +++ b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/VideoViewModel.kt @@ -2,9 +2,9 @@ package au.com.shiftyjelly.pocketcasts.player.viewmodel import android.os.Build import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsSource import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackState @@ -21,7 +21,7 @@ class VideoViewModel @Inject constructor( private val playbackManager: PlaybackManager ) : ViewModel() { - val playbackState: LiveData = LiveDataReactiveStreams.fromPublisher(playbackManager.playbackStateRelay.toFlowable(BackpressureStrategy.LATEST)) + val playbackState: LiveData = playbackManager.playbackStateRelay.toFlowable(BackpressureStrategy.LATEST).toLiveData() private var hideControlsTimer: Disposable? = null private var lastTimeHidingControls = 0L diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/ProfileEpisodeListViewModel.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/ProfileEpisodeListViewModel.kt index 92f724b9a43..e7e87211d76 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/ProfileEpisodeListViewModel.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/ProfileEpisodeListViewModel.kt @@ -1,8 +1,8 @@ package au.com.shiftyjelly.pocketcasts.podcasts.view import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsSource import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper @@ -41,7 +41,7 @@ class ProfileEpisodeListViewModel @Inject constructor( is ProfileEpisodeListFragment.Mode.History -> episodeManager.observePlaybackHistoryEpisodes() } - episodeList = LiveDataReactiveStreams.fromPublisher(episodeListFlowable) + episodeList = episodeListFlowable.toLiveData() } @Suppress("UNUSED_PARAMETER") diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragmentViewModel.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragmentViewModel.kt index 8366cdad41f..17161b199b5 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragmentViewModel.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragmentViewModel.kt @@ -3,10 +3,10 @@ package au.com.shiftyjelly.pocketcasts.podcasts.view.episode import android.content.Context import androidx.annotation.ColorInt import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations import androidx.lifecycle.ViewModel +import androidx.lifecycle.map +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsSource import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper @@ -61,7 +61,7 @@ class EpisodeFragmentViewModel @Inject constructor( lateinit var state: LiveData val showNotes: MutableLiveData = MutableLiveData() lateinit var inUpNext: LiveData - val isPlaying: LiveData = Transformations.map(playbackManager.playbackStateLive) { + val isPlaying: LiveData = playbackManager.playbackStateLive.map { it.episodeUuid == episode?.uuid && it.isPlaying } @@ -120,11 +120,11 @@ class EpisodeFragmentViewModel @Inject constructor( .observeOn(AndroidSchedulers.mainThread()) .subscribeOn(Schedulers.io()) - state = LiveDataReactiveStreams.fromPublisher(stateObservable) + state = stateObservable.toLiveData() val inUpNextObservable = playbackManager.upNextQueue.changesObservable.toFlowable(BackpressureStrategy.LATEST) .map { upNext -> (upNext is UpNextQueue.State.Loaded) && (upNext.episode == episode || upNext.queue.map { it.uuid }.contains(episodeUUID)) } - inUpNext = LiveDataReactiveStreams.fromPublisher(inUpNextObservable) + inUpNext = inUpNextObservable.toLiveData() } override fun onCleared() { diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAutoArchiveViewModel.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAutoArchiveViewModel.kt index 609a4934e47..12a31cd5229 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAutoArchiveViewModel.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAutoArchiveViewModel.kt @@ -1,18 +1,18 @@ package au.com.shiftyjelly.pocketcasts.podcasts.view.podcast import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.models.entity.Podcast import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager import dagger.hilt.android.lifecycle.HiltViewModel import io.reactivex.schedulers.Schedulers +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import javax.inject.Inject -import kotlin.coroutines.CoroutineContext @HiltViewModel class PodcastAutoArchiveViewModel @Inject constructor( @@ -29,7 +29,7 @@ class PodcastAutoArchiveViewModel @Inject constructor( } fun setup(podcastUUID: String) { - podcast = LiveDataReactiveStreams.fromPublisher(podcastManager.observePodcastByUuid(podcastUUID).subscribeOn(Schedulers.io())) + podcast = podcastManager.observePodcastByUuid(podcastUUID).subscribeOn(Schedulers.io()).toLiveData() } fun updateGlobalOverride(checked: Boolean) { diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/share/ShareListIncomingViewModel.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/share/ShareListIncomingViewModel.kt index 0a4fb26e7fd..02b0dbbea35 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/share/ShareListIncomingViewModel.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/share/ShareListIncomingViewModel.kt @@ -1,8 +1,8 @@ package au.com.shiftyjelly.pocketcasts.podcasts.view.share -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import androidx.lifecycle.viewModelScope import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper @@ -28,12 +28,12 @@ class ShareListIncomingViewModel ) : ViewModel(), CoroutineScope { var isFragmentChangingConfigurations: Boolean = false val share = MutableLiveData() - val subscribedUuids = LiveDataReactiveStreams.fromPublisher( + val subscribedUuids = podcastManager.getSubscribedPodcastUuids() .subscribeOn(Schedulers.io()) .toFlowable() .mergeWith(podcastManager.observePodcastSubscriptions()) - ) + .toLiveData() override val coroutineContext: CoroutineContext get() = Dispatchers.Default diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastEffectsViewModel.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastEffectsViewModel.kt index f8c0e41dda6..8e93cd15f42 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastEffectsViewModel.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastEffectsViewModel.kt @@ -1,8 +1,8 @@ package au.com.shiftyjelly.pocketcasts.podcasts.viewmodel import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsSource import au.com.shiftyjelly.pocketcasts.models.entity.Episode @@ -36,7 +36,7 @@ class PodcastEffectsViewModel lateinit var podcast: LiveData fun loadPodcast(uuid: String) { - podcast = LiveDataReactiveStreams.fromPublisher(podcastManager.observePodcastByUuid(uuid).subscribeOn(Schedulers.io())) + podcast = podcastManager.observePodcastByUuid(uuid).subscribeOn(Schedulers.io()).toLiveData() } fun updateOverrideGlobalEffects(override: Boolean) { diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastSettingsViewModel.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastSettingsViewModel.kt index 3898b54d7a5..3e83ff91475 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastSettingsViewModel.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastSettingsViewModel.kt @@ -1,8 +1,8 @@ package au.com.shiftyjelly.pocketcasts.podcasts.viewmodel import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsSource import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper @@ -42,24 +42,24 @@ class PodcastSettingsViewModel @Inject constructor( lateinit var includedFilters: LiveData> lateinit var availableFilters: LiveData> - val globalSettings = LiveDataReactiveStreams.fromPublisher( + val globalSettings = settings.autoAddUpNextLimit.toFlowable(BackpressureStrategy.LATEST) .combineLatest(settings.autoAddUpNextLimitBehaviour.toFlowable(BackpressureStrategy.LATEST)) - ) + .toLiveData() fun loadPodcast(uuid: String) { this.podcastUuid = uuid - podcast = LiveDataReactiveStreams.fromPublisher(podcastManager.observePodcastByUuid(uuid).subscribeOn(Schedulers.io())) + podcast = podcastManager.observePodcastByUuid(uuid).subscribeOn(Schedulers.io()).toLiveData() val filters = playlistManager.observeAll().map { it.filter { filter -> filter.podcastUuidList.contains(uuid) } } - includedFilters = LiveDataReactiveStreams.fromPublisher(filters) + includedFilters = filters.toLiveData() val availablePodcastFilters = playlistManager.observeAll().map { it.filter { filter -> !filter.allPodcasts } } - availableFilters = LiveDataReactiveStreams.fromPublisher(availablePodcastFilters) + availableFilters = availablePodcastFilters.toLiveData() } fun isAutoAddToUpNextOn(): Boolean { diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastViewModel.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastViewModel.kt index 50f73b0ecf6..dadead8d0b0 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastViewModel.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastViewModel.kt @@ -4,9 +4,9 @@ import android.content.Context import android.content.res.Resources import android.widget.Toast import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsSource import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper @@ -75,18 +75,17 @@ class PodcastViewModel lateinit var podcastUuid: String lateinit var episodes: LiveData val groupedEpisodes: MutableLiveData>> = MutableLiveData() - val signInState = LiveDataReactiveStreams.fromPublisher(userManager.getSignInState()) + val signInState = userManager.getSignInState().toLiveData() val tintColor = MutableLiveData() val observableHeaderExpanded = MutableLiveData() private val searchQueryRelay = BehaviorRelay.create() .apply { accept("") } - val castConnected = LiveDataReactiveStreams.fromPublisher( + val castConnected = castManager.isConnectedObservable.toFlowable( BackpressureStrategy.LATEST - ) - ) + ).toLiveData() override val coroutineContext: CoroutineContext get() = Dispatchers.Default @@ -166,7 +165,7 @@ class PodcastViewModel } .observeOn(AndroidSchedulers.mainThread()) - episodes = LiveDataReactiveStreams.fromPublisher(episodeStateFlowable) + episodes = episodeStateFlowable.toLiveData() } override fun onCleared() { diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastsViewModel.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastsViewModel.kt index 9378a9e3aff..feed583a4c6 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastsViewModel.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastsViewModel.kt @@ -1,8 +1,8 @@ package au.com.shiftyjelly.pocketcasts.podcasts.viewmodel import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper import au.com.shiftyjelly.pocketcasts.models.entity.Folder @@ -54,9 +54,9 @@ class PodcastsViewModel val isSignedInAsPlus: Boolean ) - val signInState: LiveData = LiveDataReactiveStreams.fromPublisher(userManager.getSignInState()) + val signInState: LiveData = userManager.getSignInState().toLiveData() - val folderState: LiveData = LiveDataReactiveStreams.fromPublisher( + val folderState: LiveData = combineLatest( // monitor all subscribed podcasts, get the podcast in 'Episode release date' as the rest can be done in memory podcastManager.observePodcastsOrderByLatestEpisode(), @@ -120,7 +120,7 @@ class PodcastsViewModel } } .doOnNext { adapterState = it.items.toMutableList() } - ) + .toLiveData() val folder: Folder? get() = folderState.value?.folder @@ -167,7 +167,6 @@ class PodcastsViewModel } val podcastUuidToBadge: LiveData> = - LiveDataReactiveStreams.fromPublisher( settings.podcastBadgeTypeObservable .toFlowable(BackpressureStrategy.LATEST) .switchMap { badgeType -> @@ -177,12 +176,12 @@ class PodcastsViewModel else -> Flowable.just(emptyMap()) } } - ) + .toLiveData() // We only want the current badge type when loading for this observable or else it will rebind the adapter every time the badge changes. We use take(1) for this. - val layoutChangedLiveData = LiveDataReactiveStreams.fromPublisher(Observables.combineLatest(settings.podcastLayoutObservable, settings.podcastBadgeTypeObservable.take(1)).toFlowable(BackpressureStrategy.LATEST)) + val layoutChangedLiveData = Observables.combineLatest(settings.podcastLayoutObservable, settings.podcastBadgeTypeObservable.take(1)).toFlowable(BackpressureStrategy.LATEST).toLiveData() - val refreshObservable: LiveData = LiveDataReactiveStreams.fromPublisher(settings.refreshStateObservable.toFlowable(BackpressureStrategy.LATEST)) + val refreshObservable: LiveData = settings.refreshStateObservable.toFlowable(BackpressureStrategy.LATEST).toLiveData() private var adapterState: MutableList = mutableListOf() diff --git a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/AccountDetailsViewModel.kt b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/AccountDetailsViewModel.kt index efe53a4bbe2..c82b9435b3a 100644 --- a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/AccountDetailsViewModel.kt +++ b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/AccountDetailsViewModel.kt @@ -1,10 +1,10 @@ package au.com.shiftyjelly.pocketcasts.profile import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.map +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.account.viewmodel.NewsletterSource import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper @@ -58,19 +58,18 @@ class AccountDetailsViewModel Optional.empty() } } - val signInState = LiveDataReactiveStreams.fromPublisher(userManager.getSignInState()) - val viewState: LiveData> = LiveDataReactiveStreams - .fromPublisher(userManager.getSignInState().combineLatest(subscription)) + val signInState = userManager.getSignInState().toLiveData() + val viewState: LiveData> = userManager.getSignInState().combineLatest(subscription).toLiveData() .combineLatest(deleteAccountState) .map { Triple(it.first.first, it.first.second.get(), it.second) } val accountStartDate: LiveData = MutableLiveData().apply { value = Date(statsManager.statsStartTime) } - val marketingOptInState: LiveData = LiveDataReactiveStreams.fromPublisher( + val marketingOptInState: LiveData = settings.marketingOptObservable .distinctUntilChanged() .toFlowable(BackpressureStrategy.LATEST) - ) + .toLiveData() fun deleteAccount() { syncServerManager.deleteAccount() diff --git a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/ProfileViewModel.kt b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/ProfileViewModel.kt index 71e50a3c1a6..180207b3777 100644 --- a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/ProfileViewModel.kt +++ b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/ProfileViewModel.kt @@ -1,9 +1,9 @@ package au.com.shiftyjelly.pocketcasts.profile import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.models.to.RefreshState import au.com.shiftyjelly.pocketcasts.models.to.SignInState import au.com.shiftyjelly.pocketcasts.preferences.Settings @@ -24,18 +24,17 @@ class ProfileViewModel @Inject constructor( private val endOfYearManager: EndOfYearManager ) : ViewModel() { var isFragmentChangingConfigurations: Boolean = false - val podcastCount: LiveData = LiveDataReactiveStreams.fromPublisher(podcastManager.observeCountSubscribed()) + val podcastCount: LiveData = podcastManager.observeCountSubscribed().toLiveData() val daysListenedCount: MutableLiveData = MutableLiveData() val daysSavedCount: MutableLiveData = MutableLiveData() val isSignedIn: Boolean get() = signInState.value?.isSignedIn ?: false - val signInState: LiveData = LiveDataReactiveStreams.fromPublisher(userManager.getSignInState()) + val signInState: LiveData = userManager.getSignInState().toLiveData() - val refreshObservable: LiveData = LiveDataReactiveStreams.fromPublisher( - settings.refreshStateObservable.toFlowable(BackpressureStrategy.LATEST) - ) + val refreshObservable: LiveData = + settings.refreshStateObservable.toFlowable(BackpressureStrategy.LATEST).toLiveData() suspend fun isEndOfYearStoriesEligible() = endOfYearManager.isEligibleForStories() diff --git a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/AddFileViewModel.kt b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/AddFileViewModel.kt index e8fc7b40414..c83db12d1d2 100644 --- a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/AddFileViewModel.kt +++ b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/AddFileViewModel.kt @@ -1,8 +1,8 @@ package au.com.shiftyjelly.pocketcasts.profile.cloud import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.models.entity.UserEpisode import au.com.shiftyjelly.pocketcasts.models.to.SignInState import au.com.shiftyjelly.pocketcasts.repositories.podcast.UserEpisodeManager @@ -20,7 +20,7 @@ class AddFileViewModel @Inject constructor( val userEpisodeManager: UserEpisodeManager ) : ViewModel() { - val signInState: LiveData = LiveDataReactiveStreams.fromPublisher(userManager.getSignInState()) + val signInState: LiveData = userManager.getSignInState().toLiveData() suspend fun updateImageOnServer(userEpisode: UserEpisode, imageFile: File) = withContext(Dispatchers.IO) { userEpisodeManager.uploadImageToServer(userEpisode, imageFile).await() diff --git a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudBottomSheetViewModel.kt b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudBottomSheetViewModel.kt index 478dcf69fe4..8ec486dd31e 100644 --- a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudBottomSheetViewModel.kt +++ b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudBottomSheetViewModel.kt @@ -1,8 +1,8 @@ package au.com.shiftyjelly.pocketcasts.profile.cloud import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import androidx.lifecycle.viewModelScope import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsSource @@ -40,7 +40,7 @@ class CloudBottomSheetViewModel @Inject constructor( userManager: UserManager ) : ViewModel() { lateinit var state: LiveData - var signInState = LiveDataReactiveStreams.fromPublisher(userManager.getSignInState()) + var signInState = userManager.getSignInState().toLiveData() private val source = AnalyticsSource.FILES fun setup(uuid: String) { @@ -50,7 +50,7 @@ class CloudBottomSheetViewModel @Inject constructor( val combined = Flowables.combineLatest(episodeFlowable, inUpNextFlowable, isPlayingFlowable) { episode, inUpNext, isPlaying -> BottomSheetState(episode, inUpNext, isPlaying) } - state = LiveDataReactiveStreams.fromPublisher(combined) + state = combined.toLiveData() } fun getDeleteStateOnDeleteClick(episode: UserEpisode): DeleteState { diff --git a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudFilesViewModel.kt b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudFilesViewModel.kt index 3246b99bc51..ee2cbccaff7 100644 --- a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudFilesViewModel.kt +++ b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudFilesViewModel.kt @@ -1,7 +1,7 @@ package au.com.shiftyjelly.pocketcasts.profile.cloud -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsSource import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper @@ -39,9 +39,9 @@ class CloudFilesViewModel @Inject constructor( val sortOrderRelay = BehaviorRelay.create().apply { accept(settings.getCloudSortOrder()) } val sortedCloudFiles = sortOrderRelay.toFlowable(BackpressureStrategy.LATEST).switchMap { userEpisodeManager.observeUserEpisodesSorted(it) } - val cloudFilesList = LiveDataReactiveStreams.fromPublisher(sortedCloudFiles) - val accountUsage = LiveDataReactiveStreams.fromPublisher(userEpisodeManager.observeAccountUsage()) - val signInState = LiveDataReactiveStreams.fromPublisher(userManager.getSignInState()) + val cloudFilesList = sortedCloudFiles.toLiveData() + val accountUsage = userEpisodeManager.observeAccountUsage().toLiveData() + val signInState = userManager.getSignInState().toLiveData() fun refreshFiles(userInitiated: Boolean) { if (userInitiated) { diff --git a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsViewModel.kt b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsViewModel.kt index 3f12cae3837..c194f6f7aa4 100644 --- a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsViewModel.kt +++ b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsViewModel.kt @@ -1,8 +1,8 @@ package au.com.shiftyjelly.pocketcasts.profile.cloud import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper import au.com.shiftyjelly.pocketcasts.models.to.SignInState @@ -18,7 +18,7 @@ class CloudSettingsViewModel @Inject constructor( userManager: UserManager, ) : ViewModel() { - val signInState: LiveData = LiveDataReactiveStreams.fromPublisher(userManager.getSignInState()) + val signInState: LiveData = userManager.getSignInState().toLiveData() private var isFragmentChangingConfigurations: Boolean = false diff --git a/modules/features/search/src/main/java/au/com/shiftyjelly/pocketcasts/search/SearchHandler.kt b/modules/features/search/src/main/java/au/com/shiftyjelly/pocketcasts/search/SearchHandler.kt index cf115daed27..b29005345cc 100644 --- a/modules/features/search/src/main/java/au/com/shiftyjelly/pocketcasts/search/SearchHandler.kt +++ b/modules/features/search/src/main/java/au/com/shiftyjelly/pocketcasts/search/SearchHandler.kt @@ -1,7 +1,7 @@ package au.com.shiftyjelly.pocketcasts.search import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsSource import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper @@ -162,8 +162,8 @@ class SearchHandler @Inject constructor( .observeOn(AndroidSchedulers.mainThread()) .toFlowable(BackpressureStrategy.LATEST) - val searchResults: LiveData = LiveDataReactiveStreams.fromPublisher(searchFlowable) - val loading: LiveData = LiveDataReactiveStreams.fromPublisher(loadingObservable.toFlowable(BackpressureStrategy.LATEST)) + val searchResults: LiveData = searchFlowable.toLiveData() + val loading: LiveData = loadingObservable.toFlowable(BackpressureStrategy.LATEST).toLiveData() fun updateSearchQuery(query: String, immediate: Boolean = false) { searchQuery.accept(Query(query, immediate)) diff --git a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlusSettingsFragment.kt b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlusSettingsFragment.kt index 4caeb223733..ff216f911a3 100644 --- a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlusSettingsFragment.kt +++ b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlusSettingsFragment.kt @@ -10,7 +10,7 @@ import android.widget.TextView import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.appcompat.content.res.AppCompatResources -import androidx.lifecycle.LiveDataReactiveStreams +import androidx.lifecycle.toLiveData import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter @@ -57,7 +57,7 @@ class PlusSettingsFragment : BaseFragment() { val recyclerView = binding.recyclerView - LiveDataReactiveStreams.fromPublisher(subscriptionManager.observeProductDetails()).observe(viewLifecycleOwner) { productDetailsState -> + subscriptionManager.observeProductDetails().toLiveData().observe(viewLifecycleOwner) { productDetailsState -> val subscriptions = when (productDetailsState) { is ProductDetailsState.Error -> null is ProductDetailsState.Loaded -> productDetailsState.productDetails.mapNotNull { @@ -128,8 +128,7 @@ class PlusSettingsFragment : BaseFragment() { binding?.toolbar?.setup(title = getString(LR.string.pocket_casts_plus), navigationIcon = BackArrow, activity = activity, theme = theme) - LiveDataReactiveStreams - .fromPublisher(userManager.getSignInState()) + userManager.getSignInState().toLiveData() .observe(viewLifecycleOwner) { signInState -> // If the user has gone through the upgraded to Plus, we no longer want // to present this screen since it is asking them to sign up for Plus diff --git a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/viewmodel/AutoAddSettingsViewModel.kt b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/viewmodel/AutoAddSettingsViewModel.kt index fd6bab319ea..354deed1d2e 100644 --- a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/viewmodel/AutoAddSettingsViewModel.kt +++ b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/viewmodel/AutoAddSettingsViewModel.kt @@ -1,7 +1,7 @@ package au.com.shiftyjelly.pocketcasts.settings.viewmodel -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import androidx.lifecycle.viewModelScope import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper @@ -36,11 +36,11 @@ class AutoAddSettingsViewModel @Inject constructor( isFragmentChangingConfigurations = isChangingConfigurations ?: false } - val autoAddPodcasts = LiveDataReactiveStreams.fromPublisher( + val autoAddPodcasts = podcastManager.observeAutoAddToUpNextPodcasts() .combineLatest(settings.autoAddUpNextLimit.toFlowable(BackpressureStrategy.LATEST), settings.autoAddUpNextLimitBehaviour.toFlowable(BackpressureStrategy.LATEST)) .map { AutoAddSettingsState(it.first, it.second, it.third) } - ) + .toLiveData() fun updatePodcast(podcast: Podcast, autoAddOption: Podcast.AutoAddUpNext) { viewModelScope.launch { diff --git a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/viewmodel/SettingsAppearanceViewModel.kt b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/viewmodel/SettingsAppearanceViewModel.kt index 6749c33eccc..617c4a02ebe 100644 --- a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/viewmodel/SettingsAppearanceViewModel.kt +++ b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/viewmodel/SettingsAppearanceViewModel.kt @@ -2,9 +2,9 @@ package au.com.shiftyjelly.pocketcasts.settings.viewmodel import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper import au.com.shiftyjelly.pocketcasts.models.to.SignInState @@ -26,7 +26,7 @@ class SettingsAppearanceViewModel @Inject constructor( private val analyticsTracker: AnalyticsTrackerWrapper, ) : ViewModel() { - val signInState: LiveData = LiveDataReactiveStreams.fromPublisher(userManager.getSignInState()) + val signInState: LiveData = userManager.getSignInState().toLiveData() val createAccountState = MutableLiveData().apply { value = SettingsAppearanceState.Empty } val showArtworkOnLockScreen = MutableLiveData(settings.showArtworkOnLockScreen()) val useEmbeddedArtwork = MutableLiveData(settings.getUseEmbeddedArtwork()) diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/download/DownloadManagerImpl.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/download/DownloadManagerImpl.kt index deb33a928fe..7b4f6dcbca9 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/download/DownloadManagerImpl.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/download/DownloadManagerImpl.kt @@ -6,7 +6,7 @@ import android.content.Context import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams +import androidx.lifecycle.toLiveData import androidx.work.Constraints import androidx.work.Data import androidx.work.OneTimeWorkRequestBuilder @@ -120,7 +120,7 @@ class DownloadManagerImpl @Inject constructor( cleanUpStaleDownloads(workManager) } - val episodeLiveData = LiveDataReactiveStreams.fromPublisher(episodeFlowable) + val episodeLiveData = episodeFlowable.toLiveData() workManagerListener = workManager.getWorkInfosByTagLiveData(DownloadManager.WORK_MANAGER_DOWNLOAD_TAG).combineLatest(episodeLiveData) workManagerListener?.observeForever { (tasks, episodeUuids) -> diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt index 6e2dde20cc6..6bc64408176 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt @@ -10,7 +10,7 @@ import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat -import androidx.lifecycle.LiveDataReactiveStreams +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsSource import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper @@ -135,11 +135,10 @@ open class PlaybackManager @Inject constructor( Log.d(Settings.LOG_TAG_AUTO, "Init playback state") return@lazy relay } - val playbackStateLive = LiveDataReactiveStreams.fromPublisher( + val playbackStateLive = playbackStateRelay.toFlowable( BackpressureStrategy.LATEST - ) - ) + ).toLiveData() private var updateCount = 0 private var resettingPlayer = false diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/support/Support.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/support/Support.kt index 17b9d71bd1d..a9d6d194d8d 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/support/Support.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/support/Support.kt @@ -8,8 +8,8 @@ import android.os.Build import android.util.DisplayMetrics import android.view.WindowManager import androidx.core.text.HtmlCompat -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.toPublisher import androidx.work.WorkManager import au.com.shiftyjelly.pocketcasts.localization.R import au.com.shiftyjelly.pocketcasts.models.entity.Episode @@ -233,7 +233,7 @@ class Support @Inject constructor( output.append(eol) output.append("Work Manager Tasks").append(eol) - val workInfos = LiveDataReactiveStreams.toPublisher(ProcessLifecycleOwner.get(), WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadManager.WORK_MANAGER_DOWNLOAD_TAG)).awaitFirst() + val workInfos = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadManager.WORK_MANAGER_DOWNLOAD_TAG).toPublisher(ProcessLifecycleOwner.get()).awaitFirst() workInfos.forEach { workInfo -> output.append(workInfo.toString()).append(" Attempt=").append(workInfo.runAttemptCount).append(eol) } diff --git a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectFragment.kt b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectFragment.kt index 6985001b187..b0dcaf9466a 100644 --- a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectFragment.kt +++ b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectFragment.kt @@ -5,7 +5,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf -import androidx.lifecycle.LiveDataReactiveStreams +import androidx.lifecycle.toLiveData import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.SimpleItemAnimator import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent @@ -75,7 +75,7 @@ class MultiSelectFragment : BaseFragment(), MultiSelectTouchCallback.ItemTouchHe itemTouchHelper = ItemTouchHelper(callback) itemTouchHelper.attachToRecyclerView(recyclerView) - LiveDataReactiveStreams.fromPublisher(settings.multiSelectItemsObservable.toFlowable(BackpressureStrategy.LATEST)) + settings.multiSelectItemsObservable.toFlowable(BackpressureStrategy.LATEST).toLiveData() .observe(viewLifecycleOwner) { val multiSelectActions: MutableList = MultiSelectAction.listFromIds(it).toMutableList() diff --git a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectHelper.kt b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectHelper.kt index 7c3dfa44193..b4272abd11d 100644 --- a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectHelper.kt +++ b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectHelper.kt @@ -5,9 +5,9 @@ import android.content.res.Resources import android.view.View import android.widget.Toast import androidx.fragment.app.FragmentManager -import androidx.lifecycle.LiveDataReactiveStreams import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Transformations +import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsSource import au.com.shiftyjelly.pocketcasts.analytics.EpisodeAnalytics @@ -80,7 +80,7 @@ class MultiSelectHelper @Inject constructor( val selectedListLive = Transformations.map(_selectedListLive) { it } val selectedCount = Transformations.map(_selectedListLive) { it.size } - private val settingsToolbarActions = LiveDataReactiveStreams.fromPublisher(settings.multiSelectItemsObservable.toFlowable(BackpressureStrategy.LATEST).map { MultiSelectAction.listFromIds(it) }) + private val settingsToolbarActions = settings.multiSelectItemsObservable.toFlowable(BackpressureStrategy.LATEST).map { MultiSelectAction.listFromIds(it) }.toLiveData() val toolbarActions = Transformations.map(settingsToolbarActions.combineLatest(selectedListLive)) { (actions, selectedEpisodes) -> actions.mapNotNull { MultiSelectAction.actionForGroup(it.groupId, selectedEpisodes) diff --git a/wear/build.gradle b/wear/build.gradle index 827903d3255..6d948a08760 100644 --- a/wear/build.gradle +++ b/wear/build.gradle @@ -8,6 +8,8 @@ apply plugin: 'com.google.gms.google-services' apply from: "../base.gradle" android { + compileSdkPreview = "UpsideDownCake" + defaultConfig { minSdk project.minSdkVersionWear applicationId project.applicationId @@ -59,6 +61,9 @@ android { kotlinOptions { jvmTarget = "1.8" // Allow for widescale experimental APIs in Alpha libraries we build upon + freeCompilerArgs += "-opt-in=androidx.wear.compose.foundation.ExperimentalWearFoundationApi" + freeCompilerArgs += "-opt-in=com.google.android.horologist.auth.data.ExperimentalHorologistAuthDataApi" + freeCompilerArgs += "-opt-in=com.google.android.horologist.data.ExperimentalHorologistDataLayerApi" freeCompilerArgs += "-opt-in=com.google.android.horologist.media.ExperimentalHorologistMediaApi" freeCompilerArgs += "-opt-in=com.google.android.horologist.media.ui.ExperimentalHorologistMediaUiApi" freeCompilerArgs += "-opt-in=com.google.android.horologist.media.data.ExperimentalHorologistMediaDataApi" @@ -84,6 +89,8 @@ dependencies { implementation androidLibs.wearComposeNavigation // Horologist Dependencies + implementation androidLibs.horologistAuthData + implementation androidLibs.horologistDatalayer implementation androidLibs.horologistMedia implementation androidLibs.horologistMediaUi implementation androidLibs.horologistMediaData diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt index 969722c8a19..2f2747de262 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt @@ -5,13 +5,8 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.DisposableEffectResult -import androidx.compose.runtime.DisposableEffectScope import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.navigation.NavType import androidx.navigation.navArgument import androidx.wear.compose.navigation.rememberSwipeDismissableNavController @@ -26,7 +21,6 @@ import au.com.shiftyjelly.pocketcasts.wear.ui.authenticationGraph import au.com.shiftyjelly.pocketcasts.wear.ui.player.NowPlayingScreen import au.com.shiftyjelly.pocketcasts.wear.ui.podcast.PodcastScreen import au.com.shiftyjelly.pocketcasts.wear.ui.podcasts.PodcastsScreen -import com.google.android.gms.wearable.Wearable import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel import com.google.android.horologist.compose.navscaffold.WearNavScaffold import com.google.android.horologist.compose.navscaffold.composable @@ -37,42 +31,18 @@ import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { - @Inject lateinit var theme: Theme + @Inject + lateinit var theme: Theme private val viewModel: WearMainActivityViewModel by viewModels() - private val dataClient by lazy { Wearable.getDataClient(this) } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + viewModel.startMonitoringAuth() setContent { - DisposableEffect(lifecycle) { - monitorDataLayerForChanges() - } - WearApp(theme.activeTheme) } } - - private fun DisposableEffectScope.monitorDataLayerForChanges(): DisposableEffectResult { - - // immediately check for any changes on launch - viewModel.checkLatestSyncData() - - // listen for changes after launch - val listener = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_START -> dataClient.addListener(viewModel.phoneSyncDataListener) - Lifecycle.Event.ON_STOP -> dataClient.removeListener(viewModel.phoneSyncDataListener) - else -> { /* do nothing */ - } - } - } - lifecycle.addObserver(listener) - return onDispose { - lifecycle.removeObserver(listener) - } - } } @Composable diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt index c85362fc2df..cf239978a89 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt @@ -3,25 +3,23 @@ package au.com.shiftyjelly.pocketcasts.wear import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import au.com.shiftyjelly.pocketcasts.account.WatchSync -import com.google.android.gms.wearable.DataClient +import com.google.android.horologist.auth.data.tokenshare.TokenBundleRepository import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch import javax.inject.Inject +import kotlinx.coroutines.launch @HiltViewModel class WearMainActivityViewModel @Inject constructor( - private val watchSync: WatchSync + private val watchSync: WatchSync, + private val tokenBundleRepository: TokenBundleRepository ) : ViewModel() { - val phoneSyncDataListener = DataClient.OnDataChangedListener { dataEventBuffer -> - viewModelScope.launch { - watchSync.processDataChange(dataEventBuffer) - } - } - - fun checkLatestSyncData() { + fun startMonitoringAuth() { viewModelScope.launch { - watchSync.processLatestData() + tokenBundleRepository.flow + .collect { + watchSync.processAuthDataChange(it) + } } } } diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/di/AuthWatchModule.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/di/AuthWatchModule.kt new file mode 100644 index 00000000000..2f45514cd4e --- /dev/null +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/di/AuthWatchModule.kt @@ -0,0 +1,25 @@ +package au.com.shiftyjelly.pocketcasts.wear.di + +import au.com.shiftyjelly.pocketcasts.account.TokenSerializer +import com.google.android.horologist.auth.data.tokenshare.TokenBundleRepository +import com.google.android.horologist.auth.data.tokenshare.impl.TokenBundleRepositoryImpl +import com.google.android.horologist.data.WearDataLayerRegistry +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object AuthWatchModule { + + @Provides + fun providesTokenBundleRepository( + wearDataLayerRegistry: WearDataLayerRegistry, + ): TokenBundleRepository { + return TokenBundleRepositoryImpl.create( + registry = wearDataLayerRegistry, + serializer = TokenSerializer + ) + } +} From 9aa8dc120b12b2ab3b59f6466cd606b59d474c01 Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Thu, 16 Mar 2023 11:56:32 -0400 Subject: [PATCH 03/22] Update horologist to 0.3.9 --- dependencies.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dependencies.gradle b/dependencies.gradle index 47e396b9d1d..972a9ac3151 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -83,7 +83,7 @@ project.ext { versionCompose = '1.2.1' // When updating this, also check if versionComposeAccompanist should be updated as well: https://github.com/google/accompanist#compose-versions versionComposeAccompanist = '0.25.1' versionComposeCompiler = '1.3.0' - versionComposeWear = '1.2.0-alpha4' + versionComposeWear = '1.2.0-alpha05' versionDagger = '2.41' versionEspresso = '3.4.0' versionExoplayer = '2.18.2' @@ -92,7 +92,7 @@ project.ext { // When updating this, check to see if versionComposeWear will be updated as well (and update that variable if appropriate) // https://github.com/google/horologist/blob/main/gradle/libs.versions.toml - versionHorologist = '0.3.7' + versionHorologist = '0.3.9' versionKotlinCoroutines = '1.6.4' versionLifecycle = '2.5.1' From 087521ce055c7d5d92b436f3d04c8c3e54f3db21 Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Thu, 16 Mar 2023 12:28:54 -0400 Subject: [PATCH 04/22] Spotless fixes --- .../pocketcasts/account/TokenSerializer.kt | 4 ++-- .../pocketcasts/account/WatchSync.kt | 7 +++---- .../pocketcasts/account/di/Annotations.kt | 7 ------- .../pocketcasts/account/di/AuthModule.kt | 2 +- .../pocketcasts/account/di/AuthPhoneModule.kt | 5 +++++ .../player/viewmodel/UpNextEpisodeViewModel.kt | 1 - .../podcast/PodcastAutoArchiveViewModel.kt | 4 ++-- .../podcasts/viewmodel/PodcastsViewModel.kt | 18 +++++++++--------- .../settings/PlusSettingsFragment.kt | 2 +- .../wear/WearMainActivityViewModel.kt | 2 +- 10 files changed, 24 insertions(+), 28 deletions(-) delete mode 100644 modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/Annotations.kt diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/TokenSerializer.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/TokenSerializer.kt index af78a874969..09104395ac9 100644 --- a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/TokenSerializer.kt +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/TokenSerializer.kt @@ -1,11 +1,11 @@ package au.com.shiftyjelly.pocketcasts.account import androidx.datastore.core.Serializer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.InputStream import java.io.InputStreamReader import java.io.OutputStream -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext public object TokenSerializer : Serializer { override val defaultValue: String = "" diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt index a44b93f7e3c..c8e113b01f8 100644 --- a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt @@ -3,13 +3,13 @@ package au.com.shiftyjelly.pocketcasts.account import android.annotation.SuppressLint import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer -import javax.inject.Inject -import javax.inject.Singleton +import com.google.android.horologist.auth.data.phone.tokenshare.TokenBundleRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import timber.log.Timber -import com.google.android.horologist.auth.data.phone.tokenshare.TokenBundleRepository +import javax.inject.Inject +import javax.inject.Singleton @Singleton @SuppressLint("VisibleForTests") // https://issuetracker.google.com/issues/239451111 @@ -39,7 +39,6 @@ class WatchSync @Inject constructor( authData?.refreshToken.let { refreshToken -> tokenBundleRepository.update(refreshToken) } - } catch (cancellationException: CancellationException) { // Don't catch CancellationException since this represents the normal cancellation of a coroutine throw cancellationException diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/Annotations.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/Annotations.kt deleted file mode 100644 index 02dbe947786..00000000000 --- a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/Annotations.kt +++ /dev/null @@ -1,7 +0,0 @@ -package au.com.shiftyjelly.pocketcasts.account.di - -import javax.inject.Qualifier - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class ForApplicationScope diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthModule.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthModule.kt index 67b42697512..4620766bcff 100644 --- a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthModule.kt +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthModule.kt @@ -7,10 +7,10 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthPhoneModule.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthPhoneModule.kt index 2c2b3b8630f..57fdca934c2 100644 --- a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthPhoneModule.kt +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthPhoneModule.kt @@ -9,6 +9,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineScope +import javax.inject.Qualifier @Module @InstallIn(SingletonComponent::class) @@ -26,3 +27,7 @@ object AuthPhoneModule { ) } } + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ForApplicationScope diff --git a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/UpNextEpisodeViewModel.kt b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/UpNextEpisodeViewModel.kt index 6f4f1087ac6..1921fc2f7d6 100644 --- a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/UpNextEpisodeViewModel.kt +++ b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/UpNextEpisodeViewModel.kt @@ -61,7 +61,6 @@ class UpNextEpisodeViewModel }.toFlowable(BackpressureStrategy.LATEST) .toLiveData() - fun loadEpisode(episodeUuid: String) { this.episodeUuid = episodeUuid episodeManager.observePlayableByUuid(episodeUuid) diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAutoArchiveViewModel.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAutoArchiveViewModel.kt index 12a31cd5229..21cdd0a847a 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAutoArchiveViewModel.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAutoArchiveViewModel.kt @@ -8,11 +8,11 @@ import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager import dagger.hilt.android.lifecycle.HiltViewModel import io.reactivex.schedulers.Schedulers -import javax.inject.Inject -import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext @HiltViewModel class PodcastAutoArchiveViewModel @Inject constructor( diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastsViewModel.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastsViewModel.kt index feed583a4c6..406880633e8 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastsViewModel.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastsViewModel.kt @@ -167,16 +167,16 @@ class PodcastsViewModel } val podcastUuidToBadge: LiveData> = - settings.podcastBadgeTypeObservable - .toFlowable(BackpressureStrategy.LATEST) - .switchMap { badgeType -> - return@switchMap when (badgeType) { - Settings.BadgeType.ALL_UNFINISHED -> episodeManager.getPodcastUuidToBadgeUnfinished() - Settings.BadgeType.LATEST_EPISODE -> episodeManager.getPodcastUuidToBadgeLatest() - else -> Flowable.just(emptyMap()) - } + settings.podcastBadgeTypeObservable + .toFlowable(BackpressureStrategy.LATEST) + .switchMap { badgeType -> + return@switchMap when (badgeType) { + Settings.BadgeType.ALL_UNFINISHED -> episodeManager.getPodcastUuidToBadgeUnfinished() + Settings.BadgeType.LATEST_EPISODE -> episodeManager.getPodcastUuidToBadgeLatest() + else -> Flowable.just(emptyMap()) } - .toLiveData() + } + .toLiveData() // We only want the current badge type when loading for this observable or else it will rebind the adapter every time the badge changes. We use take(1) for this. val layoutChangedLiveData = Observables.combineLatest(settings.podcastLayoutObservable, settings.podcastBadgeTypeObservable.take(1)).toFlowable(BackpressureStrategy.LATEST).toLiveData() diff --git a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlusSettingsFragment.kt b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlusSettingsFragment.kt index ff216f911a3..262f260ada0 100644 --- a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlusSettingsFragment.kt +++ b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlusSettingsFragment.kt @@ -128,7 +128,7 @@ class PlusSettingsFragment : BaseFragment() { binding?.toolbar?.setup(title = getString(LR.string.pocket_casts_plus), navigationIcon = BackArrow, activity = activity, theme = theme) - userManager.getSignInState().toLiveData() + userManager.getSignInState().toLiveData() .observe(viewLifecycleOwner) { signInState -> // If the user has gone through the upgraded to Plus, we no longer want // to present this screen since it is asking them to sign up for Plus diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt index cf239978a89..bffaed1e907 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt @@ -5,8 +5,8 @@ import androidx.lifecycle.viewModelScope import au.com.shiftyjelly.pocketcasts.account.WatchSync import com.google.android.horologist.auth.data.tokenshare.TokenBundleRepository import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import kotlinx.coroutines.launch +import javax.inject.Inject @HiltViewModel class WearMainActivityViewModel @Inject constructor( From f2fc35c7416adbb4e4cbb7252880fb727ffb8fdf Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Thu, 16 Mar 2023 16:44:18 -0400 Subject: [PATCH 05/22] Turn warnings as errors back on --- .../shiftyjelly/pocketcasts/ui/MainActivity.kt | 17 +++++++---------- base.gradle | 2 +- .../pocketcasts/profile/ProfileFragment.kt | 1 + wear/build.gradle | 1 - 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt b/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt index cf6a46d2023..91bf00da00a 100644 --- a/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt +++ b/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt @@ -280,6 +280,7 @@ class MainActivity : setContentView(view) checkForNotificationPermission() + @Suppress("DEPRECATION") lifecycleScope.launchWhenCreated { val isEligible = viewModel.isEndOfYearStoriesEligible() if (isEligible) { @@ -987,18 +988,14 @@ class MainActivity : private fun showAccountUpgradeNowDialog(shouldClose: Boolean, autoSelectPlus: Boolean = false) { val observer: Observer = Observer { value -> - val intent: Intent - if (value != null && value.isSignedInAsFree) { - intent = - AccountActivity.newUpgradeInstance(this) + val intent = if (value.isSignedInAsFree) { + AccountActivity.newUpgradeInstance(this) } else if (autoSelectPlus) { - intent = - AccountActivity.newAutoSelectPlusInstance( - this - ) + AccountActivity.newAutoSelectPlusInstance( + this + ) } else { - intent = - Intent(this, AccountActivity::class.java) + Intent(this, AccountActivity::class.java) } startActivity(intent) diff --git a/base.gradle b/base.gradle index 197e812cf1a..64fc8bf8ebd 100644 --- a/base.gradle +++ b/base.gradle @@ -60,7 +60,7 @@ android { freeCompilerArgs += [ "-opt-in=kotlin.RequiresOptIn" ] - kotlinOptions.allWarningsAsErrors = false + kotlinOptions.allWarningsAsErrors = true } composeOptions { diff --git a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/ProfileFragment.kt b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/ProfileFragment.kt index 81755421224..89725fdaebf 100644 --- a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/ProfileFragment.kt +++ b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/ProfileFragment.kt @@ -155,6 +155,7 @@ class ProfileFragment : BaseFragment() { true } + @Suppress("DEPRECATION") lifecycleScope.launchWhenStarted { val isEligible = viewModel.isEndOfYearStoriesEligible() binding.setupEndOfYearPromptCard(isEligible) diff --git a/wear/build.gradle b/wear/build.gradle index 6d948a08760..8feb10628ee 100644 --- a/wear/build.gradle +++ b/wear/build.gradle @@ -61,7 +61,6 @@ android { kotlinOptions { jvmTarget = "1.8" // Allow for widescale experimental APIs in Alpha libraries we build upon - freeCompilerArgs += "-opt-in=androidx.wear.compose.foundation.ExperimentalWearFoundationApi" freeCompilerArgs += "-opt-in=com.google.android.horologist.auth.data.ExperimentalHorologistAuthDataApi" freeCompilerArgs += "-opt-in=com.google.android.horologist.data.ExperimentalHorologistDataLayerApi" freeCompilerArgs += "-opt-in=com.google.android.horologist.media.ExperimentalHorologistMediaApi" From bf2ad8a1fe50ca56dbd2aa052832b055708cd551 Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Thu, 16 Mar 2023 16:46:14 -0400 Subject: [PATCH 06/22] Remove unnecessary dependency declaration --- app/build.gradle | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index b57af0a5c0a..76ec27d3233 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -97,8 +97,6 @@ dependencies { implementation project(':modules:features:account') implementation project(':modules:features:taskerplugin') implementation project(':modules:features:endofyear') - - implementation androidLibs.liveDataReactiveStreams } task appStart(type: Exec, dependsOn: 'installDebug') { From 7465ea00418f257ec14f193a4f8f0ec9c031be3b Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Thu, 16 Mar 2023 21:07:28 -0400 Subject: [PATCH 07/22] Clarify/simplify kotlin stdlib version constraint --- base.gradle | 6 ++---- dependencies.gradle | 1 + 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/base.gradle b/base.gradle index 64fc8bf8ebd..99de7065a89 100644 --- a/base.gradle +++ b/base.gradle @@ -289,10 +289,8 @@ dependencies { coreLibraryDesugaring androidLibs.desugarJdk constraints { - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0") { - because("kotlin-stdlib-jdk7 is now a part of kotlin-stdlib") - } - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0") { + // Avoid duplicate class compile failure: https://stackoverflow.com/a/75298544/1910286 + implementation(libs.kotlinJdk8) { because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib") } } diff --git a/dependencies.gradle b/dependencies.gradle index 972a9ac3151..8502a378d46 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -207,6 +207,7 @@ project.ext { libs = [ kotlin: "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version", + kotlinJdk8: "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.10", kotlinCoroutines: "org.jetbrains.kotlinx:kotlinx-coroutines-core:$versionKotlinCoroutines", kotlinCoroutinesAndroid: "org.jetbrains.kotlinx:kotlinx-coroutines-android:$versionKotlinCoroutines", kotlinCoroutinesPlayServices: "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$versionKotlinCoroutines", From ca51cb62339d22393b46dd275acaf4da4805d17a Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Fri, 17 Mar 2023 11:12:15 -0400 Subject: [PATCH 08/22] Remove compileSdkPreview declarations --- app/build.gradle | 1 - wear/build.gradle | 1 - 2 files changed, 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 76ec27d3233..82fbe72e79d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,7 +11,6 @@ apply from: "../base.gradle" apply plugin: 'com.google.android.gms.oss-licenses-plugin' android { - compileSdkPreview = "UpsideDownCake" namespace 'au.com.shiftyjelly.pocketcasts' diff --git a/wear/build.gradle b/wear/build.gradle index 8feb10628ee..ea091e52ba6 100644 --- a/wear/build.gradle +++ b/wear/build.gradle @@ -8,7 +8,6 @@ apply plugin: 'com.google.gms.google-services' apply from: "../base.gradle" android { - compileSdkPreview = "UpsideDownCake" defaultConfig { minSdk project.minSdkVersionWear From 23319e0ea54dc81528e5330169e3ed6fbe76ecd4 Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Fri, 17 Mar 2023 14:52:42 -0400 Subject: [PATCH 09/22] Updating our lifecycle dependencies to 2.6.0 --- .../pocketcasts/ui/MainActivity.kt | 21 +-- .../pocketcasts/AutomotiveSettingsFragment.kt | 32 ++--- .../AutomotiveSettingsPreferenceFragment.kt | 8 +- dependencies.gradle | 2 +- .../filters/CreateFilterViewModel.kt | 16 ++- .../filters/FiltersFragmentViewModel.kt | 3 +- .../player/view/ChaptersFragment.kt | 10 +- .../player/viewmodel/VideoViewModel.kt | 4 +- .../podcast/PodcastAutoArchiveViewModel.kt | 5 +- .../viewmodel/PodcastEffectsViewModel.kt | 5 +- .../viewmodel/PodcastSettingsViewModel.kt | 13 +- .../podcasts/viewmodel/PodcastViewModel.kt | 7 +- .../podcasts/viewmodel/PodcastsViewModel.kt | 122 +++++++++--------- .../profile/AccountDetailsViewModel.kt | 9 +- .../pocketcasts/profile/ProfileFragment.kt | 12 +- .../pocketcasts/profile/ProfileViewModel.kt | 4 +- .../pocketcasts/search/SearchFragment.kt | 2 +- .../MediaNotificationControlsFragment.kt | 21 +-- .../settings/PlusSettingsFragment.kt | 121 ++++++++--------- .../settings/StorageSettingsFragment.kt | 13 +- .../viewmodel/SettingsAppearanceViewModel.kt | 4 +- .../repositories/playback/PlaybackManager.kt | 7 +- .../repositories/support/Support.kt | 5 +- .../pocketcasts/utils/LiveDataUtil.kt | 4 +- .../views/multiselect/MultiSelectHelper.kt | 34 ++--- 25 files changed, 269 insertions(+), 215 deletions(-) diff --git a/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt b/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt index 91bf00da00a..1e5a364a1e8 100644 --- a/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt +++ b/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt @@ -26,9 +26,11 @@ import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.fragment.app.commitNow +import androidx.lifecycle.Lifecycle import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.toLiveData import androidx.mediarouter.media.MediaControlIntent import androidx.mediarouter.media.MediaRouteSelector @@ -280,15 +282,16 @@ class MainActivity : setContentView(view) checkForNotificationPermission() - @Suppress("DEPRECATION") - lifecycleScope.launchWhenCreated { - val isEligible = viewModel.isEndOfYearStoriesEligible() - if (isEligible) { - if (!settings.getEndOfYearModalHasBeenShown()) { - setupEndOfYearLaunchBottomSheet() - } - if (settings.getEndOfYearShowBadge2022()) { - binding.bottomNavigation.getOrCreateBadge(VR.id.navigation_profile) + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) { + val isEligible = viewModel.isEndOfYearStoriesEligible() + if (isEligible) { + if (!settings.getEndOfYearModalHasBeenShown()) { + setupEndOfYearLaunchBottomSheet() + } + if (settings.getEndOfYearShowBadge2022()) { + binding.bottomNavigation.getOrCreateBadge(VR.id.navigation_profile) + } } } } diff --git a/automotive/src/main/java/au/com/shiftyjelly/pocketcasts/AutomotiveSettingsFragment.kt b/automotive/src/main/java/au/com/shiftyjelly/pocketcasts/AutomotiveSettingsFragment.kt index 90de3ca5c9d..4d2b16b3c48 100644 --- a/automotive/src/main/java/au/com/shiftyjelly/pocketcasts/AutomotiveSettingsFragment.kt +++ b/automotive/src/main/java/au/com/shiftyjelly/pocketcasts/AutomotiveSettingsFragment.kt @@ -30,24 +30,26 @@ class AutomotiveSettingsFragment : Fragment() { val userView = binding.userView - userManager.getSignInState().toLiveData().observe(viewLifecycleOwner) { signInState -> - val loggedIn = signInState.isSignedIn - - if ((userView.signedInState != null && userView.signedInState?.isSignedIn == false) && loggedIn) { - // We have to close after signing in to meet Google UX requirements - activity?.finish() - } + userManager.getSignInState() + .toLiveData() + .observe(viewLifecycleOwner) { signInState -> + val loggedIn = signInState.isSignedIn + + if ((userView.signedInState != null && userView.signedInState?.isSignedIn == false) && loggedIn) { + // We have to close after signing in to meet Google UX requirements + activity?.finish() + } - userView.signedInState = signInState - userView.setOnClickListener { - if (loggedIn) { - val fragment = AccountDetailsFragment.newInstance() - (activity as? AutomotiveSettingsActivity)?.addFragment(fragment) - } else { - signIn() + userView.signedInState = signInState + userView.setOnClickListener { + if (loggedIn) { + val fragment = AccountDetailsFragment.newInstance() + (activity as? AutomotiveSettingsActivity)?.addFragment(fragment) + } else { + signIn() + } } } - } } fun signIn() { diff --git a/automotive/src/main/java/au/com/shiftyjelly/pocketcasts/AutomotiveSettingsPreferenceFragment.kt b/automotive/src/main/java/au/com/shiftyjelly/pocketcasts/AutomotiveSettingsPreferenceFragment.kt index 680e7357c06..4929d237c71 100644 --- a/automotive/src/main/java/au/com/shiftyjelly/pocketcasts/AutomotiveSettingsPreferenceFragment.kt +++ b/automotive/src/main/java/au/com/shiftyjelly/pocketcasts/AutomotiveSettingsPreferenceFragment.kt @@ -68,8 +68,8 @@ class AutomotiveSettingsPreferenceFragment : PreferenceFragmentCompat(), SharedP .toFlowable(BackpressureStrategy.LATEST) .switchMap { state -> Flowable.interval(500, TimeUnit.MILLISECONDS).switchMap { Flowable.just(state) } - }.toLiveData() - + } + .toLiveData() refreshObservable?.observe(viewLifecycleOwner, this) } @@ -79,8 +79,8 @@ class AutomotiveSettingsPreferenceFragment : PreferenceFragmentCompat(), SharedP refreshObservable?.removeObserver(this) } - override fun onChanged(state: RefreshState) { - updateRefreshSummary(state) + override fun onChanged(value: RefreshState) { + updateRefreshSummary(value) } private fun updateRefreshSummary(state: RefreshState) { diff --git a/dependencies.gradle b/dependencies.gradle index 8502a378d46..a15a6420448 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -95,7 +95,7 @@ project.ext { versionHorologist = '0.3.9' versionKotlinCoroutines = '1.6.4' - versionLifecycle = '2.5.1' + versionLifecycle = '2.6.0' versionMedia3 = '1.0.0-beta03' versionMoshi = '1.13.0' versionNavigation = '2.5.1' diff --git a/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/CreateFilterViewModel.kt b/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/CreateFilterViewModel.kt index 892e763bedf..54deeb34ff8 100644 --- a/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/CreateFilterViewModel.kt +++ b/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/CreateFilterViewModel.kt @@ -133,18 +133,22 @@ class CreateFilterViewModel @Inject constructor( } playlist = if (playlistUUID != null) { - playlistManager.findByUuidRx(playlistUUID).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).toFlowable().toLiveData() + playlistManager.findByUuidRx(playlistUUID) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .toFlowable() } else { val newFilter = createFilter("", 0, 0) - playlistManager.observeByUuid(newFilter.uuid).toLiveData() - } + playlistManager.observeByUuid(newFilter.uuid) + }.toLiveData() hasBeenInitialised = true } - fun observeFilter(filter: Playlist): LiveData> { - return playlistManager.observeEpisodesPreview(filter, episodeManager, playbackManager).toLiveData() - } + fun observeFilter(filter: Playlist): LiveData> = + playlistManager + .observeEpisodesPreview(filter, episodeManager, playbackManager) + .toLiveData() fun updateDownloadLimit(limit: Int) { userChangedAutoDownloadEpisodeCount.recordUserChange() diff --git a/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FiltersFragmentViewModel.kt b/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FiltersFragmentViewModel.kt index 6e9556cc059..3fe60992031 100644 --- a/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FiltersFragmentViewModel.kt +++ b/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FiltersFragmentViewModel.kt @@ -35,8 +35,7 @@ class FiltersFragmentViewModel @Inject constructor( override val coroutineContext: CoroutineContext get() = Dispatchers.Default - val filters: LiveData> = - playlistManager.observeAll().toLiveData() + val filters: LiveData> = playlistManager.observeAll().toLiveData() val countGenerator = { playlist: Playlist -> playlistManager.countEpisodesRx(playlist, episodeManager, playbackManager).onErrorReturn { 0 } diff --git a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/ChaptersFragment.kt b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/ChaptersFragment.kt index 2b679b2d6b2..98b3d6de1e9 100644 --- a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/ChaptersFragment.kt +++ b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/ChaptersFragment.kt @@ -9,7 +9,9 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.SimpleItemAnimator import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper @@ -19,6 +21,7 @@ import au.com.shiftyjelly.pocketcasts.player.viewmodel.PlayerViewModel import au.com.shiftyjelly.pocketcasts.views.fragments.BaseFragment import au.com.shiftyjelly.pocketcasts.views.helper.UiUtil import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import javax.inject.Inject import au.com.shiftyjelly.pocketcasts.localization.R as LR @@ -54,8 +57,11 @@ class ChaptersFragment : BaseFragment(), ChapterListener { adapter.submitList(it.chapters) view.setBackgroundColor(it.podcastHeader.backgroundColor) } - viewLifecycleOwner.lifecycleScope.launchWhenStarted { - playerViewModel.showPlayerFlow.collect { showPlayer() } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + playerViewModel.showPlayerFlow.collect { showPlayer() } + } } } diff --git a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/VideoViewModel.kt b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/VideoViewModel.kt index 36758f38573..b7c49e8e798 100644 --- a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/VideoViewModel.kt +++ b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/VideoViewModel.kt @@ -21,7 +21,9 @@ class VideoViewModel @Inject constructor( private val playbackManager: PlaybackManager ) : ViewModel() { - val playbackState: LiveData = playbackManager.playbackStateRelay.toFlowable(BackpressureStrategy.LATEST).toLiveData() + val playbackState: LiveData = playbackManager.playbackStateRelay + .toFlowable(BackpressureStrategy.LATEST) + .toLiveData() private var hideControlsTimer: Disposable? = null private var lastTimeHidingControls = 0L diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAutoArchiveViewModel.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAutoArchiveViewModel.kt index 21cdd0a847a..b28569fc2d5 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAutoArchiveViewModel.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAutoArchiveViewModel.kt @@ -29,7 +29,10 @@ class PodcastAutoArchiveViewModel @Inject constructor( } fun setup(podcastUUID: String) { - podcast = podcastManager.observePodcastByUuid(podcastUUID).subscribeOn(Schedulers.io()).toLiveData() + podcast = podcastManager + .observePodcastByUuid(podcastUUID) + .subscribeOn(Schedulers.io()) + .toLiveData() } fun updateGlobalOverride(checked: Boolean) { diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastEffectsViewModel.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastEffectsViewModel.kt index 8e93cd15f42..9ba6b1b0efd 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastEffectsViewModel.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastEffectsViewModel.kt @@ -36,7 +36,10 @@ class PodcastEffectsViewModel lateinit var podcast: LiveData fun loadPodcast(uuid: String) { - podcast = podcastManager.observePodcastByUuid(uuid).subscribeOn(Schedulers.io()).toLiveData() + podcast = podcastManager + .observePodcastByUuid(uuid) + .subscribeOn(Schedulers.io()) + .toLiveData() } fun updateOverrideGlobalEffects(override: Boolean) { diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastSettingsViewModel.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastSettingsViewModel.kt index 3e83ff91475..41562e4a848 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastSettingsViewModel.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastSettingsViewModel.kt @@ -42,14 +42,17 @@ class PodcastSettingsViewModel @Inject constructor( lateinit var includedFilters: LiveData> lateinit var availableFilters: LiveData> - val globalSettings = - settings.autoAddUpNextLimit.toFlowable(BackpressureStrategy.LATEST) - .combineLatest(settings.autoAddUpNextLimitBehaviour.toFlowable(BackpressureStrategy.LATEST)) - .toLiveData() + val globalSettings = settings.autoAddUpNextLimit + .toFlowable(BackpressureStrategy.LATEST) + .combineLatest(settings.autoAddUpNextLimitBehaviour.toFlowable(BackpressureStrategy.LATEST)) + .toLiveData() fun loadPodcast(uuid: String) { this.podcastUuid = uuid - podcast = podcastManager.observePodcastByUuid(uuid).subscribeOn(Schedulers.io()).toLiveData() + podcast = podcastManager + .observePodcastByUuid(uuid) + .subscribeOn(Schedulers.io()) + .toLiveData() val filters = playlistManager.observeAll().map { it.filter { filter -> filter.podcastUuidList.contains(uuid) } diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastViewModel.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastViewModel.kt index dadead8d0b0..5f9ae5344fd 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastViewModel.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastViewModel.kt @@ -82,10 +82,9 @@ class PodcastViewModel private val searchQueryRelay = BehaviorRelay.create() .apply { accept("") } - val castConnected = - castManager.isConnectedObservable.toFlowable( - BackpressureStrategy.LATEST - ).toLiveData() + val castConnected = castManager.isConnectedObservable + .toFlowable(BackpressureStrategy.LATEST) + .toLiveData() override val coroutineContext: CoroutineContext get() = Dispatchers.Default diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastsViewModel.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastsViewModel.kt index 406880633e8..4e09865f888 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastsViewModel.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastsViewModel.kt @@ -56,71 +56,70 @@ class PodcastsViewModel val signInState: LiveData = userManager.getSignInState().toLiveData() - val folderState: LiveData = - combineLatest( - // monitor all subscribed podcasts, get the podcast in 'Episode release date' as the rest can be done in memory - podcastManager.observePodcastsOrderByLatestEpisode(), - // monitor all the folders - folderManager.observeFolders() - .switchMap { folders -> - if (folders.isEmpty()) { - Flowable.just(emptyList()) - } else { - // monitor the folder podcasts - val observeFolderPodcasts = folders.map { folder -> - podcastManager - .observePodcastsInFolderOrderByUserChoice(folder) - .map { podcasts -> - FolderItem.Folder( - folder = folder, - podcasts = podcasts - ) - } - } - Flowable.zip(observeFolderPodcasts) { results -> - results.toList().filterIsInstance() - } + val folderState: LiveData = combineLatest( + // monitor all subscribed podcasts, get the podcast in 'Episode release date' as the rest can be done in memory + podcastManager.observePodcastsOrderByLatestEpisode(), + // monitor all the folders + folderManager.observeFolders() + .switchMap { folders -> + if (folders.isEmpty()) { + Flowable.just(emptyList()) + } else { + // monitor the folder podcasts + val observeFolderPodcasts = folders.map { folder -> + podcastManager + .observePodcastsInFolderOrderByUserChoice(folder) + .map { podcasts -> + FolderItem.Folder( + folder = folder, + podcasts = podcasts + ) + } + } + Flowable.zip(observeFolderPodcasts) { results -> + results.toList().filterIsInstance() } - }, - // monitor the folder uuid - folderUuidObservable.toFlowable(BackpressureStrategy.LATEST), - // monitor the home folder sort order - settings.podcastSortTypeObservable.toFlowable(BackpressureStrategy.LATEST), - // show folders for Plus users - userManager.getSignInState() - ) { podcasts, folders, folderUuidOptional, podcastSortOrder, signInState -> - val folderUuid = folderUuidOptional.orElse(null) - if (!signInState.isSignedInAsPlus) { + } + }, + // monitor the folder uuid + folderUuidObservable.toFlowable(BackpressureStrategy.LATEST), + // monitor the home folder sort order + settings.podcastSortTypeObservable.toFlowable(BackpressureStrategy.LATEST), + // show folders for Plus users + userManager.getSignInState() + ) { podcasts, folders, folderUuidOptional, podcastSortOrder, signInState -> + val folderUuid = folderUuidOptional.orElse(null) + if (!signInState.isSignedInAsPlus) { + FolderState( + items = buildPodcastItems(podcasts, podcastSortOrder), + folder = null, + isSignedInAsPlus = false + ) + } else if (folderUuid == null) { + FolderState( + items = buildHomeFolderItems(podcasts, folders, podcastSortOrder), + folder = null, + isSignedInAsPlus = true + ) + } else { + val openFolder = folders.firstOrNull { it.uuid == folderUuid } + if (openFolder == null) { FolderState( - items = buildPodcastItems(podcasts, podcastSortOrder), + items = emptyList(), folder = null, - isSignedInAsPlus = false + isSignedInAsPlus = true ) - } else if (folderUuid == null) { + } else { FolderState( - items = buildHomeFolderItems(podcasts, folders, podcastSortOrder), - folder = null, + items = openFolder.podcasts.map { FolderItem.Podcast(it) }, + folder = openFolder.folder, isSignedInAsPlus = true ) - } else { - val openFolder = folders.firstOrNull { it.uuid == folderUuid } - if (openFolder == null) { - FolderState( - items = emptyList(), - folder = null, - isSignedInAsPlus = true - ) - } else { - FolderState( - items = openFolder.podcasts.map { FolderItem.Podcast(it) }, - folder = openFolder.folder, - isSignedInAsPlus = true - ) - } } } - .doOnNext { adapterState = it.items.toMutableList() } - .toLiveData() + } + .doOnNext { adapterState = it.items.toMutableList() } + .toLiveData() val folder: Folder? get() = folderState.value?.folder @@ -175,13 +174,16 @@ class PodcastsViewModel Settings.BadgeType.LATEST_EPISODE -> episodeManager.getPodcastUuidToBadgeLatest() else -> Flowable.just(emptyMap()) } - } - .toLiveData() + }.toLiveData() // We only want the current badge type when loading for this observable or else it will rebind the adapter every time the badge changes. We use take(1) for this. - val layoutChangedLiveData = Observables.combineLatest(settings.podcastLayoutObservable, settings.podcastBadgeTypeObservable.take(1)).toFlowable(BackpressureStrategy.LATEST).toLiveData() + val layoutChangedLiveData = Observables.combineLatest(settings.podcastLayoutObservable, settings.podcastBadgeTypeObservable.take(1)) + .toFlowable(BackpressureStrategy.LATEST) + .toLiveData() - val refreshObservable: LiveData = settings.refreshStateObservable.toFlowable(BackpressureStrategy.LATEST).toLiveData() + val refreshObservable: LiveData = settings.refreshStateObservable + .toFlowable(BackpressureStrategy.LATEST) + .toLiveData() private var adapterState: MutableList = mutableListOf() diff --git a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/AccountDetailsViewModel.kt b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/AccountDetailsViewModel.kt index c82b9435b3a..414044f80e3 100644 --- a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/AccountDetailsViewModel.kt +++ b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/AccountDetailsViewModel.kt @@ -59,9 +59,12 @@ class AccountDetailsViewModel } } val signInState = userManager.getSignInState().toLiveData() - val viewState: LiveData> = userManager.getSignInState().combineLatest(subscription).toLiveData() - .combineLatest(deleteAccountState) - .map { Triple(it.first.first, it.first.second.get(), it.second) } + val viewState: LiveData> = + userManager.getSignInState() + .combineLatest(subscription) + .toLiveData() + .combineLatest(deleteAccountState) + .map { Triple(it.first.first, it.first.second.get(), it.second) } val accountStartDate: LiveData = MutableLiveData().apply { value = Date(statsManager.statsStartTime) } diff --git a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/ProfileFragment.kt b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/ProfileFragment.kt index 89725fdaebf..8704fab8f8f 100644 --- a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/ProfileFragment.kt +++ b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/ProfileFragment.kt @@ -16,7 +16,9 @@ import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.widget.TextViewCompat import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -47,6 +49,7 @@ import au.com.shiftyjelly.pocketcasts.ui.helper.StatusBarColor import au.com.shiftyjelly.pocketcasts.utils.extensions.dpToPx import au.com.shiftyjelly.pocketcasts.views.fragments.BaseFragment import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import timber.log.Timber import java.util.Date import javax.inject.Inject @@ -155,10 +158,11 @@ class ProfileFragment : BaseFragment() { true } - @Suppress("DEPRECATION") - lifecycleScope.launchWhenStarted { - val isEligible = viewModel.isEndOfYearStoriesEligible() - binding.setupEndOfYearPromptCard(isEligible) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + val isEligible = viewModel.isEndOfYearStoriesEligible() + binding.setupEndOfYearPromptCard(isEligible) + } } viewModel.podcastCount.observe(viewLifecycleOwner) { diff --git a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/ProfileViewModel.kt b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/ProfileViewModel.kt index 180207b3777..06e1232b678 100644 --- a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/ProfileViewModel.kt +++ b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/ProfileViewModel.kt @@ -34,7 +34,9 @@ class ProfileViewModel @Inject constructor( val signInState: LiveData = userManager.getSignInState().toLiveData() val refreshObservable: LiveData = - settings.refreshStateObservable.toFlowable(BackpressureStrategy.LATEST).toLiveData() + settings.refreshStateObservable + .toFlowable(BackpressureStrategy.LATEST) + .toLiveData() suspend fun isEndOfYearStoriesEligible() = endOfYearManager.isEligibleForStories() diff --git a/modules/features/search/src/main/java/au/com/shiftyjelly/pocketcasts/search/SearchFragment.kt b/modules/features/search/src/main/java/au/com/shiftyjelly/pocketcasts/search/SearchFragment.kt index d5fb9ed9a1b..2cfc88928cf 100644 --- a/modules/features/search/src/main/java/au/com/shiftyjelly/pocketcasts/search/SearchFragment.kt +++ b/modules/features/search/src/main/java/au/com/shiftyjelly/pocketcasts/search/SearchFragment.kt @@ -96,7 +96,7 @@ class SearchFragment : BaseFragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { val binding = FragmentSearchBinding.inflate(inflater, container, false) - binding.setLifecycleOwner { viewLifecycleOwner.lifecycle } + binding.lifecycleOwner = viewLifecycleOwner viewModel.setOnlySearchRemote(onlySearchRemote) searchHistoryViewModel.setOnlySearchRemote(onlySearchRemote) searchHistoryViewModel.setSource(source) diff --git a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/MediaNotificationControlsFragment.kt b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/MediaNotificationControlsFragment.kt index 5ac2cb624b4..c80b6612b1e 100644 --- a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/MediaNotificationControlsFragment.kt +++ b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/MediaNotificationControlsFragment.kt @@ -22,7 +22,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ListAdapter @@ -46,6 +48,7 @@ import au.com.shiftyjelly.pocketcasts.utils.extensions.dpToPx import au.com.shiftyjelly.pocketcasts.views.extensions.setRippleBackground import au.com.shiftyjelly.pocketcasts.views.fragments.BaseFragment import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import timber.log.Timber import java.util.Collections import javax.inject.Inject @@ -115,14 +118,16 @@ class MediaNotificationControlsFragment : BaseFragment(), MediaActionTouchCallba itemTouchHelper = ItemTouchHelper(callback) itemTouchHelper.attachToRecyclerView(recyclerView) - viewLifecycleOwner.lifecycleScope.launchWhenStarted { - settings.defaultMediaNotificationControlsFlow.collect { - val itemsPlusTitles = mutableListOf() - itemsPlusTitles.addAll(it) - itemsPlusTitles.add(3, otherActionsTitle) - itemsPlusTitles.add(0, mediaTitle) - items = itemsPlusTitles - adapter.submitList(items) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + settings.defaultMediaNotificationControlsFlow.collect { + val itemsPlusTitles = mutableListOf() + itemsPlusTitles.addAll(it) + itemsPlusTitles.add(3, otherActionsTitle) + itemsPlusTitles.add(0, mediaTitle) + items = itemsPlusTitles + adapter.submitList(items) + } } } diff --git a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlusSettingsFragment.kt b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlusSettingsFragment.kt index 262f260ada0..3e32fcf55d8 100644 --- a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlusSettingsFragment.kt +++ b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlusSettingsFragment.kt @@ -57,68 +57,70 @@ class PlusSettingsFragment : BaseFragment() { val recyclerView = binding.recyclerView - subscriptionManager.observeProductDetails().toLiveData().observe(viewLifecycleOwner) { productDetailsState -> - val subscriptions = when (productDetailsState) { - is ProductDetailsState.Error -> null - is ProductDetailsState.Loaded -> productDetailsState.productDetails.mapNotNull { - Subscription.fromProductDetails( - productDetails = it, - isFreeTrialEligible = subscriptionManager.isFreeTrialEligible() - ) + subscriptionManager.observeProductDetails() + .toLiveData() + .observe(viewLifecycleOwner) { productDetailsState -> + val subscriptions = when (productDetailsState) { + is ProductDetailsState.Error -> null + is ProductDetailsState.Loaded -> productDetailsState.productDetails.mapNotNull { + Subscription.fromProductDetails( + productDetails = it, + isFreeTrialEligible = subscriptionManager.isFreeTrialEligible() + ) + } } - } - val headerText = PlusSection.TextBlock(LR.string.plus_description_title, LR.string.plus_description_body) - val feature1 = PlusSection.Feature(R.drawable.ic_desktop_apps, LR.string.plus_desktop_apps, LR.string.plus_desktop_apps_body) - val feature2 = PlusSection.Feature(R.drawable.ic_cloud_storage, LR.string.plus_cloud_storage, LR.string.plus_cloud_storage_body) - val feature3 = PlusSection.Feature(R.drawable.ic_themes_icons, LR.string.plus_themes_icons, LR.string.plus_themes_icons_body) - val feature4 = PlusSection.Feature(R.drawable.plus_folder, LR.string.plus_folder, LR.string.plus_folder_body) - val upgrade = PlusSection.UpgradeButton( - subscriptions = subscriptions, - onClick = { - analyticsTracker.track(AnalyticsEvent.SETTINGS_PLUS_UPGRADE_BUTTON_TAPPED) - val flow = if (settings.isLoggedIn()) { - OnboardingFlow.PlusAccountUpgrade(OnboardingUpgradeSource.PLUS_DETAILS) - } else { - OnboardingFlow.PlusAccountUpgradeNeedsLogin + val headerText = PlusSection.TextBlock(LR.string.plus_description_title, LR.string.plus_description_body) + val feature1 = PlusSection.Feature(R.drawable.ic_desktop_apps, LR.string.plus_desktop_apps, LR.string.plus_desktop_apps_body) + val feature2 = PlusSection.Feature(R.drawable.ic_cloud_storage, LR.string.plus_cloud_storage, LR.string.plus_cloud_storage_body) + val feature3 = PlusSection.Feature(R.drawable.ic_themes_icons, LR.string.plus_themes_icons, LR.string.plus_themes_icons_body) + val feature4 = PlusSection.Feature(R.drawable.plus_folder, LR.string.plus_folder, LR.string.plus_folder_body) + val upgrade = PlusSection.UpgradeButton( + subscriptions = subscriptions, + onClick = { + analyticsTracker.track(AnalyticsEvent.SETTINGS_PLUS_UPGRADE_BUTTON_TAPPED) + val flow = if (settings.isLoggedIn()) { + OnboardingFlow.PlusAccountUpgrade(OnboardingUpgradeSource.PLUS_DETAILS) + } else { + OnboardingFlow.PlusAccountUpgradeNeedsLogin + } + OnboardingLauncher.openOnboardingFlow(activity, flow) } - OnboardingLauncher.openOnboardingFlow(activity, flow) - } - ) - val link = PlusSection.LinkBlock( - icon = theme.verticalPlusLogoRes(), - body = LR.string.plus_description_body, - linkText = LR.string.plus_learn_more_about_plus, - onClick = { - analyticsTracker.track(AnalyticsEvent.SETTINGS_PLUS_LEARN_MORE_TAPPED) - context?.let { context -> - val intent = - WebViewActivity.newInstance(context, "Learn More", Settings.INFO_LEARN_MORE_URL) - context.startActivity(intent) + ) + val link = PlusSection.LinkBlock( + icon = theme.verticalPlusLogoRes(), + body = LR.string.plus_description_body, + linkText = LR.string.plus_learn_more_about_plus, + onClick = { + analyticsTracker.track(AnalyticsEvent.SETTINGS_PLUS_LEARN_MORE_TAPPED) + context?.let { context -> + val intent = + WebViewActivity.newInstance(context, "Learn More", Settings.INFO_LEARN_MORE_URL) + context.startActivity(intent) + } } - } - ) - - val sections = listOf( - PlusSection.Header, - headerText, - upgrade, - PlusSection.Divider, - feature1, - feature2, - feature3, - feature4, - PlusSection.Divider, - link, - upgrade - ) - - val adapter = PlusAdapter() - recyclerView.adapter = adapter - recyclerView.layoutManager = LinearLayoutManager(rootView.context, LinearLayoutManager.VERTICAL, false) - - adapter.submitList(sections) - } + ) + + val sections = listOf( + PlusSection.Header, + headerText, + upgrade, + PlusSection.Divider, + feature1, + feature2, + feature3, + feature4, + PlusSection.Divider, + link, + upgrade + ) + + val adapter = PlusAdapter() + recyclerView.adapter = adapter + recyclerView.layoutManager = LinearLayoutManager(rootView.context, LinearLayoutManager.VERTICAL, false) + + adapter.submitList(sections) + } return binding.root } @@ -128,7 +130,8 @@ class PlusSettingsFragment : BaseFragment() { binding?.toolbar?.setup(title = getString(LR.string.pocket_casts_plus), navigationIcon = BackArrow, activity = activity, theme = theme) - userManager.getSignInState().toLiveData() + userManager.getSignInState() + .toLiveData() .observe(viewLifecycleOwner) { signInState -> // If the user has gone through the upgraded to Plus, we no longer want // to present this screen since it is asking them to sign up for Plus diff --git a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/StorageSettingsFragment.kt b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/StorageSettingsFragment.kt index 47f7d6d39d2..48a443c9782 100644 --- a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/StorageSettingsFragment.kt +++ b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/StorageSettingsFragment.kt @@ -11,13 +11,16 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.ContextCompat import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import au.com.shiftyjelly.pocketcasts.compose.AppThemeWithBackground import au.com.shiftyjelly.pocketcasts.repositories.file.StorageOptions import au.com.shiftyjelly.pocketcasts.settings.viewmodel.StorageSettingsViewModel import au.com.shiftyjelly.pocketcasts.ui.helper.FragmentHostListener import au.com.shiftyjelly.pocketcasts.views.fragments.BaseFragment import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch @AndroidEntryPoint class StorageSettingsFragment : BaseFragment() { @@ -53,10 +56,12 @@ class StorageSettingsFragment : BaseFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewLifecycleOwner.lifecycleScope.launchWhenStarted { - viewModel.permissionRequest.collect { permissionRequest -> - if (permissionRequest == Manifest.permission.WRITE_EXTERNAL_STORAGE) { - requestPermissionLauncher.launch(permissionRequest) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.permissionRequest.collect { permissionRequest -> + if (permissionRequest == Manifest.permission.WRITE_EXTERNAL_STORAGE) { + requestPermissionLauncher.launch(permissionRequest) + } } } } diff --git a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/viewmodel/SettingsAppearanceViewModel.kt b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/viewmodel/SettingsAppearanceViewModel.kt index 617c4a02ebe..a6114c09698 100644 --- a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/viewmodel/SettingsAppearanceViewModel.kt +++ b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/viewmodel/SettingsAppearanceViewModel.kt @@ -28,8 +28,8 @@ class SettingsAppearanceViewModel @Inject constructor( val signInState: LiveData = userManager.getSignInState().toLiveData() val createAccountState = MutableLiveData().apply { value = SettingsAppearanceState.Empty } - val showArtworkOnLockScreen = MutableLiveData(settings.showArtworkOnLockScreen()) - val useEmbeddedArtwork = MutableLiveData(settings.getUseEmbeddedArtwork()) + val showArtworkOnLockScreen = MutableLiveData(settings.showArtworkOnLockScreen()) + val useEmbeddedArtwork = MutableLiveData(settings.getUseEmbeddedArtwork()) var changeThemeType: Pair = Pair(null, null) var changeAppIconType: Pair = Pair(null, null) diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt index 6bc64408176..7c0d6c99793 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt @@ -135,10 +135,9 @@ open class PlaybackManager @Inject constructor( Log.d(Settings.LOG_TAG_AUTO, "Init playback state") return@lazy relay } - val playbackStateLive = - playbackStateRelay.toFlowable( - BackpressureStrategy.LATEST - ).toLiveData() + val playbackStateLive = playbackStateRelay + .toFlowable(BackpressureStrategy.LATEST) + .toLiveData() private var updateCount = 0 private var resettingPlayer = false diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/support/Support.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/support/Support.kt index a9d6d194d8d..226d7e8672e 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/support/Support.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/support/Support.kt @@ -233,7 +233,10 @@ class Support @Inject constructor( output.append(eol) output.append("Work Manager Tasks").append(eol) - val workInfos = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadManager.WORK_MANAGER_DOWNLOAD_TAG).toPublisher(ProcessLifecycleOwner.get()).awaitFirst() + val workInfos = WorkManager.getInstance(context) + .getWorkInfosByTagLiveData(DownloadManager.WORK_MANAGER_DOWNLOAD_TAG) + .toPublisher(ProcessLifecycleOwner.get()) + .awaitFirst() workInfos.forEach { workInfo -> output.append(workInfo.toString()).append(" Attempt=").append(workInfo.runAttemptCount).append(eol) } diff --git a/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/LiveDataUtil.kt b/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/LiveDataUtil.kt index fc08a6ad567..c93d0004f05 100644 --- a/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/LiveDataUtil.kt +++ b/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/LiveDataUtil.kt @@ -29,9 +29,9 @@ fun LiveData.observeOnce(lifecycleOwner: LifecycleOwner, observer: Observ observe( lifecycleOwner, object : Observer { - override fun onChanged(t: T) { + override fun onChanged(value: T) { removeObserver(this) - observer.onChanged(t) + observer.onChanged(value) } } ) diff --git a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectHelper.kt b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectHelper.kt index b4272abd11d..5ab9101fbff 100644 --- a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectHelper.kt +++ b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectHelper.kt @@ -5,8 +5,9 @@ import android.content.res.Resources import android.view.View import android.widget.Toast import androidx.fragment.app.FragmentManager +import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations +import androidx.lifecycle.map import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsSource @@ -66,7 +67,7 @@ class MultiSelectHelper @Inject constructor( get() = Dispatchers.Default private val _isMultiSelectingLive = MutableLiveData().apply { value = false } - val isMultiSelectingLive = Transformations.map(_isMultiSelectingLive) { it } + val isMultiSelectingLive: LiveData = _isMultiSelectingLive var isMultiSelecting: Boolean = false set(value) { @@ -76,16 +77,19 @@ class MultiSelectHelper @Inject constructor( } private val selectedList: MutableList = mutableListOf() - private val _selectedListLive = MutableLiveData>().apply { value = listOf() } - val selectedListLive = Transformations.map(_selectedListLive) { it } - val selectedCount = Transformations.map(_selectedListLive) { it.size } - - private val settingsToolbarActions = settings.multiSelectItemsObservable.toFlowable(BackpressureStrategy.LATEST).map { MultiSelectAction.listFromIds(it) }.toLiveData() - val toolbarActions = Transformations.map(settingsToolbarActions.combineLatest(selectedListLive)) { (actions, selectedEpisodes) -> - actions.mapNotNull { - MultiSelectAction.actionForGroup(it.groupId, selectedEpisodes) + private val selectedListLive = MutableLiveData>().apply { value = listOf() } + val selectedCount: LiveData = selectedListLive.map { it.size } + + val toolbarActions = settings.multiSelectItemsObservable + .toFlowable(BackpressureStrategy.LATEST) + .map { MultiSelectAction.listFromIds(it) } + .toLiveData() + .combineLatest(selectedListLive) + .map { (actions, selectedEpisodes) -> + actions.mapNotNull { + MultiSelectAction.actionForGroup(it.groupId, selectedEpisodes) + } } - } var coordinatorLayout: View? = null var context: Context? = null @@ -201,13 +205,13 @@ class MultiSelectHelper @Inject constructor( if (!isSelected(episode)) { selectedList.add(episode) } - _selectedListLive.value = selectedList + selectedListLive.value = selectedList } fun selectAllInList(episodes: List) { val trimmed = episodes.filter { !selectedList.contains(it) } selectedList.addAll(trimmed) - _selectedListLive.value = selectedList + selectedListLive.value = selectedList } fun deselect(episode: Playable) { @@ -216,7 +220,7 @@ class MultiSelectHelper @Inject constructor( selectedList.remove(it) } - _selectedListLive.value = selectedList + selectedListLive.value = selectedList if (selectedList.isEmpty()) { closeMultiSelect() @@ -522,7 +526,7 @@ class MultiSelectHelper @Inject constructor( fun closeMultiSelect() { selectedList.clear() - _selectedListLive.value = selectedList + selectedListLive.value = selectedList isMultiSelecting = false } } From b2a243e7ac398d639308ec5638180a4a99843f43 Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Fri, 17 Mar 2023 16:34:34 -0400 Subject: [PATCH 10/22] Update core-ktx to 1.9.0 --- dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies.gradle b/dependencies.gradle index a15a6420448..1c7943692a7 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -128,7 +128,7 @@ project.ext { firebaseAnalytics: "com.google.firebase:firebase-analytics-ktx", firebaseConfig: "com.google.firebase:firebase-config-ktx", constraintLayout: "androidx.constraintlayout:constraintlayout:2.1.4", - ktx: "androidx.core:core-ktx:1.8.0", + ktx: "androidx.core:core-ktx:1.9.0", ktxFragment: "androidx.fragment:fragment-ktx:1.5.2", ktxPreference: "androidx.preference:preference-ktx:1.2.0", annotations: "androidx.annotation:annotation:1.3.0", From abe89174a80873797d9fd6bcd9724a850ffc509a Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Wed, 29 Mar 2023 16:12:57 -0400 Subject: [PATCH 11/22] Remove fixme comment --- app/build.gradle | 1 - .../java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 82fbe72e79d..565dfb8a847 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,7 +11,6 @@ apply from: "../base.gradle" apply plugin: 'com.google.android.gms.oss-licenses-plugin' android { - namespace 'au.com.shiftyjelly.pocketcasts' defaultConfig { diff --git a/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt b/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt index 4b7e9755c4d..e70b9ca7070 100644 --- a/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt +++ b/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt @@ -719,11 +719,7 @@ class MainActivity : settings.setTrialFinishedSeen(true) } - lifecycleScope.launch { - // FIXME This gets called every time MainActivity resumes. Can we reduce how often this is called? - // But if that gets fixed we may want to also start calling this explicitly in onCreate - watchSync.sendAuthToDataLayer() - } + lifecycleScope.launch { watchSync.sendAuthToDataLayer() } } val lastSeenVersionCode = settings.getWhatsNewVersionCode() From 54d36be13db22ce13e5ade666cbe790b60f8a5fd Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Wed, 5 Apr 2023 10:48:24 -0400 Subject: [PATCH 12/22] Require horologist opt-in in account module I think we'd like to keep horologist experimental api usage to a minimum outisde the wear module, so let's keep manually opting in to the ExperiementalApis here. --- modules/features/account/build.gradle | 6 ------ .../au/com/shiftyjelly/pocketcasts/account/WatchSync.kt | 2 ++ .../au/com/shiftyjelly/pocketcasts/account/di/AuthModule.kt | 2 ++ .../shiftyjelly/pocketcasts/account/di/AuthPhoneModule.kt | 3 +++ 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/modules/features/account/build.gradle b/modules/features/account/build.gradle index fc2d475574f..81f8158a570 100644 --- a/modules/features/account/build.gradle +++ b/modules/features/account/build.gradle @@ -10,12 +10,6 @@ android { dataBinding = true compose true } - - kotlinOptions { - // Allow for widescale experimental APIs in Alpha libraries we build upon - freeCompilerArgs += "-opt-in=com.google.android.horologist.auth.data.phone.ExperimentalHorologistAuthDataPhoneApi" - freeCompilerArgs += "-opt-in=com.google.android.horologist.data.ExperimentalHorologistDataLayerApi" - } } dependencies { diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt index c8e113b01f8..56659dee441 100644 --- a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt @@ -3,6 +3,7 @@ package au.com.shiftyjelly.pocketcasts.account import android.annotation.SuppressLint import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer +import com.google.android.horologist.auth.data.phone.ExperimentalHorologistAuthDataPhoneApi import com.google.android.horologist.auth.data.phone.tokenshare.TokenBundleRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -13,6 +14,7 @@ import javax.inject.Singleton @Singleton @SuppressLint("VisibleForTests") // https://issuetracker.google.com/issues/239451111 +@OptIn(ExperimentalHorologistAuthDataPhoneApi::class) class WatchSync @Inject constructor( private val settings: Settings, private val accountAuth: AccountAuth, diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthModule.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthModule.kt index 4620766bcff..4265aece17f 100644 --- a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthModule.kt +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthModule.kt @@ -1,6 +1,7 @@ package au.com.shiftyjelly.pocketcasts.account.di import android.content.Context +import com.google.android.horologist.data.ExperimentalHorologistDataLayerApi import com.google.android.horologist.data.WearDataLayerRegistry import dagger.Module import dagger.Provides @@ -22,6 +23,7 @@ object AuthModule { fun coroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + @OptIn(ExperimentalHorologistDataLayerApi::class) @Singleton @Provides fun providesWearDataLayerRegistry( diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthPhoneModule.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthPhoneModule.kt index 57fdca934c2..10c9b2139cb 100644 --- a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthPhoneModule.kt +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthPhoneModule.kt @@ -1,8 +1,10 @@ package au.com.shiftyjelly.pocketcasts.account.di import au.com.shiftyjelly.pocketcasts.account.TokenSerializer +import com.google.android.horologist.auth.data.phone.ExperimentalHorologistAuthDataPhoneApi import com.google.android.horologist.auth.data.phone.tokenshare.TokenBundleRepository import com.google.android.horologist.auth.data.phone.tokenshare.impl.TokenBundleRepositoryImpl +import com.google.android.horologist.data.ExperimentalHorologistDataLayerApi import com.google.android.horologist.data.WearDataLayerRegistry import dagger.Module import dagger.Provides @@ -15,6 +17,7 @@ import javax.inject.Qualifier @InstallIn(SingletonComponent::class) object AuthPhoneModule { + @OptIn(ExperimentalHorologistAuthDataPhoneApi::class, ExperimentalHorologistDataLayerApi::class) @Provides fun providesTokenBundleRepository( wearDataLayerRegistry: WearDataLayerRegistry, From abc3da5623fd633776351c6568c3cc43854c51de Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Wed, 5 Apr 2023 10:52:12 -0400 Subject: [PATCH 13/22] Improve logging --- .../au/com/shiftyjelly/pocketcasts/account/WatchSync.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt index 56659dee441..5a9be40287e 100644 --- a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt @@ -54,14 +54,13 @@ class WatchSync @Inject constructor( } suspend fun processAuthDataChange(refreshToken: String?) { - Timber.i("Received refreshToken change") if (refreshToken != null) { + Timber.i("Received authData change from phone with refresh token") // Don't do anything if the user is already logged in. if (!settings.isLoggedIn()) { val result = accountAuth.signInWithToken(refreshToken, SignInSource.WatchPhoneSync) when (result) { - is AccountAuth.AuthResult.Failed -> { /* do nothing */ - } + is AccountAuth.AuthResult.Failed -> { /* do nothing */ } is AccountAuth.AuthResult.Success -> { Timber.e("TODO: notify the user we have signed them in!") @@ -73,7 +72,7 @@ class WatchSync @Inject constructor( } else { // The user either was never logged in on their phone or just logged out. // Either way, leave the user's login state on the watch unchanged. - Timber.i("Received data from phone without refresh token") + Timber.i("Received authData change from phone without refresh token") } } } From 094ff279b7bc7a7f73f9ae17609a8ead9d23f0e6 Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Wed, 5 Apr 2023 10:55:04 -0400 Subject: [PATCH 14/22] Minor formatting fix --- .../kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt index ad8c45444a5..35f2dbf8c54 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt @@ -33,8 +33,7 @@ import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { - @Inject - lateinit var theme: Theme + @Inject lateinit var theme: Theme private val viewModel: WearMainActivityViewModel by viewModels() From 4566f5dc1fceab0a1aa01bb3bc7b3e5f800581ba Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Fri, 14 Apr 2023 16:46:09 -0400 Subject: [PATCH 15/22] Got login from phone working again after mergin origin/main --- .../pocketcasts/ui/MainActivity.kt | 2 +- .../pocketcasts/account/TokenSerializer.kt | 21 ----- .../pocketcasts/account/WatchSync.kt | 78 ----------------- .../pocketcasts/account/di/AuthModule.kt | 4 +- .../pocketcasts/account/di/AuthPhoneModule.kt | 14 +-- .../account/watchsync/WatchSync.kt | 87 +++++++++++++++++++ .../account/watchsync/WatchSyncAuthData.kt | 45 ++++++++++ .../preferences/AccountConstants.kt | 2 +- .../repositories/sync/LoginIdentity.kt | 11 +++ .../repositories/sync/SyncAccountManager.kt | 16 ++-- .../repositories/sync/SyncManager.kt | 4 + .../repositories/sync/SyncManagerImpl.kt | 50 +++++++---- .../servers/sync/SyncServerManager.kt | 11 --- .../wear/WearMainActivityViewModel.kt | 5 +- .../pocketcasts/wear/di/AuthWatchModule.kt | 7 +- .../wear/ui/player/PCVolumeViewModel.kt | 2 - 16 files changed, 207 insertions(+), 152 deletions(-) delete mode 100644 modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/TokenSerializer.kt delete mode 100644 modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt create mode 100644 modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/watchsync/WatchSync.kt create mode 100644 modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/watchsync/WatchSyncAuthData.kt diff --git a/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt b/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt index f2e81a9e65c..4e55dea8c30 100644 --- a/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt +++ b/app/src/main/java/au/com/shiftyjelly/pocketcasts/ui/MainActivity.kt @@ -39,10 +39,10 @@ import androidx.transition.Slide import au.com.shiftyjelly.pocketcasts.R import au.com.shiftyjelly.pocketcasts.account.AccountActivity import au.com.shiftyjelly.pocketcasts.account.PromoCodeUpgradedFragment -import au.com.shiftyjelly.pocketcasts.account.WatchSync import au.com.shiftyjelly.pocketcasts.account.onboarding.OnboardingActivity import au.com.shiftyjelly.pocketcasts.account.onboarding.OnboardingActivityContract import au.com.shiftyjelly.pocketcasts.account.onboarding.OnboardingActivityContract.OnboardingFinish +import au.com.shiftyjelly.pocketcasts.account.watchsync.WatchSync import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsSource import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/TokenSerializer.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/TokenSerializer.kt deleted file mode 100644 index 09104395ac9..00000000000 --- a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/TokenSerializer.kt +++ /dev/null @@ -1,21 +0,0 @@ -package au.com.shiftyjelly.pocketcasts.account - -import androidx.datastore.core.Serializer -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.InputStream -import java.io.InputStreamReader -import java.io.OutputStream - -public object TokenSerializer : Serializer { - override val defaultValue: String = "" - - override suspend fun readFrom(input: InputStream): String = - InputStreamReader(input).readText() - - override suspend fun writeTo(t: String, output: OutputStream) { - withContext(Dispatchers.IO) { - output.write(t.toByteArray()) - } - } -} diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt deleted file mode 100644 index 5a9be40287e..00000000000 --- a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/WatchSync.kt +++ /dev/null @@ -1,78 +0,0 @@ -package au.com.shiftyjelly.pocketcasts.account - -import android.annotation.SuppressLint -import au.com.shiftyjelly.pocketcasts.preferences.Settings -import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer -import com.google.android.horologist.auth.data.phone.ExperimentalHorologistAuthDataPhoneApi -import com.google.android.horologist.auth.data.phone.tokenshare.TokenBundleRepository -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -@SuppressLint("VisibleForTests") // https://issuetracker.google.com/issues/239451111 -@OptIn(ExperimentalHorologistAuthDataPhoneApi::class) -class WatchSync @Inject constructor( - private val settings: Settings, - private val accountAuth: AccountAuth, - private val tokenBundleRepository: TokenBundleRepository, -) { - /** - * This should be called by the phone app to update the refresh token available to - * the watch app in the data layer. - */ - suspend fun sendAuthToDataLayer() { - withContext(Dispatchers.Default) { - try { - Timber.i("Updating refresh token in data layer") - - val authData = let { - val email = settings.getSyncEmail() - val password = settings.getSyncPassword() - if (email != null && password != null) { - // FIXME nonononono this makes an api call _every_ time - accountAuth.getTokensWithEmailAndPassword(email, password) - } else null - } - - authData?.refreshToken.let { refreshToken -> - tokenBundleRepository.update(refreshToken) - } - } catch (cancellationException: CancellationException) { - // Don't catch CancellationException since this represents the normal cancellation of a coroutine - throw cancellationException - } catch (exception: Exception) { - LogBuffer.e( - LogBuffer.TAG_BACKGROUND_TASKS, - "saving refresh token to data layer failed: $exception" - ) - } - } - } - - suspend fun processAuthDataChange(refreshToken: String?) { - if (refreshToken != null) { - Timber.i("Received authData change from phone with refresh token") - // Don't do anything if the user is already logged in. - if (!settings.isLoggedIn()) { - val result = accountAuth.signInWithToken(refreshToken, SignInSource.WatchPhoneSync) - when (result) { - is AccountAuth.AuthResult.Failed -> { /* do nothing */ } - - is AccountAuth.AuthResult.Success -> { - Timber.e("TODO: notify the user we have signed them in!") - } - } - } else { - Timber.i("Received refreshToken from phone, but user is already logged in") - } - } else { - // The user either was never logged in on their phone or just logged out. - // Either way, leave the user's login state on the watch unchanged. - Timber.i("Received authData change from phone without refresh token") - } - } -} diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthModule.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthModule.kt index 4265aece17f..85cc02abe95 100644 --- a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthModule.kt +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthModule.kt @@ -1,7 +1,7 @@ package au.com.shiftyjelly.pocketcasts.account.di import android.content.Context -import com.google.android.horologist.data.ExperimentalHorologistDataLayerApi +import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.data.WearDataLayerRegistry import dagger.Module import dagger.Provides @@ -23,7 +23,7 @@ object AuthModule { fun coroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - @OptIn(ExperimentalHorologistDataLayerApi::class) + @OptIn(ExperimentalHorologistApi::class) @Singleton @Provides fun providesWearDataLayerRegistry( diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthPhoneModule.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthPhoneModule.kt index 10c9b2139cb..ee6ff3e1f0d 100644 --- a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthPhoneModule.kt +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/di/AuthPhoneModule.kt @@ -1,10 +1,10 @@ package au.com.shiftyjelly.pocketcasts.account.di -import au.com.shiftyjelly.pocketcasts.account.TokenSerializer -import com.google.android.horologist.auth.data.phone.ExperimentalHorologistAuthDataPhoneApi +import au.com.shiftyjelly.pocketcasts.account.watchsync.WatchSyncAuthData +import au.com.shiftyjelly.pocketcasts.account.watchsync.WatchSyncAuthDataSerializer +import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.auth.data.phone.tokenshare.TokenBundleRepository import com.google.android.horologist.auth.data.phone.tokenshare.impl.TokenBundleRepositoryImpl -import com.google.android.horologist.data.ExperimentalHorologistDataLayerApi import com.google.android.horologist.data.WearDataLayerRegistry import dagger.Module import dagger.Provides @@ -17,16 +17,16 @@ import javax.inject.Qualifier @InstallIn(SingletonComponent::class) object AuthPhoneModule { - @OptIn(ExperimentalHorologistAuthDataPhoneApi::class, ExperimentalHorologistDataLayerApi::class) + @ExperimentalHorologistApi @Provides fun providesTokenBundleRepository( wearDataLayerRegistry: WearDataLayerRegistry, - @ForApplicationScope coroutineScope: CoroutineScope - ): TokenBundleRepository { + @ForApplicationScope coroutineScope: CoroutineScope, + ): TokenBundleRepository { return TokenBundleRepositoryImpl( registry = wearDataLayerRegistry, coroutineScope = coroutineScope, - serializer = TokenSerializer + serializer = WatchSyncAuthDataSerializer ) } } diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/watchsync/WatchSync.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/watchsync/WatchSync.kt new file mode 100644 index 00000000000..326466950c3 --- /dev/null +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/watchsync/WatchSync.kt @@ -0,0 +1,87 @@ +package au.com.shiftyjelly.pocketcasts.account.watchsync + +import android.annotation.SuppressLint +import au.com.shiftyjelly.pocketcasts.repositories.sync.LoginResult +import au.com.shiftyjelly.pocketcasts.repositories.sync.SignInSource +import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager +import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.auth.data.phone.tokenshare.TokenBundleRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +@SuppressLint("VisibleForTests") // https://issuetracker.google.com/issues/239451111 +class WatchSync @OptIn(ExperimentalHorologistApi::class) +@Inject constructor( + private val syncManager: SyncManager, + private val tokenBundleRepository: TokenBundleRepository, +) { + /** + * This should be called by the phone app to update the refresh token available to + * the watch app in the data layer. + */ + @OptIn(ExperimentalHorologistApi::class) + suspend fun sendAuthToDataLayer() { + withContext(Dispatchers.Default) { + try { + Timber.i("Updating WatchSyncAuthData in data layer") + + val watchSyncAuthData = syncManager.getRefreshToken()?.let { refreshToken -> + syncManager.getLoginIdentity()?.let { loginIdentity -> + WatchSyncAuthData( + refreshToken = refreshToken, + loginIdentity = loginIdentity + ) + } + } + + if (watchSyncAuthData == null) { + Timber.i("Removing WatchSyncAuthData from data layer") + } + + tokenBundleRepository.update(watchSyncAuthData) + } catch (cancellationException: CancellationException) { + // Don't catch CancellationException since this represents the normal cancellation of a coroutine + throw cancellationException + } catch (exception: Exception) { + LogBuffer.e( + LogBuffer.TAG_BACKGROUND_TASKS, + "Saving refresh token to data layer failed: $exception" + ) + } + } + } + + suspend fun processAuthDataChange(data: WatchSyncAuthData?) { + if (data != null) { + Timber.i("Received authData change from phone with refresh token") + // Don't do anything if the user is already logged in. + if (!syncManager.isLoggedIn()) { + val result = syncManager.loginWithToken( + token = data.refreshToken, + loginIdentity = data.loginIdentity, + signInSource = SignInSource.WatchPhoneSync + ) + when (result) { + is LoginResult.Failed -> { /* do nothing */ } + + is LoginResult.Success -> { + + Timber.e("TODO: notify the user we have signed them in!") + } + } + } else { + Timber.i("Received WatchSyncAuthData from phone, but user is already logged in") + } + } else { + // The user either was never logged in on their phone or just logged out. + // Either way, leave the user's login state on the watch unchanged. + Timber.i("Received null WatchSyncAuthDAta change") + } + } +} diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/watchsync/WatchSyncAuthData.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/watchsync/WatchSyncAuthData.kt new file mode 100644 index 00000000000..795c9aedd47 --- /dev/null +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/watchsync/WatchSyncAuthData.kt @@ -0,0 +1,45 @@ +package au.com.shiftyjelly.pocketcasts.account.watchsync + +import androidx.datastore.core.Serializer +import au.com.shiftyjelly.pocketcasts.preferences.RefreshToken +import au.com.shiftyjelly.pocketcasts.repositories.sync.LoginIdentity +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.InputStream +import java.io.InputStreamReader +import java.io.OutputStream + +@JsonClass(generateAdapter = true) +data class WatchSyncAuthData( + @field:Json(name = "refreshToken") val refreshToken: RefreshToken, + @field:Json(name = "loginIdentity") val loginIdentity: LoginIdentity, +) + +object WatchSyncAuthDataSerializer : Serializer { + + private val adapter = WatchSyncAuthDataJsonAdapter( + Moshi.Builder() + .add(RefreshToken::class.java, RefreshToken.Adapter) + .add(LoginIdentity.Adapter) + .build() + ) + + override val defaultValue: WatchSyncAuthData? = null + + override suspend fun readFrom(input: InputStream): WatchSyncAuthData? { + val string = InputStreamReader(input).readText() + return adapter.fromJson(string) + } + + override suspend fun writeTo(t: WatchSyncAuthData?, output: OutputStream) { + withContext(Dispatchers.IO) { + if (t != null) { + val jsonString = adapter.toJson(t) + output.write(jsonString.toByteArray()) + } + } + } +} diff --git a/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/AccountConstants.kt b/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/AccountConstants.kt index d2ffe8f5823..ad417e3d0b8 100644 --- a/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/AccountConstants.kt +++ b/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/AccountConstants.kt @@ -39,7 +39,7 @@ value class AccessToken(val value: String) { @JvmInline value class RefreshToken(val value: String) { object Adapter : JsonAdapter() { - override fun fromJson(reader: JsonReader) = RefreshToken((reader.nextString())) + override fun fromJson(reader: JsonReader) = RefreshToken(reader.nextString()) override fun toJson(writer: JsonWriter, refreshToken: RefreshToken?) { writer.value(refreshToken?.value) diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/LoginIdentity.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/LoginIdentity.kt index 5a89a9d6719..065783b9f09 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/LoginIdentity.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/LoginIdentity.kt @@ -1,5 +1,8 @@ package au.com.shiftyjelly.pocketcasts.repositories.sync +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson + sealed class LoginIdentity(val value: String) { object PocketCasts : LoginIdentity("PocketCasts") object Google : LoginIdentity("Google") @@ -13,4 +16,12 @@ sealed class LoginIdentity(val value: String) { } } } + + object Adapter { + @ToJson + fun toJson(loginIdentity: LoginIdentity): String = loginIdentity.value + + @FromJson + fun fromJson(value: String): LoginIdentity? = valueOf(value) + } } diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncAccountManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncAccountManager.kt index d22207fe17b..1cee39a4c9e 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncAccountManager.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncAccountManager.kt @@ -46,10 +46,7 @@ class SyncAccountManager @Inject constructor( accountManager.getUserData(account, AccountConstants.UUID) } - fun isGoogleLogin(): Boolean = - getLoginIdentity() == LoginIdentity.Google - - private fun getLoginIdentity(): LoginIdentity? { + fun getLoginIdentity(): LoginIdentity? { val account = getAccount() ?: return null val loginIdentity = accountManager.getUserData(account, AccountConstants.LOGIN_IDENTITY) return LoginIdentity.valueOf(loginIdentity) ?: LoginIdentity.PocketCasts @@ -135,15 +132,15 @@ class SyncAccountManager @Inject constructor( accountManager.setAuthToken(account, AccountConstants.TOKEN_TYPE, accessToken.value) } - fun getRefreshToken(account: Account): RefreshToken? { - return accountManager.getPassword(account)?.let { - if (it.isNotEmpty()) { - RefreshToken(it) + fun getRefreshToken(account: Account? = null): RefreshToken? = + (account ?: getAccount())?.let { + val refreshToken = accountManager.getPassword(it) + if (refreshToken.isNotEmpty()) { + RefreshToken(refreshToken) } else { null } } - } fun setEmail(email: String) { val account = getAccount() ?: return @@ -155,4 +152,5 @@ enum class SignInSource(val analyticsValue: String) { AccountAuthenticator("account_manager"), SignInViewModel("sign_in_view_model"), Onboarding("onboarding"), + WatchPhoneSync("watch_phone_sync"), } diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncManager.kt index 5355c22e2ad..2521ac9a7cf 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncManager.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncManager.kt @@ -8,6 +8,7 @@ import au.com.shiftyjelly.pocketcasts.models.to.HistorySyncRequest import au.com.shiftyjelly.pocketcasts.models.to.HistorySyncResponse import au.com.shiftyjelly.pocketcasts.models.to.StatsBundle import au.com.shiftyjelly.pocketcasts.preferences.AccessToken +import au.com.shiftyjelly.pocketcasts.preferences.RefreshToken import au.com.shiftyjelly.pocketcasts.servers.sync.EpisodeSyncRequest import au.com.shiftyjelly.pocketcasts.servers.sync.FileAccount import au.com.shiftyjelly.pocketcasts.servers.sync.FilePost @@ -39,13 +40,16 @@ interface SyncManager : NamedSettingsCaller, AccountStatusInfo { override fun getUuid(): String? override fun isLoggedIn(): Boolean fun isGoogleLogin(): Boolean + fun getLoginIdentity(): LoginIdentity? fun getEmail(): String? fun signOut(action: () -> Unit = {}) suspend fun loginWithGoogle(idToken: String, signInSource: SignInSource): LoginResult suspend fun loginWithEmailAndPassword(email: String, password: String, signInSource: SignInSource): LoginResult + suspend fun loginWithToken(token: RefreshToken, loginIdentity: LoginIdentity, signInSource: SignInSource): LoginResult suspend fun createUserWithEmailAndPassword(email: String, password: String): LoginResult suspend fun forgotPassword(email: String, onSuccess: () -> Unit, onError: (String) -> Unit) suspend fun getAccessToken(account: Account): AccessToken? + fun getRefreshToken(): RefreshToken? fun emailChange(newEmail: String, password: String): Single fun deleteAccount(): Single suspend fun updatePassword(newPassword: String, oldPassword: String) diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncManagerImpl.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncManagerImpl.kt index 2edc42f74be..e2d259e819f 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncManagerImpl.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncManagerImpl.kt @@ -112,7 +112,10 @@ class SyncManagerImpl @Inject constructor( syncAccountManager.isLoggedIn() override fun isGoogleLogin(): Boolean = - syncAccountManager.isGoogleLogin() + getLoginIdentity() == LoginIdentity.Google + + override fun getLoginIdentity(): LoginIdentity? = + syncAccountManager.getLoginIdentity() override fun getEmail(): String? = syncAccountManager.getEmail() @@ -121,6 +124,9 @@ class SyncManagerImpl @Inject constructor( syncAccountManager.peekAccessToken(account) ?: fetchAccessToken(account) + override fun getRefreshToken(): RefreshToken? = + syncAccountManager.getRefreshToken() + private suspend fun fetchAccessToken(account: Account): AccessToken? { val refreshToken = syncAccountManager.getRefreshToken(account) ?: return null return try { @@ -151,23 +157,37 @@ class SyncManagerImpl @Inject constructor( isLoggedInObservable.accept(false) } - override suspend fun loginWithGoogle(idToken: String, signInSource: SignInSource): LoginResult { - val loginResult = try { - val response = syncServerManager.loginGoogle(idToken) - val result = handleTokenResponse(loginIdentity = LoginIdentity.Google, response = response) - LoginResult.Success(result) - } catch (ex: Exception) { - Timber.e(ex, "Failed to sign in with Google") - exceptionToAuthResult(exception = ex, fallbackMessage = LR.string.error_login_failed) - } - trackSignIn(loginResult, signInSource) - return loginResult + override suspend fun loginWithGoogle( + idToken: String, + signInSource: SignInSource, + ): LoginResult = handleLogin(signInSource, LoginIdentity.Google) { + syncServerManager.loginGoogle(idToken) } - override suspend fun loginWithEmailAndPassword(email: String, password: String, signInSource: SignInSource): LoginResult { + override suspend fun loginWithToken( + token: RefreshToken, + loginIdentity: LoginIdentity, + signInSource: SignInSource, + ): LoginResult = handleLogin(signInSource, loginIdentity) { + syncServerManager.loginToken(token) + } + + override suspend fun loginWithEmailAndPassword( + email: String, + password: String, + signInSource: SignInSource + ): LoginResult = handleLogin(signInSource, LoginIdentity.PocketCasts) { + syncServerManager.login(email = email, password = password) + } + + private suspend fun handleLogin( + signInSource: SignInSource, + loginIdentity: LoginIdentity, + loginFunction: suspend () -> LoginTokenResponse, + ): LoginResult { val loginResult = try { - val response = syncServerManager.login(email = email, password = password) - val result = handleTokenResponse(loginIdentity = LoginIdentity.PocketCasts, response = response) + val response = loginFunction() + val result = handleTokenResponse(loginIdentity = loginIdentity, response = response) LoginResult.Success(result) } catch (ex: Exception) { Timber.e(ex, "Failed to sign in with Pocket Casts") diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncServerManager.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncServerManager.kt index 23559a77385..f7ec3ae8ceb 100644 --- a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncServerManager.kt +++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncServerManager.kt @@ -17,8 +17,6 @@ import au.com.shiftyjelly.pocketcasts.servers.sync.history.HistoryYearResponse import au.com.shiftyjelly.pocketcasts.servers.sync.history.HistoryYearSyncRequest import au.com.shiftyjelly.pocketcasts.servers.sync.login.ExchangeSonosResponse import au.com.shiftyjelly.pocketcasts.servers.sync.login.LoginGoogleRequest -import au.com.shiftyjelly.pocketcasts.servers.sync.login.LoginPocketCastsRequest -import au.com.shiftyjelly.pocketcasts.servers.sync.login.LoginPocketCastsResponse import au.com.shiftyjelly.pocketcasts.servers.sync.login.LoginRequest import au.com.shiftyjelly.pocketcasts.servers.sync.login.LoginTokenRequest import au.com.shiftyjelly.pocketcasts.servers.sync.login.LoginTokenResponse @@ -65,15 +63,6 @@ open class SyncServerManager @Inject constructor( return server.login(request) } - // FIXME find a better function name - suspend fun loginPocketCasts(email: String, password: String): LoginPocketCastsResponse = - server.loginPocketCasts( - LoginPocketCastsRequest( - email = email, - password = password - ) - ) - suspend fun loginGoogle(idToken: String): LoginTokenResponse { val request = LoginGoogleRequest(idToken = idToken, scope = SCOPE_MOBILE) return server.loginGoogle(request) diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt index bffaed1e907..463ad441399 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt @@ -2,7 +2,8 @@ package au.com.shiftyjelly.pocketcasts.wear import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import au.com.shiftyjelly.pocketcasts.account.WatchSync +import au.com.shiftyjelly.pocketcasts.account.watchsync.WatchSync +import au.com.shiftyjelly.pocketcasts.account.watchsync.WatchSyncAuthData import com.google.android.horologist.auth.data.tokenshare.TokenBundleRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch @@ -11,7 +12,7 @@ import javax.inject.Inject @HiltViewModel class WearMainActivityViewModel @Inject constructor( private val watchSync: WatchSync, - private val tokenBundleRepository: TokenBundleRepository + private val tokenBundleRepository: TokenBundleRepository, ) : ViewModel() { fun startMonitoringAuth() { diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/di/AuthWatchModule.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/di/AuthWatchModule.kt index 2f45514cd4e..7edbcf9c40d 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/di/AuthWatchModule.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/di/AuthWatchModule.kt @@ -1,6 +1,7 @@ package au.com.shiftyjelly.pocketcasts.wear.di -import au.com.shiftyjelly.pocketcasts.account.TokenSerializer +import au.com.shiftyjelly.pocketcasts.account.watchsync.WatchSyncAuthData +import au.com.shiftyjelly.pocketcasts.account.watchsync.WatchSyncAuthDataSerializer import com.google.android.horologist.auth.data.tokenshare.TokenBundleRepository import com.google.android.horologist.auth.data.tokenshare.impl.TokenBundleRepositoryImpl import com.google.android.horologist.data.WearDataLayerRegistry @@ -16,10 +17,10 @@ object AuthWatchModule { @Provides fun providesTokenBundleRepository( wearDataLayerRegistry: WearDataLayerRegistry, - ): TokenBundleRepository { + ): TokenBundleRepository { return TokenBundleRepositoryImpl.create( registry = wearDataLayerRegistry, - serializer = TokenSerializer + serializer = WatchSyncAuthDataSerializer ) } } diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/player/PCVolumeViewModel.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/player/PCVolumeViewModel.kt index 53570ed356c..be955491b95 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/player/PCVolumeViewModel.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/player/PCVolumeViewModel.kt @@ -2,12 +2,10 @@ package au.com.shiftyjelly.pocketcasts.wear.ui.player import android.os.Vibrator import com.google.android.horologist.audio.SystemAudioRepository -import com.google.android.horologist.audio.ui.ExperimentalHorologistAudioUiApi import com.google.android.horologist.audio.ui.VolumeViewModel import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -@OptIn(ExperimentalHorologistAudioUiApi::class) @HiltViewModel class PCVolumeViewModel @Inject constructor( systemAudioRepository: SystemAudioRepository, From ecf106354fae5b5d0087f6868bebe986450f1b9d Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Fri, 14 Apr 2023 21:11:33 -0400 Subject: [PATCH 16/22] Show notification when user is logged into watch from phone --- .../account/watchsync/WatchSync.kt | 18 +++----- .../src/main/res/values/strings.xml | 1 + .../pocketcasts/wear/MainActivity.kt | 45 +++++++++++++++++-- .../wear/WearMainActivityViewModel.kt | 33 ++++++++++++-- .../wear/ui/episode/EpisodeScreenFlow.kt | 4 +- .../wear/ui/episode/NotificationScreen.kt | 18 +++++--- 6 files changed, 90 insertions(+), 29 deletions(-) diff --git a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/watchsync/WatchSync.kt b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/watchsync/WatchSync.kt index 326466950c3..2b2b6bab137 100644 --- a/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/watchsync/WatchSync.kt +++ b/modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/watchsync/WatchSync.kt @@ -57,31 +57,25 @@ class WatchSync @OptIn(ExperimentalHorologistApi::class) } } - suspend fun processAuthDataChange(data: WatchSyncAuthData?) { + suspend fun processAuthDataChange(data: WatchSyncAuthData?, onResult: (LoginResult) -> Unit) { if (data != null) { - Timber.i("Received authData change from phone with refresh token") - // Don't do anything if the user is already logged in. + + Timber.i("Received WatchSyncAuthData change from phone") + if (!syncManager.isLoggedIn()) { val result = syncManager.loginWithToken( token = data.refreshToken, loginIdentity = data.loginIdentity, signInSource = SignInSource.WatchPhoneSync ) - when (result) { - is LoginResult.Failed -> { /* do nothing */ } - - is LoginResult.Success -> { - - Timber.e("TODO: notify the user we have signed them in!") - } - } + onResult(result) } else { Timber.i("Received WatchSyncAuthData from phone, but user is already logged in") } } else { // The user either was never logged in on their phone or just logged out. // Either way, leave the user's login state on the watch unchanged. - Timber.i("Received null WatchSyncAuthDAta change") + Timber.i("Received null WatchSyncAuthData change") } } } diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml index ab2a8c3e780..b38beaa3baf 100644 --- a/modules/services/localization/src/main/res/values/strings.xml +++ b/modules/services/localization/src/main/res/values/strings.xml @@ -833,6 +833,7 @@ @string/sign_out Are you sure you want to sign out of syncing? Signed in as + Logged in Sonos Connect Connecting to Sonos will allow the Sonos app to access your episode information.\n\nYour email address, password and other sensitive items are never shared. diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt index 5900dc05c9f..39ef63b0f47 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt @@ -6,6 +6,9 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel @@ -25,6 +28,7 @@ import au.com.shiftyjelly.pocketcasts.wear.ui.authenticationSubGraph import au.com.shiftyjelly.pocketcasts.wear.ui.downloads.DownloadsScreen import au.com.shiftyjelly.pocketcasts.wear.ui.episode.EpisodeScreenFlow import au.com.shiftyjelly.pocketcasts.wear.ui.episode.EpisodeScreenFlow.episodeGraph +import au.com.shiftyjelly.pocketcasts.wear.ui.episode.NotificationScreen import au.com.shiftyjelly.pocketcasts.wear.ui.player.NowPlayingScreen import au.com.shiftyjelly.pocketcasts.wear.ui.player.NowPlayingViewModel import au.com.shiftyjelly.pocketcasts.wear.ui.player.StreamingConfirmationScreen @@ -37,6 +41,8 @@ import com.google.android.horologist.compose.navscaffold.composable import com.google.android.horologist.compose.navscaffold.scrollable import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds +import au.com.shiftyjelly.pocketcasts.localization.R as LR @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -47,16 +53,29 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - viewModel.startMonitoringAuth() setContent { - WearApp(theme.activeTheme) + val state by viewModel.state.collectAsState() + WearApp( + themeType = theme.activeTheme, + showSignInConfirmation = state.showSignInConfirmation, + onSignInConfirmationShown = viewModel::onSignInConfirmationShown, + ) } } } +private object Routes { + const val signedInNotificationScreen = "signedInNotificationScreen" +} + @Composable -fun WearApp(themeType: Theme.ThemeType) { +fun WearApp( + themeType: Theme.ThemeType, + showSignInConfirmation: Boolean, + onSignInConfirmationShown: () -> Unit, +) { WearAppTheme(themeType) { + val navController = rememberSwipeDismissableNavController() WearNavScaffold( @@ -64,6 +83,11 @@ fun WearApp(themeType: Theme.ThemeType) { startDestination = WatchListScreen.route ) { + if (showSignInConfirmation) { + navController.navigate(Routes.signedInNotificationScreen) + onSignInConfirmationShown() + } + scrollable( route = WatchListScreen.route, columnStateFactory = ScalingLazyColumnDefaults.belowTimeText() @@ -175,6 +199,15 @@ fun WearApp(themeType: Theme.ThemeType) { } authenticationGraph(navController) + + composable(Routes.signedInNotificationScreen) { + it.viewModel.timeTextMode = NavScaffoldViewModel.TimeTextMode.Off + NotificationScreen( + text = stringResource(LR.string.profile_logged_in), + delayDuration = 4.seconds, + onClose = { navController.popBackStack() }, + ) + } } } } @@ -182,5 +215,9 @@ fun WearApp(themeType: Theme.ThemeType) { @Preview(device = Devices.WEAR_OS_SMALL_ROUND, showSystemUi = true) @Composable fun DefaultPreview() { - WearApp(Theme.ThemeType.DARK) + WearApp( + themeType = Theme.ThemeType.DARK, + showSignInConfirmation = false, + onSignInConfirmationShown = {}, + ) } diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt index 463ad441399..336981653d6 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt @@ -4,23 +4,48 @@ 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.repositories.sync.LoginResult 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 javax.inject.Inject @HiltViewModel class WearMainActivityViewModel @Inject constructor( - private val watchSync: WatchSync, private val tokenBundleRepository: TokenBundleRepository, + private val watchSync: WatchSync, ) : ViewModel() { - fun startMonitoringAuth() { + data class State( + val showSignInConfirmation: Boolean = false, + ) + + private val _state = MutableStateFlow(State()) + val state = _state.asStateFlow() + + init { viewModelScope.launch { tokenBundleRepository.flow - .collect { - watchSync.processAuthDataChange(it) + .collect { watchSyncAuthData -> + watchSync.processAuthDataChange(watchSyncAuthData) { loginResult -> + when (loginResult) { + is LoginResult.Failed -> { /* do nothing */ } + is LoginResult.Success -> { + _state.update { it.copy(showSignInConfirmation = true) } + } + } + } } } } + + /** + * This should be invoked by the UI when it shows the sign in confirmation. + */ + fun onSignInConfirmationShown() { + _state.update { it.copy(showSignInConfirmation = false) } + } } diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/episode/EpisodeScreenFlow.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/episode/EpisodeScreenFlow.kt index b031ca1a1f4..e12cfd45064 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/episode/EpisodeScreenFlow.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/episode/EpisodeScreenFlow.kt @@ -115,7 +115,7 @@ object EpisodeScreenFlow { it.viewModel.timeTextMode = NavScaffoldViewModel.TimeTextMode.Off NotificationScreen( text = stringResource(LR.string.removed), - onClick = { navController.popBackStack() }, + onClose = { navController.popBackStack() }, ) } @@ -123,7 +123,7 @@ object EpisodeScreenFlow { it.viewModel.timeTextMode = NavScaffoldViewModel.TimeTextMode.Off NotificationScreen( text = stringResource(LR.string.episode_removed_from_up_next), - onClick = { navController.popBackStack() }, + onClose = { navController.popBackStack() }, ) } } diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/episode/NotificationScreen.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/episode/NotificationScreen.kt index 23bc20bd75d..9131c41ccca 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/episode/NotificationScreen.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/episode/NotificationScreen.kt @@ -22,25 +22,29 @@ import au.com.shiftyjelly.pocketcasts.ui.theme.Theme import au.com.shiftyjelly.pocketcasts.wear.theme.WearAppTheme import au.com.shiftyjelly.pocketcasts.wear.theme.WearColors import kotlinx.coroutines.delay +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds import au.com.shiftyjelly.pocketcasts.images.R as IR @Composable fun NotificationScreen( text: String, - onClick: () -> Unit, + delayDuration: Duration? = 2.seconds, + onClose: () -> Unit, ) { - LaunchedEffect(Unit) { - // Close the screen after a short delay - delay(2000) - onClick() + LaunchedEffect(delayDuration) { + if (delayDuration != null) { + delay(delayDuration) + onClose() + } } Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .clickable { onClick() } + .clickable { onClose() } .padding(16.dp) .fillMaxSize() ) { @@ -64,7 +68,7 @@ private fun NotificationScreenPreview() { WearAppTheme(Theme.ThemeType.DARK) { NotificationScreen( text = "Done", - onClick = {} + onClose = {} ) } } From 946c71b42032f3831dd58bbdae9aff377922b99c Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Fri, 14 Apr 2023 21:33:06 -0400 Subject: [PATCH 17/22] Sync podcasts after watch login from phone --- .../pocketcasts/wear/WearMainActivityViewModel.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt index 336981653d6..2627a28b048 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt @@ -4,6 +4,7 @@ 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.repositories.podcast.PodcastManager import au.com.shiftyjelly.pocketcasts.repositories.sync.LoginResult import com.google.android.horologist.auth.data.tokenshare.TokenBundleRepository import dagger.hilt.android.lifecycle.HiltViewModel @@ -15,6 +16,7 @@ import javax.inject.Inject @HiltViewModel class WearMainActivityViewModel @Inject constructor( + private val podcastManager: PodcastManager, private val tokenBundleRepository: TokenBundleRepository, private val watchSync: WatchSync, ) : ViewModel() { @@ -35,6 +37,9 @@ class WearMainActivityViewModel @Inject constructor( is LoginResult.Failed -> { /* do nothing */ } is LoginResult.Success -> { _state.update { it.copy(showSignInConfirmation = true) } + viewModelScope.launch { + podcastManager.refreshPodcastsAfterSignIn() + } } } } From 35e79538e929d594784ae1e9572c67f402ed4e35 Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Sat, 15 Apr 2023 06:03:49 -0400 Subject: [PATCH 18/22] Show sign in notification until refresh is done This helps avoid putting user in the state where some of the data is loaded. --- .../src/main/res/values/strings.xml | 2 +- .../pocketcasts/wear/MainActivity.kt | 82 ++++++++++++++++--- .../wear/WearMainActivityViewModel.kt | 63 ++++++++++---- .../wear/ui/episode/NotificationScreen.kt | 23 +++--- 4 files changed, 129 insertions(+), 41 deletions(-) diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml index b38beaa3baf..513e949dff9 100644 --- a/modules/services/localization/src/main/res/values/strings.xml +++ b/modules/services/localization/src/main/res/values/strings.xml @@ -833,7 +833,7 @@ @string/sign_out Are you sure you want to sign out of syncing? Signed in as - Logged in + Logging in Sonos Connect Connecting to Sonos will allow the Sonos app to access your episode information.\n\nYour email address, password and other sensitive items are never shared. diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt index 39ef63b0f47..ef369323e11 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt @@ -4,17 +4,28 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController import androidx.navigation.NavType import androidx.navigation.navArgument +import androidx.wear.compose.material.Icon import androidx.wear.compose.navigation.rememberSwipeDismissableNavController import au.com.shiftyjelly.pocketcasts.ui.theme.Theme import au.com.shiftyjelly.pocketcasts.wear.theme.WearAppTheme @@ -41,7 +52,7 @@ import com.google.android.horologist.compose.navscaffold.composable import com.google.android.horologist.compose.navscaffold.scrollable import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlin.time.Duration.Companion.seconds +import au.com.shiftyjelly.pocketcasts.images.R as IR import au.com.shiftyjelly.pocketcasts.localization.R as LR @AndroidEntryPoint @@ -57,8 +68,8 @@ class MainActivity : ComponentActivity() { val state by viewModel.state.collectAsState() WearApp( themeType = theme.activeTheme, - showSignInConfirmation = state.showSignInConfirmation, - onSignInConfirmationShown = viewModel::onSignInConfirmationShown, + signInConfirmationAction = state.signInConfirmationAction, + onSignInConfirmationActionHandled = viewModel::onSignInConfirmationActionHandled, ) } } @@ -71,8 +82,8 @@ private object Routes { @Composable fun WearApp( themeType: Theme.ThemeType, - showSignInConfirmation: Boolean, - onSignInConfirmationShown: () -> Unit, + signInConfirmationAction: SignInConfirmationAction?, + onSignInConfirmationActionHandled: () -> Unit, ) { WearAppTheme(themeType) { @@ -83,10 +94,11 @@ fun WearApp( startDestination = WatchListScreen.route ) { - if (showSignInConfirmation) { - navController.navigate(Routes.signedInNotificationScreen) - onSignInConfirmationShown() - } + handleSignInConfirmation( + signInConfirmationAction = signInConfirmationAction, + onSignInConfirmationActionHandled = onSignInConfirmationActionHandled, + navController = navController + ) scrollable( route = WatchListScreen.route, @@ -203,21 +215,65 @@ fun WearApp( composable(Routes.signedInNotificationScreen) { it.viewModel.timeTextMode = NavScaffoldViewModel.TimeTextMode.Off NotificationScreen( - text = stringResource(LR.string.profile_logged_in), - delayDuration = 4.seconds, + text = stringResource(LR.string.profile_logging_in), + closeAfterDuration = null, onClose = { navController.popBackStack() }, + icon = { + val infiniteTransition = rememberInfiniteTransition() + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ) + ) + + Icon( + painter = painterResource(IR.drawable.ic_retry), + contentDescription = null, + modifier = Modifier.graphicsLayer { + rotationZ = rotation + } + ) + } ) } } } } +private fun handleSignInConfirmation( + signInConfirmationAction: SignInConfirmationAction?, + onSignInConfirmationActionHandled: () -> Unit, + navController: NavController, +) { + + val signInNotificationShowing = navController.currentDestination?.route == Routes.signedInNotificationScreen + + when (signInConfirmationAction) { + SignInConfirmationAction.Show -> { + if (!signInNotificationShowing) { + navController.navigate(Routes.signedInNotificationScreen) + } + } + SignInConfirmationAction.Hide -> { + if (signInNotificationShowing) { + navController.popBackStack() + } + } + null -> { /* do nothing */ } + } + + onSignInConfirmationActionHandled() +} + @Preview(device = Devices.WEAR_OS_SMALL_ROUND, showSystemUi = true) @Composable fun DefaultPreview() { WearApp( themeType = Theme.ThemeType.DARK, - showSignInConfirmation = false, - onSignInConfirmationShown = {}, + signInConfirmationAction = null, + onSignInConfirmationActionHandled = {}, ) } diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt index 2627a28b048..ee64fc3d2b0 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt @@ -4,6 +4,8 @@ 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.RefreshState +import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager import au.com.shiftyjelly.pocketcasts.repositories.sync.LoginResult import com.google.android.horologist.auth.data.tokenshare.TokenBundleRepository @@ -12,17 +14,19 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.rx2.asFlow import javax.inject.Inject @HiltViewModel class WearMainActivityViewModel @Inject constructor( - private val podcastManager: PodcastManager, - private val tokenBundleRepository: TokenBundleRepository, - private val watchSync: WatchSync, + podcastManager: PodcastManager, + settings: Settings, + tokenBundleRepository: TokenBundleRepository, + watchSync: WatchSync, ) : ViewModel() { data class State( - val showSignInConfirmation: Boolean = false, + val signInConfirmationAction: SignInConfirmationAction? = null, ) private val _state = MutableStateFlow(State()) @@ -32,25 +36,50 @@ class WearMainActivityViewModel @Inject constructor( viewModelScope.launch { tokenBundleRepository.flow .collect { watchSyncAuthData -> - watchSync.processAuthDataChange(watchSyncAuthData) { loginResult -> - when (loginResult) { - is LoginResult.Failed -> { /* do nothing */ } - is LoginResult.Success -> { - _state.update { it.copy(showSignInConfirmation = true) } - viewModelScope.launch { - podcastManager.refreshPodcastsAfterSignIn() - } - } - } + watchSync.processAuthDataChange(watchSyncAuthData) { + onLoginResult(it, podcastManager) } } } + + viewModelScope.launch { + settings.refreshStateObservable + .asFlow() + .collect(::onRefreshStateChange) + } + } + + private fun onLoginResult(loginResult: LoginResult, podcastManager: PodcastManager) { + when (loginResult) { + is LoginResult.Failed -> { /* do nothing */ } + is LoginResult.Success -> { + _state.update { it.copy(signInConfirmationAction = SignInConfirmationAction.Show) } + viewModelScope.launch { + podcastManager.refreshPodcastsAfterSignIn() + } + } + } + } + + private fun onRefreshStateChange(refreshState: RefreshState) { + when (refreshState) { + RefreshState.Never, + RefreshState.Refreshing -> { /* Do nothing */ } + + is RefreshState.Failed, + is RefreshState.Success -> { + _state.update { it.copy(signInConfirmationAction = SignInConfirmationAction.Hide) } + } + } } /** - * This should be invoked by the UI when it shows the sign in confirmation. + * This should be invoked when the UI it has handled showing or hiding the sign in + * confirmation. */ - fun onSignInConfirmationShown() { - _state.update { it.copy(showSignInConfirmation = false) } + fun onSignInConfirmationActionHandled() { + _state.update { it.copy(signInConfirmationAction = null) } } } + +enum class SignInConfirmationAction { Show, Hide } diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/episode/NotificationScreen.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/episode/NotificationScreen.kt index 9131c41ccca..17b08c7f977 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/episode/NotificationScreen.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/episode/NotificationScreen.kt @@ -29,13 +29,21 @@ import au.com.shiftyjelly.pocketcasts.images.R as IR @Composable fun NotificationScreen( text: String, - delayDuration: Duration? = 2.seconds, onClose: () -> Unit, + closeAfterDuration: Duration? = 2.seconds, + icon: @Composable () -> Unit = { + Icon( + painter = painterResource(IR.drawable.ic_check_black_24dp), + tint = WearColors.FFA1E7B0, + contentDescription = null, + modifier = Modifier.size(52.dp) + ) + }, ) { - LaunchedEffect(delayDuration) { - if (delayDuration != null) { - delay(delayDuration) + LaunchedEffect(closeAfterDuration) { + if (closeAfterDuration != null) { + delay(closeAfterDuration) onClose() } } @@ -48,12 +56,7 @@ fun NotificationScreen( .padding(16.dp) .fillMaxSize() ) { - Icon( - painter = painterResource(IR.drawable.ic_check_black_24dp), - tint = WearColors.FFA1E7B0, - contentDescription = null, - modifier = Modifier.size(52.dp) - ) + icon() Spacer(modifier = Modifier.height(4.dp)) TextH30( text = text, From d486b0ac7924402685a5f38422ce4d1796a29b8e Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Fri, 21 Apr 2023 21:22:43 -0400 Subject: [PATCH 19/22] Add email to logging in notification --- .../src/main/res/values/strings.xml | 2 +- .../repositories/sync/SyncManagerImpl.kt | 3 +- .../servers/model/AuthResultModel.kt | 3 +- .../pocketcasts/wear/MainActivity.kt | 71 +++++------- .../wear/WearMainActivityViewModel.kt | 9 +- .../pocketcasts/wear/ui/LoggingInScreen.kt | 108 ++++++++++++++++++ .../NotificationScreen.kt | 2 +- .../wear/ui/episode/EpisodeScreenFlow.kt | 1 + 8 files changed, 148 insertions(+), 51 deletions(-) create mode 100644 wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/LoggingInScreen.kt rename wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/{episode => component}/NotificationScreen.kt (97%) diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml index 513e949dff9..8dc5ae51f16 100644 --- a/modules/services/localization/src/main/res/values/strings.xml +++ b/modules/services/localization/src/main/res/values/strings.xml @@ -833,7 +833,7 @@ @string/sign_out Are you sure you want to sign out of syncing? Signed in as - Logging in + Logging in… Sonos Connect Connecting to Sonos will allow the Sonos app to access your episode information.\n\nYour email address, password and other sensitive items are never shared. diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncManagerImpl.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncManagerImpl.kt index e2d259e819f..23f767817a3 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncManagerImpl.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncManagerImpl.kt @@ -541,9 +541,10 @@ class SyncManagerImpl @Inject constructor( settings.setLastModified(null) return AuthResultModel( + email = response.email, + isNewAccount = response.isNew, token = response.accessToken, uuid = response.uuid, - isNewAccount = response.isNew ) } } diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/AuthResultModel.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/AuthResultModel.kt index 4406734c60e..0182b4a75cd 100644 --- a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/AuthResultModel.kt +++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/AuthResultModel.kt @@ -2,7 +2,8 @@ package au.com.shiftyjelly.pocketcasts.servers.model import au.com.shiftyjelly.pocketcasts.preferences.AccessToken data class AuthResultModel( + val email: String, + val isNewAccount: Boolean, val token: AccessToken, val uuid: String, - val isNewAccount: Boolean ) diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt index ef369323e11..c1b8df42a8c 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt @@ -4,20 +4,10 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel @@ -25,12 +15,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import androidx.navigation.NavType import androidx.navigation.navArgument -import androidx.wear.compose.material.Icon import androidx.wear.compose.navigation.rememberSwipeDismissableNavController 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.UpNextScreen import au.com.shiftyjelly.pocketcasts.wear.ui.WatchListScreen @@ -39,7 +29,6 @@ import au.com.shiftyjelly.pocketcasts.wear.ui.authenticationSubGraph import au.com.shiftyjelly.pocketcasts.wear.ui.downloads.DownloadsScreen import au.com.shiftyjelly.pocketcasts.wear.ui.episode.EpisodeScreenFlow import au.com.shiftyjelly.pocketcasts.wear.ui.episode.EpisodeScreenFlow.episodeGraph -import au.com.shiftyjelly.pocketcasts.wear.ui.episode.NotificationScreen import au.com.shiftyjelly.pocketcasts.wear.ui.player.NowPlayingScreen import au.com.shiftyjelly.pocketcasts.wear.ui.player.NowPlayingViewModel import au.com.shiftyjelly.pocketcasts.wear.ui.player.StreamingConfirmationScreen @@ -52,8 +41,6 @@ import com.google.android.horologist.compose.navscaffold.composable import com.google.android.horologist.compose.navscaffold.scrollable import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import au.com.shiftyjelly.pocketcasts.images.R as IR -import au.com.shiftyjelly.pocketcasts.localization.R as LR @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -75,10 +62,6 @@ class MainActivity : ComponentActivity() { } } -private object Routes { - const val signedInNotificationScreen = "signedInNotificationScreen" -} - @Composable fun WearApp( themeType: Theme.ThemeType, @@ -212,31 +195,20 @@ fun WearApp( authenticationGraph(navController) - composable(Routes.signedInNotificationScreen) { - it.viewModel.timeTextMode = NavScaffoldViewModel.TimeTextMode.Off - NotificationScreen( - text = stringResource(LR.string.profile_logging_in), - closeAfterDuration = null, - onClose = { navController.popBackStack() }, - icon = { - val infiniteTransition = rememberInfiniteTransition() - val rotation by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween(1000, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ) - ) - - Icon( - painter = painterResource(IR.drawable.ic_retry), - contentDescription = null, - modifier = Modifier.graphicsLayer { - rotationZ = rotation - } - ) + composable( + route = LoggingInScreen.route, + arguments = listOf( + navArgument(LoggingInScreen.emailArgument) { + type = NavType.StringType } + ), + ) { context -> + val email = context.backStackEntry.arguments?.getString(LoggingInScreen.emailArgument) + LoggingInScreen( + email = email, + onClose = { + navController.popBackStack() + }, ) } } @@ -249,19 +221,28 @@ private fun handleSignInConfirmation( navController: NavController, ) { - val signInNotificationShowing = navController.currentDestination?.route == Routes.signedInNotificationScreen + val signInNotificationShowing = navController + .currentDestination + ?.route + ?.startsWith(LoggingInScreen.baseRoute) + ?: false when (signInConfirmationAction) { - SignInConfirmationAction.Show -> { + + is SignInConfirmationAction.Show -> { if (!signInNotificationShowing) { - navController.navigate(Routes.signedInNotificationScreen) + val email = signInConfirmationAction.email + val route = LoggingInScreen.navigateRoute(email) + navController.navigate(route) } } + SignInConfirmationAction.Hide -> { if (signInNotificationShowing) { navController.popBackStack() } } + null -> { /* do nothing */ } } diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt index ee64fc3d2b0..0b9579d9aa9 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt @@ -53,7 +53,9 @@ class WearMainActivityViewModel @Inject constructor( when (loginResult) { is LoginResult.Failed -> { /* do nothing */ } is LoginResult.Success -> { - _state.update { it.copy(signInConfirmationAction = SignInConfirmationAction.Show) } + _state.update { + it.copy(signInConfirmationAction = SignInConfirmationAction.Show(loginResult.result.email)) + } viewModelScope.launch { podcastManager.refreshPodcastsAfterSignIn() } @@ -82,4 +84,7 @@ class WearMainActivityViewModel @Inject constructor( } } -enum class SignInConfirmationAction { Show, Hide } +sealed class SignInConfirmationAction { + class Show(val email: String) : SignInConfirmationAction() + object Hide : SignInConfirmationAction() +} diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/LoggingInScreen.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/LoggingInScreen.kt new file mode 100644 index 00000000000..c35ac0b3425 --- /dev/null +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/LoggingInScreen.kt @@ -0,0 +1,108 @@ +package au.com.shiftyjelly.pocketcasts.wear.ui + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text +import au.com.shiftyjelly.pocketcasts.ui.theme.Theme +import au.com.shiftyjelly.pocketcasts.wear.theme.WearAppTheme +import au.com.shiftyjelly.pocketcasts.images.R as IR +import au.com.shiftyjelly.pocketcasts.localization.R as LR + +object LoggingInScreen { + const val baseRoute = "loggingInScreen" + const val emailArgument = "emailArgument" + const val route = "$baseRoute/{$emailArgument}" + fun navigateRoute(email: String) = "loggingInScreen/$email" +} + +@Composable +fun LoggingInScreen( + email: String?, + onClose: () -> Unit, +) { + + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .clickable { onClose() } + .padding(16.dp) + .fillMaxSize() + ) { + SpinningIcon() + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(LR.string.profile_logging_in), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.title3 + ) + if (email != null) { + Text( + text = email, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body2 + ) + } + } +} + +@Composable +private fun SpinningIcon() { + val infiniteTransition = rememberInfiniteTransition() + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ) + ) + + Icon( + painter = painterResource(IR.drawable.ic_retry), + contentDescription = null, + modifier = Modifier + .size(48.dp) + .graphicsLayer { + rotationZ = rotation + } + ) +} + +@Preview +@Composable +private fun LoggingInScreenPreview() { + WearAppTheme(Theme.ThemeType.DARK) { + LoggingInScreen( + email = "iluvpodcasts@pocketcasts.com", + onClose = {} + ) + } +} diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/episode/NotificationScreen.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/component/NotificationScreen.kt similarity index 97% rename from wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/episode/NotificationScreen.kt rename to wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/component/NotificationScreen.kt index 17b08c7f977..bdafeac98d6 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/episode/NotificationScreen.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/component/NotificationScreen.kt @@ -1,4 +1,4 @@ -package au.com.shiftyjelly.pocketcasts.wear.ui.episode +package au.com.shiftyjelly.pocketcasts.wear.ui.component import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/episode/EpisodeScreenFlow.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/episode/EpisodeScreenFlow.kt index e12cfd45064..1f86406a5fe 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/episode/EpisodeScreenFlow.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/episode/EpisodeScreenFlow.kt @@ -13,6 +13,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavType import androidx.navigation.compose.navigation import androidx.navigation.navArgument +import au.com.shiftyjelly.pocketcasts.wear.ui.component.NotificationScreen import au.com.shiftyjelly.pocketcasts.wear.ui.component.ObtainConfirmationScreen import au.com.shiftyjelly.pocketcasts.wear.ui.player.StreamingConfirmationScreen import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults From 95c5287890669d9d4af4bcf83eebc79e04feb937 Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Fri, 21 Apr 2023 21:38:24 -0400 Subject: [PATCH 20/22] Make sure login notification doesn't dismiss too quickly --- .../wear/WearMainActivityViewModel.kt | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt index 0b9579d9aa9..15d24a8fc3e 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt @@ -10,12 +10,16 @@ import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager import au.com.shiftyjelly.pocketcasts.repositories.sync.LoginResult import com.google.android.horologist.auth.data.tokenshare.TokenBundleRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.rx2.asFlow +import timber.log.Timber import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds @HiltViewModel class WearMainActivityViewModel @Inject constructor( @@ -32,6 +36,10 @@ class WearMainActivityViewModel @Inject constructor( private val _state = MutableStateFlow(State()) val state = _state.asStateFlow() + // The time that the most recent login notification was shown. + private var logInNotificationShownMs: Long? = null + private val logInNotificationMinDuration = 5.seconds + init { viewModelScope.launch { tokenBundleRepository.flow @@ -53,6 +61,7 @@ class WearMainActivityViewModel @Inject constructor( when (loginResult) { is LoginResult.Failed -> { /* do nothing */ } is LoginResult.Success -> { + logInNotificationShownMs = System.currentTimeMillis() _state.update { it.copy(signInConfirmationAction = SignInConfirmationAction.Show(loginResult.result.email)) } @@ -70,11 +79,30 @@ class WearMainActivityViewModel @Inject constructor( is RefreshState.Failed, is RefreshState.Success -> { - _state.update { it.copy(signInConfirmationAction = SignInConfirmationAction.Hide) } + viewModelScope.launch { + delayHidingLoginNotification(refreshState) + _state.update { it.copy(signInConfirmationAction = SignInConfirmationAction.Hide) } + } } } } + private suspend fun delayHidingLoginNotification(refreshState: RefreshState) { + if (logInNotificationShownMs == null) { + Timber.e("logInNotificationShownMs was null when refresh state changed to $refreshState. This should never happen") + } + + val notificationDuration = logInNotificationShownMs?.let { + (System.currentTimeMillis() - it).milliseconds + } ?: 0.milliseconds + + if (notificationDuration < logInNotificationMinDuration) { + val delayAmount = logInNotificationMinDuration - notificationDuration + delay(delayAmount) + } + logInNotificationShownMs = null + } + /** * This should be invoked when the UI it has handled showing or hiding the sign in * confirmation. From b49526a7d70ab1e9955cb28c1269767ada896ba3 Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Sat, 22 Apr 2023 21:37:32 -0400 Subject: [PATCH 21/22] Show gravatar avatar on logging in notification --- .../pocketcasts/profile/UserView.kt | 6 +-- .../compose/images/GravatarProfileImage.kt | 48 +++++++++++++++++++ .../repositories/sync/SyncManagerImpl.kt | 3 +- .../servers/model/AuthResultModel.kt | 3 +- .../shiftyjelly/pocketcasts/utils/Gravatar.kt | 15 ++++++ .../pocketcasts/wear/MainActivity.kt | 25 ++-------- .../wear/WearMainActivityViewModel.kt | 4 +- .../pocketcasts/wear/ui/LoggingInScreen.kt | 38 +++++++++++---- .../wear/ui/LoggingInScreenViewModel.kt | 14 ++++++ 9 files changed, 115 insertions(+), 41 deletions(-) create mode 100644 modules/services/compose/src/main/java/au/com/shiftyjelly/pocketcasts/compose/images/GravatarProfileImage.kt create mode 100644 modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/Gravatar.kt create mode 100644 wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/LoggingInScreenViewModel.kt diff --git a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/UserView.kt b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/UserView.kt index 23fcf723db5..d32dc66cd96 100644 --- a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/UserView.kt +++ b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/UserView.kt @@ -14,9 +14,9 @@ import au.com.shiftyjelly.pocketcasts.models.type.SubscriptionFrequency import au.com.shiftyjelly.pocketcasts.models.type.SubscriptionPlatform import au.com.shiftyjelly.pocketcasts.models.type.SubscriptionType import au.com.shiftyjelly.pocketcasts.ui.extensions.getThemeColor +import au.com.shiftyjelly.pocketcasts.utils.Gravatar import au.com.shiftyjelly.pocketcasts.utils.TimeConstants import au.com.shiftyjelly.pocketcasts.utils.days -import au.com.shiftyjelly.pocketcasts.utils.extensions.md5Hex import au.com.shiftyjelly.pocketcasts.utils.extensions.toLocalizedFormatLongStyle import java.util.Date import au.com.shiftyjelly.pocketcasts.localization.R as LR @@ -54,9 +54,7 @@ open class UserView @JvmOverloads constructor( is SignInState.SignedIn -> { val strPocketCastsPlus = context.getString(LR.string.pocket_casts_plus).uppercase() val strSignedInAs = context.getString(LR.string.profile_signed_in_as).uppercase() - /* https://en.gravatar.com/site/implement/images/ - d=404: display no image if there is not one associated with the requested email hash */ - val gravatarUrl = "https://www.gravatar.com/avatar/${signInState.email.md5Hex()}?d=404" + val gravatarUrl = Gravatar.getUrl(signInState.email) lblUsername.text = signInState.email lblSignInStatus.text = if (signInState.isSignedInAsPlus) strPocketCastsPlus else strSignedInAs diff --git a/modules/services/compose/src/main/java/au/com/shiftyjelly/pocketcasts/compose/images/GravatarProfileImage.kt b/modules/services/compose/src/main/java/au/com/shiftyjelly/pocketcasts/compose/images/GravatarProfileImage.kt new file mode 100644 index 00000000000..a739f9cbf87 --- /dev/null +++ b/modules/services/compose/src/main/java/au/com/shiftyjelly/pocketcasts/compose/images/GravatarProfileImage.kt @@ -0,0 +1,48 @@ +package au.com.shiftyjelly.pocketcasts.compose.images + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import au.com.shiftyjelly.pocketcasts.utils.Gravatar +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest + +@Composable +fun GravatarProfileImage( + email: String, + modifier: Modifier = Modifier, + contentDescription: String?, + placeholder: @Composable (() -> Unit) = {}, +) { + + val gravatarUrl = remember(email) { + Gravatar.getUrl(email) + } + + val gravatarPainter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(gravatarUrl) + .crossfade(true) + .build(), + ) + + Box { + // Image component that attempts to load the gravatar image + Image( + painter = gravatarPainter, + contentDescription = contentDescription, + modifier = modifier, + ) + + // If the gravatar image has not loaded or fails to load (because there is no gravatar image associated + // with this account), show the placeholder. We are doing this because setting a placeholder on an + // AsyncImagePainter fails to show the placeholder when there is no gravatar image associated with + // the account. + if (gravatarPainter.state.painter == null) { + placeholder() + } + } +} diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncManagerImpl.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncManagerImpl.kt index 23f767817a3..5165b083e81 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncManagerImpl.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/sync/SyncManagerImpl.kt @@ -541,10 +541,9 @@ class SyncManagerImpl @Inject constructor( settings.setLastModified(null) return AuthResultModel( - email = response.email, - isNewAccount = response.isNew, token = response.accessToken, uuid = response.uuid, + isNewAccount = response.isNew, ) } } diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/AuthResultModel.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/AuthResultModel.kt index 0182b4a75cd..6d6d1c0d2fb 100644 --- a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/AuthResultModel.kt +++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/AuthResultModel.kt @@ -2,8 +2,7 @@ package au.com.shiftyjelly.pocketcasts.servers.model import au.com.shiftyjelly.pocketcasts.preferences.AccessToken data class AuthResultModel( - val email: String, - val isNewAccount: Boolean, val token: AccessToken, val uuid: String, + val isNewAccount: Boolean, ) diff --git a/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/Gravatar.kt b/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/Gravatar.kt new file mode 100644 index 00000000000..acad7a3f7f7 --- /dev/null +++ b/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/Gravatar.kt @@ -0,0 +1,15 @@ +package au.com.shiftyjelly.pocketcasts.utils + +import au.com.shiftyjelly.pocketcasts.utils.extensions.md5Hex + +object Gravatar { + + /** + * d=404: display no image if there is not one associated with the requested email hash + * https://en.gravatar.com/site/implement/images/ + */ + fun getUrl(email: String): String? = + email.md5Hex()?.let { md5Email -> + "https://www.gravatar.com/avatar/$md5Email?d=404" + } +} diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt index c1b8df42a8c..72f85a14150 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/MainActivity.kt @@ -195,20 +195,9 @@ fun WearApp( authenticationGraph(navController) - composable( - route = LoggingInScreen.route, - arguments = listOf( - navArgument(LoggingInScreen.emailArgument) { - type = NavType.StringType - } - ), - ) { context -> - val email = context.backStackEntry.arguments?.getString(LoggingInScreen.emailArgument) + composable(LoggingInScreen.route) { LoggingInScreen( - email = email, - onClose = { - navController.popBackStack() - }, + onClose = { navController.popBackStack() }, ) } } @@ -221,19 +210,13 @@ private fun handleSignInConfirmation( navController: NavController, ) { - val signInNotificationShowing = navController - .currentDestination - ?.route - ?.startsWith(LoggingInScreen.baseRoute) - ?: false + val signInNotificationShowing = navController.currentDestination?.route == LoggingInScreen.route when (signInConfirmationAction) { is SignInConfirmationAction.Show -> { if (!signInNotificationShowing) { - val email = signInConfirmationAction.email - val route = LoggingInScreen.navigateRoute(email) - navController.navigate(route) + navController.navigate(LoggingInScreen.route) } } diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt index 15d24a8fc3e..926715e4cc2 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/WearMainActivityViewModel.kt @@ -63,7 +63,7 @@ class WearMainActivityViewModel @Inject constructor( is LoginResult.Success -> { logInNotificationShownMs = System.currentTimeMillis() _state.update { - it.copy(signInConfirmationAction = SignInConfirmationAction.Show(loginResult.result.email)) + it.copy(signInConfirmationAction = SignInConfirmationAction.Show) } viewModelScope.launch { podcastManager.refreshPodcastsAfterSignIn() @@ -113,6 +113,6 @@ class WearMainActivityViewModel @Inject constructor( } sealed class SignInConfirmationAction { - class Show(val email: String) : SignInConfirmationAction() + object Show : SignInConfirmationAction() object Hide : SignInConfirmationAction() } diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/LoggingInScreen.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/LoggingInScreen.kt index c35ac0b3425..e82d21aa265 100644 --- a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/LoggingInScreen.kt +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/LoggingInScreen.kt @@ -14,10 +14,13 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -25,27 +28,28 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.wear.compose.material.Icon import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.Text +import au.com.shiftyjelly.pocketcasts.compose.images.GravatarProfileImage import au.com.shiftyjelly.pocketcasts.ui.theme.Theme import au.com.shiftyjelly.pocketcasts.wear.theme.WearAppTheme import au.com.shiftyjelly.pocketcasts.images.R as IR import au.com.shiftyjelly.pocketcasts.localization.R as LR object LoggingInScreen { - const val baseRoute = "loggingInScreen" - const val emailArgument = "emailArgument" - const val route = "$baseRoute/{$emailArgument}" - fun navigateRoute(email: String) = "loggingInScreen/$email" + const val route = "loggingInScreen" } @Composable fun LoggingInScreen( - email: String?, onClose: () -> Unit, ) { + val viewModel = hiltViewModel() + val email = remember { viewModel.getEmail() } + Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, @@ -54,7 +58,21 @@ fun LoggingInScreen( .padding(16.dp) .fillMaxSize() ) { - SpinningIcon() + val placeholder = @Composable { + SpinningIcon(Modifier.size(48.dp)) + } + if (email == null) { + placeholder() + } else { + GravatarProfileImage( + email = email, + contentDescription = null, + placeholder = placeholder, + modifier = Modifier + .clip(CircleShape) + .size(48.dp), + ) + } Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(LR.string.profile_logging_in), @@ -74,7 +92,9 @@ fun LoggingInScreen( } @Composable -private fun SpinningIcon() { +private fun SpinningIcon( + modifier: Modifier = Modifier, +) { val infiniteTransition = rememberInfiniteTransition() val rotation by infiniteTransition.animateFloat( initialValue = 0f, @@ -88,8 +108,7 @@ private fun SpinningIcon() { Icon( painter = painterResource(IR.drawable.ic_retry), contentDescription = null, - modifier = Modifier - .size(48.dp) + modifier = modifier .graphicsLayer { rotationZ = rotation } @@ -101,7 +120,6 @@ private fun SpinningIcon() { private fun LoggingInScreenPreview() { WearAppTheme(Theme.ThemeType.DARK) { LoggingInScreen( - email = "iluvpodcasts@pocketcasts.com", onClose = {} ) } diff --git a/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/LoggingInScreenViewModel.kt b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/LoggingInScreenViewModel.kt new file mode 100644 index 00000000000..bf6cb894dd5 --- /dev/null +++ b/wear/src/main/kotlin/au/com/shiftyjelly/pocketcasts/wear/ui/LoggingInScreenViewModel.kt @@ -0,0 +1,14 @@ +package au.com.shiftyjelly.pocketcasts.wear.ui + +import androidx.lifecycle.ViewModel +import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class LoggingInScreenViewModel @Inject constructor( + private val syncManager: SyncManager, +) : ViewModel() { + + fun getEmail() = syncManager.getEmail() +} From 83328f3416e2e5c076728d58c2d73d039d98402a Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Sun, 23 Apr 2023 05:42:56 -0400 Subject: [PATCH 22/22] Crossfade gravatar placeholder --- .../compose/images/GravatarProfileImage.kt | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/modules/services/compose/src/main/java/au/com/shiftyjelly/pocketcasts/compose/images/GravatarProfileImage.kt b/modules/services/compose/src/main/java/au/com/shiftyjelly/pocketcasts/compose/images/GravatarProfileImage.kt index a739f9cbf87..daaec4aae01 100644 --- a/modules/services/compose/src/main/java/au/com/shiftyjelly/pocketcasts/compose/images/GravatarProfileImage.kt +++ b/modules/services/compose/src/main/java/au/com/shiftyjelly/pocketcasts/compose/images/GravatarProfileImage.kt @@ -1,10 +1,12 @@ package au.com.shiftyjelly.pocketcasts.compose.images +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.LocalContext import au.com.shiftyjelly.pocketcasts.utils.Gravatar import coil.compose.rememberAsyncImagePainter @@ -29,19 +31,22 @@ fun GravatarProfileImage( .build(), ) - Box { - // Image component that attempts to load the gravatar image + Crossfade( + gravatarPainter.state.painter == null, + animationSpec = tween(500), + ) { showPlaceholder -> Image( painter = gravatarPainter, contentDescription = contentDescription, - modifier = modifier, + modifier = modifier + .alpha(if (showPlaceholder) 0f else 1f) ) // If the gravatar image has not loaded or fails to load (because there is no gravatar image associated - // with this account), show the placeholder. We are doing this because setting a placeholder on an - // AsyncImagePainter fails to show the placeholder when there is no gravatar image associated with - // the account. - if (gravatarPainter.state.painter == null) { + // with this account), show the placeholder. We are settings the placeholder this way instead of + // setting a placeholder on an AsyncImagePainter because this approach continues showing the placeholder + // when there is not a gravatar image associated with the account. + if (showPlaceholder) { placeholder() } }