From cf68c57959b76659b079fd3e8147c646e6219009 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Tue, 11 Aug 2026 11:03:13 +0530 Subject: [PATCH 1/7] feat(android): add Simkl authentication, scrobbling, and sync support --- .../kotlin/com/arflix/tv/data/api/SimklApi.kt | 220 ++++++++++++++++++ .../data/repository/simkl/SimklAuthManager.kt | 58 +++++ .../data/repository/simkl/SimklScrobbler.kt | 115 +++++++++ .../data/repository/simkl/SimklSyncService.kt | 119 ++++++++++ .../data/repository/sync/RemoteSyncManager.kt | 4 +- .../repository/sync/SimklRemoteProvider.kt | 76 ++++++ .../tv/data/repository/sync/SyncProvider.kt | 4 +- .../data/repository/sync/SyncProviderStore.kt | 20 ++ .../main/kotlin/com/arflix/tv/di/AppModule.kt | 12 + .../ui/screens/settings/SettingsViewModel.kt | 65 +++++- .../kotlin/com/arflix/tv/util/Constants.kt | 3 + .../repository/simkl/SimklIntegrationTest.kt | 68 ++++++ secrets.defaults.properties | 2 + 13 files changed, 763 insertions(+), 3 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/data/api/SimklApi.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklScrobbler.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/sync/SimklRemoteProvider.kt create mode 100644 app/src/test/kotlin/com/arflix/tv/data/repository/simkl/SimklIntegrationTest.kt diff --git a/app/src/main/kotlin/com/arflix/tv/data/api/SimklApi.kt b/app/src/main/kotlin/com/arflix/tv/data/api/SimklApi.kt new file mode 100644 index 000000000..e6af0e7a4 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/api/SimklApi.kt @@ -0,0 +1,220 @@ +package com.arflix.tv.data.api + +import com.google.gson.annotations.SerializedName +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface SimklApi { + + // ========== Authentication ========== + + @GET("oauth/pin") + suspend fun getPinCode( + @Query("client_id") clientId: String + ): SimklPinResponse + + @GET("oauth/pin/{code}") + suspend fun pollPinToken( + @Path("code") code: String, + @Query("client_id") clientId: String + ): SimklPinPollResponse + + // ========== Scrobble ========== + + @POST("scrobble/start") + suspend fun scrobbleStart( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String, + @Body body: SimklScrobbleBody + ): Response + + @POST("scrobble/pause") + suspend fun scrobblePause( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String, + @Body body: SimklScrobbleBody + ): Response + + @POST("scrobble/stop") + suspend fun scrobbleStop( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String, + @Body body: SimklScrobbleBody + ): Response + + // ========== Sync & Watch History ========== + + @GET("sync/activities") + suspend fun getActivities( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String + ): SimklActivitiesResponse + + @GET("sync/all-items/{type}") + suspend fun getAllItems( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String, + @Path("type") type: String, // "movies", "shows", "anime" + @Query("date_from") dateFrom: String? = null + ): SimklAllItemsResponse + + @POST("sync/history") + suspend fun addToHistory( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String, + @Body body: SimklSyncHistoryBody, + @Query("allow_rewatch") allowRewatch: String? = null + ): Response + + @POST("sync/history/remove") + suspend fun removeFromHistory( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String, + @Body body: SimklSyncHistoryBody + ): Response + + @POST("sync/watchlist") + suspend fun addToWatchlist( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String, + @Body body: SimklSyncWatchlistBody + ): Response + + @POST("sync/watchlist/remove") + suspend fun removeFromWatchlist( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String, + @Body body: SimklSyncWatchlistBody + ): Response +} + +// Data Transfer Objects + +data class SimklPinResponse( + @SerializedName("user_code") val userCode: String, + @SerializedName("verification_url") val verificationUrl: String, + @SerializedName("expires_in") val expiresIn: Int = 600, + @SerializedName("interval") val interval: Int = 5, + @SerializedName("device_code") val deviceCode: String? = null +) + +data class SimklPinPollResponse( + @SerializedName("result") val result: String, // "KO", "pending", "OK" + @SerializedName("access_token") val accessToken: String? = null, + @SerializedName("token_type") val tokenType: String? = null, + @SerializedName("expires_in") val expiresIn: Long? = null +) + +data class SimklIds( + @SerializedName("simkl") val simkl: Long? = null, + @SerializedName("tmdb") val tmdb: Int? = null, + @SerializedName("imdb") val imdb: String? = null, + @SerializedName("tvdb") val tvdb: String? = null +) + +data class SimklMovieRef( + @SerializedName("title") val title: String? = null, + @SerializedName("year") val year: Int? = null, + @SerializedName("ids") val ids: SimklIds +) + +data class SimklEpisodeRef( + @SerializedName("number") val number: Int? = null, + @SerializedName("ids") val ids: SimklIds? = null +) + +data class SimklShowRef( + @SerializedName("title") val title: String? = null, + @SerializedName("year") val year: Int? = null, + @SerializedName("ids") val ids: SimklIds, + @SerializedName("seasons") val seasons: List? = null +) + +data class SimklSeasonRef( + @SerializedName("number") val number: Int, + @SerializedName("episodes") val episodes: List +) + +data class SimklScrobbleBody( + @SerializedName("movie") val movie: SimklMovieRef? = null, + @SerializedName("show") val show: SimklShowRef? = null, + @SerializedName("episode") val episode: SimklEpisodeRef? = null, + @SerializedName("progress") val progress: Float // 0.0 - 100.0 +) + +data class SimklScrobbleResponse( + @SerializedName("action") val action: String? = null, + @SerializedName("progress") val progress: Float? = null +) + +data class SimklActivitiesResponse( + @SerializedName("all") val all: String? = null, + @SerializedName("movies") val movies: SimklActivityGroup? = null, + @SerializedName("shows") val shows: SimklActivityGroup? = null, + @SerializedName("anime") val anime: SimklActivityGroup? = null +) + +data class SimklActivityGroup( + @SerializedName("all") val all: String? = null, + @SerializedName("watched_at") val watchedAt: String? = null, + @SerializedName("rated_at") val ratedAt: String? = null, + @SerializedName("plantowatch") val planToWatch: String? = null +) + +data class SimklAllItemsResponse( + @SerializedName("movies") val movies: List? = null, + @SerializedName("shows") val shows: List? = null, + @SerializedName("anime") val anime: List? = null +) + +data class SimklHistoryMovieItem( + @SerializedName("last_watched_at") val lastWatchedAt: String? = null, + @SerializedName("user_rating") val userRating: Int? = null, + @SerializedName("status") val status: String? = null, // "completed", "watching", "plantowatch", "hold", "dropped" + @SerializedName("movie") val movie: SimklMovieRef? = null +) + +data class SimklHistoryShowItem( + @SerializedName("last_watched_at") val lastWatchedAt: String? = null, + @SerializedName("status") val status: String? = null, + @SerializedName("show") val show: SimklShowRef? = null, + @SerializedName("seasons") val seasons: List? = null +) + +data class SimklHistorySeasonItem( + @SerializedName("number") val number: Int, + @SerializedName("episodes") val episodes: List +) + +data class SimklHistoryEpisodeItem( + @SerializedName("number") val number: Int, + @SerializedName("watched_at") val watchedAt: String? = null +) + +data class SimklSyncHistoryBody( + @SerializedName("movies") val movies: List? = null, + @SerializedName("shows") val shows: List? = null, + @SerializedName("episodes") val episodes: List? = null +) + +data class SimklSyncWatchlistBody( + @SerializedName("movies") val movies: List? = null, + @SerializedName("shows") val shows: List? = null +) + +data class SimklSyncResponse( + @SerializedName("added") val added: SimklSyncCount? = null, + @SerializedName("deleted") val deleted: SimklSyncCount? = null, + @SerializedName("not_found") val notFound: SimklSyncCount? = null +) + +data class SimklSyncCount( + @SerializedName("movies") val movies: Int = 0, + @SerializedName("shows") val shows: Int = 0, + @SerializedName("episodes") val episodes: Int = 0 +) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt new file mode 100644 index 000000000..55e781d33 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt @@ -0,0 +1,58 @@ +package com.arflix.tv.data.repository.simkl + +import com.arflix.tv.data.api.SimklApi +import com.arflix.tv.data.api.SimklPinResponse +import com.arflix.tv.data.repository.sync.SyncProvider +import com.arflix.tv.data.repository.sync.SyncProviderStore +import com.arflix.tv.util.Constants +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +sealed class SimklPinAuthState { + object Idle : SimklPinAuthState() + data class CodeRequested(val userCode: String, val verificationUrl: String, val expiresIn: Int) : SimklPinAuthState() + object Success : SimklPinAuthState() + data class Error(val message: String) : SimklPinAuthState() +} + +@Singleton +class SimklAuthManager @Inject constructor( + private val simklApi: SimklApi, + private val syncProviderStore: SyncProviderStore +) { + private val clientId: String get() = Constants.SIMKL_CLIENT_ID + + suspend fun getAccessToken(): String? { + return syncProviderStore.getSimklAccessToken() + } + + suspend fun isConnected(): Boolean { + val token = getAccessToken() + return !token.isNullOrBlank() + } + + suspend fun startPinAuth(): SimklPinResponse { + check(clientId.isNotBlank()) { "Simkl Client ID is missing" } + return simklApi.getPinCode(clientId) + } + + suspend fun pollPinAuth(userCode: String): Boolean { + check(clientId.isNotBlank()) { "Simkl Client ID is missing" } + val response = simklApi.pollPinToken(userCode, clientId) + if (response.result.equals("OK", ignoreCase = true) && !response.accessToken.isNullOrBlank()) { + syncProviderStore.setSimklAccessToken(response.accessToken) + syncProviderStore.setProvider(SyncProvider.SIMKL) + return true + } + return false + } + + suspend fun disconnect() { + syncProviderStore.setSimklAccessToken(null) + if (syncProviderStore.getProvider() == SyncProvider.SIMKL) { + syncProviderStore.setProvider(SyncProvider.NONE) + } + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklScrobbler.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklScrobbler.kt new file mode 100644 index 000000000..2fbae3ad7 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklScrobbler.kt @@ -0,0 +1,115 @@ +package com.arflix.tv.data.repository.simkl + +import com.arflix.tv.data.api.SimklApi +import com.arflix.tv.data.api.SimklEpisodeRef +import com.arflix.tv.data.api.SimklIds +import com.arflix.tv.data.api.SimklMovieRef +import com.arflix.tv.data.api.SimklScrobbleBody +import com.arflix.tv.data.api.SimklSeasonRef +import com.arflix.tv.data.api.SimklShowRef +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.util.AppLogger +import com.arflix.tv.util.Constants +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SimklScrobbler @Inject constructor( + private val simklApi: SimklApi, + private val authManager: SimklAuthManager +) { + private val clientId: String get() = Constants.SIMKL_CLIENT_ID + + private fun normalizeProgress(progress: Float): Float { + // If progress is in 0.0 - 1.0 range, scale to 0.0 - 100.0 + return if (progress in 0.0f..1.0f) progress * 100f else progress.coerceIn(0f, 100f) + } + + suspend fun scrobbleStart( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int? = null, + episode: Int? = null + ) { + val token = authManager.getAccessToken() ?: return + val authHeader = "Bearer $token" + val body = buildScrobbleBody(mediaType, tmdbId, progress, season, episode) + + try { + simklApi.scrobbleStart(authHeader, clientId, body) + } catch (e: Exception) { + AppLogger.e("SimklScrobbler", "Error scrobbling start for tmdbId=$tmdbId: ${e.message}") + } + } + + suspend fun scrobblePause( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int? = null, + episode: Int? = null + ) { + val token = authManager.getAccessToken() ?: return + val authHeader = "Bearer $token" + val body = buildScrobbleBody(mediaType, tmdbId, progress, season, episode) + + try { + simklApi.scrobblePause(authHeader, clientId, body) + } catch (e: Exception) { + AppLogger.e("SimklScrobbler", "Error scrobbling pause for tmdbId=$tmdbId: ${e.message}") + } + } + + suspend fun scrobbleStop( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int? = null, + episode: Int? = null + ) { + val token = authManager.getAccessToken() ?: return + val authHeader = "Bearer $token" + val body = buildScrobbleBody(mediaType, tmdbId, progress, season, episode) + + try { + simklApi.scrobbleStop(authHeader, clientId, body) + } catch (e: Exception) { + AppLogger.e("SimklScrobbler", "Error scrobbling stop for tmdbId=$tmdbId: ${e.message}") + } + } + + private fun buildScrobbleBody( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int?, + episode: Int? + ): SimklScrobbleBody { + val normProgress = normalizeProgress(progress) + return if (mediaType == MediaType.MOVIE) { + SimklScrobbleBody( + movie = SimklMovieRef(ids = SimklIds(tmdb = tmdbId)), + progress = normProgress + ) + } else { + SimklScrobbleBody( + show = SimklShowRef( + ids = SimklIds(tmdb = tmdbId), + seasons = if (season != null && episode != null) { + listOf( + SimklSeasonRef( + number = season, + episodes = listOf(SimklEpisodeRef(number = episode)) + ) + ) + } else null + ), + episode = if (season != null && episode != null) { + SimklEpisodeRef(number = episode) + } else null, + progress = normProgress + ) + } + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt new file mode 100644 index 000000000..5823d84eb --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt @@ -0,0 +1,119 @@ +package com.arflix.tv.data.repository.simkl + +import com.arflix.tv.data.api.SimklApi +import com.arflix.tv.data.api.SimklEpisodeRef +import com.arflix.tv.data.api.SimklIds +import com.arflix.tv.data.api.SimklMovieRef +import com.arflix.tv.data.api.SimklSeasonRef +import com.arflix.tv.data.api.SimklShowRef +import com.arflix.tv.data.api.SimklSyncHistoryBody +import com.arflix.tv.data.api.SimklSyncWatchlistBody +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.util.AppLogger +import com.arflix.tv.util.Constants +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SimklSyncService @Inject constructor( + private val simklApi: SimklApi, + private val authManager: SimklAuthManager +) { + private val clientId: String get() = Constants.SIMKL_CLIENT_ID + + suspend fun getWatchedMovies(): Set { + val token = authManager.getAccessToken() ?: return emptySet() + val authHeader = "Bearer $token" + return try { + val response = simklApi.getAllItems(authHeader, clientId, "movies") + response.movies + ?.filter { it.status == "completed" || it.status == "watching" } + ?.mapNotNull { it.movie?.ids?.tmdb } + ?.toSet() ?: emptySet() + } catch (e: Exception) { + AppLogger.e("SimklSyncService", "Error fetching watched movies: ${e.message}") + emptySet() + } + } + + suspend fun getWatchedEpisodes(): Set { + val token = authManager.getAccessToken() ?: return emptySet() + val authHeader = "Bearer $token" + return try { + val response = simklApi.getAllItems(authHeader, clientId, "shows") + val watched = mutableSetOf() + response.shows?.forEach { showItem -> + val showTmdb = showItem.show?.ids?.tmdb ?: return@forEach + showItem.seasons?.forEach { season -> + season.episodes.forEach { episode -> + watched.add("${showTmdb}_S${season.number}_E${episode.number}") + } + } + } + watched + } catch (e: Exception) { + AppLogger.e("SimklSyncService", "Error fetching watched episodes: ${e.message}") + emptySet() + } + } + + suspend fun addToWatchlist(mediaType: MediaType, tmdbId: Int): Boolean { + val token = authManager.getAccessToken() ?: return false + val authHeader = "Bearer $token" + val body = if (mediaType == MediaType.MOVIE) { + SimklSyncWatchlistBody(movies = listOf(SimklMovieRef(ids = SimklIds(tmdb = tmdbId)))) + } else { + SimklSyncWatchlistBody(shows = listOf(SimklShowRef(ids = SimklIds(tmdb = tmdbId)))) + } + return try { + val res = simklApi.addToWatchlist(authHeader, clientId, body) + res.isSuccessful + } catch (e: Exception) { + AppLogger.e("SimklSyncService", "Error adding to watchlist: ${e.message}") + false + } + } + + suspend fun removeFromWatchlist(mediaType: MediaType, tmdbId: Int): Boolean { + val token = authManager.getAccessToken() ?: return false + val authHeader = "Bearer $token" + val body = if (mediaType == MediaType.MOVIE) { + SimklSyncWatchlistBody(movies = listOf(SimklMovieRef(ids = SimklIds(tmdb = tmdbId)))) + } else { + SimklSyncWatchlistBody(shows = listOf(SimklShowRef(ids = SimklIds(tmdb = tmdbId)))) + } + return try { + val res = simklApi.removeFromWatchlist(authHeader, clientId, body) + res.isSuccessful + } catch (e: Exception) { + AppLogger.e("SimklSyncService", "Error removing from watchlist: ${e.message}") + false + } + } + + suspend fun markWatched(mediaType: MediaType, tmdbId: Int, season: Int? = null, episode: Int? = null): Boolean { + val token = authManager.getAccessToken() ?: return false + val authHeader = "Bearer $token" + val body = if (mediaType == MediaType.MOVIE) { + SimklSyncHistoryBody(movies = listOf(SimklMovieRef(ids = SimklIds(tmdb = tmdbId)))) + } else { + SimklSyncHistoryBody( + shows = listOf( + SimklShowRef( + ids = SimklIds(tmdb = tmdbId), + seasons = if (season != null && episode != null) { + listOf(SimklSeasonRef(number = season, episodes = listOf(SimklEpisodeRef(number = episode)))) + } else null + ) + ) + ) + } + return try { + val res = simklApi.addToHistory(authHeader, clientId, body, allowRewatch = "yes") + res.isSuccessful + } catch (e: Exception) { + AppLogger.e("SimklSyncService", "Error marking watched: ${e.message}") + false + } + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/RemoteSyncManager.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/RemoteSyncManager.kt index 33a95b79a..cb86fc337 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/RemoteSyncManager.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/RemoteSyncManager.kt @@ -19,7 +19,8 @@ import javax.inject.Singleton class RemoteSyncManager @Inject constructor( private val store: SyncProviderStore, private val traktProvider: TraktRemoteProvider, - private val mdbListProvider: MdbListRemoteProvider + private val mdbListProvider: MdbListRemoteProvider, + private val simklProvider: SimklRemoteProvider ) { /** The provider explicitly selected for this profile (may be NONE). */ suspend fun selectedProvider(): SyncProvider = store.getProvider() @@ -31,6 +32,7 @@ class RemoteSyncManager @Inject constructor( suspend fun active(): RemoteSyncProvider? { val candidate = when (store.getProvider()) { SyncProvider.MDBLIST -> mdbListProvider + SyncProvider.SIMKL -> simklProvider // TRAKT or NONE (legacy: infer Trakt from an existing token). SyncProvider.TRAKT, SyncProvider.NONE -> traktProvider } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SimklRemoteProvider.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SimklRemoteProvider.kt new file mode 100644 index 000000000..1133c82c9 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SimklRemoteProvider.kt @@ -0,0 +1,76 @@ +package com.arflix.tv.data.repository.sync + +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.repository.ContinueWatchingItem +import com.arflix.tv.data.repository.simkl.SimklAuthManager +import com.arflix.tv.data.repository.simkl.SimklScrobbler +import com.arflix.tv.data.repository.simkl.SimklSyncService +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Simkl implementation of [RemoteSyncProvider]. + */ +@Singleton +class SimklRemoteProvider @Inject constructor( + private val authManager: SimklAuthManager, + private val scrobbler: SimklScrobbler, + private val syncService: SimklSyncService +) : RemoteSyncProvider { + + override val provider: SyncProvider = SyncProvider.SIMKL + + override suspend fun isConnected(): Boolean = authManager.isConnected() + + override suspend fun addToWatchlist(mediaType: MediaType, tmdbId: Int): Boolean = + syncService.addToWatchlist(mediaType, tmdbId) + + override suspend fun removeFromWatchlist(mediaType: MediaType, tmdbId: Int): Boolean = + syncService.removeFromWatchlist(mediaType, tmdbId) + + override suspend fun getWatchlist(): RemoteWatchlistResult { + val connected = isConnected() + return RemoteWatchlistResult( + connected = connected, + items = emptyList(), + rawCount = 0 + ) + } + + override suspend fun scrobbleStart( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int?, + episode: Int? + ) { + scrobbler.scrobbleStart(mediaType, tmdbId, progress, season, episode) + } + + override suspend fun scrobblePause( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int?, + episode: Int? + ) { + scrobbler.scrobblePause(mediaType, tmdbId, progress, season, episode) + } + + override suspend fun scrobbleStop( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int?, + episode: Int? + ) { + scrobbler.scrobbleStop(mediaType, tmdbId, progress, season, episode) + } + + override suspend fun getWatchedMovies(): Set = syncService.getWatchedMovies() + + override suspend fun getWatchedEpisodes(): Set = syncService.getWatchedEpisodes() + + override suspend fun getContinueWatching(forceRefresh: Boolean): List = + emptyList() +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProvider.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProvider.kt index 4c06feea2..436f0eb72 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProvider.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProvider.kt @@ -7,12 +7,14 @@ package com.arflix.tv.data.repository.sync enum class SyncProvider { NONE, TRAKT, - MDBLIST; + MDBLIST, + SIMKL; companion object { fun fromStorage(value: String?): SyncProvider = when (value?.lowercase()) { "trakt" -> TRAKT "mdblist" -> MDBLIST + "simkl" -> SIMKL else -> NONE } } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProviderStore.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProviderStore.kt index 4518c3565..66d3e2354 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProviderStore.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProviderStore.kt @@ -55,6 +55,26 @@ class SyncProviderStore @Inject constructor( } } + private fun simklAccessTokenKey() = profileManager.profileStringKey("simkl_access_token") + private fun simklAccessTokenKeyFor(profileId: String) = + profileManager.profileStringKeyFor(profileId, "simkl_access_token") + + suspend fun getSimklAccessToken(): String? { + val prefs = context.traktDataStore.data.first() + return prefs[simklAccessTokenKey()]?.trim()?.takeIf { it.isNotEmpty() } + } + + suspend fun setSimklAccessToken(token: String?) { + context.traktDataStore.edit { prefs -> + val trimmed = token?.trim().orEmpty() + if (trimmed.isEmpty()) { + prefs.remove(simklAccessTokenKey()) + } else { + prefs[simklAccessTokenKey()] = trimmed + } + } + } + suspend fun getMdbListApiKey(): String? { val prefs = context.traktDataStore.data.first() return prefs[mdbListKey()]?.trim()?.takeIf { it.isNotEmpty() } diff --git a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt index 92dfe5c6e..1e83b00f7 100644 --- a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt +++ b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt @@ -88,6 +88,18 @@ object AppModule { .create(com.arflix.tv.data.api.MdbListApi::class.java) } + @Provides + @Singleton + @JvmStatic + fun provideSimklApi(okHttpClient: OkHttpClient): com.arflix.tv.data.api.SimklApi { + return Retrofit.Builder() + .baseUrl(Constants.SIMKL_BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(com.arflix.tv.data.api.SimklApi::class.java) + } + @Provides @Singleton @JvmStatic diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 6960e492a..030fe6d71 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -143,6 +143,12 @@ data class SettingsUiState( val isMdbListConnected: Boolean = false, val mdbListConnecting: Boolean = false, val mdbListUsername: String? = null, + // Simkl (alternative remote sync provider) + val isSimklConnected: Boolean = false, + val isSimklAuthStarting: Boolean = false, + val isSimklPolling: Boolean = false, + val simklUserCode: String? = null, + val simklVerificationUrl: String? = null, // Trakt Sync val isSyncing: Boolean = false, val syncProgress: SyncProgress = SyncProgress(), @@ -245,7 +251,8 @@ class SettingsViewModel @Inject constructor( private val apkDownloader: ApkDownloader, private val updateStatusManager: com.arflix.tv.updater.UpdateStatusManager, private val mdbListRepository: com.arflix.tv.data.repository.MdbListRepository, - private val syncProviderStore: com.arflix.tv.data.repository.sync.SyncProviderStore + private val syncProviderStore: com.arflix.tv.data.repository.sync.SyncProviderStore, + private val simklAuthManager: com.arflix.tv.data.repository.simkl.SimklAuthManager ) : ViewModel() { private fun visibleCatalogs(catalogs: List): List { return catalogs.filter { config -> @@ -3501,6 +3508,62 @@ class SettingsViewModel @Inject constructor( } } + // ========== Simkl Authentication ========== + + fun startSimklAuth() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSimklAuthStarting = true) + runCatching { + val pinRes = simklAuthManager.startPinAuth() + _uiState.value = _uiState.value.copy( + isSimklAuthStarting = false, + isSimklPolling = true, + simklUserCode = pinRes.userCode, + simklVerificationUrl = pinRes.verificationUrl + ) + }.onFailure { e -> + _uiState.value = _uiState.value.copy( + isSimklAuthStarting = false, + toastMessage = "Simkl Auth Error: ${e.message}", + toastType = ToastType.ERROR + ) + } + } + } + + fun pollSimklAuth() { + val userCode = _uiState.value.simklUserCode ?: return + viewModelScope.launch { + runCatching { + val success = simklAuthManager.pollPinAuth(userCode) + if (success) { + _uiState.value = _uiState.value.copy( + isSimklPolling = false, + isSimklConnected = true, + simklUserCode = null, + simklVerificationUrl = null, + toastMessage = "Connected to Simkl!", + toastType = ToastType.SUCCESS + ) + } + } + } + } + + fun disconnectSimkl() { + viewModelScope.launch { + simklAuthManager.disconnect() + _uiState.value = _uiState.value.copy( + isSimklConnected = false, + isSimklPolling = false, + simklUserCode = null, + simklVerificationUrl = null, + toastMessage = "Disconnected from Simkl", + toastType = ToastType.SUCCESS + ) + } + } + fun dismissToast() { _uiState.value = _uiState.value.copy(toastMessage = null) } diff --git a/app/src/main/kotlin/com/arflix/tv/util/Constants.kt b/app/src/main/kotlin/com/arflix/tv/util/Constants.kt index 70c206fa7..64505a53b 100644 --- a/app/src/main/kotlin/com/arflix/tv/util/Constants.kt +++ b/app/src/main/kotlin/com/arflix/tv/util/Constants.kt @@ -37,6 +37,7 @@ object Constants { // API base URLs. const val TMDB_BASE_URL = "https://api.themoviedb.org/3/" const val TRAKT_API_URL = "https://api.trakt.tv/" + const val SIMKL_BASE_URL = "https://api.simkl.com/" // MDBList is an optional per-profile alternative to Trakt. Auth is a static // API key passed as an `?apikey=` query parameter (no OAuth), so no client // secret needs to ship in the APK. @@ -53,6 +54,8 @@ object Constants { val TRAKT_CLIENT_ID: String get() = usableSecret(BuildConfig.TRAKT_CLIENT_ID) val TRAKT_CLIENT_SECRET: String get() = usableSecret(BuildConfig.TRAKT_CLIENT_SECRET) + val SIMKL_CLIENT_ID: String get() = usableSecret(BuildConfig.SIMKL_CLIENT_ID) + val SIMKL_CLIENT_SECRET: String get() = usableSecret(BuildConfig.SIMKL_CLIENT_SECRET) // Image URLs - tuned for TV quality with smooth scrolling/perf. const val IMAGE_BASE = "https://image.tmdb.org/t/p/w780" diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/simkl/SimklIntegrationTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/simkl/SimklIntegrationTest.kt new file mode 100644 index 000000000..e81f2caef --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/simkl/SimklIntegrationTest.kt @@ -0,0 +1,68 @@ +package com.arflix.tv.data.repository.simkl + +import com.arflix.tv.data.api.SimklApi +import com.arflix.tv.data.api.SimklPinPollResponse +import com.arflix.tv.data.api.SimklPinResponse +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.repository.sync.SyncProvider +import com.arflix.tv.data.repository.sync.SyncProviderStore +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock + +class SimklIntegrationTest { + + private lateinit var simklApi: SimklApi + private lateinit var syncProviderStore: SyncProviderStore + private lateinit var authManager: SimklAuthManager + private lateinit var scrobbler: SimklScrobbler + private lateinit var syncService: SimklSyncService + + @Before + fun setUp() { + simklApi = mock(SimklApi::class.java) + syncProviderStore = mock(SyncProviderStore::class.java) + authManager = SimklAuthManager(simklApi, syncProviderStore) + scrobbler = SimklScrobbler(simklApi, authManager) + syncService = SimklSyncService(simklApi, authManager) + } + + @Test + fun testStartPinAuthReturnsResponse() = runBlocking { + val expected = SimklPinResponse( + userCode = "SIMKL-123", + verificationUrl = "https://simkl.com/pin", + expiresIn = 600 + ) + `when`(simklApi.getPinCode(anyString())).thenReturn(expected) + + val result = authManager.startPinAuth() + assertEquals("SIMKL-123", result.userCode) + assertEquals("https://simkl.com/pin", result.verificationUrl) + } + + @Test + fun testPollPinAuthSuccessStoresToken() = runBlocking { + val pollRes = SimklPinPollResponse( + result = "OK", + accessToken = "token_abc123" + ) + `when`(simklApi.pollPinToken(anyString(), anyString())).thenReturn(pollRes) + + val success = authManager.pollPinAuth("SIMKL-123") + assertTrue(success) + } + + @Test + fun testDisconnectClearsToken() = runBlocking { + authManager.disconnect() + `when`(syncProviderStore.getSimklAccessToken()).thenReturn(null) + assertFalse(authManager.isConnected()) + } +} diff --git a/secrets.defaults.properties b/secrets.defaults.properties index 5cd5f2764..8629396cb 100644 --- a/secrets.defaults.properties +++ b/secrets.defaults.properties @@ -13,3 +13,5 @@ SENTRY_DSN=disabled TMDB_API_KEY=your-tmdb-api-key TRAKT_CLIENT_ID=your-trakt-client-id TRAKT_CLIENT_SECRET=your-trakt-client-secret +SIMKL_CLIENT_ID=your-simkl-client-id +SIMKL_CLIENT_SECRET=your-simkl-client-secret From 1bc19dd15cb60f5f4f7fcf97defa8d6cf491782d Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Tue, 11 Aug 2026 21:08:17 +0530 Subject: [PATCH 2/7] feat(android): add active Simkl UI, Netlify proxy interceptor, and auth manager --- app/build.gradle.kts | 6 +- .../data/repository/simkl/SimklAuthManager.kt | 8 +- .../arflix/tv/network/ApiProxyInterceptor.kt | 32 +++++ .../tv/ui/screens/settings/SettingsScreen.kt | 116 +++++++++++++----- .../kotlin/com/arflix/tv/util/Constants.kt | 1 + 5 files changed, 129 insertions(+), 34 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ae738ed7c..33d641da3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -44,7 +44,11 @@ android { buildConfigField("Boolean", "ENABLE_PERIODIC_CLOUD_PULL", "false") buildConfigField("Boolean", "ENABLE_NETLIFY_CLOUD_SYNC", "true") buildConfigField("Boolean", "ENABLE_SUPABASE_SYNC_MIRROR", "false") - buildConfigField("String", "NETLIFY_BACKEND_URL", "\"https://auth.arvio.tv/.netlify/functions\"") + buildConfigField( + "String", + "NETLIFY_BACKEND_URL", + "\"${escapeBuildConfigString(localSecretValue("NETLIFY_BACKEND_URL").ifBlank { "https://simkl-backend--arvio-auth.netlify.app/.netlify/functions" })}\"" + ) buildConfigField( "String", "APP_ANON_KEY", diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt index 55e781d33..db6ea2c6e 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt @@ -34,13 +34,13 @@ class SimklAuthManager @Inject constructor( } suspend fun startPinAuth(): SimklPinResponse { - check(clientId.isNotBlank()) { "Simkl Client ID is missing" } - return simklApi.getPinCode(clientId) + val effectiveClientId = clientId.ifBlank { "simkl_proxy" } + return simklApi.getPinCode(effectiveClientId) } suspend fun pollPinAuth(userCode: String): Boolean { - check(clientId.isNotBlank()) { "Simkl Client ID is missing" } - val response = simklApi.pollPinToken(userCode, clientId) + val effectiveClientId = clientId.ifBlank { "simkl_proxy" } + val response = simklApi.pollPinToken(userCode, effectiveClientId) if (response.result.equals("OK", ignoreCase = true) && !response.accessToken.isNullOrBlank()) { syncProviderStore.setSimklAccessToken(response.accessToken) syncProviderStore.setProvider(SyncProvider.SIMKL) diff --git a/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt b/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt index 5135009a3..b19db419a 100644 --- a/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt +++ b/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt @@ -44,6 +44,10 @@ class ApiProxyInterceptor : Interceptor { // user API key on the query string. Keep it direct, same as Trakt. chain.proceed(originalRequest) } + "api.simkl.com" -> { + val proxyRequest = rewriteForSimklProxy(originalRequest) ?: originalRequest + chain.proceed(proxyRequest) + } else -> { // Pass through other requests unchanged chain.proceed(originalRequest) @@ -51,6 +55,34 @@ class ApiProxyInterceptor : Interceptor { } } + private fun rewriteForSimklProxy(originalRequest: Request): Request? { + val originalUrl = originalRequest.url + val path = originalUrl.encodedPath + + val proxyUrlBuilder = (Constants.SIMKL_PROXY_URL.toHttpUrlOrNull() ?: return null).newBuilder() + .addQueryParameter("path", path) + .addQueryParameter("method", originalRequest.method) + + for (i in 0 until originalUrl.querySize) { + val name = originalUrl.queryParameterName(i) + originalUrl.queryParameterValue(i)?.let { value -> + proxyUrlBuilder.addQueryParameter(name, value) + } + } + + val userToken = originalRequest.header("Authorization")?.removePrefix("Bearer ") + val builder = originalRequest.newBuilder() + .url(proxyUrlBuilder.build()) + .header("apikey", Constants.APP_ANON_KEY) + .header("Authorization", "Bearer ${Constants.APP_ANON_KEY}") + + if (!userToken.isNullOrBlank()) { + builder.header("x-user-token", userToken) + } + + return builder.build() + } + private fun rewriteForTmdbProxy(originalRequest: Request): Request? { val originalUrl = originalRequest.url diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index a8da05cd8..ea95fb331 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -459,7 +459,7 @@ fun SettingsScreen( "catalogs" -> uiState.catalogs.size + 1 // Add + Import + catalogs "stremio" -> stremioAddons.size + 1 // rows + refresh + add button "plugins" -> pluginsMaxIndex - "accounts" -> 8 // Cloud, integrations, sync, update, diagnostics, privacy, deletion + "accounts" -> 9 // Cloud, Trakt, MDBList, Simkl, Telegram, sync, update, diagnostics, privacy, deletion else -> 0 } } @@ -1125,7 +1125,6 @@ fun SettingsScreen( } contentFocusIndex == stremioAddons.size -> { viewModel.refreshAddons() - } else -> { showCustomAddonInput = true } @@ -1156,18 +1155,25 @@ fun SettingsScreen( showMdbListConnect = true } } - 3 -> onNavigateToTelegramSettings() - 4 -> viewModel.forceCloudSyncNow() - 5 -> { + 3 -> { + if (uiState.isSimklConnected || uiState.isSimklPolling) { + viewModel.disconnectSimkl() + } else { + viewModel.startSimklAuth() + } + } + 4 -> onNavigateToTelegramSettings() + 5 -> viewModel.forceCloudSyncNow() + 6 -> { if (uiState.updateStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall) { viewModel.installAppUpdateOrRequestPermission() } else { viewModel.checkForAppUpdates(force = true, showNoUpdateFeedback = true) } } - 6 -> viewModel.setDiagnosticsSharingEnabled(!uiState.diagnosticsSharingEnabled) - 7 -> openExternalUrl(context, PRIVACY_POLICY_URL) - 8 -> openExternalUrl(context, ACCOUNT_DELETION_URL) + 7 -> viewModel.setDiagnosticsSharingEnabled(!uiState.diagnosticsSharingEnabled) + 8 -> openExternalUrl(context, PRIVACY_POLICY_URL) + 9 -> openExternalUrl(context, ACCOUNT_DELETION_URL) } } "plugins" -> { @@ -1694,6 +1700,13 @@ fun SettingsScreen( isMdbListConnected = uiState.isMdbListConnected, onConnectMdbList = { showMdbListConnect = true }, onDisconnectMdbList = { showMdbListDisconnectConfirm = true }, + isSimklConnected = uiState.isSimklConnected, + simklCode = uiState.simklUserCode, + simklUrl = uiState.simklVerificationUrl, + isSimklAuthStarting = uiState.isSimklAuthStarting, + isSimklPolling = uiState.isSimklPolling, + onConnectSimkl = { viewModel.startSimklAuth() }, + onDisconnectSimkl = { viewModel.disconnectSimkl() }, onForceCloudSync = { viewModel.forceCloudSyncNow() }, onSwitchProfile = onSwitchProfile, onCheckUpdates = { viewModel.checkForAppUpdates(force = true, showNoUpdateFeedback = true) }, @@ -2218,6 +2231,17 @@ fun SettingsScreen( ) } + uiState.simklUserCode?.let { simklCode -> + val verificationUrl = uiState.simklVerificationUrl ?: "https://simkl.com/pin" + TraktActivationModal( + title = "Connect Simkl", + instruction = "Visit $verificationUrl on your phone or computer and enter this code:", + verificationUrl = verificationUrl, + userCode = simklCode, + onDismiss = { viewModel.disconnectSimkl() } + ) + } + uiState.plexHomeServerAuth?.let { plexAuth -> TraktActivationModal( title = stringResource(R.string.settings_connect_with_code), @@ -4284,7 +4308,9 @@ private fun MobileSettingsSubPage( onConnectTrakt = onConnectTrakt, onDisconnectTrakt = onDisconnectTrakt, onConnectMdbList = onConnectMdbList, - onDisconnectMdbList = onDisconnectMdbList + onDisconnectMdbList = onDisconnectMdbList, + onConnectSimkl = { viewModel.startSimklAuth() }, + onDisconnectSimkl = { viewModel.disconnectSimkl() } ) } } @@ -7912,6 +7938,13 @@ private fun AccountsSettings( isMdbListConnected: Boolean, onConnectMdbList: () -> Unit, onDisconnectMdbList: () -> Unit, + isSimklConnected: Boolean = false, + simklCode: String? = null, + simklUrl: String? = null, + isSimklAuthStarting: Boolean = false, + isSimklPolling: Boolean = false, + onConnectSimkl: () -> Unit = {}, + onDisconnectSimkl: () -> Unit = {}, isForceCloudSyncing: Boolean, lastCloudSyncStatus: String?, diagnosticsSharingEnabled: Boolean, @@ -7994,14 +8027,31 @@ private fun AccountsSettings( Spacer(modifier = Modifier.height(16.dp)) + // Simkl + AccountRow( + name = "Simkl", + description = stringResource(R.string.settings_simkl_tagline), + isConnected = isSimklConnected, + isWorking = isSimklAuthStarting || isSimklPolling, + authCode = simklCode, + authUrl = simklUrl, + isFocused = focusedIndex == 3, + onConnect = { if (isSimklPolling) onDisconnectSimkl() else onConnectSimkl() }, + onDisconnect = onDisconnectSimkl, + modifier = Modifier.settingsFocusSlot(3), + expirationText = null + ) + + Spacer(modifier = Modifier.height(16.dp)) + // Telegram SettingsActionRow( title = "Telegram", description = stringResource(R.string.settings_telegram_desc), actionLabel = stringResource(R.string.settings_badge_open), - isFocused = focusedIndex == 3, + isFocused = focusedIndex == 4, onClick = onNavigateToTelegram, - modifier = Modifier.settingsFocusSlot(3) + modifier = Modifier.settingsFocusSlot(4) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8018,9 +8068,9 @@ private fun AccountsSettings( stringResource(R.string.settings_signin_to_force_sync) }, actionLabel = if (isForceCloudSyncing) stringResource(R.string.settings_badge_syncing) else stringResource(R.string.settings_badge_sync), - isFocused = focusedIndex == 4, + isFocused = focusedIndex == 5, onClick = { if (!isForceCloudSyncing) onForceCloudSync() }, - modifier = Modifier.settingsFocusSlot(4) + modifier = Modifier.settingsFocusSlot(5) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8042,11 +8092,11 @@ private fun AccountsSettings( updateStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable -> stringResource(R.string.settings_badge_update) else -> stringResource(R.string.settings_badge_check) }, - isFocused = focusedIndex == 5, + isFocused = focusedIndex == 6, onClick = { if (updateStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall) onInstallUpdate() else onCheckUpdates() }, - modifier = Modifier.settingsFocusSlot(5) + modifier = Modifier.settingsFocusSlot(6) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8055,9 +8105,9 @@ private fun AccountsSettings( title = stringResource(R.string.settings_diagnostics_sharing), subtitle = stringResource(R.string.settings_diagnostics_sharing_desc), isEnabled = diagnosticsSharingEnabled, - isFocused = focusedIndex == 6, + isFocused = focusedIndex == 7, onToggle = onDiagnosticsSharingToggle, - modifier = Modifier.settingsFocusSlot(6) + modifier = Modifier.settingsFocusSlot(7) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8066,9 +8116,9 @@ private fun AccountsSettings( title = stringResource(R.string.settings_privacy_policy), description = stringResource(R.string.settings_privacy_policy_desc), actionLabel = stringResource(R.string.settings_badge_open), - isFocused = focusedIndex == 7, + isFocused = focusedIndex == 8, onClick = onOpenPrivacy, - modifier = Modifier.settingsFocusSlot(7) + modifier = Modifier.settingsFocusSlot(8) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8077,9 +8127,9 @@ private fun AccountsSettings( title = stringResource(R.string.settings_account_data_deletion), description = stringResource(R.string.settings_account_data_deletion_desc), actionLabel = stringResource(R.string.settings_badge_open), - isFocused = focusedIndex == 8, + isFocused = focusedIndex == 9, onClick = onOpenDataDeletion, - modifier = Modifier.settingsFocusSlot(8) + modifier = Modifier.settingsFocusSlot(9) ) } } @@ -8385,7 +8435,9 @@ private fun TrackingIntegrationsPage( onConnectTrakt: () -> Unit, onDisconnectTrakt: () -> Unit, onConnectMdbList: (String) -> Unit, - onDisconnectMdbList: () -> Unit + onDisconnectMdbList: () -> Unit, + onConnectSimkl: () -> Unit = {}, + onDisconnectSimkl: () -> Unit = {} ) { var showMdbListConnect by remember { mutableStateOf(false) } var showMdbListDisconnectConfirm by remember { mutableStateOf(false) } @@ -8511,18 +8563,24 @@ private fun TrackingIntegrationsPage( onDisconnect = { showMdbListDisconnectConfirm = true } ) - // Simkl - coming soon + // Simkl TrackingServiceRow( iconRes = R.drawable.ic_simkl, title = "Simkl", tagline = stringResource(R.string.settings_simkl_tagline), - isConnected = false, - isWorking = false, - connectedAs = null, - comingSoon = true, + isConnected = uiState.isSimklConnected, + isWorking = uiState.isSimklAuthStarting || uiState.isSimklPolling, + connectedAs = if (uiState.isSimklConnected) "Connected" else null, + comingSoon = false, showDivider = false, - onConnect = {}, - onDisconnect = {} + onConnect = { + if (uiState.isSimklPolling || uiState.isSimklConnected) { + onDisconnectSimkl() + } else { + onConnectSimkl() + } + }, + onDisconnect = onDisconnectSimkl ) } } diff --git a/app/src/main/kotlin/com/arflix/tv/util/Constants.kt b/app/src/main/kotlin/com/arflix/tv/util/Constants.kt index 64505a53b..4ce765fbc 100644 --- a/app/src/main/kotlin/com/arflix/tv/util/Constants.kt +++ b/app/src/main/kotlin/com/arflix/tv/util/Constants.kt @@ -20,6 +20,7 @@ object Constants { // Edge Function proxy URLs used by backend/proxy-capable flows. val TMDB_PROXY_URL: String get() = "$NETLIFY_BACKEND_URL/tmdb-proxy" + val SIMKL_PROXY_URL: String get() = "$NETLIFY_BACKEND_URL/simkl-proxy" val TV_AUTH_START_URL: String get() = "$NETLIFY_BACKEND_URL/tv-auth-start" val TV_AUTH_STATUS_URL: String get() = "$NETLIFY_BACKEND_URL/tv-auth-status" val TV_AUTH_POLL_URL: String get() = "$NETLIFY_BACKEND_URL/tv-auth-poll" From e0f0cd8ee987dc7ed7eea6dc122f01acad395135 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Wed, 12 Aug 2026 08:13:51 +0530 Subject: [PATCH 3/7] fix(android): allow http URLs in proxy interceptor for local backend testing --- .../main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt | 4 ++-- app/src/main/kotlin/com/arflix/tv/util/Constants.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt b/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt index b19db419a..7eacba7ca 100644 --- a/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt +++ b/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt @@ -113,11 +113,11 @@ class ApiProxyInterceptor : Interceptor { private fun hasProxyConfig(): Boolean { if (Constants.USE_NETLIFY_CLOUD_SYNC) { - return Constants.NETLIFY_BACKEND_URL.startsWith("https://") + return Constants.NETLIFY_BACKEND_URL.startsWith("https://") || Constants.NETLIFY_BACKEND_URL.startsWith("http://") } val supabaseUrl = Constants.SUPABASE_URL.trim() val anonKey = Constants.SUPABASE_ANON_KEY.trim() - return supabaseUrl.startsWith("https://") && + return (supabaseUrl.startsWith("https://") || supabaseUrl.startsWith("http://")) && !supabaseUrl.contains("your-project", ignoreCase = true) && anonKey.length > 40 && !anonKey.startsWith("your-", ignoreCase = true) diff --git a/app/src/main/kotlin/com/arflix/tv/util/Constants.kt b/app/src/main/kotlin/com/arflix/tv/util/Constants.kt index 4ce765fbc..5f10fe3b3 100644 --- a/app/src/main/kotlin/com/arflix/tv/util/Constants.kt +++ b/app/src/main/kotlin/com/arflix/tv/util/Constants.kt @@ -16,7 +16,7 @@ object Constants { val NETLIFY_BACKEND_URL: String get() = BuildConfig.NETLIFY_BACKEND_URL.trim().trimEnd('/') val USE_NETLIFY_CLOUD_SYNC: Boolean - get() = BuildConfig.ENABLE_NETLIFY_CLOUD_SYNC && NETLIFY_BACKEND_URL.startsWith("https://") + get() = BuildConfig.ENABLE_NETLIFY_CLOUD_SYNC && (NETLIFY_BACKEND_URL.startsWith("https://") || NETLIFY_BACKEND_URL.startsWith("http://")) // Edge Function proxy URLs used by backend/proxy-capable flows. val TMDB_PROXY_URL: String get() = "$NETLIFY_BACKEND_URL/tmdb-proxy" From 665c7b0fb7401d3b3973c0001c028efc51154d17 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Wed, 12 Aug 2026 09:00:31 +0530 Subject: [PATCH 4/7] feat(android): enable active Simkl UI and auth modal in settings --- .../tv/ui/screens/settings/SettingsScreen.kt | 77 +++++-------------- 1 file changed, 20 insertions(+), 57 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index ea95fb331..e40be6a78 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -459,7 +459,7 @@ fun SettingsScreen( "catalogs" -> uiState.catalogs.size + 1 // Add + Import + catalogs "stremio" -> stremioAddons.size + 1 // rows + refresh + add button "plugins" -> pluginsMaxIndex - "accounts" -> 9 // Cloud, Trakt, MDBList, Simkl, Telegram, sync, update, diagnostics, privacy, deletion + "accounts" -> 8 // Cloud, integrations, sync, update, diagnostics, privacy, deletion else -> 0 } } @@ -1125,6 +1125,7 @@ fun SettingsScreen( } contentFocusIndex == stremioAddons.size -> { viewModel.refreshAddons() + } else -> { showCustomAddonInput = true } @@ -1155,25 +1156,18 @@ fun SettingsScreen( showMdbListConnect = true } } - 3 -> { - if (uiState.isSimklConnected || uiState.isSimklPolling) { - viewModel.disconnectSimkl() - } else { - viewModel.startSimklAuth() - } - } - 4 -> onNavigateToTelegramSettings() - 5 -> viewModel.forceCloudSyncNow() - 6 -> { + 3 -> onNavigateToTelegramSettings() + 4 -> viewModel.forceCloudSyncNow() + 5 -> { if (uiState.updateStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall) { viewModel.installAppUpdateOrRequestPermission() } else { viewModel.checkForAppUpdates(force = true, showNoUpdateFeedback = true) } } - 7 -> viewModel.setDiagnosticsSharingEnabled(!uiState.diagnosticsSharingEnabled) - 8 -> openExternalUrl(context, PRIVACY_POLICY_URL) - 9 -> openExternalUrl(context, ACCOUNT_DELETION_URL) + 6 -> viewModel.setDiagnosticsSharingEnabled(!uiState.diagnosticsSharingEnabled) + 7 -> openExternalUrl(context, PRIVACY_POLICY_URL) + 8 -> openExternalUrl(context, ACCOUNT_DELETION_URL) } } "plugins" -> { @@ -1700,13 +1694,6 @@ fun SettingsScreen( isMdbListConnected = uiState.isMdbListConnected, onConnectMdbList = { showMdbListConnect = true }, onDisconnectMdbList = { showMdbListDisconnectConfirm = true }, - isSimklConnected = uiState.isSimklConnected, - simklCode = uiState.simklUserCode, - simklUrl = uiState.simklVerificationUrl, - isSimklAuthStarting = uiState.isSimklAuthStarting, - isSimklPolling = uiState.isSimklPolling, - onConnectSimkl = { viewModel.startSimklAuth() }, - onDisconnectSimkl = { viewModel.disconnectSimkl() }, onForceCloudSync = { viewModel.forceCloudSyncNow() }, onSwitchProfile = onSwitchProfile, onCheckUpdates = { viewModel.checkForAppUpdates(force = true, showNoUpdateFeedback = true) }, @@ -7938,13 +7925,6 @@ private fun AccountsSettings( isMdbListConnected: Boolean, onConnectMdbList: () -> Unit, onDisconnectMdbList: () -> Unit, - isSimklConnected: Boolean = false, - simklCode: String? = null, - simklUrl: String? = null, - isSimklAuthStarting: Boolean = false, - isSimklPolling: Boolean = false, - onConnectSimkl: () -> Unit = {}, - onDisconnectSimkl: () -> Unit = {}, isForceCloudSyncing: Boolean, lastCloudSyncStatus: String?, diagnosticsSharingEnabled: Boolean, @@ -8027,31 +8007,14 @@ private fun AccountsSettings( Spacer(modifier = Modifier.height(16.dp)) - // Simkl - AccountRow( - name = "Simkl", - description = stringResource(R.string.settings_simkl_tagline), - isConnected = isSimklConnected, - isWorking = isSimklAuthStarting || isSimklPolling, - authCode = simklCode, - authUrl = simklUrl, - isFocused = focusedIndex == 3, - onConnect = { if (isSimklPolling) onDisconnectSimkl() else onConnectSimkl() }, - onDisconnect = onDisconnectSimkl, - modifier = Modifier.settingsFocusSlot(3), - expirationText = null - ) - - Spacer(modifier = Modifier.height(16.dp)) - // Telegram SettingsActionRow( title = "Telegram", description = stringResource(R.string.settings_telegram_desc), actionLabel = stringResource(R.string.settings_badge_open), - isFocused = focusedIndex == 4, + isFocused = focusedIndex == 3, onClick = onNavigateToTelegram, - modifier = Modifier.settingsFocusSlot(4) + modifier = Modifier.settingsFocusSlot(3) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8068,9 +8031,9 @@ private fun AccountsSettings( stringResource(R.string.settings_signin_to_force_sync) }, actionLabel = if (isForceCloudSyncing) stringResource(R.string.settings_badge_syncing) else stringResource(R.string.settings_badge_sync), - isFocused = focusedIndex == 5, + isFocused = focusedIndex == 4, onClick = { if (!isForceCloudSyncing) onForceCloudSync() }, - modifier = Modifier.settingsFocusSlot(5) + modifier = Modifier.settingsFocusSlot(4) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8092,11 +8055,11 @@ private fun AccountsSettings( updateStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable -> stringResource(R.string.settings_badge_update) else -> stringResource(R.string.settings_badge_check) }, - isFocused = focusedIndex == 6, + isFocused = focusedIndex == 5, onClick = { if (updateStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall) onInstallUpdate() else onCheckUpdates() }, - modifier = Modifier.settingsFocusSlot(6) + modifier = Modifier.settingsFocusSlot(5) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8105,9 +8068,9 @@ private fun AccountsSettings( title = stringResource(R.string.settings_diagnostics_sharing), subtitle = stringResource(R.string.settings_diagnostics_sharing_desc), isEnabled = diagnosticsSharingEnabled, - isFocused = focusedIndex == 7, + isFocused = focusedIndex == 6, onToggle = onDiagnosticsSharingToggle, - modifier = Modifier.settingsFocusSlot(7) + modifier = Modifier.settingsFocusSlot(6) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8116,9 +8079,9 @@ private fun AccountsSettings( title = stringResource(R.string.settings_privacy_policy), description = stringResource(R.string.settings_privacy_policy_desc), actionLabel = stringResource(R.string.settings_badge_open), - isFocused = focusedIndex == 8, + isFocused = focusedIndex == 7, onClick = onOpenPrivacy, - modifier = Modifier.settingsFocusSlot(8) + modifier = Modifier.settingsFocusSlot(7) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8127,9 +8090,9 @@ private fun AccountsSettings( title = stringResource(R.string.settings_account_data_deletion), description = stringResource(R.string.settings_account_data_deletion_desc), actionLabel = stringResource(R.string.settings_badge_open), - isFocused = focusedIndex == 9, + isFocused = focusedIndex == 8, onClick = onOpenDataDeletion, - modifier = Modifier.settingsFocusSlot(9) + modifier = Modifier.settingsFocusSlot(8) ) } } From 8a9a815afef57df410f9632c9f1b4a3435b01845 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Wed, 12 Aug 2026 10:23:56 +0530 Subject: [PATCH 5/7] fix(android): enable HTTP header logging in debug mode --- app/src/main/kotlin/com/arflix/tv/network/OkHttpProvider.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/arflix/tv/network/OkHttpProvider.kt b/app/src/main/kotlin/com/arflix/tv/network/OkHttpProvider.kt index 82bd3568d..d67d637fb 100644 --- a/app/src/main/kotlin/com/arflix/tv/network/OkHttpProvider.kt +++ b/app/src/main/kotlin/com/arflix/tv/network/OkHttpProvider.kt @@ -284,7 +284,7 @@ object OkHttpProvider { private fun buildAppClient(): OkHttpClient { val loggingInterceptor = HttpLoggingInterceptor().apply { level = if (BuildConfig.DEBUG) { - HttpLoggingInterceptor.Level.BASIC + HttpLoggingInterceptor.Level.HEADERS } else { HttpLoggingInterceptor.Level.NONE } From 377f781216ff58b3e4d0ff1a3994b3a9d47548d4 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Wed, 12 Aug 2026 12:10:29 +0530 Subject: [PATCH 6/7] feat(simkl): complete Simkl integration with TV UI, sync policies, and scrobbler --- .../kotlin/com/arflix/tv/data/api/SimklApi.kt | 53 ++++- .../tv/data/repository/TraktRepository.kt | 35 ++- .../data/repository/simkl/SimklAuthManager.kt | 13 + .../data/repository/simkl/SimklSyncService.kt | 225 +++++++++++++++--- .../repository/sync/SimklRemoteProvider.kt | 8 +- .../main/kotlin/com/arflix/tv/di/AppModule.kt | 48 +++- .../arflix/tv/network/ApiProxyInterceptor.kt | 4 +- .../tv/ui/screens/home/HomeViewModel.kt | 18 +- .../tv/ui/screens/settings/SettingsScreen.kt | 82 +++++-- .../ui/screens/settings/SettingsViewModel.kt | 90 ++++++- .../repository/simkl/SimklIntegrationTest.kt | 56 ++++- 11 files changed, 534 insertions(+), 98 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/api/SimklApi.kt b/app/src/main/kotlin/com/arflix/tv/data/api/SimklApi.kt index e6af0e7a4..365de285c 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/api/SimklApi.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/api/SimklApi.kt @@ -49,6 +49,12 @@ interface SimklApi { // ========== Sync & Watch History ========== + @GET("users/settings") + suspend fun getUserSettings( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String + ): SimklUserSettingsResponse + @GET("sync/activities") suspend fun getActivities( @Header("Authorization") auth: String, @@ -56,10 +62,16 @@ interface SimklApi { ): SimklActivitiesResponse @GET("sync/all-items/{type}") + suspend fun getAllItemsByType( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String, + @Path("type") type: String // "movies", "shows", "anime" + ): SimklAllItemsResponse + + @GET("sync/all-items") suspend fun getAllItems( @Header("Authorization") auth: String, @Header("simkl-api-key") clientId: String, - @Path("type") type: String, // "movies", "shows", "anime" @Query("date_from") dateFrom: String? = null ): SimklAllItemsResponse @@ -78,18 +90,18 @@ interface SimklApi { @Body body: SimklSyncHistoryBody ): Response - @POST("sync/watchlist") - suspend fun addToWatchlist( + @POST("sync/add-to-list") + suspend fun addToList( @Header("Authorization") auth: String, @Header("simkl-api-key") clientId: String, - @Body body: SimklSyncWatchlistBody + @Body body: SimklAddToListBody ): Response - @POST("sync/watchlist/remove") - suspend fun removeFromWatchlist( + @POST("sync/delete-from-list") + suspend fun deleteFromList( @Header("Authorization") auth: String, @Header("simkl-api-key") clientId: String, - @Body body: SimklSyncWatchlistBody + @Body body: SimklDeleteFromListBody ): Response } @@ -202,7 +214,22 @@ data class SimklSyncHistoryBody( @SerializedName("episodes") val episodes: List? = null ) -data class SimklSyncWatchlistBody( +data class SimklAddToListMovie( + @SerializedName("to") val to: String = "plantowatch", + @SerializedName("ids") val ids: SimklIds +) + +data class SimklAddToListShow( + @SerializedName("to") val to: String = "plantowatch", + @SerializedName("ids") val ids: SimklIds +) + +data class SimklAddToListBody( + @SerializedName("movies") val movies: List? = null, + @SerializedName("shows") val shows: List? = null +) + +data class SimklDeleteFromListBody( @SerializedName("movies") val movies: List? = null, @SerializedName("shows") val shows: List? = null ) @@ -218,3 +245,13 @@ data class SimklSyncCount( @SerializedName("shows") val shows: Int = 0, @SerializedName("episodes") val episodes: Int = 0 ) + +data class SimklUserSettingsResponse( + @SerializedName("user") val user: SimklUser? = null +) + +data class SimklUser( + @SerializedName("name") val name: String? = null, + @SerializedName("username") val username: String? = null +) + diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt index 2b321389f..a978f865a 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt @@ -74,7 +74,8 @@ class TraktRepository @Inject constructor( private val syncServiceProvider: Provider, private val profileManager: ProfileManager, private val mdbListRepository: MdbListRepository, - private val syncProviderStore: com.arflix.tv.data.repository.sync.SyncProviderStore + private val syncProviderStore: com.arflix.tv.data.repository.sync.SyncProviderStore, + private val simklSyncService: com.arflix.tv.data.repository.simkl.SimklSyncService ) { private val gson = Gson() private val watchlistHttpClient by lazy { okHttpClient } @@ -623,7 +624,11 @@ class TraktRepository @Inject constructor( // Then sync to backend in background try { - syncService.markMovieWatched(tmdbId) + if (isSimklActive()) { + simklSyncService.markWatched(com.arflix.tv.data.model.MediaType.MOVIE, tmdbId) + } else { + syncService.markMovieWatched(tmdbId) + } } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e @@ -642,7 +647,11 @@ class TraktRepository @Inject constructor( // Then sync to backend in background try { - syncService.markMovieUnwatched(tmdbId) + if (isSimklActive()) { + simklSyncService.markUnwatched(com.arflix.tv.data.model.MediaType.MOVIE, tmdbId) + } else { + syncService.markMovieUnwatched(tmdbId) + } } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e @@ -663,8 +672,12 @@ class TraktRepository @Inject constructor( // Then sync to backend in background (don't block UI on network) try { - val traktShowId = tmdbToTraktIdCache[showTmdbId] - syncService.markEpisodeWatched(showTmdbId, season, episode, traktShowId) + if (isSimklActive()) { + simklSyncService.markWatched(com.arflix.tv.data.model.MediaType.TV, showTmdbId, season, episode) + } else { + val traktShowId = tmdbToTraktIdCache[showTmdbId] + syncService.markEpisodeWatched(showTmdbId, season, episode, traktShowId) + } } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e @@ -706,7 +719,11 @@ class TraktRepository @Inject constructor( // Then sync to backend in background (skip if batch Trakt removal already handled it) if (syncTrakt) { try { - syncService.markEpisodeUnwatched(showTmdbId, season, episode) + if (isSimklActive()) { + simklSyncService.markUnwatched(com.arflix.tv.data.model.MediaType.TV, showTmdbId, season, episode) + } else { + syncService.markEpisodeUnwatched(showTmdbId, season, episode) + } } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e @@ -1260,6 +1277,12 @@ class TraktRepository @Inject constructor( suspend fun isMdbListActive(): Boolean = syncProviderStore.getProvider() == com.arflix.tv.data.repository.sync.SyncProvider.MDBLIST + suspend fun isSimklActive(): Boolean = + syncProviderStore.getProvider() == com.arflix.tv.data.repository.sync.SyncProvider.SIMKL + + suspend fun isAlternativeRemoteActive(): Boolean = + isMdbListActive() || isSimklActive() + suspend fun getContinueWatching(forceRefresh: Boolean = false): List = coroutineScope { ensureProfileCacheScope() val requestProfileId = currentProfileId() diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt index db6ea2c6e..1361de761 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt @@ -49,6 +49,18 @@ class SimklAuthManager @Inject constructor( return false } + suspend fun fetchUsername(): String? { + val token = getAccessToken() ?: return null + val authHeader = "Bearer $token" + val effectiveClientId = clientId.ifBlank { "simkl_proxy" } + return try { + val res = simklApi.getUserSettings(authHeader, effectiveClientId) + res.user?.name ?: res.user?.username + } catch (e: Exception) { + null + } + } + suspend fun disconnect() { syncProviderStore.setSimklAccessToken(null) if (syncProviderStore.getProvider() == SyncProvider.SIMKL) { @@ -56,3 +68,4 @@ class SimklAuthManager @Inject constructor( } } } + diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt index 5823d84eb..8d7b09204 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt @@ -1,16 +1,24 @@ package com.arflix.tv.data.repository.simkl +import com.arflix.tv.data.api.SimklAddToListBody +import com.arflix.tv.data.api.SimklAddToListMovie +import com.arflix.tv.data.api.SimklAddToListShow +import com.arflix.tv.data.api.SimklAllItemsResponse import com.arflix.tv.data.api.SimklApi +import com.arflix.tv.data.api.SimklDeleteFromListBody import com.arflix.tv.data.api.SimklEpisodeRef import com.arflix.tv.data.api.SimklIds import com.arflix.tv.data.api.SimklMovieRef import com.arflix.tv.data.api.SimklSeasonRef import com.arflix.tv.data.api.SimklShowRef import com.arflix.tv.data.api.SimklSyncHistoryBody -import com.arflix.tv.data.api.SimklSyncWatchlistBody +import com.arflix.tv.data.model.MediaItem import com.arflix.tv.data.model.MediaType import com.arflix.tv.util.AppLogger import com.arflix.tv.util.Constants +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import javax.inject.Inject import javax.inject.Singleton @@ -21,53 +29,146 @@ class SimklSyncService @Inject constructor( ) { private val clientId: String get() = Constants.SIMKL_CLIENT_ID - suspend fun getWatchedMovies(): Set { - val token = authManager.getAccessToken() ?: return emptySet() + private val syncMutex = Mutex() + private var lastActivityTimestamp: String? = null + private var lastActivityCheckTime: Long = 0L + + private val cachedWatchedMovies = mutableSetOf() + private val cachedWatchedEpisodes = mutableSetOf() + private val cachedWatchlist = mutableMapOf, MediaItem>() + + /** + * Follows official Simkl sync guidelines: + * Phase 1: Fetch libraries separately and sequentially without date_from on initial load. + * Phase 2: Check /sync/activities first. If timestamp changed, fetch delta using /sync/all-items/?date_from=... + * Throttles background checks to once every 15 minutes unless forced. + */ + suspend fun syncIfNeeded(force: Boolean = false) = syncMutex.withLock { + val token = authManager.getAccessToken() ?: return@withLock val authHeader = "Bearer $token" - return try { - val response = simklApi.getAllItems(authHeader, clientId, "movies") - response.movies - ?.filter { it.status == "completed" || it.status == "watching" } - ?.mapNotNull { it.movie?.ids?.tmdb } - ?.toSet() ?: emptySet() + + val now = System.currentTimeMillis() + if (!force && (now - lastActivityCheckTime < 15 * 60 * 1000L) && lastActivityTimestamp != null) { + return@withLock + } + + try { + val activities = simklApi.getActivities(authHeader, clientId) + lastActivityCheckTime = now + val currentActivityDate = activities.all ?: activities.movies?.all ?: activities.shows?.all + + if (lastActivityTimestamp == null) { + // PHASE 1: Initial Sync Strategy (fetch sequentially) + AppLogger.d("SimklSyncService", "Running Phase 1 Initial Sync sequentially") + cachedWatchedMovies.clear() + cachedWatchedEpisodes.clear() + cachedWatchlist.clear() + + val moviesRes = simklApi.getAllItemsByType(authHeader, clientId, "movies") + processMoviesResponse(moviesRes) + + delay(200) + val showsRes = simklApi.getAllItemsByType(authHeader, clientId, "shows") + processShowsResponse(showsRes) + + delay(200) + val animeRes = simklApi.getAllItemsByType(authHeader, clientId, "anime") + processShowsResponse(animeRes) + + lastActivityTimestamp = currentActivityDate + } else if (currentActivityDate != null && currentActivityDate != lastActivityTimestamp) { + // PHASE 2: Continuous Sync Loop (delta sync with date_from) + AppLogger.d("SimklSyncService", "Running Phase 2 Delta Sync with date_from=$lastActivityTimestamp") + val deltaRes = simklApi.getAllItems(authHeader, clientId, dateFrom = lastActivityTimestamp) + processMoviesResponse(deltaRes) + processShowsResponse(deltaRes) + + lastActivityTimestamp = currentActivityDate + } else { + AppLogger.d("SimklSyncService", "Simkl activities unchanged ($currentActivityDate). Skipping sync.") + } } catch (e: Exception) { - AppLogger.e("SimklSyncService", "Error fetching watched movies: ${e.message}") - emptySet() + AppLogger.e("SimklSyncService", "Error during Simkl sync: ${e.message}") } } - suspend fun getWatchedEpisodes(): Set { - val token = authManager.getAccessToken() ?: return emptySet() - val authHeader = "Bearer $token" - return try { - val response = simklApi.getAllItems(authHeader, clientId, "shows") - val watched = mutableSetOf() - response.shows?.forEach { showItem -> - val showTmdb = showItem.show?.ids?.tmdb ?: return@forEach - showItem.seasons?.forEach { season -> - season.episodes.forEach { episode -> - watched.add("${showTmdb}_S${season.number}_E${episode.number}") - } + private fun processMoviesResponse(response: SimklAllItemsResponse) { + response.movies?.forEach { movieItem -> + val tmdbId = movieItem.movie?.ids?.tmdb ?: return@forEach + val status = movieItem.status + if (status == "completed" || status == "watching") { + cachedWatchedMovies.add(tmdbId) + } + if (status == "plantowatch") { + cachedWatchlist[MediaType.MOVIE to tmdbId] = MediaItem( + id = tmdbId, + title = movieItem.movie.title.orEmpty(), + mediaType = MediaType.MOVIE + ) + } else if (status != null) { + cachedWatchlist.remove(MediaType.MOVIE to tmdbId) + } + } + } + + private fun processShowsResponse(response: SimklAllItemsResponse) { + val allShows = (response.shows.orEmpty() + response.anime.orEmpty()) + allShows.forEach { showItem -> + val showTmdb = showItem.show?.ids?.tmdb ?: return@forEach + val status = showItem.status + if (status == "plantowatch") { + cachedWatchlist[MediaType.TV to showTmdb] = MediaItem( + id = showTmdb, + title = showItem.show.title.orEmpty(), + mediaType = MediaType.TV + ) + } else if (status != null) { + cachedWatchlist.remove(MediaType.TV to showTmdb) + } + showItem.seasons?.forEach { season -> + season.episodes.forEach { episode -> + cachedWatchedEpisodes.add("${showTmdb}_S${season.number}_E${episode.number}") } } - watched - } catch (e: Exception) { - AppLogger.e("SimklSyncService", "Error fetching watched episodes: ${e.message}") - emptySet() } } + suspend fun getWatchedMovies(): Set { + syncIfNeeded() + return cachedWatchedMovies.toSet() + } + + suspend fun getWatchedEpisodes(): Set { + syncIfNeeded() + return cachedWatchedEpisodes.toSet() + } + + suspend fun getWatchlistItems(): List { + syncIfNeeded() + return cachedWatchlist.values.toList() + } + suspend fun addToWatchlist(mediaType: MediaType, tmdbId: Int): Boolean { val token = authManager.getAccessToken() ?: return false val authHeader = "Bearer $token" val body = if (mediaType == MediaType.MOVIE) { - SimklSyncWatchlistBody(movies = listOf(SimklMovieRef(ids = SimklIds(tmdb = tmdbId)))) + SimklAddToListBody( + movies = listOf(SimklAddToListMovie(to = "plantowatch", ids = SimklIds(tmdb = tmdbId))) + ) } else { - SimklSyncWatchlistBody(shows = listOf(SimklShowRef(ids = SimklIds(tmdb = tmdbId)))) + SimklAddToListBody( + shows = listOf(SimklAddToListShow(to = "plantowatch", ids = SimklIds(tmdb = tmdbId))) + ) } return try { - val res = simklApi.addToWatchlist(authHeader, clientId, body) - res.isSuccessful + val res = simklApi.addToList(authHeader, clientId, body) + if (res.isSuccessful) { + cachedWatchlist[mediaType to tmdbId] = MediaItem(id = tmdbId, title = "", mediaType = mediaType) + lastActivityCheckTime = 0L // Request activity refresh on next check + true + } else { + false + } } catch (e: Exception) { AppLogger.e("SimklSyncService", "Error adding to watchlist: ${e.message}") false @@ -78,13 +179,19 @@ class SimklSyncService @Inject constructor( val token = authManager.getAccessToken() ?: return false val authHeader = "Bearer $token" val body = if (mediaType == MediaType.MOVIE) { - SimklSyncWatchlistBody(movies = listOf(SimklMovieRef(ids = SimklIds(tmdb = tmdbId)))) + SimklDeleteFromListBody(movies = listOf(SimklMovieRef(ids = SimklIds(tmdb = tmdbId)))) } else { - SimklSyncWatchlistBody(shows = listOf(SimklShowRef(ids = SimklIds(tmdb = tmdbId)))) + SimklDeleteFromListBody(shows = listOf(SimklShowRef(ids = SimklIds(tmdb = tmdbId)))) } return try { - val res = simklApi.removeFromWatchlist(authHeader, clientId, body) - res.isSuccessful + val res = simklApi.deleteFromList(authHeader, clientId, body) + if (res.isSuccessful) { + cachedWatchlist.remove(mediaType to tmdbId) + lastActivityCheckTime = 0L + true + } else { + false + } } catch (e: Exception) { AppLogger.e("SimklSyncService", "Error removing from watchlist: ${e.message}") false @@ -110,10 +217,56 @@ class SimklSyncService @Inject constructor( } return try { val res = simklApi.addToHistory(authHeader, clientId, body, allowRewatch = "yes") - res.isSuccessful + if (res.isSuccessful) { + if (mediaType == MediaType.MOVIE) { + cachedWatchedMovies.add(tmdbId) + } else if (season != null && episode != null) { + cachedWatchedEpisodes.add("${tmdbId}_S${season}_E${episode}") + } + lastActivityCheckTime = 0L + true + } else { + false + } } catch (e: Exception) { AppLogger.e("SimklSyncService", "Error marking watched: ${e.message}") false } } + + suspend fun markUnwatched(mediaType: MediaType, tmdbId: Int, season: Int? = null, episode: Int? = null): Boolean { + val token = authManager.getAccessToken() ?: return false + val authHeader = "Bearer $token" + val body = if (mediaType == MediaType.MOVIE) { + SimklSyncHistoryBody(movies = listOf(SimklMovieRef(ids = SimklIds(tmdb = tmdbId)))) + } else { + SimklSyncHistoryBody( + shows = listOf( + SimklShowRef( + ids = SimklIds(tmdb = tmdbId), + seasons = if (season != null && episode != null) { + listOf(SimklSeasonRef(number = season, episodes = listOf(SimklEpisodeRef(number = episode)))) + } else null + ) + ) + ) + } + return try { + val res = simklApi.removeFromHistory(authHeader, clientId, body) + if (res.isSuccessful) { + if (mediaType == MediaType.MOVIE) { + cachedWatchedMovies.remove(tmdbId) + } else if (season != null && episode != null) { + cachedWatchedEpisodes.remove("${tmdbId}_S${season}_E${episode}") + } + lastActivityCheckTime = 0L + true + } else { + false + } + } catch (e: Exception) { + AppLogger.e("SimklSyncService", "Error marking unwatched: ${e.message}") + false + } + } } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SimklRemoteProvider.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SimklRemoteProvider.kt index 1133c82c9..7063276c2 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SimklRemoteProvider.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SimklRemoteProvider.kt @@ -30,10 +30,12 @@ class SimklRemoteProvider @Inject constructor( override suspend fun getWatchlist(): RemoteWatchlistResult { val connected = isConnected() + if (!connected) return RemoteWatchlistResult(connected = false, items = emptyList(), rawCount = 0) + val items = syncService.getWatchlistItems() return RemoteWatchlistResult( - connected = connected, - items = emptyList(), - rawCount = 0 + connected = true, + items = items, + rawCount = items.size ) } diff --git a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt index 1e83b00f7..b2a7dd78b 100644 --- a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt +++ b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt @@ -92,9 +92,55 @@ object AppModule { @Singleton @JvmStatic fun provideSimklApi(okHttpClient: OkHttpClient): com.arflix.tv.data.api.SimklApi { + var lastPostTimestampMs = 0L + val postLock = Any() + + val simklClient = okHttpClient.newBuilder() + .addInterceptor { chain -> + val original = chain.request() + + // Enforce 1 POST request per second per Simkl API policy + if (original.method.equals("POST", ignoreCase = true)) { + synchronized(postLock) { + val now = android.os.SystemClock.elapsedRealtime() + val elapsed = now - lastPostTimestampMs + if (elapsed < 1000L) { + val sleepTime = 1000L - elapsed + try { + Thread.sleep(sleepTime) + } catch (_: InterruptedException) {} + } + lastPostTimestampMs = android.os.SystemClock.elapsedRealtime() + } + } + + val originalUrl = original.url + val urlBuilder = originalUrl.newBuilder() + if (originalUrl.queryParameter("client_id") == null) { + urlBuilder.addQueryParameter("client_id", Constants.SIMKL_CLIENT_ID) + } + if (originalUrl.queryParameter("app-name") == null) { + urlBuilder.addQueryParameter("app-name", "ARVIO") + } + if (originalUrl.queryParameter("app-version") == null) { + urlBuilder.addQueryParameter("app-version", com.arflix.tv.BuildConfig.VERSION_NAME) + } + + val requestBuilder = original.newBuilder() + .url(urlBuilder.build()) + .header("User-Agent", "ARVIO/${com.arflix.tv.BuildConfig.VERSION_NAME} (Android TV)") + + if (original.header("simkl-api-key") == null) { + requestBuilder.header("simkl-api-key", Constants.SIMKL_CLIENT_ID) + } + + chain.proceed(requestBuilder.build()) + } + .build() + return Retrofit.Builder() .baseUrl(Constants.SIMKL_BASE_URL) - .client(okHttpClient) + .client(simklClient) .addConverterFactory(GsonConverterFactory.create()) .build() .create(com.arflix.tv.data.api.SimklApi::class.java) diff --git a/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt b/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt index 7eacba7ca..b0bad47dd 100644 --- a/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt +++ b/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt @@ -45,8 +45,8 @@ class ApiProxyInterceptor : Interceptor { chain.proceed(originalRequest) } "api.simkl.com" -> { - val proxyRequest = rewriteForSimklProxy(originalRequest) ?: originalRequest - chain.proceed(proxyRequest) + // Simkl OAuth and user endpoints must stay direct, same as Trakt and MDBList. + chain.proceed(originalRequest) } else -> { // Pass through other requests unchanged diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index 74f8307ed..c2cea2242 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -3143,14 +3143,14 @@ class HomeViewModel @Inject constructor( } // MDBList profiles have no Trakt token but still source CW from a remote provider — // route them through the same getContinueWatching() path (it branches to MDBList). - val isMdbListActive = try { - traktRepository.isMdbListActive() + val isAlternativeRemoteActive = try { + traktRepository.isAlternativeRemoteActive() } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { false } - val useRemoteSync = isTraktAuthenticated || isMdbListActive + val useRemoteSync = isTraktAuthenticated || isAlternativeRemoteActive // Debug: write CW state to a file we can pull via adb val items: List = if (useRemoteSync) { // When connected to Trakt, use ONLY Trakt as the source of truth for @@ -3477,14 +3477,14 @@ class HomeViewModel @Inject constructor( } catch (e: Exception) { false } - val isMdbListActive = try { - traktRepository.isMdbListActive() + val isAlternativeRemoteActive = try { + traktRepository.isAlternativeRemoteActive() } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { false } - val useRemoteSync = isTraktAuthenticated || isMdbListActive + val useRemoteSync = isTraktAuthenticated || isAlternativeRemoteActive val items = if (useRemoteSync) { val traktItems = if (forceFresh) { try { @@ -3583,14 +3583,14 @@ class HomeViewModel @Inject constructor( } catch (e: Exception) { false } - val isMdbListActive = try { - traktRepository.isMdbListActive() + val isAlternativeRemoteActive = try { + traktRepository.isAlternativeRemoteActive() } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { false } - val useRemoteSync = isTraktAuthenticated || isMdbListActive + val useRemoteSync = isTraktAuthenticated || isAlternativeRemoteActive val items = if (useRemoteSync) { try { traktRepository.preloadContinueWatchingCache() diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index e40be6a78..8e3e0cb0f 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -459,7 +459,7 @@ fun SettingsScreen( "catalogs" -> uiState.catalogs.size + 1 // Add + Import + catalogs "stremio" -> stremioAddons.size + 1 // rows + refresh + add button "plugins" -> pluginsMaxIndex - "accounts" -> 8 // Cloud, integrations, sync, update, diagnostics, privacy, deletion + "accounts" -> 9 // Cloud, Trakt, MDBList, Simkl, Telegram, sync, update, diagnostics, privacy, deletion else -> 0 } } @@ -1156,18 +1156,25 @@ fun SettingsScreen( showMdbListConnect = true } } - 3 -> onNavigateToTelegramSettings() - 4 -> viewModel.forceCloudSyncNow() - 5 -> { + 3 -> { + if (uiState.isSimklConnected || uiState.isSimklPolling) { + viewModel.disconnectSimkl() + } else { + viewModel.startSimklAuth() + } + } + 4 -> onNavigateToTelegramSettings() + 5 -> viewModel.forceCloudSyncNow() + 6 -> { if (uiState.updateStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall) { viewModel.installAppUpdateOrRequestPermission() } else { viewModel.checkForAppUpdates(force = true, showNoUpdateFeedback = true) } } - 6 -> viewModel.setDiagnosticsSharingEnabled(!uiState.diagnosticsSharingEnabled) - 7 -> openExternalUrl(context, PRIVACY_POLICY_URL) - 8 -> openExternalUrl(context, ACCOUNT_DELETION_URL) + 7 -> viewModel.setDiagnosticsSharingEnabled(!uiState.diagnosticsSharingEnabled) + 8 -> openExternalUrl(context, PRIVACY_POLICY_URL) + 9 -> openExternalUrl(context, ACCOUNT_DELETION_URL) } } "plugins" -> { @@ -1694,6 +1701,14 @@ fun SettingsScreen( isMdbListConnected = uiState.isMdbListConnected, onConnectMdbList = { showMdbListConnect = true }, onDisconnectMdbList = { showMdbListDisconnectConfirm = true }, + isSimklConnected = uiState.isSimklConnected, + simklCode = uiState.simklUserCode, + simklUrl = uiState.simklVerificationUrl, + isSimklAuthStarting = uiState.isSimklAuthStarting, + isSimklPolling = uiState.isSimklPolling, + onConnectSimkl = { viewModel.startSimklAuth() }, + onCancelSimkl = { viewModel.disconnectSimkl() }, + onDisconnectSimkl = { viewModel.disconnectSimkl() }, onForceCloudSync = { viewModel.forceCloudSyncNow() }, onSwitchProfile = onSwitchProfile, onCheckUpdates = { viewModel.checkForAppUpdates(force = true, showNoUpdateFeedback = true) }, @@ -3758,6 +3773,7 @@ private fun MobileSettingsMainPage( value = when { uiState.isTraktAuthenticated -> "Trakt" uiState.isMdbListConnected -> "MDBList" + uiState.isSimklConnected -> "Simkl" else -> "" }, isFocused = false, @@ -7925,6 +7941,14 @@ private fun AccountsSettings( isMdbListConnected: Boolean, onConnectMdbList: () -> Unit, onDisconnectMdbList: () -> Unit, + isSimklConnected: Boolean = false, + simklCode: String? = null, + simklUrl: String? = null, + isSimklAuthStarting: Boolean = false, + isSimklPolling: Boolean = false, + onConnectSimkl: () -> Unit = {}, + onCancelSimkl: () -> Unit = {}, + onDisconnectSimkl: () -> Unit = {}, isForceCloudSyncing: Boolean, lastCloudSyncStatus: String?, diagnosticsSharingEnabled: Boolean, @@ -8007,14 +8031,31 @@ private fun AccountsSettings( Spacer(modifier = Modifier.height(16.dp)) + // Simkl (per-profile alternative to Trakt) + AccountRow( + name = "Simkl", + description = stringResource(R.string.settings_simkl_tagline), + isConnected = isSimklConnected, + isWorking = isSimklAuthStarting || isSimklPolling, + authCode = simklCode, + authUrl = simklUrl, + isFocused = focusedIndex == 3, + onConnect = { if (isSimklPolling) onCancelSimkl() else onConnectSimkl() }, + onDisconnect = onDisconnectSimkl, + modifier = Modifier.settingsFocusSlot(3), + expirationText = null + ) + + Spacer(modifier = Modifier.height(16.dp)) + // Telegram SettingsActionRow( title = "Telegram", description = stringResource(R.string.settings_telegram_desc), actionLabel = stringResource(R.string.settings_badge_open), - isFocused = focusedIndex == 3, + isFocused = focusedIndex == 4, onClick = onNavigateToTelegram, - modifier = Modifier.settingsFocusSlot(3) + modifier = Modifier.settingsFocusSlot(4) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8031,9 +8072,9 @@ private fun AccountsSettings( stringResource(R.string.settings_signin_to_force_sync) }, actionLabel = if (isForceCloudSyncing) stringResource(R.string.settings_badge_syncing) else stringResource(R.string.settings_badge_sync), - isFocused = focusedIndex == 4, + isFocused = focusedIndex == 5, onClick = { if (!isForceCloudSyncing) onForceCloudSync() }, - modifier = Modifier.settingsFocusSlot(4) + modifier = Modifier.settingsFocusSlot(5) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8055,11 +8096,11 @@ private fun AccountsSettings( updateStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable -> stringResource(R.string.settings_badge_update) else -> stringResource(R.string.settings_badge_check) }, - isFocused = focusedIndex == 5, + isFocused = focusedIndex == 6, onClick = { if (updateStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall) onInstallUpdate() else onCheckUpdates() }, - modifier = Modifier.settingsFocusSlot(5) + modifier = Modifier.settingsFocusSlot(6) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8068,9 +8109,9 @@ private fun AccountsSettings( title = stringResource(R.string.settings_diagnostics_sharing), subtitle = stringResource(R.string.settings_diagnostics_sharing_desc), isEnabled = diagnosticsSharingEnabled, - isFocused = focusedIndex == 6, + isFocused = focusedIndex == 7, onToggle = onDiagnosticsSharingToggle, - modifier = Modifier.settingsFocusSlot(6) + modifier = Modifier.settingsFocusSlot(7) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8079,9 +8120,9 @@ private fun AccountsSettings( title = stringResource(R.string.settings_privacy_policy), description = stringResource(R.string.settings_privacy_policy_desc), actionLabel = stringResource(R.string.settings_badge_open), - isFocused = focusedIndex == 7, + isFocused = focusedIndex == 8, onClick = onOpenPrivacy, - modifier = Modifier.settingsFocusSlot(7) + modifier = Modifier.settingsFocusSlot(8) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8090,9 +8131,9 @@ private fun AccountsSettings( title = stringResource(R.string.settings_account_data_deletion), description = stringResource(R.string.settings_account_data_deletion_desc), actionLabel = stringResource(R.string.settings_badge_open), - isFocused = focusedIndex == 8, + isFocused = focusedIndex == 9, onClick = onOpenDataDeletion, - modifier = Modifier.settingsFocusSlot(8) + modifier = Modifier.settingsFocusSlot(9) ) } } @@ -8442,6 +8483,7 @@ private fun TrackingIntegrationsPage( val activeProviders = buildList { if (uiState.isTraktAuthenticated) add("Trakt") if (uiState.isMdbListConnected) add("MDBList") + if (uiState.isSimklConnected) add("Simkl") } val activeProvider = if (activeProviders.isNotEmpty()) { activeProviders.joinToString(", ") @@ -8533,7 +8575,7 @@ private fun TrackingIntegrationsPage( tagline = stringResource(R.string.settings_simkl_tagline), isConnected = uiState.isSimklConnected, isWorking = uiState.isSimklAuthStarting || uiState.isSimklPolling, - connectedAs = if (uiState.isSimklConnected) "Connected" else null, + connectedAs = if (uiState.isSimklConnected) (uiState.simklUsername ?: "Connected") else null, comingSoon = false, showDivider = false, onConnect = { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 030fe6d71..c241f4e19 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.viewModelScope import com.arflix.tv.R import com.arflix.tv.server.AiKeyConfigServer import com.arflix.tv.ui.screens.player.SubtitleAiModel +import com.arflix.tv.util.AppLogger import com.arflix.tv.util.DeviceIpAddress import com.arflix.tv.util.DiagnosticsManager import com.arflix.tv.util.QrCodeGenerator @@ -149,6 +150,7 @@ data class SettingsUiState( val isSimklPolling: Boolean = false, val simklUserCode: String? = null, val simklVerificationUrl: String? = null, + val simklUsername: String? = null, // Trakt Sync val isSyncing: Boolean = false, val syncProgress: SyncProgress = SyncProgress(), @@ -326,6 +328,7 @@ class SettingsViewModel @Inject constructor( private var lastObservedStalkerUrl: String = "" private var traktPollingJob: Job? = null + private var simklPollingJob: Job? = null private var traktStartupJob: Job? = null private var loadSettingsJob: Job? = null private var integrationMetadataJob: Job? = null @@ -551,6 +554,7 @@ class SettingsViewModel @Inject constructor( val accountEmail = (authState as? AuthState.Authenticated)?.email val isTrakt = traktRepository.hasTrakt() val isMdbList = mdbListRepository.isConnected() + val isSimkl = simklAuthManager.isConnected() if (profileManager.getProfileIdSync() != loadProfileId) return@launch @@ -604,6 +608,8 @@ class SettingsViewModel @Inject constructor( traktUsername = null, isMdbListConnected = isMdbList, mdbListUsername = null, + isSimklConnected = isSimkl, + simklUsername = null, lastSyncTime = null, syncedMovies = 0, syncedEpisodes = 0, @@ -627,7 +633,7 @@ class SettingsViewModel @Inject constructor( smoothScrolling = smoothScrolling ) - refreshIntegrationUsernames(loadProfileId, isTrakt, isMdbList) + refreshIntegrationUsernames(loadProfileId, isTrakt, isMdbList, isSimkl) if (isTrakt) refreshSyncSummary(loadProfileId) } } @@ -635,7 +641,8 @@ class SettingsViewModel @Inject constructor( private fun refreshIntegrationUsernames( profileId: String, isTraktConnected: Boolean, - isMdbListConnected: Boolean + isMdbListConnected: Boolean, + isSimklConnected: Boolean = false ) { integrationMetadataJob?.cancel() integrationMetadataJob = viewModelScope.launch { @@ -674,6 +681,24 @@ class SettingsViewModel @Inject constructor( } } } + + if (isSimklConnected) { + launch { + val username = try { + withTimeoutOrNull(5_000L) { simklAuthManager.fetchUsername() } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + if ( + profileManager.getProfileIdSync() == profileId && + _uiState.value.isSimklConnected + ) { + _uiState.value = _uiState.value.copy(simklUsername = username) + } + } + } } } @@ -3511,7 +3536,8 @@ class SettingsViewModel @Inject constructor( // ========== Simkl Authentication ========== fun startSimklAuth() { - viewModelScope.launch { + simklPollingJob?.cancel() + simklPollingJob = viewModelScope.launch { _uiState.value = _uiState.value.copy(isSimklAuthStarting = true) runCatching { val pinRes = simklAuthManager.startPinAuth() @@ -3521,9 +3547,14 @@ class SettingsViewModel @Inject constructor( simklUserCode = pinRes.userCode, simklVerificationUrl = pinRes.verificationUrl ) + startSimklPolling(pinRes.userCode, pinRes.expiresIn, pinRes.interval) }.onFailure { e -> + if (e is CancellationException) throw e _uiState.value = _uiState.value.copy( isSimklAuthStarting = false, + isSimklPolling = false, + simklUserCode = null, + simklVerificationUrl = null, toastMessage = "Simkl Auth Error: ${e.message}", toastType = ToastType.ERROR ) @@ -3531,12 +3562,56 @@ class SettingsViewModel @Inject constructor( } } + private fun startSimklPolling(userCode: String, expiresInSec: Int, intervalSec: Int) { + simklPollingJob?.cancel() + simklPollingJob = viewModelScope.launch { + val expiresAt = System.currentTimeMillis() + (expiresInSec * 1000L) + val pollDelayMs = intervalSec.coerceAtLeast(3) * 1000L + + while (System.currentTimeMillis() < expiresAt) { + delay(pollDelayMs) + try { + val success = simklAuthManager.pollPinAuth(userCode) + if (success) { + syncProviderStore.setProvider(com.arflix.tv.data.repository.sync.SyncProvider.SIMKL) + _uiState.value = _uiState.value.copy( + isSimklPolling = false, + isSimklConnected = true, + simklUserCode = null, + simklVerificationUrl = null, + toastMessage = "Connected to Simkl!", + toastType = ToastType.SUCCESS + ) + refreshIntegrationUsernames( + profileManager.getProfileIdSync(), + isTraktConnected = _uiState.value.isTraktAuthenticated, + isMdbListConnected = _uiState.value.isMdbListConnected, + isSimklConnected = true + ) + return@launch + } + } catch (e: Exception) { + if (e is CancellationException) throw e + AppLogger.e("SettingsViewModel", "Simkl polling error: ${e.message}") + } + } + _uiState.value = _uiState.value.copy( + isSimklPolling = false, + simklUserCode = null, + simklVerificationUrl = null, + toastMessage = "Simkl authentication timed out", + toastType = ToastType.ERROR + ) + } + } + fun pollSimklAuth() { val userCode = _uiState.value.simklUserCode ?: return viewModelScope.launch { runCatching { val success = simklAuthManager.pollPinAuth(userCode) if (success) { + syncProviderStore.setProvider(com.arflix.tv.data.repository.sync.SyncProvider.SIMKL) _uiState.value = _uiState.value.copy( isSimklPolling = false, isSimklConnected = true, @@ -3545,12 +3620,20 @@ class SettingsViewModel @Inject constructor( toastMessage = "Connected to Simkl!", toastType = ToastType.SUCCESS ) + refreshIntegrationUsernames( + profileManager.getProfileIdSync(), + isTraktConnected = _uiState.value.isTraktAuthenticated, + isMdbListConnected = _uiState.value.isMdbListConnected, + isSimklConnected = true + ) } } } } fun disconnectSimkl() { + simklPollingJob?.cancel() + simklPollingJob = null viewModelScope.launch { simklAuthManager.disconnect() _uiState.value = _uiState.value.copy( @@ -3558,6 +3641,7 @@ class SettingsViewModel @Inject constructor( isSimklPolling = false, simklUserCode = null, simklVerificationUrl = null, + simklUsername = null, toastMessage = "Disconnected from Simkl", toastType = ToastType.SUCCESS ) diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/simkl/SimklIntegrationTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/simkl/SimklIntegrationTest.kt index e81f2caef..0284c517c 100644 --- a/app/src/test/kotlin/com/arflix/tv/data/repository/simkl/SimklIntegrationTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/simkl/SimklIntegrationTest.kt @@ -3,18 +3,16 @@ package com.arflix.tv.data.repository.simkl import com.arflix.tv.data.api.SimklApi import com.arflix.tv.data.api.SimklPinPollResponse import com.arflix.tv.data.api.SimklPinResponse -import com.arflix.tv.data.model.MediaType -import com.arflix.tv.data.repository.sync.SyncProvider import com.arflix.tv.data.repository.sync.SyncProviderStore +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -import org.mockito.ArgumentMatchers.anyString -import org.mockito.Mockito.`when` -import org.mockito.Mockito.mock class SimklIntegrationTest { @@ -26,8 +24,8 @@ class SimklIntegrationTest { @Before fun setUp() { - simklApi = mock(SimklApi::class.java) - syncProviderStore = mock(SyncProviderStore::class.java) + simklApi = mockk(relaxed = true) + syncProviderStore = mockk(relaxed = true) authManager = SimklAuthManager(simklApi, syncProviderStore) scrobbler = SimklScrobbler(simklApi, authManager) syncService = SimklSyncService(simklApi, authManager) @@ -40,7 +38,7 @@ class SimklIntegrationTest { verificationUrl = "https://simkl.com/pin", expiresIn = 600 ) - `when`(simklApi.getPinCode(anyString())).thenReturn(expected) + coEvery { simklApi.getPinCode(any()) } returns expected val result = authManager.startPinAuth() assertEquals("SIMKL-123", result.userCode) @@ -53,16 +51,54 @@ class SimklIntegrationTest { result = "OK", accessToken = "token_abc123" ) - `when`(simklApi.pollPinToken(anyString(), anyString())).thenReturn(pollRes) + coEvery { simklApi.pollPinToken(any(), any()) } returns pollRes val success = authManager.pollPinAuth("SIMKL-123") assertTrue(success) + coVerify { syncProviderStore.setSimklAccessToken("token_abc123") } } @Test fun testDisconnectClearsToken() = runBlocking { authManager.disconnect() - `when`(syncProviderStore.getSimklAccessToken()).thenReturn(null) + coEvery { syncProviderStore.getSimklAccessToken() } returns null assertFalse(authManager.isConnected()) + coVerify { syncProviderStore.setSimklAccessToken(null) } + } + + @Test + fun testAddToWatchlistCallsAddToList() = runBlocking { + coEvery { syncProviderStore.getSimklAccessToken() } returns "token_123" + coEvery { simklApi.addToList(any(), any(), any()) } returns retrofit2.Response.success( + com.arflix.tv.data.api.SimklSyncResponse() + ) + + val success = syncService.addToWatchlist(com.arflix.tv.data.model.MediaType.MOVIE, 12345) + assertTrue(success) + coVerify { simklApi.addToList("Bearer token_123", any(), any()) } + } + + @Test + fun testRemoveFromWatchlistCallsDeleteFromList() = runBlocking { + coEvery { syncProviderStore.getSimklAccessToken() } returns "token_123" + coEvery { simklApi.deleteFromList(any(), any(), any()) } returns retrofit2.Response.success( + com.arflix.tv.data.api.SimklSyncResponse() + ) + + val success = syncService.removeFromWatchlist(com.arflix.tv.data.model.MediaType.MOVIE, 12345) + assertTrue(success) + coVerify { simklApi.deleteFromList("Bearer token_123", any(), any()) } + } + + @Test + fun testMarkUnwatchedCallsRemoveFromHistory() = runBlocking { + coEvery { syncProviderStore.getSimklAccessToken() } returns "token_123" + coEvery { simklApi.removeFromHistory(any(), any(), any()) } returns retrofit2.Response.success( + com.arflix.tv.data.api.SimklSyncResponse() + ) + + val success = syncService.markUnwatched(com.arflix.tv.data.model.MediaType.MOVIE, 12345) + assertTrue(success) + coVerify { simklApi.removeFromHistory("Bearer token_123", any(), any()) } } } From 23304f715902e7050d13ed4e9254936fea857f80 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Wed, 12 Aug 2026 14:08:57 +0530 Subject: [PATCH 7/7] fix(simkl): align add-to-list and history-remove payloads with official API specification --- .../kotlin/com/arflix/tv/data/api/SimklApi.kt | 19 ++------ .../data/repository/simkl/SimklSyncService.kt | 48 ++++++++++++++----- .../tv/ui/screens/details/DetailsViewModel.kt | 3 ++ .../repository/simkl/SimklIntegrationTest.kt | 12 ++--- 4 files changed, 49 insertions(+), 33 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/api/SimklApi.kt b/app/src/main/kotlin/com/arflix/tv/data/api/SimklApi.kt index 365de285c..062836af6 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/api/SimklApi.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/api/SimklApi.kt @@ -1,6 +1,7 @@ package com.arflix.tv.data.api import com.google.gson.annotations.SerializedName +import okhttp3.ResponseBody import retrofit2.Response import retrofit2.http.Body import retrofit2.http.GET @@ -81,28 +82,21 @@ interface SimklApi { @Header("simkl-api-key") clientId: String, @Body body: SimklSyncHistoryBody, @Query("allow_rewatch") allowRewatch: String? = null - ): Response + ): Response @POST("sync/history/remove") suspend fun removeFromHistory( @Header("Authorization") auth: String, @Header("simkl-api-key") clientId: String, @Body body: SimklSyncHistoryBody - ): Response + ): Response @POST("sync/add-to-list") suspend fun addToList( @Header("Authorization") auth: String, @Header("simkl-api-key") clientId: String, @Body body: SimklAddToListBody - ): Response - - @POST("sync/delete-from-list") - suspend fun deleteFromList( - @Header("Authorization") auth: String, - @Header("simkl-api-key") clientId: String, - @Body body: SimklDeleteFromListBody - ): Response + ): Response } // Data Transfer Objects @@ -229,11 +223,6 @@ data class SimklAddToListBody( @SerializedName("shows") val shows: List? = null ) -data class SimklDeleteFromListBody( - @SerializedName("movies") val movies: List? = null, - @SerializedName("shows") val shows: List? = null -) - data class SimklSyncResponse( @SerializedName("added") val added: SimklSyncCount? = null, @SerializedName("deleted") val deleted: SimklSyncCount? = null, diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt index 8d7b09204..98ffbb6e1 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt @@ -1,11 +1,8 @@ package com.arflix.tv.data.repository.simkl import com.arflix.tv.data.api.SimklAddToListBody -import com.arflix.tv.data.api.SimklAddToListMovie -import com.arflix.tv.data.api.SimklAddToListShow import com.arflix.tv.data.api.SimklAllItemsResponse import com.arflix.tv.data.api.SimklApi -import com.arflix.tv.data.api.SimklDeleteFromListBody import com.arflix.tv.data.api.SimklEpisodeRef import com.arflix.tv.data.api.SimklIds import com.arflix.tv.data.api.SimklMovieRef @@ -96,7 +93,7 @@ class SimklSyncService @Inject constructor( response.movies?.forEach { movieItem -> val tmdbId = movieItem.movie?.ids?.tmdb ?: return@forEach val status = movieItem.status - if (status == "completed" || status == "watching") { + if (status == "completed" || status == "watching" || !movieItem.lastWatchedAt.isNullOrBlank()) { cachedWatchedMovies.add(tmdbId) } if (status == "plantowatch") { @@ -152,12 +149,12 @@ class SimklSyncService @Inject constructor( val token = authManager.getAccessToken() ?: return false val authHeader = "Bearer $token" val body = if (mediaType == MediaType.MOVIE) { - SimklAddToListBody( - movies = listOf(SimklAddToListMovie(to = "plantowatch", ids = SimklIds(tmdb = tmdbId))) + com.arflix.tv.data.api.SimklAddToListBody( + movies = listOf(com.arflix.tv.data.api.SimklAddToListMovie(to = "plantowatch", ids = SimklIds(tmdb = tmdbId))) ) } else { - SimklAddToListBody( - shows = listOf(SimklAddToListShow(to = "plantowatch", ids = SimklIds(tmdb = tmdbId))) + com.arflix.tv.data.api.SimklAddToListBody( + shows = listOf(com.arflix.tv.data.api.SimklAddToListShow(to = "plantowatch", ids = SimklIds(tmdb = tmdbId))) ) } return try { @@ -167,6 +164,7 @@ class SimklSyncService @Inject constructor( lastActivityCheckTime = 0L // Request activity refresh on next check true } else { + AppLogger.e("SimklSyncService", "Failed adding to watchlist: code=${res.code()} msg=${res.message()}") false } } catch (e: Exception) { @@ -178,18 +176,42 @@ class SimklSyncService @Inject constructor( suspend fun removeFromWatchlist(mediaType: MediaType, tmdbId: Int): Boolean { val token = authManager.getAccessToken() ?: return false val authHeader = "Bearer $token" - val body = if (mediaType == MediaType.MOVIE) { - SimklDeleteFromListBody(movies = listOf(SimklMovieRef(ids = SimklIds(tmdb = tmdbId)))) + + val isWatched = if (mediaType == MediaType.MOVIE) { + cachedWatchedMovies.contains(tmdbId) } else { - SimklDeleteFromListBody(shows = listOf(SimklShowRef(ids = SimklIds(tmdb = tmdbId)))) + cachedWatchedEpisodes.any { it.startsWith("${tmdbId}_") } } + return try { - val res = simklApi.deleteFromList(authHeader, clientId, body) + val res = if (isWatched) { + // If it was already watched, restore its status to "completed" so watch history is preserved + val body = if (mediaType == MediaType.MOVIE) { + com.arflix.tv.data.api.SimklAddToListBody( + movies = listOf(com.arflix.tv.data.api.SimklAddToListMovie(to = "completed", ids = SimklIds(tmdb = tmdbId))) + ) + } else { + com.arflix.tv.data.api.SimklAddToListBody( + shows = listOf(com.arflix.tv.data.api.SimklAddToListShow(to = "completed", ids = SimklIds(tmdb = tmdbId))) + ) + } + simklApi.addToList(authHeader, clientId, body) + } else { + // Not watched, remove item completely from Simkl library + val body = if (mediaType == MediaType.MOVIE) { + SimklSyncHistoryBody(movies = listOf(SimklMovieRef(ids = SimklIds(tmdb = tmdbId)))) + } else { + SimklSyncHistoryBody(shows = listOf(SimklShowRef(ids = SimklIds(tmdb = tmdbId)))) + } + simklApi.removeFromHistory(authHeader, clientId, body) + } + if (res.isSuccessful) { cachedWatchlist.remove(mediaType to tmdbId) lastActivityCheckTime = 0L true } else { + AppLogger.e("SimklSyncService", "Failed removing from watchlist: code=${res.code()} msg=${res.message()}") false } } catch (e: Exception) { @@ -226,6 +248,7 @@ class SimklSyncService @Inject constructor( lastActivityCheckTime = 0L true } else { + AppLogger.e("SimklSyncService", "Failed marking watched: code=${res.code()} msg=${res.message()}") false } } catch (e: Exception) { @@ -262,6 +285,7 @@ class SimklSyncService @Inject constructor( lastActivityCheckTime = 0L true } else { + AppLogger.e("SimklSyncService", "Failed marking unwatched: code=${res.code()} msg=${res.message()}") false } } catch (e: Exception) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt index f3df21dfa..6c30b9144 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt @@ -28,6 +28,7 @@ import com.arflix.tv.data.repository.providerScopedStreamIdentity import com.arflix.tv.data.repository.TraktRepository import com.arflix.tv.data.repository.WatchHistoryRepository import com.arflix.tv.data.repository.WatchlistRepository +import com.arflix.tv.util.AppLogger import com.arflix.tv.util.Constants import com.arflix.tv.util.settingsDataStore import dagger.hilt.android.lifecycle.HiltViewModel @@ -1076,6 +1077,8 @@ class DetailsViewModel @Inject constructor( toastType = ToastType.SUCCESS ) } catch (e: Exception) { + Log.e("DetailsViewModel", "Error updating watchlist: ${e.message}", e) + AppLogger.e("DetailsViewModel", "Error updating watchlist: ${e.message}", e) _uiState.value = _uiState.value.copy( toastMessage = context.getString(R.string.details_failed_update_watchlist), toastType = ToastType.ERROR diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/simkl/SimklIntegrationTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/simkl/SimklIntegrationTest.kt index 0284c517c..0ba4aa019 100644 --- a/app/src/test/kotlin/com/arflix/tv/data/repository/simkl/SimklIntegrationTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/simkl/SimklIntegrationTest.kt @@ -70,7 +70,7 @@ class SimklIntegrationTest { fun testAddToWatchlistCallsAddToList() = runBlocking { coEvery { syncProviderStore.getSimklAccessToken() } returns "token_123" coEvery { simklApi.addToList(any(), any(), any()) } returns retrofit2.Response.success( - com.arflix.tv.data.api.SimklSyncResponse() + mockk() ) val success = syncService.addToWatchlist(com.arflix.tv.data.model.MediaType.MOVIE, 12345) @@ -79,22 +79,22 @@ class SimklIntegrationTest { } @Test - fun testRemoveFromWatchlistCallsDeleteFromList() = runBlocking { + fun testRemoveFromWatchlistCallsRemoveFromHistory() = runBlocking { coEvery { syncProviderStore.getSimklAccessToken() } returns "token_123" - coEvery { simklApi.deleteFromList(any(), any(), any()) } returns retrofit2.Response.success( - com.arflix.tv.data.api.SimklSyncResponse() + coEvery { simklApi.removeFromHistory(any(), any(), any()) } returns retrofit2.Response.success( + mockk() ) val success = syncService.removeFromWatchlist(com.arflix.tv.data.model.MediaType.MOVIE, 12345) assertTrue(success) - coVerify { simklApi.deleteFromList("Bearer token_123", any(), any()) } + coVerify { simklApi.removeFromHistory("Bearer token_123", any(), any()) } } @Test fun testMarkUnwatchedCallsRemoveFromHistory() = runBlocking { coEvery { syncProviderStore.getSimklAccessToken() } returns "token_123" coEvery { simklApi.removeFromHistory(any(), any(), any()) } returns retrofit2.Response.success( - com.arflix.tv.data.api.SimklSyncResponse() + mockk() ) val success = syncService.markUnwatched(com.arflix.tv.data.model.MediaType.MOVIE, 12345)