diff --git a/api/trakt/build.gradle.kts b/api/trakt/build.gradle.kts index e5c25ebc56..e95e8c723b 100644 --- a/api/trakt/build.gradle.kts +++ b/api/trakt/build.gradle.kts @@ -43,6 +43,7 @@ kotlin { implementation(libs.ktor.client.core) implementation(libs.ktor.client.auth) + implementation(libs.ktor.client.contentnegotiation) api(libs.kotlin.coroutines.core) diff --git a/api/trakt/src/commonMain/kotlin/app/tivi/trakt/TiviTrakt.kt b/api/trakt/src/commonMain/kotlin/app/tivi/trakt/TiviTrakt.kt new file mode 100644 index 0000000000..41c57b6536 --- /dev/null +++ b/api/trakt/src/commonMain/kotlin/app/tivi/trakt/TiviTrakt.kt @@ -0,0 +1,124 @@ +// Copyright 2024, Christopher Banes and the Tivi project contributors +// SPDX-License-Identifier: Apache-2.0 + +@file:Suppress("invisible_reference", "invisible_member") + +package app.tivi.trakt + +import app.moviebase.trakt.TraktBearerTokens +import app.moviebase.trakt.TraktClientConfig +import app.moviebase.trakt.api.TraktAuthApi +import app.moviebase.trakt.api.TraktCheckinApi +import app.moviebase.trakt.api.TraktCommentsApi +import app.moviebase.trakt.api.TraktEpisodesApi +import app.moviebase.trakt.api.TraktMoviesApi +import app.moviebase.trakt.api.TraktRecommendationsApi +import app.moviebase.trakt.api.TraktSearchApi +import app.moviebase.trakt.api.TraktSeasonsApi +import app.moviebase.trakt.api.TraktShowsApi +import app.moviebase.trakt.api.TraktSyncApi +import app.moviebase.trakt.api.TraktUsersApi +import app.moviebase.trakt.core.HttpClientFactory +import app.moviebase.trakt.core.TraktDsl +import app.moviebase.trakt.core.interceptRequest +import app.tivi.app.ApplicationInfo +import app.tivi.data.traktauth.TraktAuthRepository +import app.tivi.data.traktauth.TraktOAuthInfo +import co.touchlab.kermit.Logger +import io.ktor.client.HttpClient +import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.providers.BearerAuthProvider +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.pluginOrNull +import io.ktor.client.request.header + +@TraktDsl +fun TiviTrakt(block: TraktClientConfig.() -> Unit): TiviTrakt { + val config = TraktClientConfig().apply(block) + return TiviTrakt(config) +} + +class TiviTrakt internal constructor(private val config: TraktClientConfig) { + + private val client: HttpClient by lazy { + HttpClientFactory.create(config = config, useAuthentication = true).apply { + interceptRequest { + it.header(TraktHeader.API_KEY, config.traktApiKey) + it.header(TraktHeader.API_VERSION, TraktWebConfig.VERSION) + } + } + } + + init { + requireNotNull(config.traktApiKey) { + "Trakt API key unavailable. Set the traktApiKey field in the class TraktClientConfig " + + "when instantiate the Trakt client." + } + } + + val auth by lazy { TraktAuthApi(client, config) } + val movies by buildApi(::TraktMoviesApi) + val shows by buildApi(::TraktShowsApi) + val seasons by buildApi(::TraktSeasonsApi) + val episodes by buildApi(::TraktEpisodesApi) + val checkin by buildApi(::TraktCheckinApi) + val search by buildApi(::TraktSearchApi) + val users by buildApi(::TraktUsersApi) + val sync by buildApi(::TraktSyncApi) + val recommendations by buildApi(::TraktRecommendationsApi) + val comments by buildApi(::TraktCommentsApi) + + fun invalidateAuth() { + // Force Ktor to re-fetch bearer tokens + // https://youtrack.jetbrains.com/issue/KTOR-4759 + client.pluginOrNull(Auth) + ?.providers + ?.filterIsInstance() + ?.firstOrNull() + ?.clearToken() + } + + private inline fun buildApi(crossinline builder: (HttpClient) -> T) = lazy { builder(client) } +} + +internal object TraktWebConfig { + const val VERSION = "2" +} + +internal object TraktHeader { + const val API_KEY = "trakt-api-key" + const val API_VERSION = "trakt-api-version" +} + +internal fun TraktClientConfig.applyTiviConfig( + oauthInfo: TraktOAuthInfo, + applicationInfo: ApplicationInfo, + traktAuthRepository: () -> TraktAuthRepository, +) { + traktApiKey = oauthInfo.clientId + maxRetries = 3 + + logging { + logger = object : io.ktor.client.plugins.logging.Logger { + override fun log(message: String) { + Logger.d("trakt-ktor") { message } + } + } + level = when { + applicationInfo.debugBuild -> LogLevel.HEADERS + else -> LogLevel.NONE + } + } + + userAuthentication { + loadTokens { + traktAuthRepository().getAuthState() + ?.let { TraktBearerTokens(it.accessToken, it.refreshToken) } + } + + refreshTokens { + traktAuthRepository().refreshTokens() + ?.let { TraktBearerTokens(it.accessToken, it.refreshToken) } + } + } +} diff --git a/api/trakt/src/commonMain/kotlin/app/tivi/trakt/TraktComponent.kt b/api/trakt/src/commonMain/kotlin/app/tivi/trakt/TraktComponent.kt index c49756c84f..518b554c06 100644 --- a/api/trakt/src/commonMain/kotlin/app/tivi/trakt/TraktComponent.kt +++ b/api/trakt/src/commonMain/kotlin/app/tivi/trakt/TraktComponent.kt @@ -3,7 +3,6 @@ package app.tivi.trakt -import app.moviebase.trakt.Trakt import app.moviebase.trakt.api.TraktEpisodesApi import app.moviebase.trakt.api.TraktRecommendationsApi import app.moviebase.trakt.api.TraktSearchApi @@ -12,18 +11,14 @@ import app.moviebase.trakt.api.TraktShowsApi import app.moviebase.trakt.api.TraktSyncApi import app.moviebase.trakt.api.TraktUsersApi import app.tivi.app.ApplicationInfo +import app.tivi.data.traktauth.TraktClient import app.tivi.data.traktauth.TraktOAuthInfo import app.tivi.inject.ApplicationScope import me.tatarka.inject.annotations.Provides -interface TraktComponent : - TraktCommonComponent, - TraktPlatformComponent - expect interface TraktPlatformComponent -interface TraktCommonComponent { - +interface TraktComponent : TraktPlatformComponent { @ApplicationScope @Provides fun provideTraktOAuthInfo( @@ -48,23 +43,30 @@ interface TraktCommonComponent { ) @Provides - fun provideTraktUsersService(trakt: Trakt): TraktUsersApi = trakt.users + fun provideTraktUsersService(trakt: TiviTrakt): TraktUsersApi = trakt.users + + @Provides + fun provideTraktShowsService(trakt: TiviTrakt): TraktShowsApi = trakt.shows @Provides - fun provideTraktShowsService(trakt: Trakt): TraktShowsApi = trakt.shows + fun provideTraktEpisodesService(trakt: TiviTrakt): TraktEpisodesApi = trakt.episodes @Provides - fun provideTraktEpisodesService(trakt: Trakt): TraktEpisodesApi = trakt.episodes + fun provideTraktSeasonsService(trakt: TiviTrakt): TraktSeasonsApi = trakt.seasons @Provides - fun provideTraktSeasonsService(trakt: Trakt): TraktSeasonsApi = trakt.seasons + fun provideTraktSyncService(trakt: TiviTrakt): TraktSyncApi = trakt.sync @Provides - fun provideTraktSyncService(trakt: Trakt): TraktSyncApi = trakt.sync + fun provideTraktSearchService(trakt: TiviTrakt): TraktSearchApi = trakt.search @Provides - fun provideTraktSearchService(trakt: Trakt): TraktSearchApi = trakt.search + fun provideTraktRecommendationsService(trakt: TiviTrakt): TraktRecommendationsApi = trakt.recommendations @Provides - fun provideTraktRecommendationsService(trakt: Trakt): TraktRecommendationsApi = trakt.recommendations + fun provideTraktClient(trakt: Lazy): TraktClient = object : TraktClient { + override fun invalidateAuthTokens() { + trakt.value.invalidateAuth() + } + } } diff --git a/api/trakt/src/iosMain/kotlin/app/tivi/trakt/TraktPlatformComponent.kt b/api/trakt/src/iosMain/kotlin/app/tivi/trakt/TraktPlatformComponent.kt index 74f7fd4f77..7ab22ad8b8 100644 --- a/api/trakt/src/iosMain/kotlin/app/tivi/trakt/TraktPlatformComponent.kt +++ b/api/trakt/src/iosMain/kotlin/app/tivi/trakt/TraktPlatformComponent.kt @@ -3,19 +3,11 @@ package app.tivi.trakt -import app.moviebase.trakt.Trakt import app.tivi.app.ApplicationInfo import app.tivi.data.traktauth.TraktAuthRepository import app.tivi.data.traktauth.TraktOAuthInfo import app.tivi.inject.ApplicationScope -import co.touchlab.kermit.Logger import io.ktor.client.engine.darwin.Darwin -import io.ktor.client.plugins.HttpRequestRetry -import io.ktor.client.plugins.auth.Auth -import io.ktor.client.plugins.auth.providers.BearerTokens -import io.ktor.client.plugins.auth.providers.bearer -import io.ktor.client.plugins.logging.LogLevel -import io.ktor.http.HttpStatusCode import me.tatarka.inject.annotations.Provides actual interface TraktPlatformComponent { @@ -25,21 +17,12 @@ actual interface TraktPlatformComponent { oauthInfo: TraktOAuthInfo, applicationInfo: ApplicationInfo, traktAuthRepository: Lazy, - ): Trakt = Trakt { - traktApiKey = oauthInfo.clientId - maxRetries = 3 - - logging { - logger = object : io.ktor.client.plugins.logging.Logger { - override fun log(message: String) { - Logger.d("trakt-ktor") { message } - } - } - level = when { - applicationInfo.debugBuild -> LogLevel.HEADERS - else -> LogLevel.NONE - } - } + ): TiviTrakt = TiviTrakt { + applyTiviConfig( + oauthInfo = oauthInfo, + applicationInfo = applicationInfo, + traktAuthRepository = traktAuthRepository::value, + ) httpClient(Darwin) { engine { @@ -47,34 +30,6 @@ actual interface TraktPlatformComponent { setAllowsCellularAccess(true) } } - - install(HttpRequestRetry) { - retryIf(5) { _, httpResponse -> - when { - httpResponse.status.value in 500..599 -> true - httpResponse.status == HttpStatusCode.TooManyRequests -> true - else -> false - } - } - } - - install(Auth) { - bearer { - loadTokens { - traktAuthRepository.value.getAuthState() - ?.let { BearerTokens(it.accessToken, it.refreshToken) } - } - - refreshTokens { - traktAuthRepository.value.refreshTokens() - ?.let { BearerTokens(it.accessToken, it.refreshToken) } - } - - sendWithoutRequest { request -> - request.url.host == "api.trakt.tv" - } - } - } } } } diff --git a/api/trakt/src/jvmMain/kotlin/app/tivi/trakt/TraktPlatformComponent.kt b/api/trakt/src/jvmMain/kotlin/app/tivi/trakt/TraktPlatformComponent.kt index 68beae15ec..1efec6dfb5 100644 --- a/api/trakt/src/jvmMain/kotlin/app/tivi/trakt/TraktPlatformComponent.kt +++ b/api/trakt/src/jvmMain/kotlin/app/tivi/trakt/TraktPlatformComponent.kt @@ -3,17 +3,11 @@ package app.tivi.trakt -import app.moviebase.trakt.Trakt +import app.tivi.app.ApplicationInfo import app.tivi.data.traktauth.TraktAuthRepository import app.tivi.data.traktauth.TraktOAuthInfo -import app.tivi.data.traktauth.store.AuthStore import app.tivi.inject.ApplicationScope import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.client.plugins.HttpRequestRetry -import io.ktor.client.plugins.auth.Auth -import io.ktor.client.plugins.auth.providers.BearerTokens -import io.ktor.client.plugins.auth.providers.bearer -import io.ktor.http.HttpStatusCode import me.tatarka.inject.annotations.Provides import okhttp3.OkHttpClient @@ -22,46 +16,21 @@ actual interface TraktPlatformComponent { @Provides fun provideTrakt( client: OkHttpClient, - authStore: AuthStore, oauthInfo: TraktOAuthInfo, + applicationInfo: ApplicationInfo, traktAuthRepository: Lazy, - ): Trakt = Trakt { - traktApiKey = oauthInfo.clientId - maxRetries = 3 + ): TiviTrakt = TiviTrakt { + applyTiviConfig( + oauthInfo = oauthInfo, + applicationInfo = applicationInfo, + traktAuthRepository = traktAuthRepository::value, + ) httpClient(OkHttp) { // Probably want to move to using Ktor's caching, timeouts, etc eventually engine { preconfigured = client } - - install(HttpRequestRetry) { - retryIf(5) { _, httpResponse -> - when { - httpResponse.status.value in 500..599 -> true - httpResponse.status == HttpStatusCode.TooManyRequests -> true - else -> false - } - } - } - - install(Auth) { - bearer { - loadTokens { - traktAuthRepository.value.getAuthState() - ?.let { BearerTokens(it.accessToken, it.refreshToken) } - } - - refreshTokens { - traktAuthRepository.value.refreshTokens() - ?.let { BearerTokens(it.accessToken, it.refreshToken) } - } - - sendWithoutRequest { request -> - request.url.host == "api.trakt.tv" - } - } - } } } } diff --git a/data/episodes/src/commonMain/kotlin/app/tivi/data/episodes/SeasonsEpisodesRepository.kt b/data/episodes/src/commonMain/kotlin/app/tivi/data/episodes/SeasonsEpisodesRepository.kt index 094755f1a4..8b235b6492 100644 --- a/data/episodes/src/commonMain/kotlin/app/tivi/data/episodes/SeasonsEpisodesRepository.kt +++ b/data/episodes/src/commonMain/kotlin/app/tivi/data/episodes/SeasonsEpisodesRepository.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.datetime.Clock import kotlinx.datetime.Instant import me.tatarka.inject.annotations.Inject @@ -241,7 +242,7 @@ class SeasonsEpisodesRepository( it.isNotEmpty() && processPendingAdditions(it) } - if (traktAuthRepository.state.value == TraktAuthState.LOGGED_IN) { + if (traktAuthRepository.state.first() == TraktAuthState.LOGGED_IN) { updateShowEpisodeWatches(showId) } } @@ -359,13 +360,13 @@ class SeasonsEpisodesRepository( needUpdate = true } - if (needUpdate && traktAuthRepository.state.value == TraktAuthState.LOGGED_IN) { + if (needUpdate && traktAuthRepository.isLoggedIn()) { fetchEpisodeWatchesFromRemote(episodeId) } } suspend fun updateShowEpisodeWatches(showId: Long) { - if (traktAuthRepository.state.value != TraktAuthState.LOGGED_IN) return + if (!traktAuthRepository.isLoggedIn()) return val response = traktEpisodeWatchesDataSource.getShowEpisodeWatches(showId) @@ -390,7 +391,7 @@ class SeasonsEpisodesRepository( * @return true if a network service was updated */ private suspend fun processPendingDeletes(entries: List): Boolean { - if (traktAuthRepository.state.value == TraktAuthState.LOGGED_IN) { + if (traktAuthRepository.isLoggedIn()) { val localOnlyDeletes = entries.filter { it.traktId == null } // If we've got deletes which are local only, just remove them from the DB if (localOnlyDeletes.isNotEmpty()) { @@ -417,7 +418,7 @@ class SeasonsEpisodesRepository( * @return true if a network service was updated */ private suspend fun processPendingAdditions(entries: List): Boolean { - if (traktAuthRepository.state.value == TraktAuthState.LOGGED_IN) { + if (traktAuthRepository.isLoggedIn()) { traktEpisodeWatchesDataSource.addEpisodeWatches(entries) // Now update the database episodeWatchStore.updateEntriesWithAction(entries.map { it.id }, PendingAction.NOTHING) diff --git a/data/followedshows/src/commonMain/kotlin/app/tivi/data/followedshows/FollowedShowsRepository.kt b/data/followedshows/src/commonMain/kotlin/app/tivi/data/followedshows/FollowedShowsRepository.kt index 4ac87dd740..bb1ccb0c01 100644 --- a/data/followedshows/src/commonMain/kotlin/app/tivi/data/followedshows/FollowedShowsRepository.kt +++ b/data/followedshows/src/commonMain/kotlin/app/tivi/data/followedshows/FollowedShowsRepository.kt @@ -10,7 +10,6 @@ import app.tivi.data.db.DatabaseTransactionRunner import app.tivi.data.models.FollowedShowEntry import app.tivi.data.models.PendingAction import app.tivi.data.traktauth.TraktAuthRepository -import app.tivi.data.traktauth.TraktAuthState import app.tivi.data.util.ItemSyncerResult import app.tivi.data.util.inPast import app.tivi.data.util.syncerForEntity @@ -90,10 +89,7 @@ class FollowedShowsRepository( } suspend fun syncFollowedShows(): ItemSyncerResult { - val listId = when (traktAuthRepository.state.value) { - TraktAuthState.LOGGED_IN -> getFollowedTraktListId() - else -> null - } + val listId = if (traktAuthRepository.isLoggedIn()) getFollowedTraktListId() else null processPendingAdditions(listId) processPendingDelete(listId) @@ -132,7 +128,7 @@ class FollowedShowsRepository( return } - if (listId != null && traktAuthRepository.state.value == TraktAuthState.LOGGED_IN) { + if (listId != null && traktAuthRepository.isLoggedIn()) { val shows = pending.mapNotNull { showDao.getShowWithId(it.showId) } logger.v { "processPendingAdditions. Entries mapped: $shows" } @@ -161,7 +157,7 @@ class FollowedShowsRepository( return } - if (listId != null && traktAuthRepository.state.value == TraktAuthState.LOGGED_IN) { + if (listId != null && traktAuthRepository.isLoggedIn()) { val shows = pending.mapNotNull { showDao.getShowWithId(it.showId) } logger.v { "processPendingDelete. Entries mapped: $shows" } diff --git a/data/test/src/commonTest/kotlin/app/tivi/utils/ObjectGraph.kt b/data/test/src/commonTest/kotlin/app/tivi/utils/ObjectGraph.kt index b6fc6cbd69..a44e3341fb 100644 --- a/data/test/src/commonTest/kotlin/app/tivi/utils/ObjectGraph.kt +++ b/data/test/src/commonTest/kotlin/app/tivi/utils/ObjectGraph.kt @@ -25,6 +25,7 @@ import app.tivi.data.episodes.ShowSeasonsLastRequestStore import app.tivi.data.followedshows.FollowedShowsLastRequestStore import app.tivi.data.followedshows.FollowedShowsRepository import app.tivi.data.traktauth.TraktAuthRepository +import app.tivi.data.traktauth.TraktClient import app.tivi.util.AppCoroutineDispatchers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -47,13 +48,16 @@ class ObjectGraph( val tmdbEpisodeDataSource: FakeEpisodeDataSource = FakeEpisodeDataSource(), val traktEpisodeDataSource: FakeEpisodeDataSource = FakeEpisodeDataSource(), val episodeWatchesDataSource: FakeEpisodeWatchesDataSource = FakeEpisodeWatchesDataSource(), - + val traktClient: TraktClient = object : TraktClient { + override fun invalidateAuthTokens() {} + }, val traktAuthRepository: TraktAuthRepository = TraktAuthRepository( scope = backgroundScope, dispatchers = appCoroutineDispatchers, authStore = AuthorizedAuthStore, loginAction = lazy { SuccessTraktLoginAction }, refreshTokenAction = lazy { SuccessRefreshTokenAction }, + traktClient = lazy { traktClient }, ), val followedShowsRepository: FollowedShowsRepository = FollowedShowsRepository( followedShowsDao = followedShowsDao, diff --git a/data/traktauth/src/commonMain/kotlin/app/tivi/data/traktauth/TraktAuthRepository.kt b/data/traktauth/src/commonMain/kotlin/app/tivi/data/traktauth/TraktAuthRepository.kt index 8ed2a43033..e8cbc36ded 100644 --- a/data/traktauth/src/commonMain/kotlin/app/tivi/data/traktauth/TraktAuthRepository.kt +++ b/data/traktauth/src/commonMain/kotlin/app/tivi/data/traktauth/TraktAuthRepository.kt @@ -9,9 +9,10 @@ import app.tivi.inject.ApplicationScope import app.tivi.util.AppCoroutineDispatchers import co.touchlab.kermit.Logger import kotlin.time.Duration.Companion.hours +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.datetime.Clock @@ -26,12 +27,21 @@ class TraktAuthRepository( private val authStore: AuthStore, private val loginAction: Lazy, private val refreshTokenAction: Lazy, + private val traktClient: Lazy, ) { - private val _state = MutableStateFlow(TraktAuthState.LOGGED_OUT) - val state: StateFlow get() = _state.asStateFlow() + private val authState = MutableStateFlow(null) + private var authStateExpiry: Instant = Instant.DISTANT_PAST - private var lastAuthState: AuthState? = null - private var lastAuthStateExpiry: Instant = Instant.DISTANT_PAST + val state: Flow = authState.map { + when (it?.isAuthorized) { + true -> TraktAuthState.LOGGED_IN + else -> TraktAuthState.LOGGED_OUT + } + } + + fun isLoggedIn(): Boolean { + return authState.value?.isAuthorized == true + } private val logger by lazy { Logger.withTag("TraktAuthRepository") } @@ -44,8 +54,8 @@ class TraktAuthRepository( } suspend fun getAuthState(): AuthState? { - val state = lastAuthState - if (state != null && Clock.System.now() < lastAuthStateExpiry) { + val state = authState.value + if (state != null && state.isAuthorized && Clock.System.now() < authStateExpiry) { logger.d { "getAuthState. Using cached tokens: $state" } return state } @@ -81,35 +91,35 @@ class TraktAuthRepository( } private fun cacheAuthState(authState: AuthState) { - if (authState.isAuthorized) { - lastAuthState = authState - lastAuthStateExpiry = Clock.System.now() + 1.hours - } else { - lastAuthState = null - lastAuthStateExpiry = Instant.DISTANT_PAST + this.authState.update { authState } + authStateExpiry = when { + authState.isAuthorized -> Clock.System.now() + 1.hours + else -> Instant.DISTANT_PAST } } private suspend fun updateAuthState(authState: AuthState, persist: Boolean = true) { - logger.d { " updateAuthState: $authState. Persist: $persist" } - _state.value = when { - authState.isAuthorized -> TraktAuthState.LOGGED_IN - else -> TraktAuthState.LOGGED_OUT - } - cacheAuthState(authState) - logger.d { " Updated AuthState: ${_state.value}" } - if (persist) { // Persist auth state withContext(dispatchers.io) { if (authState.isAuthorized) { authStore.save(authState) - logger.d { " Saved state to AuthStore: $authState" } + logger.d { "Saved state to AuthStore: $authState" } } else { authStore.clear() - logger.d { " Cleared AuthStore" } + logger.d { "Cleared AuthStore" } } } } + + logger.d { "updateAuthState: $authState. Persist: $persist" } + cacheAuthState(authState) + + logger.d { "updateAuthState: Clearing TraktClient auth tokens" } + traktClient.value.invalidateAuthTokens() } } + +interface TraktClient { + fun invalidateAuthTokens() +} diff --git a/domain/src/commonMain/kotlin/app/tivi/domain/interactors/GetTraktAuthState.kt b/domain/src/commonMain/kotlin/app/tivi/domain/interactors/GetTraktAuthState.kt index d0f85812ef..37c8efd7fa 100644 --- a/domain/src/commonMain/kotlin/app/tivi/domain/interactors/GetTraktAuthState.kt +++ b/domain/src/commonMain/kotlin/app/tivi/domain/interactors/GetTraktAuthState.kt @@ -6,6 +6,7 @@ package app.tivi.domain.interactors import app.tivi.data.traktauth.TraktAuthRepository import app.tivi.data.traktauth.TraktAuthState import app.tivi.domain.Interactor +import kotlinx.coroutines.flow.first import me.tatarka.inject.annotations.Inject @Inject @@ -15,6 +16,6 @@ class GetTraktAuthState( private val traktAuthRepository by traktAuthRepository override suspend fun doWork(params: Unit): TraktAuthState { - return traktAuthRepository.state.value + return traktAuthRepository.state.first() } } diff --git a/domain/src/commonMain/kotlin/app/tivi/domain/interactors/UpdateRecommendedShows.kt b/domain/src/commonMain/kotlin/app/tivi/domain/interactors/UpdateRecommendedShows.kt index b9215de824..b54161d270 100644 --- a/domain/src/commonMain/kotlin/app/tivi/domain/interactors/UpdateRecommendedShows.kt +++ b/domain/src/commonMain/kotlin/app/tivi/domain/interactors/UpdateRecommendedShows.kt @@ -6,7 +6,6 @@ package app.tivi.domain.interactors import app.tivi.data.recommendedshows.RecommendedShowsStore import app.tivi.data.shows.ShowStore import app.tivi.data.traktauth.TraktAuthRepository -import app.tivi.data.traktauth.TraktAuthState import app.tivi.data.util.fetch import app.tivi.domain.Interactor import app.tivi.domain.UserInitiatedParams @@ -27,7 +26,7 @@ class UpdateRecommendedShows( ) : Interactor() { override suspend fun doWork(params: Params) { // If we're not logged in, we can't load the recommended shows - if (traktAuthRepository.value.state.value != TraktAuthState.LOGGED_IN) return + if (!traktAuthRepository.value.isLoggedIn()) return withContext(dispatchers.io) { recommendedShowsStore.value.fetch(0, forceFresh = params.isUserInitiated).parallelForEach { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f432b23e44..a6439c6e9b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -130,6 +130,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } +ktor-client-contentnegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } playservices-blockstore = "com.google.android.gms:play-services-auth-blockstore:16.4.0" diff --git a/ios-app/Tivi/Tivi/Auth.swift b/ios-app/Tivi/Tivi/Auth.swift index 9f7ce15035..fdf39195b0 100644 --- a/ios-app/Tivi/Tivi/Auth.swift +++ b/ios-app/Tivi/Tivi/Auth.swift @@ -8,6 +8,7 @@ import AppAuth import Foundation import TiviKt +import os private let configuration = OIDServiceConfiguration( authorizationEndpoint: URL(string: "https://trakt.tv/oauth/authorize")!, @@ -16,12 +17,15 @@ private let configuration = OIDServiceConfiguration( class IosTraktRefreshTokenAction: TraktRefreshTokenAction { private let traktOAuthInfo: TraktOAuthInfo + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "network") init(traktOAuthInfo: TraktOAuthInfo) { self.traktOAuthInfo = traktOAuthInfo } func invoke(state: AuthState) async throws -> AuthState? { + logger.info("IosTraktRefreshTokenAction.invoke()") + let request = OIDTokenRequest( configuration: configuration, grantType: OIDGrantTypeRefreshToken, @@ -34,20 +38,27 @@ class IosTraktRefreshTokenAction: TraktRefreshTokenAction { codeVerifier: nil, additionalParameters: nil) - return await refresh(request: request) + do { + let result = try await refresh(request: request) + logger.info("IosTraktRefreshTokenAction. invoke result") + return result + } catch { + logger.error("IosTraktRefreshTokenAction. Error: \(error)") + throw error + } } - @MainActor private func refresh(request: OIDTokenRequest) async -> AuthState? { - return await withCheckedContinuation { continuation in - OIDAuthorizationService.perform(request) { response, _ in + @MainActor private func refresh(request: OIDTokenRequest) async throws -> AuthState? { + return try await withCheckedThrowingContinuation { continuation in + OIDAuthorizationService.perform(request) { response, error in if let response = response { let authState = SimpleAuthState( accessToken: response.accessToken ?? "", refreshToken: response.refreshToken ?? "" ) continuation.resume(returning: authState) - } else { - continuation.resume(returning: nil) + } else if let error = error { + continuation.resume(throwing: error) } } } @@ -59,6 +70,8 @@ class IosTraktLoginAction: TraktLoginAction { private let uiViewController: () -> UIViewController private let traktOAuthInfo: TraktOAuthInfo + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "network") + init( appDelegate: AppDelegate, uiViewController: @escaping () -> UIViewController, @@ -70,6 +83,8 @@ class IosTraktLoginAction: TraktLoginAction { } func invoke() async throws -> AuthState? { + logger.info("IosTraktLoginAction.invoke()") + let request = OIDAuthorizationRequest( configuration: configuration, clientId: traktOAuthInfo.clientId, @@ -79,24 +94,32 @@ class IosTraktLoginAction: TraktLoginAction { responseType: OIDResponseTypeCode, additionalParameters: nil ) - return await login(request: request) + + do { + let result = try await login(request: request) + logger.info("IosTraktLoginAction. invoke result") + return result + } catch { + logger.error("IosTraktLoginAction. Error: \(error)") + throw error + } } - @MainActor private func login(request: OIDAuthorizationRequest) async -> AuthState? { - return await withCheckedContinuation { continuation in + @MainActor private func login(request: OIDAuthorizationRequest) async throws -> AuthState? { + return try await withCheckedThrowingContinuation { continuation in self.appDelegate.currentAuthorizationFlow = OIDAuthState.authState( byPresenting: request, presenting: self.uiViewController(), prefersEphemeralSession: true - ) { authState, _ in + ) { authState, error in if let authState = authState { let tiviAuthState = SimpleAuthState( accessToken: authState.lastTokenResponse?.accessToken ?? "", refreshToken: authState.lastTokenResponse?.refreshToken ?? "" ) continuation.resume(returning: tiviAuthState) - } else { - continuation.resume(returning: nil) + } else if let error = error { + continuation.resume(throwing: error) } } } diff --git a/ui/root/src/commonMain/kotlin/app/tivi/home/RootViewModel.kt b/ui/root/src/commonMain/kotlin/app/tivi/home/RootViewModel.kt index 40c1b7f77c..f60eec94e0 100644 --- a/ui/root/src/commonMain/kotlin/app/tivi/home/RootViewModel.kt +++ b/ui/root/src/commonMain/kotlin/app/tivi/home/RootViewModel.kt @@ -8,15 +8,20 @@ import app.tivi.domain.interactors.LogoutTrakt import app.tivi.domain.interactors.UpdateUserDetails import app.tivi.domain.invoke import app.tivi.domain.observers.ObserveTraktAuthState +import app.tivi.util.cancellableRunCatching import io.ktor.client.plugins.ResponseException import io.ktor.http.HttpStatusCode -import kotlinx.coroutines.CancellationException +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import me.tatarka.inject.annotations.Assisted import me.tatarka.inject.annotations.Inject +@OptIn(FlowPreview::class) @Inject class RootViewModel( @Assisted private val coroutineScope: CoroutineScope, @@ -27,26 +32,23 @@ class RootViewModel( init { coroutineScope.launch { - observeTraktAuthState.value.flow.collect { state -> - if (state == TraktAuthState.LOGGED_IN) refreshMe() - } + observeTraktAuthState.value.flow + .debounce(200.milliseconds) + .filter { it == TraktAuthState.LOGGED_IN } + .collect { refreshMe() } } observeTraktAuthState.value.invoke(Unit) } private fun refreshMe() { coroutineScope.launch { - try { + cancellableRunCatching { updateUserDetails.value.invoke(UpdateUserDetails.Params("me", false)) - } catch (e: ResponseException) { - if (e.response.status == HttpStatusCode.Unauthorized) { + }.onFailure { e -> + if (e is ResponseException && e.response.status == HttpStatusCode.Unauthorized) { // If we got a 401 back from Trakt, we should clear out the auth state logoutTrakt.value.invoke() } - } catch (ce: CancellationException) { - throw ce - } catch (t: Throwable) { - // no-op } } }