From 1b75ca6ace40e043ae01445ba5a3608cceaacfe3 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Fri, 8 Aug 2025 01:22:22 +0200 Subject: [PATCH 001/790] Feat: Initial setup for trakt, login only --- .../cloudstream3/syncproviders/AuthAPI.kt | 7 +- .../syncproviders/providers/TraktApi.kt | 249 ++++++++++++++++++ .../ui/settings/SettingsAccount.kt | 2 + app/src/main/res/drawable/trakt.xml | 19 ++ app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/settings_account.xml | 4 + .../com/lagradost/cloudstream3/MainAPI.kt | 6 +- .../metaproviders/TraktProvider.kt | 3 + 8 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/TraktApi.kt create mode 100644 app/src/main/res/drawable/trakt.xml diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt index 53309b604..6ac3aad29 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt @@ -35,6 +35,7 @@ import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi import com.lagradost.cloudstream3.syncproviders.providers.SimklApi import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi +import com.lagradost.cloudstream3.syncproviders.providers.TraktApi import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery @@ -276,7 +277,7 @@ abstract class SyncAPI : AuthAPI() { open var requireLibraryRefresh: Boolean = true open val mainUrl: String = "NONE" - /** Currently unused, but will be used to correctly render the UI. + /** Currently unused, but will be used to correctly render the UI. * This should specify what sync watch types can be used with this service. */ open val supportedWatchTypes: Set = SyncWatchType.entries.toSet() /** @@ -732,6 +733,7 @@ abstract class AccountManager { val malApi = MALApi() val aniListApi = AniListApi() val simklApi = SimklApi() + val traktApi = TraktApi() val localListApi = LocalList() val openSubtitlesApi = OpenSubtitlesApi() @@ -773,6 +775,7 @@ abstract class AccountManager { SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(simklApi), + SyncRepo(traktApi), SyncRepo(localListApi), SubtitleRepo(openSubtitlesApi), @@ -822,6 +825,7 @@ abstract class AccountManager { LoadResponse.malIdPrefix = malApi.idPrefix LoadResponse.aniListIdPrefix = aniListApi.idPrefix LoadResponse.simklIdPrefix = simklApi.idPrefix + LoadResponse.traktIdPrefix = traktApi.idPrefix } val subtitleProviders = arrayOf( @@ -834,6 +838,7 @@ abstract class AccountManager { SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(simklApi), + SyncRepo(traktApi), SyncRepo(localListApi) ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/TraktApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/TraktApi.kt new file mode 100644 index 000000000..339a58858 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/TraktApi.kt @@ -0,0 +1,249 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.syncproviders.AuthLoginPage +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.ui.SyncWatchType + +/* https://trakt.docs.apiary.io */ +class TraktApi : SyncAPI() { + override val name = "Trakt" + override val idPrefix = "trakt" + + override val mainUrl = "https://trakt.tv" + val api = "https://api.trakt.tv" + + override val supportedWatchTypes: Set = emptySet() + + override val icon = R.drawable.trakt + override val hasOAuth2 = true + override val redirectUrlIdentifier = "NONE" + val redirectUri = "cloudstreamapp://$redirectUrlIdentifier" + + companion object { + val id: String get() = throw NotImplementedError() + val secret: String get() = throw NotImplementedError() + + fun getHeaders(token: AuthToken) = mapOf( + "Authorization" to "Bearer ${token.accessToken}", + "Content-Type" to "application/json", + "trakt-api-version" to "2", + "trakt-api-key" to id, + ) + } + + data class TokenRoot( + @JsonProperty("access_token") + val accessToken: String, + @JsonProperty("token_type") + val tokenType: String, + @JsonProperty("expires_in") + val expiresIn: Long, + @JsonProperty("refresh_token") + val refreshToken: String, + @JsonProperty("scope") + val scope: String, + @JsonProperty("created_at") + val createdAt: Long, + ) + + data class UserRoot( + @JsonProperty("username") + val username: String, + @JsonProperty("private") + val private: Boolean?, + @JsonProperty("name") + val name: String, + @JsonProperty("vip") + val vip: Boolean?, + @JsonProperty("vip_ep") + val vipEp: Boolean?, + @JsonProperty("ids") + val ids: Ids?, + @JsonProperty("joined_at") + val joinedAt: String?, + @JsonProperty("location") + val location: String?, + @JsonProperty("about") + val about: String?, + @JsonProperty("gender") + val gender: String?, + @JsonProperty("age") + val age: Long?, + @JsonProperty("images") + val images: Images?, + ) { + data class Ids( + @JsonProperty("slug") + val slug: String, + ) + + data class Images( + @JsonProperty("avatar") + val avatar: Avatar, + ) + + data class Avatar( + @JsonProperty("full") + val full: String, + ) + } + + + override suspend fun user(token: AuthToken?): AuthUser? { + if (token == null) return null + // https://trakt.docs.apiary.io/#reference/users/profile/get-user-profile + + val userData = app.get( + "$api/users/me?extended=full", headers = getHeaders(token) + ).parsed() + + return AuthUser( + name = userData.name, + id = userData.username.hashCode(), + profilePicture = userData.images?.avatar?.full + ) + } + + override suspend fun login(redirectUrl: String, payload: String?): AuthToken? { + val sanitizer = + splitRedirectUrl(redirectUrl) + + if (sanitizer["state"] != payload) { + return null + } + + // https://trakt.docs.apiary.io/#reference/authentication-oauth/get-token/exchange-code-for-access_token + val tokenData = app.post( + "$api/oauth/token", + json = mapOf( + "code" to (sanitizer["code"] ?: throw ErrorLoadingException("No code")), + "client_id" to id, + "client_secret" to secret, + "redirect_uri" to redirectUri, + "grant_type" to "authorization_code" + ) + ).parsed() + + return AuthToken( + accessToken = tokenData.accessToken, + refreshToken = tokenData.refreshToken, + accessTokenLifetime = unixTime + tokenData.expiresIn + ) + } + + override suspend fun refreshToken(token: AuthToken): AuthToken? { + // https://trakt.docs.apiary.io/#reference/authentication-oauth/get-token/exchange-refresh_token-for-access_token + val tokenData = app.post( + "$api/oauth/token", + json = mapOf( + "refresh_token" to (token.refreshToken + ?: throw ErrorLoadingException("No refreshtoken")), + "client_id" to id, + "client_secret" to secret, + "redirect_uri" to redirectUri, + "grant_type" to "refresh_token", + ) + ).parsed() + + return AuthToken( + accessToken = tokenData.accessToken, + refreshToken = tokenData.refreshToken, + accessTokenLifetime = unixTime + tokenData.expiresIn + ) + } + + override fun loginRequest(): AuthLoginPage? { + // https://trakt.docs.apiary.io/#reference/authentication-oauth/authorize/authorize-application + val codeChallenge = generateCodeVerifier() + return AuthLoginPage( + "$mainUrl/oauth/authorize?client_id=$id&response_type=code&redirect_uri=$redirectUri&state=$codeChallenge", + payload = codeChallenge + ) + } + + data class RatingRoot( + @JsonProperty("rated_at") + val ratedAt: String?, + @JsonProperty("rating") + val rating: Int?, + @JsonProperty("type") + val type: String, + @JsonProperty("season") + val season: Season?, + @JsonProperty("show") + val show: Show?, + @JsonProperty("movie") + val movie: Movie?, + ) { + data class Season( + @JsonProperty("number") + val number: Long?, + @JsonProperty("ids") + val ids: Ids?, + ) + + data class Show( + @JsonProperty("title") + val title: String?, + @JsonProperty("year") + val year: Long?, + @JsonProperty("ids") + val ids: Ids?, + ) + + data class Movie( + @JsonProperty("title") + val title: String?, + @JsonProperty("year") + val year: Long?, + @JsonProperty("ids") + val ids: Ids?, + ) + + data class Ids( + @JsonProperty("trakt") + val trakt: String?, + @JsonProperty("slug") + val slug: String?, + @JsonProperty("tvdb") + val tvdb: String?, + @JsonProperty("imdb") + val imdb: String?, + @JsonProperty("tmdb") + val tmdb: String?, + ) + } + + + data class TraktSyncStatus( + override var status: SyncWatchType = SyncWatchType.NONE, + override var score: Score?, + override var watchedEpisodes: Int? = null, + override var isFavorite: Boolean? = null, + override var maxEpisodes: Int? = null, + val type: String, + ) : AbstractSyncStatus() + + override suspend fun status(token: AuthToken?, id: String): AbstractSyncStatus? { + if (token == null) return null + + val response = app.get("$api/sync/ratings/all", headers = getHeaders(token)) + .parsed>() + + // This is criminally wrong, but there is no api to get the rating directly + for (x in response) { + if (x.show?.ids?.trakt == id || x.movie?.ids?.trakt == id || x.season?.ids?.trakt == id) { + return TraktSyncStatus(score = Score.from10(x.rating), type = x.type) + } + } + + return SyncStatus(SyncWatchType.NONE, null, null, null, null) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index 20a8b943b..5571bd23f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt @@ -33,6 +33,7 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.openSubtitlesApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subDlApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.traktApi import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse import com.lagradost.cloudstream3.syncproviders.AuthRepo import com.lagradost.cloudstream3.syncproviders.AuthUser @@ -461,6 +462,7 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { R.string.mal_key to SyncRepo(malApi), R.string.anilist_key to SyncRepo(aniListApi), R.string.simkl_key to SyncRepo(simklApi), + R.string.trakt_key to SyncRepo(traktApi), R.string.opensubtitles_key to SubtitleRepo(openSubtitlesApi), R.string.subdl_key to SubtitleRepo(subDlApi), ) diff --git a/app/src/main/res/drawable/trakt.xml b/app/src/main/res/drawable/trakt.xml new file mode 100644 index 000000000..72840471e --- /dev/null +++ b/app/src/main/res/drawable/trakt.xml @@ -0,0 +1,19 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8e7f86cd1..77d3cf5bf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -513,6 +513,7 @@ mal_key opensubtitles_key subdl_key + trakt_key nginx_key password123 Username diff --git a/app/src/main/res/xml/settings_account.xml b/app/src/main/res/xml/settings_account.xml index bbef5f05b..24c09edf4 100644 --- a/app/src/main/res/xml/settings_account.xml +++ b/app/src/main/res/xml/settings_account.xml @@ -17,6 +17,10 @@ android:icon="@drawable/simkl_logo" android:key="@string/simkl_key" /> + + diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index c41189b07..aee438fe4 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -11,6 +11,7 @@ import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.json.JsonMapper import com.fasterxml.jackson.module.kotlin.kotlinModule +import com.lagradost.cloudstream3.metaproviders.TraktProvider import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.syncproviders.SyncIdName @@ -58,7 +59,7 @@ object APIHolder { get() = System.currentTimeMillis() // ConcurrentModificationException is possible!!! - val allProviders = threadSafeListOf() + val allProviders = threadSafeListOf(TraktProvider()) fun initAll() { synchronized(allProviders) { @@ -1695,6 +1696,7 @@ interface LoadResponse { var malIdPrefix = "" //malApi.idPrefix var aniListIdPrefix = "" //aniListApi.idPrefix var simklIdPrefix = "" //simklApi.idPrefix + var traktIdPrefix = "" //simklApi.idPrefix var isTrailersEnabled = true /** @@ -1890,7 +1892,7 @@ interface LoadResponse { @Suppress("UNUSED_PARAMETER") fun LoadResponse.addTraktId(id: String?) { - // TODO add Trakt sync + this.syncData[traktIdPrefix] = (id ?: return).toString() } @Suppress("UNUSED_PARAMETER") diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt index d040886fa..904725529 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt @@ -12,6 +12,7 @@ import com.lagradost.cloudstream3.LoadResponse.Companion.addImdbId import com.lagradost.cloudstream3.LoadResponse.Companion.addRating import com.lagradost.cloudstream3.LoadResponse.Companion.addTMDbId import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer +import com.lagradost.cloudstream3.LoadResponse.Companion.addTraktId import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainPageRequest import com.lagradost.cloudstream3.NextAiring @@ -192,6 +193,7 @@ open class TraktProvider : MainAPI() { addTrailer(mediaDetails.trailer) addImdbId(mediaDetails.ids?.imdb) addTMDbId(mediaDetails.ids?.tmdb.toString()) + addTraktId(mediaDetails.ids?.trakt?.toString()) } } else { @@ -281,6 +283,7 @@ open class TraktProvider : MainAPI() { addTrailer(mediaDetails.trailer) addImdbId(mediaDetails.ids?.imdb) addTMDbId(mediaDetails.ids?.tmdb.toString()) + addTraktId(mediaDetails.ids?.trakt?.toString()) } } } From 0054b58b63924de46359c1ab659e31b2cdf14c73 Mon Sep 17 00:00:00 2001 From: KingLucius Date: Fri, 8 Aug 2025 14:53:34 +0300 Subject: [PATCH 002/790] Show PIN login only on TV UI (#1814) --- .../com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index 20a8b943b..23b4087d6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt @@ -40,6 +40,7 @@ import com.lagradost.cloudstream3.syncproviders.SubtitleRepo import com.lagradost.cloudstream3.syncproviders.SyncRepo import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn @@ -382,7 +383,7 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { @UiThread fun addAccount(activity: FragmentActivity, api: AuthRepo) { try { - if (api.hasPin) { + if (api.hasPin && !isLayout(PHONE)) { showPin(activity, api) } else if (api.hasOAuth2) { api.openOAuth2PageWithToast() From df9d16a110d0cfaf007ee32a851e5892f5e95ef2 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Fri, 8 Aug 2025 18:11:13 +0200 Subject: [PATCH 003/790] Fix: Readded old tokens to nonTransferableKeys, added `invalidateToken` to AuthAPI --- .../cloudstream3/syncproviders/AuthAPI.kt | 27 ++++++++++++++++--- .../ui/settings/SettingsAccount.kt | 2 +- .../cloudstream3/utils/BackupUtils.kt | 18 ++++++++++--- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt index 53309b604..2598c5637 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt @@ -232,6 +232,15 @@ abstract class AuthAPI { @Throws open suspend fun user(token: AuthToken?): AuthUser? = throw NotImplementedError() + /** + * An optional security measure to make sure that even if an attacker gets ahold of the token, it will be invalid. + * + * Note that this will currently only be called *once* on logout, + * and as such any network issues it will fail silently, and the token will not be revoked. + **/ + @Throws + open suspend fun invalidateToken(token: AuthToken): Nothing = throw NotImplementedError() + @Throws @Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING) fun toRepo(): AuthRepo = when (this) { @@ -276,7 +285,7 @@ abstract class SyncAPI : AuthAPI() { open var requireLibraryRefresh: Boolean = true open val mainUrl: String = "NONE" - /** Currently unused, but will be used to correctly render the UI. + /** Currently unused, but will be used to correctly render the UI. * This should specify what sync watch types can be used with this service. */ open val supportedWatchTypes: Set = SyncWatchType.entries.toSet() /** @@ -528,13 +537,23 @@ abstract class AuthRepo(open val api: AuthAPI) { } } - fun logout(from: AuthUser) { + suspend fun logout(from: AuthUser) { val currentAccounts = AccountManager.accounts(idPrefix) - val newAccounts = currentAccounts.filter { it.user.id != from.id }.toTypedArray() + val (newAccounts, oldAccounts) = currentAccounts.partition { it.user.id != from.id } if (newAccounts.size < currentAccounts.size) { - AccountManager.updateAccounts(idPrefix, newAccounts) + AccountManager.updateAccounts(idPrefix, newAccounts.toTypedArray()) AccountManager.updateAccountsId(idPrefix, 0) } + + for (oldAccount in oldAccounts) { + try { + api.invalidateToken(oldAccount.token) + } catch (_ : NotImplementedError) { + // no-op + } catch (t: Throwable) { + logError(t) + } + } } fun refreshUser(newAuth: AuthData) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index 23b4087d6..f216219de 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt @@ -91,7 +91,7 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { binding.accountLogout.isVisible = info != null binding.accountLogout.setOnClickListener { if (info != null) { - api.logout(info) + ioSafe { api.logout(info) } } dialog.dismissSafe(activity) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt index 23eaaa280..3e003c7ea 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -47,7 +47,6 @@ object BackupUtils { * No sensitive or breaking data in the backup * */ private val nonTransferableKeys = listOf( - // When sharing backup we do not want to transfer what is essentially the password ANILIST_CACHED_LIST, MAL_CACHED_LIST, @@ -65,7 +64,19 @@ object BackupUtils { "download_path_key", "download_path_key_visual", "backup_path_key", - "backup_dir_path_key" + "backup_dir_path_key", + + // When sharing backup we do not want to transfer what is essentially the password + // Note that this is deprecated, and can be removed after all tokens have expired + "anilist_token", + "anilist_user", + "mal_user", + "mal_token", + "mal_refresh_token", + "mal_unixtime", + "open_subtitles_user", + "subdl_user", + "simkl_token", ) /** false if key should not be contained in backup */ @@ -283,7 +294,8 @@ object BackupUtils { fun getCurrentBackupDir(context: Context): Pair { val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) - val basePathSetting = settingsManager.getString(context.getString(R.string.backup_path_key), null) + val basePathSetting = + settingsManager.getString(context.getString(R.string.backup_path_key), null) return baseBackupPathToFile(context, basePathSetting) to basePathSetting } From eecce5b00754cf3eb2e8099627c0fbd34bf91589 Mon Sep 17 00:00:00 2001 From: rockhero1234 <149141736+rockhero1234@users.noreply.github.com> Date: Mon, 11 Aug 2025 21:52:21 +0530 Subject: [PATCH 004/790] Always show pinned providers (#1820) * always show pinned * fix --- .../java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index 4ff870fce..35c7e1271 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -415,7 +415,7 @@ class HomeFragment : Fragment() { pinnedphashset = pinnedp.toHashSet() arrayAdapter.clear() val sortedApis = validAPIs - .filter { it.hasMainPage && it.supportedTypes.any(preSelectedTypes::contains) } + .filter {it.hasMainPage && (pinnedphashset.contains(it.name) || it.supportedTypes.any(preSelectedTypes::contains)) } .sortedBy { it.name.lowercase() } val sortedApiMap = LinkedHashMap().apply { From 89dee1bd720c4bb9b65a19ea7ab9d3526c0f935d Mon Sep 17 00:00:00 2001 From: Diogo <24511782+diogob003@users.noreply.github.com> Date: Mon, 11 Aug 2025 16:42:03 -0300 Subject: [PATCH 005/790] Fix: unclosed file descriptor, bump libs min ver (#1818) * Fix: close inputPFD:ParcelFileDescriptor in AppContextUtils * Security: bump libs minor version --- .gitignore | 5 ----- .vscode/settings.json | 6 ------ .../com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt | 2 +- .../com/lagradost/cloudstream3/utils/AppContextUtils.kt | 4 +++- gradle/libs.versions.toml | 6 +++--- 5 files changed, 7 insertions(+), 16 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index a6833cddf..5fc9f0870 100644 --- a/.gitignore +++ b/.gitignore @@ -96,11 +96,6 @@ replay_pid* ### VisualStudioCode ### .vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets # Local History for Visual Studio Code .history/ diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 7282979ad..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "githubPullRequests.ignoredPullRequestBranches": [ - "master" - ], - "java.configuration.updateBuildConfiguration": "interactive" -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt index cc99b585f..7e9c39b01 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt @@ -118,4 +118,4 @@ class PlayerPipHelper { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt index 28b194531..1c196c5e0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -967,7 +967,7 @@ object AppContextUtils { //val media = medialibrary.getMedia(data.lastPathSegment!!.toLong()) uri = null//media.uri*/ } else { - val inputPFD: ParcelFileDescriptor? + var inputPFD: ParcelFileDescriptor? = null try { inputPFD = ctx.contentResolver.openFileDescriptor(data, "r") if (inputPFD == null) return data @@ -999,6 +999,8 @@ object AppContextUtils { } catch (e: SecurityException) { Log.e("TAG", "${e.message} for $data", e) return null + } finally { + inputPFD?.close() } }// Media or MMS URI } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1cbb2ef18..7a3fd9165 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] acraCore = "5.12.0" -appcompat = "1.7.0" -biometric = "1.4.0-alpha03" +appcompat = "1.7.1" +biometric = "1.4.0-alpha04" buildkonfigGradlePlugin = "0.15.2" coil = "3.1.0" colorpicker = "6b46b49bd5" @@ -12,7 +12,7 @@ desugar_jdk_libs_nio = "2.1.5" dokkaGradlePlugin = "2.0.0" espressoCore = "3.6.1" fuzzywuzzy = "1.4.0" -gradle = "8.9.2" +gradle = "8.9.3" jacksonModuleKotlin = "2.13.1" json = "20250107" junit = "4.13.2" From d103884ac0cd20da2547701e89bd0122bdf4b358 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Mon, 11 Aug 2025 21:44:29 +0200 Subject: [PATCH 006/790] Chore: Removed unused code --- .../cloudstream3/utils/AppContextUtils.kt | 115 ------------------ 1 file changed, 115 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt index 1c196c5e0..cf4e20815 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -376,25 +376,6 @@ object AppContextUtils { } } - @SuppressLint("Range") - fun getVideoContentUri(context: Context, videoFilePath: String): Uri? { - val cursor = context.contentResolver.query( - MediaStore.Video.Media.EXTERNAL_CONTENT_URI, arrayOf(MediaStore.Video.Media._ID), - MediaStore.Video.Media.DATA + "=? ", arrayOf(videoFilePath), null - ) - return if (cursor != null && cursor.moveToFirst()) { - val id = cursor.getInt(cursor.getColumnIndex(MediaStore.MediaColumns._ID)) - cursor.close() - Uri.withAppendedPath(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, "" + id) - } else { - val values = ContentValues() - values.put(MediaStore.Video.Media.DATA, videoFilePath) - context.contentResolver.insert( - MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values - ) - } - } - fun sortSubs(subs: Set): List { return subs.sortedBy { it.name } } @@ -911,102 +892,6 @@ object AppContextUtils { } } - // Copied from https://github.com/videolan/vlc-android/blob/master/application/vlc-android/src/org/videolan/vlc/util/FileUtils.kt - @SuppressLint("Range") - fun Context.getUri(data: Uri?): Uri? { - var uri = data - val ctx = this - if (data != null && data.scheme == "content") { - // Mail-based apps - download the stream to a temporary file and play it - if ("com.fsck.k9.attachmentprovider" == data.host || "gmail-ls" == data.host) { - var inputStream: InputStream? = null - var os: OutputStream? = null - var cursor: Cursor? = null - try { - cursor = ctx.contentResolver.query( - data, - arrayOf(MediaStore.MediaColumns.DISPLAY_NAME), null, null, null - ) - if (cursor != null && cursor.moveToFirst()) { - val filename = - cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME)) - .replace("/", "") - inputStream = ctx.contentResolver.openInputStream(data) - if (inputStream == null) return data - os = - FileOutputStream(Environment.getExternalStorageDirectory().path + "/Download/" + filename) - val buffer = ByteArray(1024) - var bytesRead = inputStream.read(buffer) - while (bytesRead >= 0) { - os.write(buffer, 0, bytesRead) - bytesRead = inputStream.read(buffer) - } - uri = - Uri.fromFile(File(Environment.getExternalStorageDirectory().path + "/Download/" + filename)) - } - } catch (e: Exception) { - return null - } finally { - inputStream?.close() - os?.close() - cursor?.close() - } - } else if (data.authority == "media") { - uri = this.contentResolver.query( - data, - arrayOf(MediaStore.Video.Media.DATA), null, null, null - )?.use { - val columnIndex = it.getColumnIndexOrThrow(MediaStore.Video.Media.DATA) - if (it.moveToFirst()) Uri.fromFile(File(it.getString(columnIndex))) - ?: data else data - } - //uri = MediaUtils.getContentMediaUri(data) - /*} else if (data.authority == ctx.getString(R.string.tv_provider_authority)) { - println("TV AUTHORITY") - //val medialibrary = Medialibrary.getInstance() - //val media = medialibrary.getMedia(data.lastPathSegment!!.toLong()) - uri = null//media.uri*/ - } else { - var inputPFD: ParcelFileDescriptor? = null - try { - inputPFD = ctx.contentResolver.openFileDescriptor(data, "r") - if (inputPFD == null) return data - uri = Uri.parse("fd://" + inputPFD.fd) - // Cursor returnCursor = - // getContentResolver().query(data, null, null, null, null); - // if (returnCursor != null) { - // if (returnCursor.getCount() > 0) { - // int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); - // if (nameIndex > -1) { - // returnCursor.moveToFirst(); - // title = returnCursor.getString(nameIndex); - // } - // } - // returnCursor.close(); - // } - } catch (e: FileNotFoundException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } catch (e: IllegalArgumentException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } catch (e: IllegalStateException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } catch (e: NullPointerException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } catch (e: SecurityException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } finally { - inputPFD?.close() - } - }// Media or MMS URI - } - return uri - } - fun Context.isUsingMobileData(): Boolean { val connectionManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { From 4169fd5ae474d9f8c38f1364588238e2943007cc Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Tue, 12 Aug 2025 00:51:29 +0200 Subject: [PATCH 007/790] Fix: Migrate to authdata for better set/get key security, split up auth into files, added preliminary BackupAPI, use Result instead of Resource. (#1822) --- .../syncproviders/AccountManager.kt | 158 +++++ .../cloudstream3/syncproviders/AuthAPI.kt | 620 +----------------- .../cloudstream3/syncproviders/AuthRepo.kt | 165 +++++ .../cloudstream3/syncproviders/BackupAPI.kt | 14 + .../cloudstream3/syncproviders/SubtitleAPI.kt | 37 ++ .../syncproviders/SubtitleRepo.kt | 89 +++ .../cloudstream3/syncproviders/SyncAPI.kt | 194 ++++++ .../cloudstream3/syncproviders/SyncRepo.kt | 30 + .../syncproviders/providers/Addic7ed.kt | 10 +- .../syncproviders/providers/AniListApi.kt | 62 +- .../syncproviders/providers/LocalList.kt | 10 +- .../syncproviders/providers/MALApi.kt | 29 +- .../providers/OpenSubtitlesApi.kt | 12 +- .../syncproviders/providers/SimklApi.kt | 52 +- .../syncproviders/providers/SubSource.kt | 12 +- .../syncproviders/providers/Subdl.kt | 17 +- .../cloudstream3/ui/player/GeneratorPlayer.kt | 27 +- .../cloudstream3/mvvm/ArchComponentExt.kt | 11 + 18 files changed, 819 insertions(+), 730 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt new file mode 100644 index 000000000..20a0b6446 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -0,0 +1,158 @@ +package com.lagradost.cloudstream3.syncproviders + +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed +import com.lagradost.cloudstream3.syncproviders.providers.AniListApi +import com.lagradost.cloudstream3.syncproviders.providers.LocalList +import com.lagradost.cloudstream3.syncproviders.providers.MALApi +import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi +import com.lagradost.cloudstream3.syncproviders.providers.SimklApi +import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi +import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi +import com.lagradost.cloudstream3.utils.DataStoreHelper +import java.util.concurrent.TimeUnit + +abstract class AccountManager { + companion object { + const val NONE_ID: Int = -1 + val malApi = MALApi() + val aniListApi = AniListApi() + val simklApi = SimklApi() + val localListApi = LocalList() + + val openSubtitlesApi = OpenSubtitlesApi() + val addic7ed = Addic7ed() + val subDlApi = SubDlApi() + val subSourceApi = SubSourceApi() + + var cachedAccounts: MutableMap> + var cachedAccountIds: MutableMap + + const val ACCOUNT_TOKEN = "auth_tokens" + const val ACCOUNT_IDS = "auth_ids" + + fun accounts(prefix: String): Array { + require(prefix != "NONE") + return getKey>( + ACCOUNT_TOKEN, + "${prefix}/${DataStoreHelper.currentAccount}" + ) ?: arrayOf() + } + + fun updateAccounts(prefix: String, array: Array) { + require(prefix != "NONE") + setKey(ACCOUNT_TOKEN, "${prefix}/${DataStoreHelper.currentAccount}", array) + synchronized(cachedAccounts) { + cachedAccounts[prefix] = array + } + } + + fun updateAccountsId(prefix: String, id: Int) { + require(prefix != "NONE") + setKey(ACCOUNT_IDS, "${prefix}/${DataStoreHelper.currentAccount}", id) + synchronized(cachedAccountIds) { + cachedAccountIds[prefix] = id + } + } + + val allApis = arrayOf( + SyncRepo(malApi), + SyncRepo(aniListApi), + SyncRepo(simklApi), + SyncRepo(localListApi), + + SubtitleRepo(openSubtitlesApi), + SubtitleRepo(addic7ed), + SubtitleRepo(subDlApi), + SubtitleRepo(subSourceApi) + ) + + fun updateAccountIds() { + val ids = mutableMapOf() + for (api in allApis) { + ids.put( + api.idPrefix, + getKey( + ACCOUNT_IDS, + "${api.idPrefix}/${DataStoreHelper.currentAccount}", + NONE_ID + ) ?: NONE_ID + ) + } + synchronized(cachedAccountIds) { + cachedAccountIds = ids + } + } + + init { + val data = mutableMapOf>() + val ids = mutableMapOf() + for (api in allApis) { + data.put(api.idPrefix, accounts(api.idPrefix)) + ids.put( + api.idPrefix, + getKey( + ACCOUNT_IDS, + "${api.idPrefix}/${DataStoreHelper.currentAccount}", + NONE_ID + ) ?: NONE_ID + ) + } + cachedAccounts = data + cachedAccountIds = ids + } + + // I do not want to place this in the init block as JVM initialization order is weird, and it may cause exceptions + // accessing other classes + fun initMainAPI() { + LoadResponse.malIdPrefix = malApi.idPrefix + LoadResponse.aniListIdPrefix = aniListApi.idPrefix + LoadResponse.simklIdPrefix = simklApi.idPrefix + } + + val subtitleProviders = arrayOf( + SubtitleRepo(openSubtitlesApi), + SubtitleRepo(addic7ed), + SubtitleRepo(subDlApi), + SubtitleRepo(subSourceApi) + ) + val syncApis = arrayOf( + SyncRepo(malApi), + SyncRepo(aniListApi), + SyncRepo(simklApi), + SyncRepo(localListApi) + ) + + const val APP_STRING = "cloudstreamapp" + const val APP_STRING_REPO = "cloudstreamrepo" + const val APP_STRING_PLAYER = "cloudstreamplayer" + + // Instantly start the search given a query + const val APP_STRING_SEARCH = "cloudstreamsearch" + + // Instantly resume watching a show + const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching" + + fun secondsToReadable(seconds: Int, completedValue: String): String { + var secondsLong = seconds.toLong() + val days = TimeUnit.SECONDS + .toDays(secondsLong) + secondsLong -= TimeUnit.DAYS.toSeconds(days) + + val hours = TimeUnit.SECONDS + .toHours(secondsLong) + secondsLong -= TimeUnit.HOURS.toSeconds(hours) + + val minutes = TimeUnit.SECONDS + .toMinutes(secondsLong) + secondsLong -= TimeUnit.MINUTES.toSeconds(minutes) + if (minutes < 0) { + return completedValue + } + //println("$days $hours $minutes") + return "${if (days != 0L) "$days" + "d " else ""}${if (hours != 0L) "$hours" + "h " else ""}${minutes}m" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt index 2598c5637..457efce99 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt @@ -105,6 +105,14 @@ data class AuthUser( val profilePictureHeaders: Map? = null ) +/** + * Stores all information that should be used to authorize access. + * Be aware that token and user may change independently when a refresh is needed, + * and as such there should be no strong pairing between the two. + * + * Any local set/get key should use user.id.toString(), + * as token.accessToken (even hashed) is unsecure, and will rotate. + * */ data class AuthData( @JsonProperty("user") val user: AuthUser, @@ -273,618 +281,6 @@ abstract class AuthAPI { ) } -/** - * Stateless synchronization class, used for syncing status about a specific movie/show. - * - * All non-null `AuthToken` will be non-expired when each function is called. - */ -abstract class SyncAPI : AuthAPI() { - /** - * Set this to true if the user updates something on the list like watch status or score - **/ - open var requireLibraryRefresh: Boolean = true - open val mainUrl: String = "NONE" - /** Currently unused, but will be used to correctly render the UI. - * This should specify what sync watch types can be used with this service. */ - open val supportedWatchTypes: Set = SyncWatchType.entries.toSet() - /** - * Allows certain providers to open pages from - * library links. - **/ - open val syncIdName: SyncIdName? = null - /** Modify the current status of an item */ - @Throws - @WorkerThread - open suspend fun updateStatus( - token: AuthToken?, - id: String, - newStatus: AbstractSyncStatus - ): Boolean = throw NotImplementedError() - - /** Get the current status of an item */ - @Throws - @WorkerThread - open suspend fun status(token: AuthToken?, id: String): AbstractSyncStatus? = - throw NotImplementedError() - - /** Get metadata about an item */ - @Throws - @WorkerThread - open suspend fun load(token: AuthToken?, id: String): SyncResult? = throw NotImplementedError() - - /** Search this service for any results for a given query */ - @Throws - @WorkerThread - open suspend fun search(token: AuthToken?, query: String): List? = - throw NotImplementedError() - - /** Get the current library/bookmarks of this service */ - @Throws - @WorkerThread - open suspend fun library(token: AuthToken?): LibraryMetadata? = throw NotImplementedError() - - /** Helper function, may be used in the future */ - @Throws - open fun urlToId(url: String): String? = null - - data class SyncSearchResult( - override val name: String, - override val apiName: String, - var syncId: String, - override val url: String, - override var posterUrl: String?, - override var type: TvType? = null, - override var quality: SearchQuality? = null, - override var posterHeaders: Map? = null, - override var id: Int? = null, - override var score: Score? = null, - ) : SearchResponse - - abstract class AbstractSyncStatus { - abstract var status: SyncWatchType - abstract var score: Score? - abstract var watchedEpisodes: Int? - abstract var isFavorite: Boolean? - abstract var maxEpisodes: Int? - } - - data class SyncStatus( - override var status: SyncWatchType, - override var score: Score?, - override var watchedEpisodes: Int?, - override var isFavorite: Boolean? = null, - override var maxEpisodes: Int? = null, - ) : AbstractSyncStatus() - - data class SyncResult( - /**Used to verify*/ - var id: String, - - var totalEpisodes: Int? = null, - - var title: String? = null, - var publicScore: Score? = null, - /**In minutes*/ - var duration: Int? = null, - var synopsis: String? = null, - var airStatus: ShowStatus? = null, - var nextAiring: NextAiring? = null, - var studio: List? = null, - var genres: List? = null, - var synonyms: List? = null, - var trailers: List? = null, - var isAdult: Boolean? = null, - var posterUrl: String? = null, - var backgroundPosterUrl: String? = null, - - /** In unixtime */ - var startDate: Long? = null, - /** In unixtime */ - var endDate: Long? = null, - var recommendations: List? = null, - var nextSeason: SyncSearchResult? = null, - var prevSeason: SyncSearchResult? = null, - var actors: List? = null, - ) - - data class Page( - val title: UiText, var items: List - ) { - fun sort(method: ListSorting?, query: String? = null) { - items = when (method) { - ListSorting.Query -> - if (query != null) { - items.sortedBy { - -FuzzySearch.partialRatio( - query.lowercase(), it.name.lowercase() - ) - } - } else items - - ListSorting.RatingHigh -> items.sortedBy { -(it.personalRating?.toInt(100) ?: 0) } - ListSorting.RatingLow -> items.sortedBy { (it.personalRating?.toInt(100) ?: 0) } - ListSorting.AlphabeticalA -> items.sortedBy { it.name } - ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed() - ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) } - ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime } - ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate } - ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate } - else -> items - } - } - } - - data class LibraryMetadata( - val allLibraryLists: List, - val supportedListSorting: Set - ) - - data class LibraryList( - val name: UiText, - val items: List - ) - - data class LibraryItem( - override val name: String, - override val url: String, - /** - * Unique unchanging string used for data storage. - * This should be the actual id when you change scores and status - * since score changes from library might get added in the future. - **/ - val syncId: String, - val episodesCompleted: Int?, - val episodesTotal: Int?, - val personalRating: Score?, - val lastUpdatedUnixTime: Long?, - override val apiName: String, - override var type: TvType?, - override var posterUrl: String?, - override var posterHeaders: Map?, - override var quality: SearchQuality?, - val releaseDate: Date?, - override var id: Int? = null, - val plot: String? = null, - override var score: Score? = null, - val tags: List? = null - ) : SearchResponse -} - -/** - * Stateless subtitle class for external subtitles. - * - * All non-null `AuthToken` will be non-expired when each function is called. - */ -abstract class SubtitleAPI : AuthAPI() { - @WorkerThread - @Throws - open suspend fun search(token: AuthToken?, query: SubtitleSearch): List? = - throw NotImplementedError() - - @WorkerThread - @Throws - open suspend fun load(token: AuthToken?, data: SubtitleEntity): String? = - throw NotImplementedError() - - @WorkerThread - @Throws - open suspend fun SubtitleResource.getResources(token: AuthToken?, data: SubtitleEntity) { - this.addUrl(load(token, data)) - } - - @WorkerThread - @Throws - suspend fun getResource(token: AuthToken?, data: SubtitleEntity): SubtitleResource { - return SubtitleResource().apply { - this.getResources(token, data) - } - } -} - -/** Safe abstraction for AuthAPI that provides both a catching interface, and automatic token management. */ -abstract class AuthRepo(open val api: AuthAPI) { - fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false - val idPrefix get() = api.idPrefix - val name get() = api.name - val icon get() = api.icon - val requiresLogin get() = api.requiresLogin - val createAccountUrl get() = api.createAccountUrl - val hasOAuth2 get() = api.hasOAuth2 - val hasPin get() = api.hasPin - val hasInApp get() = api.hasInApp - val inAppLoginRequirement get() = api.inAppLoginRequirement - val isAvailable get() = !api.requiresLogin || authUser() != null - - companion object { - private val oauthPayload: MutableMap = mutableMapOf() - } - - @Throws - protected suspend fun freshToken(): AuthToken? { - val data = authData() ?: return null - if (data.token.isAccessTokenExpired()) { - val newToken = api.refreshToken(data.token) ?: return null - refreshUser(AuthData(user = data.user, token = newToken)) - return newToken - } - return data.token - } - - @Throws - fun openOAuth2Page(): Boolean { - val page = api.loginRequest() ?: return false - synchronized(oauthPayload) { - oauthPayload.put(idPrefix, page.payload) - } - openBrowser(page.url) - return true - } - - fun openOAuth2PageWithToast() { - try { - if (!openOAuth2Page()) { - showToast(txt(R.string.authenticated_user_fail, api.name)) - } - } catch (t: Throwable) { - logError(t) - if (t is ErrorLoadingException && t.message != null) { - showToast(t.message) - return - } - showToast(txt(R.string.authenticated_user_fail, api.name)) - } - } - - suspend fun logout(from: AuthUser) { - val currentAccounts = AccountManager.accounts(idPrefix) - val (newAccounts, oldAccounts) = currentAccounts.partition { it.user.id != from.id } - if (newAccounts.size < currentAccounts.size) { - AccountManager.updateAccounts(idPrefix, newAccounts.toTypedArray()) - AccountManager.updateAccountsId(idPrefix, 0) - } - - for (oldAccount in oldAccounts) { - try { - api.invalidateToken(oldAccount.token) - } catch (_ : NotImplementedError) { - // no-op - } catch (t: Throwable) { - logError(t) - } - } - } - - fun refreshUser(newAuth: AuthData) { - val currentAccounts = AccountManager.accounts(idPrefix) - val newAccounts = currentAccounts.map { - if (it.user.id == newAuth.user.id) { - newAuth - } else { - it - } - }.toTypedArray() - AccountManager.updateAccounts(idPrefix, newAccounts) - } - - fun authData(): AuthData? = synchronized(AccountManager.cachedAccountIds) { - AccountManager.cachedAccountIds[idPrefix]?.let { id -> - AccountManager.cachedAccounts[idPrefix]?.firstOrNull { data -> data.user.id == id } - } - } - - fun authToken(): AuthToken? = authData()?.token - - fun authUser(): AuthUser? = authData()?.user - - val accounts - get() = synchronized(AccountManager.cachedAccounts) { - AccountManager.cachedAccounts[idPrefix] ?: emptyArray() - } - var accountId - get() = synchronized(AccountManager.cachedAccountIds) { - AccountManager.cachedAccountIds[idPrefix] ?: NONE_ID - } - set(value) { - AccountManager.updateAccountsId(idPrefix, value) - } - - @Throws - suspend fun pinRequest() = - api.pinRequest() - - @Throws - private suspend fun setupLogin(token: AuthToken): Boolean { - val user = api.user(token) ?: return false - - val newAccount = AuthData( - token = token, - user = user, - ) - - val currentAccounts = AccountManager.accounts(idPrefix) - if (currentAccounts.any { it.user.id == newAccount.user.id }) { - throw ErrorLoadingException("Already logged into this account") - } - - val newAccounts = currentAccounts + newAccount - AccountManager.updateAccounts(idPrefix, newAccounts) - AccountManager.updateAccountsId(idPrefix, user.id) - if (this is SyncRepo) { - requireLibraryRefresh = true - } - return true - } - - @Throws - suspend fun login(form: AuthLoginResponse): Boolean { - return setupLogin(api.login(form) ?: return false) - } - - @Throws - suspend fun login(payload: AuthPinData): Boolean { - return setupLogin(api.login(payload) ?: return false) - } - - @Throws - suspend fun login(redirectUrl: String): Boolean { - return setupLogin( - api.login( - redirectUrl, - synchronized(oauthPayload) { oauthPayload[api.idPrefix] }) ?: return false - ) - } -} - -/** Stateless safe abstraction of SyncAPI */ -class SyncRepo(override val api: SyncAPI) : AuthRepo(api) { - val syncIdName = api.syncIdName - var requireLibraryRefresh: Boolean - get() = api.requireLibraryRefresh - set(value) { - api.requireLibraryRefresh = value - } - - suspend fun updateStatus(id: String, newStatus: SyncAPI.AbstractSyncStatus): Result = - runCatching { - val status = api.updateStatus(freshToken() ?: return@runCatching false, id, newStatus) - requireLibraryRefresh = true - status - } - - suspend fun status(id: String): Result = runCatching { - api.status(freshToken(), id) - } - - suspend fun load(id: String): Result = runCatching { - api.load(freshToken(), id) - } - - suspend fun library(): Result = runCatching { - api.library(freshToken()) - } -} - -/** Stateless safe abstraction of SubtitleAPI */ -class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { - companion object { - data class SavedSearchResponse( - val unixTime: Long, - val response: List, - val query: SubtitleSearch - ) - - data class SavedResourceResponse( - val unixTime: Long, - val response: SubtitleResource, - val query: SubtitleEntity - ) - - // maybe make this a generic struct? right now there is a lot of boilerplate - private val searchCache = threadSafeListOf() - private var searchCacheIndex: Int = 0 - private val resourceCache = threadSafeListOf() - private var resourceCacheIndex: Int = 0 - const val CACHE_SIZE = 20 - } - - @WorkerThread - suspend fun getResource(data: SubtitleEntity): Resource = safeApiCall { - synchronized(resourceCache) { - for (item in resourceCache) { - // 20 min save - if (item.query == data && (unixTime - item.unixTime) < 60 * 20) { - return@safeApiCall item.response - } - } - } - - val returnValue = api.getResource(freshToken(), data) - synchronized(resourceCache) { - val add = SavedResourceResponse(unixTime, returnValue, data) - if (resourceCache.size > CACHE_SIZE) { - resourceCache[resourceCacheIndex] = add // rolling cache - resourceCacheIndex = (resourceCacheIndex + 1) % CACHE_SIZE - } else { - resourceCache.add(add) - } - } - returnValue - } - - @WorkerThread - suspend fun search(query: SubtitleSearch): Resource> { - return safeApiCall { - synchronized(searchCache) { - for (item in searchCache) { - // 120 min save - if (item.query == query && (unixTime - item.unixTime) < 60 * 120) { - return@safeApiCall item.response - } - } - } - - val returnValue = - api.search(freshToken(), query) ?: throw ErrorLoadingException("Null subtitles") - - // only cache valid return values - if (returnValue.isNotEmpty()) { - val add = SavedSearchResponse(unixTime, returnValue, query) - synchronized(searchCache) { - if (searchCache.size > CACHE_SIZE) { - searchCache[searchCacheIndex] = add // rolling cache - searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE - } else { - searchCache.add(add) - } - } - } - returnValue - } - } -} - -abstract class AccountManager { - companion object { - const val NONE_ID: Int = -1 - val malApi = MALApi() - val aniListApi = AniListApi() - val simklApi = SimklApi() - val localListApi = LocalList() - - val openSubtitlesApi = OpenSubtitlesApi() - val addic7ed = Addic7ed() - val subDlApi = SubDlApi() - val subSourceApi = SubSourceApi() - - var cachedAccounts: MutableMap> - var cachedAccountIds: MutableMap - - const val ACCOUNT_TOKEN = "auth_tokens" - const val ACCOUNT_IDS = "auth_ids" - - fun accounts(prefix: String): Array { - require(prefix != "NONE") - return getKey>( - ACCOUNT_TOKEN, - "${prefix}/${DataStoreHelper.currentAccount}" - ) ?: arrayOf() - } - - fun updateAccounts(prefix: String, array: Array) { - require(prefix != "NONE") - setKey(ACCOUNT_TOKEN, "${prefix}/${DataStoreHelper.currentAccount}", array) - synchronized(cachedAccounts) { - cachedAccounts[prefix] = array - } - } - - fun updateAccountsId(prefix: String, id: Int) { - require(prefix != "NONE") - setKey(ACCOUNT_IDS, "${prefix}/${DataStoreHelper.currentAccount}", id) - synchronized(cachedAccountIds) { - cachedAccountIds[prefix] = id - } - } - - val allApis = arrayOf( - SyncRepo(malApi), - SyncRepo(aniListApi), - SyncRepo(simklApi), - SyncRepo(localListApi), - - SubtitleRepo(openSubtitlesApi), - SubtitleRepo(addic7ed), - SubtitleRepo(subDlApi), - SubtitleRepo(subSourceApi) - ) - - fun updateAccountIds() { - val ids = mutableMapOf() - for (api in allApis) { - ids.put( - api.idPrefix, - getKey( - ACCOUNT_IDS, - "${api.idPrefix}/${DataStoreHelper.currentAccount}", - NONE_ID - ) ?: NONE_ID - ) - } - synchronized(cachedAccountIds) { - cachedAccountIds = ids - } - } - - init { - val data = mutableMapOf>() - val ids = mutableMapOf() - for (api in allApis) { - data.put(api.idPrefix, accounts(api.idPrefix)) - ids.put( - api.idPrefix, - getKey( - ACCOUNT_IDS, - "${api.idPrefix}/${DataStoreHelper.currentAccount}", - NONE_ID - ) ?: NONE_ID - ) - } - cachedAccounts = data - cachedAccountIds = ids - } - - // I do not want to place this in the init block as JVM initialization order is weird, and it may cause exceptions - // accessing other classes - fun initMainAPI() { - LoadResponse.malIdPrefix = malApi.idPrefix - LoadResponse.aniListIdPrefix = aniListApi.idPrefix - LoadResponse.simklIdPrefix = simklApi.idPrefix - } - - val subtitleProviders = arrayOf( - SubtitleRepo(openSubtitlesApi), - SubtitleRepo(addic7ed), - SubtitleRepo(subDlApi), - SubtitleRepo(subSourceApi) - ) - val syncApis = arrayOf( - SyncRepo(malApi), - SyncRepo(aniListApi), - SyncRepo(simklApi), - SyncRepo(localListApi) - ) - - const val APP_STRING = "cloudstreamapp" - const val APP_STRING_REPO = "cloudstreamrepo" - const val APP_STRING_PLAYER = "cloudstreamplayer" - - // Instantly start the search given a query - const val APP_STRING_SEARCH = "cloudstreamsearch" - - // Instantly resume watching a show - const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching" - - fun secondsToReadable(seconds: Int, completedValue: String): String { - var secondsLong = seconds.toLong() - val days = TimeUnit.SECONDS - .toDays(secondsLong) - secondsLong -= TimeUnit.DAYS.toSeconds(days) - - val hours = TimeUnit.SECONDS - .toHours(secondsLong) - secondsLong -= TimeUnit.HOURS.toSeconds(hours) - - val minutes = TimeUnit.SECONDS - .toMinutes(secondsLong) - secondsLong -= TimeUnit.MINUTES.toSeconds(minutes) - if (minutes < 0) { - return completedValue - } - //println("$days $hours $minutes") - return "${if (days != 0L) "$days" + "d " else ""}${if (hours != 0L) "$hours" + "h " else ""}${minutes}m" - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt new file mode 100644 index 000000000..9444c6367 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt @@ -0,0 +1,165 @@ +package com.lagradost.cloudstream3.syncproviders + +import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID +import com.lagradost.cloudstream3.utils.txt + +/** Safe abstraction for AuthAPI that provides both a catching interface, and automatic token management. */ +abstract class AuthRepo(open val api: AuthAPI) { + fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false + val idPrefix get() = api.idPrefix + val name get() = api.name + val icon get() = api.icon + val requiresLogin get() = api.requiresLogin + val createAccountUrl get() = api.createAccountUrl + val hasOAuth2 get() = api.hasOAuth2 + val hasPin get() = api.hasPin + val hasInApp get() = api.hasInApp + val inAppLoginRequirement get() = api.inAppLoginRequirement + val isAvailable get() = !api.requiresLogin || authUser() != null + + companion object { + private val oauthPayload: MutableMap = mutableMapOf() + } + + @Throws + protected suspend fun freshAuth(): AuthData? { + val data = authData() ?: return null + if (data.token.isAccessTokenExpired()) { + val newToken = api.refreshToken(data.token) ?: return null + val newAuth = AuthData(user = data.user, token = newToken) + refreshUser(newAuth) + return newAuth + } + return data + } + + @Throws + fun openOAuth2Page(): Boolean { + val page = api.loginRequest() ?: return false + synchronized(oauthPayload) { + oauthPayload.put(idPrefix, page.payload) + } + openBrowser(page.url) + return true + } + + fun openOAuth2PageWithToast() { + try { + if (!openOAuth2Page()) { + showToast(txt(R.string.authenticated_user_fail, api.name)) + } + } catch (t: Throwable) { + logError(t) + if (t is ErrorLoadingException && t.message != null) { + showToast(t.message) + return + } + showToast(txt(R.string.authenticated_user_fail, api.name)) + } + } + + suspend fun logout(from: AuthUser) { + val currentAccounts = AccountManager.accounts(idPrefix) + val (newAccounts, oldAccounts) = currentAccounts.partition { it.user.id != from.id } + if (newAccounts.size < currentAccounts.size) { + AccountManager.updateAccounts(idPrefix, newAccounts.toTypedArray()) + AccountManager.updateAccountsId(idPrefix, 0) + } + + for (oldAccount in oldAccounts) { + try { + api.invalidateToken(oldAccount.token) + } catch (_: NotImplementedError) { + // no-op + } catch (t: Throwable) { + logError(t) + } + } + } + + fun refreshUser(newAuth: AuthData) { + val currentAccounts = AccountManager.accounts(idPrefix) + val newAccounts = currentAccounts.map { + if (it.user.id == newAuth.user.id) { + newAuth + } else { + it + } + }.toTypedArray() + AccountManager.updateAccounts(idPrefix, newAccounts) + } + + fun authData(): AuthData? = synchronized(AccountManager.cachedAccountIds) { + AccountManager.cachedAccountIds[idPrefix]?.let { id -> + AccountManager.cachedAccounts[idPrefix]?.firstOrNull { data -> data.user.id == id } + } + } + + fun authToken(): AuthToken? = authData()?.token + + fun authUser(): AuthUser? = authData()?.user + + val accounts + get() = synchronized(AccountManager.cachedAccounts) { + AccountManager.cachedAccounts[idPrefix] ?: emptyArray() + } + var accountId + get() = synchronized(AccountManager.cachedAccountIds) { + AccountManager.cachedAccountIds[idPrefix] ?: NONE_ID + } + set(value) { + AccountManager.updateAccountsId(idPrefix, value) + } + + @Throws + suspend fun pinRequest() = + api.pinRequest() + + @Throws + private suspend fun setupLogin(token: AuthToken): Boolean { + val user = api.user(token) ?: return false + + val newAccount = AuthData( + token = token, + user = user, + ) + + val currentAccounts = AccountManager.accounts(idPrefix) + if (currentAccounts.any { it.user.id == newAccount.user.id }) { + throw ErrorLoadingException("Already logged into this account") + } + + val newAccounts = currentAccounts + newAccount + AccountManager.updateAccounts(idPrefix, newAccounts) + AccountManager.updateAccountsId(idPrefix, user.id) + if (this is SyncRepo) { + requireLibraryRefresh = true + } + return true + } + + @Throws + suspend fun login(form: AuthLoginResponse): Boolean { + return setupLogin(api.login(form) ?: return false) + } + + @Throws + suspend fun login(payload: AuthPinData): Boolean { + return setupLogin(api.login(payload) ?: return false) + } + + @Throws + suspend fun login(redirectUrl: String): Boolean { + return setupLogin( + api.login( + redirectUrl, + synchronized(oauthPayload) { oauthPayload[api.idPrefix] }) ?: return false + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt new file mode 100644 index 000000000..5efb88e5b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -0,0 +1,14 @@ +package com.lagradost.cloudstream3.syncproviders + +/** Work in progress */ +abstract class BackupAPI : AuthAPI() { + open val filename : String = "cloudstream-backup.json" + + /** Get the backup file as a JSON string from the remote storage. Return null if not found/empty */ + @Throws + open suspend fun downloadFile(auth: AuthData?) : String? = throw NotImplementedError() + + /** Get the backup file as a JSON string from the remote storage. */ + @Throws + open suspend fun uploadFile(auth: AuthData?, data : String) : String? = throw NotImplementedError() +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt new file mode 100644 index 000000000..a1149b5f8 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt @@ -0,0 +1,37 @@ +package com.lagradost.cloudstream3.syncproviders + +import androidx.annotation.WorkerThread +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch +import com.lagradost.cloudstream3.subtitles.SubtitleResource + +/** + * Stateless subtitle class for external subtitles. + * + * All non-null `AuthToken` will be non-expired when each function is called. + */ +abstract class SubtitleAPI : AuthAPI() { + @WorkerThread + @Throws + open suspend fun search(auth: AuthData?, query: SubtitleSearch): List? = + throw NotImplementedError() + + @WorkerThread + @Throws + open suspend fun load(auth: AuthData?, subtitle: SubtitleEntity): String? = + throw NotImplementedError() + + @WorkerThread + @Throws + open suspend fun SubtitleResource.getResources(auth: AuthData?, subtitle: SubtitleEntity) { + this.addUrl(load(auth, subtitle)) + } + + @WorkerThread + @Throws + suspend fun resource(auth: AuthData?, subtitle: SubtitleEntity): SubtitleResource { + return SubtitleResource().apply { + this.getResources(auth, subtitle) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt new file mode 100644 index 000000000..e831fb3e8 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt @@ -0,0 +1,89 @@ +package com.lagradost.cloudstream3.syncproviders + +import androidx.annotation.WorkerThread +import com.lagradost.cloudstream3.APIHolder.unixTime +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch +import com.lagradost.cloudstream3.subtitles.SubtitleResource +import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf + +/** Stateless safe abstraction of SubtitleAPI */ +class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { + companion object { + data class SavedSearchResponse( + val unixTime: Long, + val response: List, + val query: SubtitleSearch + ) + + data class SavedResourceResponse( + val unixTime: Long, + val response: SubtitleResource, + val query: SubtitleEntity + ) + + // maybe make this a generic struct? right now there is a lot of boilerplate + private val searchCache = threadSafeListOf() + private var searchCacheIndex: Int = 0 + private val resourceCache = threadSafeListOf() + private var resourceCacheIndex: Int = 0 + const val CACHE_SIZE = 20 + } + + @WorkerThread + suspend fun resource(data: SubtitleEntity): Result = runCatching { + synchronized(resourceCache) { + for (item in resourceCache) { + // 20 min save + if (item.query == data && (unixTime - item.unixTime) < 60 * 20) { + return@runCatching item.response + } + } + } + + val returnValue = api.resource(freshAuth(), data) + synchronized(resourceCache) { + val add = SavedResourceResponse(unixTime, returnValue, data) + if (resourceCache.size > CACHE_SIZE) { + resourceCache[resourceCacheIndex] = add // rolling cache + resourceCacheIndex = (resourceCacheIndex + 1) % CACHE_SIZE + } else { + resourceCache.add(add) + } + } + returnValue + } + + @WorkerThread + suspend fun search(query: SubtitleSearch): Result> { + return runCatching { + synchronized(searchCache) { + for (item in searchCache) { + // 120 min save + if (item.query == query && (unixTime - item.unixTime) < 60 * 120) { + return@runCatching item.response + } + } + } + + val returnValue = + api.search(freshAuth(), query) ?: throw ErrorLoadingException("Null subtitles") + + // only cache valid return values + if (returnValue.isNotEmpty()) { + val add = SavedSearchResponse(unixTime, returnValue, query) + synchronized(searchCache) { + if (searchCache.size > CACHE_SIZE) { + searchCache[searchCacheIndex] = add // rolling cache + searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE + } else { + searchCache.add(add) + } + } + } + returnValue + } + } +} + diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt new file mode 100644 index 000000000..e5f9aca84 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt @@ -0,0 +1,194 @@ +package com.lagradost.cloudstream3.syncproviders + +import androidx.annotation.WorkerThread +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.NextAiring +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.SearchQuality +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.ui.SyncWatchType +import com.lagradost.cloudstream3.ui.library.ListSorting +import com.lagradost.cloudstream3.utils.UiText +import me.xdrop.fuzzywuzzy.FuzzySearch +import java.util.Date + +/** + * Stateless synchronization class, used for syncing status about a specific movie/show. + * + * All non-null `AuthToken` will be non-expired when each function is called. + */ +abstract class SyncAPI : AuthAPI() { + /** + * Set this to true if the user updates something on the list like watch status or score + **/ + open var requireLibraryRefresh: Boolean = true + open val mainUrl: String = "NONE" + + /** Currently unused, but will be used to correctly render the UI. + * This should specify what sync watch types can be used with this service. */ + open val supportedWatchTypes: Set = SyncWatchType.entries.toSet() + /** + * Allows certain providers to open pages from + * library links. + **/ + open val syncIdName: SyncIdName? = null + + /** Modify the current status of an item */ + @Throws + @WorkerThread + open suspend fun updateStatus( + auth: AuthData?, + id: String, + newStatus: AbstractSyncStatus + ): Boolean = throw NotImplementedError() + + /** Get the current status of an item */ + @Throws + @WorkerThread + open suspend fun status(auth: AuthData?, id: String): AbstractSyncStatus? = + throw NotImplementedError() + + /** Get metadata about an item */ + @Throws + @WorkerThread + open suspend fun load(auth: AuthData?, id: String): SyncResult? = throw NotImplementedError() + + /** Search this service for any results for a given query */ + @Throws + @WorkerThread + open suspend fun search(auth: AuthData?, query: String): List? = + throw NotImplementedError() + + /** Get the current library/bookmarks of this service */ + @Throws + @WorkerThread + open suspend fun library(auth: AuthData?): LibraryMetadata? = throw NotImplementedError() + + /** Helper function, may be used in the future */ + @Throws + open fun urlToId(url: String): String? = null + + data class SyncSearchResult( + override val name: String, + override val apiName: String, + var syncId: String, + override val url: String, + override var posterUrl: String?, + override var type: TvType? = null, + override var quality: SearchQuality? = null, + override var posterHeaders: Map? = null, + override var id: Int? = null, + override var score: Score? = null, + ) : SearchResponse + + abstract class AbstractSyncStatus { + abstract var status: SyncWatchType + abstract var score: Score? + abstract var watchedEpisodes: Int? + abstract var isFavorite: Boolean? + abstract var maxEpisodes: Int? + } + + data class SyncStatus( + override var status: SyncWatchType, + override var score: Score?, + override var watchedEpisodes: Int?, + override var isFavorite: Boolean? = null, + override var maxEpisodes: Int? = null, + ) : AbstractSyncStatus() + + data class SyncResult( + /**Used to verify*/ + var id: String, + + var totalEpisodes: Int? = null, + + var title: String? = null, + var publicScore: Score? = null, + /**In minutes*/ + var duration: Int? = null, + var synopsis: String? = null, + var airStatus: ShowStatus? = null, + var nextAiring: NextAiring? = null, + var studio: List? = null, + var genres: List? = null, + var synonyms: List? = null, + var trailers: List? = null, + var isAdult: Boolean? = null, + var posterUrl: String? = null, + var backgroundPosterUrl: String? = null, + + /** In unixtime */ + var startDate: Long? = null, + /** In unixtime */ + var endDate: Long? = null, + var recommendations: List? = null, + var nextSeason: SyncSearchResult? = null, + var prevSeason: SyncSearchResult? = null, + var actors: List? = null, + ) + + data class Page( + val title: UiText, var items: List + ) { + fun sort(method: ListSorting?, query: String? = null) { + items = when (method) { + ListSorting.Query -> + if (query != null) { + items.sortedBy { + -FuzzySearch.partialRatio( + query.lowercase(), it.name.lowercase() + ) + } + } else items + + ListSorting.RatingHigh -> items.sortedBy { -(it.personalRating?.toInt(100) ?: 0) } + ListSorting.RatingLow -> items.sortedBy { (it.personalRating?.toInt(100) ?: 0) } + ListSorting.AlphabeticalA -> items.sortedBy { it.name } + ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed() + ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) } + ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime } + ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate } + ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate } + else -> items + } + } + } + + data class LibraryMetadata( + val allLibraryLists: List, + val supportedListSorting: Set + ) + + data class LibraryList( + val name: UiText, + val items: List + ) + + data class LibraryItem( + override val name: String, + override val url: String, + /** + * Unique unchanging string used for data storage. + * This should be the actual id when you change scores and status + * since score changes from library might get added in the future. + **/ + val syncId: String, + val episodesCompleted: Int?, + val episodesTotal: Int?, + val personalRating: Score?, + val lastUpdatedUnixTime: Long?, + override val apiName: String, + override var type: TvType?, + override var posterUrl: String?, + override var posterHeaders: Map?, + override var quality: SearchQuality?, + val releaseDate: Date?, + override var id: Int? = null, + val plot: String? = null, + override var score: Score? = null, + val tags: List? = null + ) : SearchResponse +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt new file mode 100644 index 000000000..de82624fc --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt @@ -0,0 +1,30 @@ +package com.lagradost.cloudstream3.syncproviders + +/** Stateless safe abstraction of SyncAPI */ +class SyncRepo(override val api: SyncAPI) : AuthRepo(api) { + val syncIdName = api.syncIdName + var requireLibraryRefresh: Boolean + get() = api.requireLibraryRefresh + set(value) { + api.requireLibraryRefresh = value + } + + suspend fun updateStatus(id: String, newStatus: SyncAPI.AbstractSyncStatus): Result = + runCatching { + val status = api.updateStatus(freshAuth() ?: return@runCatching false, id, newStatus) + requireLibraryRefresh = true + status + } + + suspend fun status(id: String): Result = runCatching { + api.status(freshAuth(), id) + } + + suspend fun load(id: String): Result = runCatching { + api.load(freshAuth(), id) + } + + suspend fun library(): Result = runCatching { + api.library(freshAuth()) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt index 7b58fed92..5f71ac9a1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt @@ -3,7 +3,7 @@ package com.lagradost.cloudstream3.syncproviders.providers import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities -import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.SubtitleAPI import com.lagradost.cloudstream3.utils.SubtitleHelper @@ -25,7 +25,7 @@ class Addic7ed : SubtitleAPI() { } override suspend fun search( - token: AuthToken?, + auth: AuthData?, query: AbstractSubtitleEntities.SubtitleSearch ): List? { val lang = query.lang @@ -101,9 +101,9 @@ class Addic7ed : SubtitleAPI() { } override suspend fun load( - token: AuthToken?, - data: AbstractSubtitleEntities.SubtitleEntity + auth: AuthData?, + subtitle: AbstractSubtitleEntities.SubtitleEntity ): String? { - return data.data + return subtitle.data } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt index 72b0b5727..a4cd42848 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt @@ -14,6 +14,7 @@ import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.AuthLoginPage import com.lagradost.cloudstream3.syncproviders.AuthToken import com.lagradost.cloudstream3.syncproviders.AuthUser @@ -53,8 +54,7 @@ class AniListApi : SyncAPI() { //refreshToken = sanitizer["refresh_token"], accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(), ) - val user = getUser(token) ?: throw ErrorLoadingException("Unable to fetch user data") - return token.copy(payload = user.id.toString()) + return token } // https://docs.anilist.co/guide/auth/ @@ -83,7 +83,7 @@ class AniListApi : SyncAPI() { return "$mainUrl/anime/$id" } - override suspend fun search(token: AuthToken?, query: String): List? { + override suspend fun search(auth : AuthData?, query: String): List? { val data = searchShows(name) ?: return null return data.data?.page?.media?.map { SyncAPI.SyncSearchResult( @@ -96,7 +96,7 @@ class AniListApi : SyncAPI() { } } - override suspend fun load(token: AuthToken?, id: String): SyncAPI.SyncResult? { + override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? { val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1) ?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId") val season = getSeason(internalId).data.media @@ -158,9 +158,9 @@ class AniListApi : SyncAPI() { ) } - override suspend fun status(token: AuthToken?, id: String): SyncAPI.AbstractSyncStatus? { + override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? { val internalId = id.toIntOrNull() ?: return null - val data = getDataAboutId(token ?: return null, internalId) ?: return null + val data = getDataAboutId(auth ?: return null, internalId) ?: return null return SyncAPI.SyncStatus( score = Score.from100(data.score), @@ -172,12 +172,12 @@ class AniListApi : SyncAPI() { } override suspend fun updateStatus( - token: AuthToken?, + auth: AuthData?, id: String, - newStatus: SyncAPI.AbstractSyncStatus + newStatus: AbstractSyncStatus ): Boolean { return postDataAboutId( - token ?: return false, + auth ?: return false, id.toIntOrNull() ?: return false, fromIntToAnimeStatus(newStatus.status.internalId), newStatus.score, @@ -459,7 +459,7 @@ class AniListApi : SyncAPI() { } } - private suspend fun getDataAboutId(token: AuthToken, id: Int): AniListTitleHolder? { + private suspend fun getDataAboutId(auth : AuthData, id: Int): AniListTitleHolder? { val q = """query (${'$'}id: Int = $id) { # Define which variables will be used in the query (id) Media (id: ${'$'}id, type: ANIME) { # Insert our variables into the query arguments (id) (type: ANIME is hard-coded in the query) @@ -478,7 +478,7 @@ class AniListApi : SyncAPI() { } }""" - val data = postApi(token, q, true) + val data = postApi(auth.token, q, true) val d = parseJson(data ?: return null) val main = d.data?.media @@ -506,11 +506,11 @@ class AniListApi : SyncAPI() { } - private suspend fun postApi(token: AuthToken, q: String, cache: Boolean = false): String? = - app.post( + private suspend fun postApi(token : AuthToken, q: String, cache: Boolean = false): String? { + return app.post( "https://graphql.anilist.co/", headers = mapOf( - "Authorization" to "Bearer ${token.accessToken}", + "Authorization" to "Bearer ${token.accessToken ?: return null}", if (cache) "Cache-Control" to "max-stale=$MAX_STALE" else "Cache-Control" to "no-cache" ), cacheTime = 0, @@ -522,6 +522,8 @@ class AniListApi : SyncAPI() { ), //(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars)) timeout = 5 // REASONABLE TIMEOUT ).text.replace("\\/", "/") + } + data class MediaRecommendation( @JsonProperty("id") val id: Int, @@ -621,23 +623,23 @@ class AniListApi : SyncAPI() { @JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection ) - private suspend fun getAniListAnimeListSmart(token: AuthToken): Array? { + private suspend fun getAniListAnimeListSmart(auth: AuthData): Array? { return if (requireLibraryRefresh) { - val list = getFullAniListList(token)?.data?.mediaListCollection?.lists?.toTypedArray() + val list = getFullAniListList(auth)?.data?.mediaListCollection?.lists?.toTypedArray() if (list != null) { - setKey(ANILIST_CACHED_LIST, token.accessToken ?: "", list) + setKey(ANILIST_CACHED_LIST, auth.user.id.toString(), list) } list } else { getKey>( ANILIST_CACHED_LIST, - token.accessToken ?: "" + auth.user.id.toString() ) as? Array } } - override suspend fun library(token: AuthToken?): SyncAPI.LibraryMetadata? { - val list = getAniListAnimeListSmart(token ?: return null)?.groupBy { + override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? { + val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy { convertAniListStringToStatus(it.status ?: "").stringRes }?.mapValues { group -> group.value.map { it.entries.map { entry -> entry.toLibraryItem() } }.flatten() @@ -664,8 +666,8 @@ class AniListApi : SyncAPI() { ) } - private suspend fun getFullAniListList(token: AuthToken): FullAnilistList? { - val userID = token.payload ?: return null + private suspend fun getFullAniListList(auth : AuthData): FullAnilistList? { + val userID = auth.user.id val mediaType = "ANIME" val query = """ @@ -708,11 +710,11 @@ class AniListApi : SyncAPI() { } } """ - val text = postApi(token, query) + val text = postApi(auth.token, query) return text?.toKotlinObject() } - suspend fun toggleLike(token: AuthToken, id: Int): Boolean { + suspend fun toggleLike(auth : AuthData, id: Int): Boolean { val q = """mutation (${'$'}animeId: Int = $id) { ToggleFavourite (animeId: ${'$'}animeId) { anime { @@ -725,7 +727,7 @@ class AniListApi : SyncAPI() { } } }""" - val data = postApi(token, q) + val data = postApi(auth.token, q) return data != "" } @@ -735,13 +737,13 @@ class AniListApi : SyncAPI() { data class MediaListId(@JsonProperty("id") val id: Long? = null) private suspend fun postDataAboutId( - token: AuthToken, + auth : AuthData, id: Int, type: AniListStatusType, score: Score?, progress: Int? ): Boolean { - val userID = token.payload ?: return false + val userID = auth.user.id val q = // Delete item if status type is None @@ -754,7 +756,7 @@ class AniListApi : SyncAPI() { } } """ - val response = postApi(token, idQuery) + val response = postApi(auth.token, idQuery) val listId = tryParseJson(response)?.data?.mediaList?.id ?: return false """ @@ -780,11 +782,11 @@ class AniListApi : SyncAPI() { }""" } - val data = postApi(token, q) + val data = postApi(auth.token, q) return data != "" } - private suspend fun getUser(token: AuthToken): AniListUser? { + private suspend fun getUser(token : AuthToken): AniListUser? { val q = """ { Viewer { diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt index f83b45aad..8f0d7ca6d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt @@ -1,12 +1,11 @@ package com.lagradost.cloudstream3.syncproviders.providers import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.library.ListSorting -import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.Coroutines.ioWork @@ -15,6 +14,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState +import com.lagradost.cloudstream3.utils.txt class LocalList : SyncAPI() { override val name = "Local" @@ -26,7 +26,7 @@ class LocalList : SyncAPI() { override var requireLibraryRefresh = true override val syncIdName = SyncIdName.LocalList - override suspend fun library(token: AuthToken?): SyncAPI.LibraryMetadata? { + override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? { val watchStatusIds = ioWork { getAllWatchStateIds()?.map { id -> Pair(id, getResultWatchState(id)) @@ -74,8 +74,8 @@ class LocalList : SyncAPI() { result } - return SyncAPI.LibraryMetadata( - list.map { SyncAPI.LibraryList(txt(it.key), it.value) }, + return LibraryMetadata( + list.map { LibraryList(txt(it.key), it.value) }, setOf( ListSorting.AlphabeticalA, ListSorting.AlphabeticalZ, diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt index 370a18bea..e8c343519 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt @@ -9,6 +9,7 @@ import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.ShowStatus import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.AuthLoginPage import com.lagradost.cloudstream3.syncproviders.AuthToken import com.lagradost.cloudstream3.syncproviders.AuthUser @@ -97,8 +98,8 @@ class MALApi : SyncAPI() { ) } - override suspend fun search(token: AuthToken?, query: String): List? { - val auth = token?.accessToken ?: return null + override suspend fun search(auth : AuthData?, query: String): List? { + val auth = auth?.token?.accessToken ?: return null val url = "$apiUrl/v2/anime?q=$name&limit=$MAL_MAX_SEARCH_LIMIT" val res = app.get( url, headers = mapOf( @@ -121,12 +122,12 @@ class MALApi : SyncAPI() { Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() override suspend fun updateStatus( - token: AuthToken?, + auth : AuthData?, id: String, newStatus: SyncAPI.AbstractSyncStatus ): Boolean { return setScoreRequest( - token ?: return false, + auth?.token ?: return false, id.toIntOrNull() ?: return false, fromIntToAnimeStatus(newStatus.status), newStatus.score?.toInt(10), @@ -224,8 +225,8 @@ class MALApi : SyncAPI() { ) } - override suspend fun load(token: AuthToken?, id: String): SyncAPI.SyncResult? { - val auth = token?.accessToken ?: return null + override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? { + val auth = auth?.token?.accessToken ?: return null val internalId = id.toIntOrNull() ?: return null val url = "$apiUrl/v2/anime/$internalId?fields=id,title,main_picture,alternative_titles,start_date,end_date,synopsis,mean,rank,popularity,num_list_users,num_scoring_users,nsfw,created_at,updated_at,media_type,status,genres,my_list_status,num_episodes,start_season,broadcast,source,average_episode_duration,rating,pictures,background,related_anime,related_manga,recommendations,studios,statistics" @@ -270,8 +271,8 @@ class MALApi : SyncAPI() { } } - override suspend fun status(token: AuthToken?, id: String): SyncAPI.AbstractSyncStatus? { - val auth = token?.accessToken ?: return null + override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? { + val auth = auth?.token?.accessToken ?: return null // https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get val url = @@ -476,8 +477,8 @@ class MALApi : SyncAPI() { @JsonProperty("start_time") val startTime: String? ) - override suspend fun library(token: AuthToken?): LibraryMetadata? { - val list = getMalAnimeListSmart(token ?: return null)?.groupBy { + override suspend fun library(auth : AuthData?): LibraryMetadata? { + val list = getMalAnimeListSmart(auth ?: return null)?.groupBy { convertToStatus(it.listStatus?.status ?: "").stringRes }?.mapValues { group -> group.value.map { it.toLibraryItem() } @@ -504,13 +505,13 @@ class MALApi : SyncAPI() { ) } - private suspend fun getMalAnimeListSmart(token: AuthToken): Array? { + private suspend fun getMalAnimeListSmart(auth : AuthData): Array? { return if (requireLibraryRefresh) { - val list = getMalAnimeList(token) - setKey(MAL_CACHED_LIST, token.accessToken ?: "", list) + val list = getMalAnimeList(auth.token) + setKey(MAL_CACHED_LIST, auth.user.id.toString(), list) list } else { - getKey>(MAL_CACHED_LIST, token.accessToken ?: "") as? Array + getKey>(MAL_CACHED_LIST, auth.user.id.toString()) as? Array } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt index 1df3ea54d..02f828a22 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt @@ -7,6 +7,7 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities +import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse import com.lagradost.cloudstream3.syncproviders.AuthToken @@ -114,7 +115,7 @@ class OpenSubtitlesApi : SubtitleAPI() { * Returns list of Subtitles which user can select to download (see load). * */ override suspend fun search( - token: AuthToken?, + auth : AuthData?, query: AbstractSubtitleEntities.SubtitleSearch ): List? { throwIfCantDoRequest() @@ -197,9 +198,10 @@ class OpenSubtitlesApi : SubtitleAPI() { */ override suspend fun load( - token: AuthToken?, - data: AbstractSubtitleEntities.SubtitleEntity + auth : AuthData?, + subtitle: AbstractSubtitleEntities.SubtitleEntity ): String? { + if(auth == null) return null throwIfCantDoRequest() val req = app.post( @@ -207,13 +209,13 @@ class OpenSubtitlesApi : SubtitleAPI() { headers = mapOf( Pair( "Authorization", - "Bearer ${token?.accessToken ?: throw ErrorLoadingException("No access token active in current session")}" + "Bearer ${auth.token.accessToken ?: throw ErrorLoadingException("No access token active in current session")}" ), Pair("Content-Type", "application/json"), Pair("Accept", "*/*") ) + headers, data = mapOf( - Pair("file_id", data.data) + Pair("file_id", subtitle.data) ) ) Log.i(TAG, "Request result => (${req.code}) ${req.text}") diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt index d7cbe12f5..9518f5a20 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt @@ -20,6 +20,7 @@ import com.lagradost.cloudstream3.mapper import com.lagradost.cloudstream3.mvvm.debugPrint import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING +import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.AuthLoginPage import com.lagradost.cloudstream3.syncproviders.AuthPinData import com.lagradost.cloudstream3.syncproviders.AuthToken @@ -795,8 +796,8 @@ class SimklApi : SyncAPI() { val oldStatus: String? ) : SyncAPI.AbstractSyncStatus() - override suspend fun status(token: AuthToken?, id: String): SyncAPI.AbstractSyncStatus? { - if (token == null) return null + override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? { + if (auth == null) return null val realIds = readIdFromString(id) // Key which assumes all ids are the same each time :/ @@ -820,7 +821,7 @@ class SimklApi : SyncAPI() { searchResult.hasEnded() ) - val foundItem = getSyncListSmart(token)?.let { list -> + val foundItem = getSyncListSmart(auth)?.let { list -> listOf(list.shows, list.anime, list.movies).flatten().firstOrNull { show -> realIds.any { (database, id) -> show.getIds().matchesId(database, id) @@ -861,9 +862,9 @@ class SimklApi : SyncAPI() { } override suspend fun updateStatus( - token: AuthToken?, + auth: AuthData?, id: String, - newStatus: SyncAPI.AbstractSyncStatus + newStatus: AbstractSyncStatus ): Boolean { val parsedId = readIdFromString(id) lastScoreTime = unixTime @@ -879,7 +880,7 @@ class SimklApi : SyncAPI() { it.originalName == oldStatus }?.value }) - .token(token ?: return false) + .token(auth?.token ?: return false) .ids(MediaObject.Ids.fromMap(parsedId)) @@ -913,7 +914,7 @@ class SimklApi : SyncAPI() { ).parsedSafe() } - override suspend fun search(token: AuthToken?, query: String): List? { + override suspend fun search(auth: AuthData?, query: String): List? { return app.get( "$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name) ).parsedSafe>()?.mapNotNull { it.toSyncSearchResult() } @@ -930,9 +931,9 @@ class SimklApi : SyncAPI() { ) } - override suspend fun load(token: AuthToken?, id: String): SyncAPI.SyncResult? = null + override suspend fun load(auth: AuthData?, id: String): SyncResult? = null - private suspend fun getSyncListSince(token: AuthToken, since: Long?): AllItemsResponse? { + private suspend fun getSyncListSince(auth: AuthData, since: Long?): AllItemsResponse? { val params = getDateTime(since)?.let { mapOf("date_from" to it) } ?: emptyMap() @@ -941,7 +942,7 @@ class SimklApi : SyncAPI() { return app.get( "$mainUrl/sync/all-items/", params = params, - headers = getHeaders(token) + headers = getHeaders(auth.token) ).parsedSafe() } @@ -949,15 +950,14 @@ class SimklApi : SyncAPI() { return app.post("$mainUrl/sync/activities", headers = getHeaders(token)).parsedSafe() } - private fun getSyncListCached(token: AuthToken): AllItemsResponse? { - return getKey(token.accessToken ?: return null, SIMKL_CACHED_LIST) + private fun getSyncListCached(auth: AuthData): AllItemsResponse? { + return getKey(SIMKL_CACHED_LIST, auth.user.id.toString()) } - private suspend fun getSyncListSmart(token: AuthToken): AllItemsResponse? { - - val activities = getActivities(token) - val accessToken = token.accessToken ?: return null - val lastCacheUpdate = getKey(accessToken, SIMKL_CACHED_LIST_TIME) + private suspend fun getSyncListSmart(auth: AuthData): AllItemsResponse? { + val activities = getActivities(auth.token) + val userId = auth.user.id.toString() + val lastCacheUpdate = getKey(SIMKL_CACHED_LIST_TIME, auth.user.id.toString()) val lastRemoval = listOf( activities?.tvShows?.removedFromList, activities?.anime?.removedFromList, @@ -977,28 +977,28 @@ class SimklApi : SyncAPI() { debugPrint { "Cache times: lastCacheUpdate=$lastCacheUpdate, lastRemoval=$lastRemoval, lastRealUpdate=$lastRealUpdate" } val list = if (lastCacheUpdate == null || lastCacheUpdate < lastRemoval) { debugPrint { "Full list update in ${this.name}." } - setKey(accessToken, SIMKL_CACHED_LIST_TIME, lastRemoval) - getSyncListSince(token, null) + setKey(SIMKL_CACHED_LIST_TIME, userId, lastRemoval) + getSyncListSince(auth, null) } else if (lastCacheUpdate < lastRealUpdate || lastCacheUpdate < lastScoreTime) { debugPrint { "Partial list update in ${this.name}." } - setKey(accessToken, SIMKL_CACHED_LIST_TIME, lastCacheUpdate) + setKey(SIMKL_CACHED_LIST_TIME, userId, lastCacheUpdate) AllItemsResponse.merge( - getSyncListCached(token), - getSyncListSince(token, lastCacheUpdate) + getSyncListCached(auth), + getSyncListSince(auth, lastCacheUpdate) ) } else { debugPrint { "Cached list update in ${this.name}." } - getSyncListCached(token) + getSyncListCached(auth) } debugPrint { "List sizes: movies=${list?.movies?.size}, shows=${list?.shows?.size}, anime=${list?.anime?.size}" } - setKey(accessToken, SIMKL_CACHED_LIST, list) + setKey(SIMKL_CACHED_LIST, userId, list) return list } - override suspend fun library(token: AuthToken?): SyncAPI.LibraryMetadata? { - val list = getSyncListSmart(token ?: return null) ?: return null + override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? { + val list = getSyncListSmart(auth ?: return null) ?: return null val baseMap = SimklListStatusType.entries diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt index 47d4dd5dc..df635c13c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt @@ -5,7 +5,7 @@ import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.SubtitleResource -import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.SubtitleAPI import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson @@ -23,7 +23,7 @@ class SubSourceApi : SubtitleAPI() { } override suspend fun search( - token: AuthToken?, + auth: AuthData?, query: AbstractSubtitleEntities.SubtitleSearch ): List? { @@ -94,16 +94,16 @@ class SubSourceApi : SubtitleAPI() { } override suspend fun SubtitleResource.getResources( - token: AuthToken?, - data: AbstractSubtitleEntities.SubtitleEntity + auth: AuthData?, + subtitle: AbstractSubtitleEntities.SubtitleEntity ) { - val parsedSub = parseJson(data.data) + val parsedSub = parseJson(subtitle.data) val subRes = app.post( url = "$APIURL/getSub", data = mapOf( "movie" to parsedSub.movie, - "lang" to data.lang, + "lang" to subtitle.lang, "id" to parsedSub.id ) ).parsedSafe() ?: return diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt index 31a3ca373..efe96371f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt @@ -6,6 +6,7 @@ import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.SubtitleResource +import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse import com.lagradost.cloudstream3.syncproviders.AuthToken @@ -55,11 +56,11 @@ class SubDlApi : SubtitleAPI() { } override suspend fun search( - token: AuthToken?, + auth : AuthData?, query: AbstractSubtitleEntities.SubtitleSearch ): List? { - if (token == null) return null - + if (auth == null) return null + val apiKey = auth.token.accessToken ?: return null val queryText = query.query val epNum = query.epNumber ?: 0 val seasonNum = query.seasonNumber ?: 0 @@ -77,8 +78,8 @@ class SubDlApi : SubtitleAPI() { val searchQueryUrl = when (idQuery) { //Use imdb/tmdb id to search if its valid - null -> "$APIENDPOINT?api_key=${token.accessToken}&film_name=$queryText&languages=${query.lang}$epQuery$seasonQuery$yearQuery" - else -> "$APIENDPOINT?api_key=${token.accessToken}$idQuery&languages=${query.lang}$epQuery$seasonQuery$yearQuery" + null -> "$APIENDPOINT?api_key=${apiKey}&film_name=$queryText&languages=${query.lang}$epQuery$seasonQuery$yearQuery" + else -> "$APIENDPOINT?api_key=${apiKey}$idQuery&languages=${query.lang}$epQuery$seasonQuery$yearQuery" } val req = app.get( @@ -110,10 +111,10 @@ class SubDlApi : SubtitleAPI() { } override suspend fun SubtitleResource.getResources( - token: AuthToken?, - data: AbstractSubtitleEntities.SubtitleEntity + auth: AuthData?, + subtitle: AbstractSubtitleEntities.SubtitleEntity ) { - this.addZipUrl(data.data) { name, _ -> + this.addZipUrl(subtitle.data) { name, _ -> name } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index 57dabdefa..be9d01bbe 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -745,7 +745,7 @@ class GeneratorPlayer : FullScreenPlayer() { // TODO Make ui a lot better, like search with tabs val results = providers.amap { - when (val response = it.search(search)) { + when (val response = Resource.fromResult(it.search(search))) { is Resource.Success -> { response.value } @@ -804,7 +804,8 @@ class GeneratorPlayer : FullScreenPlayer() { currentSubtitle?.let { currentSubtitle -> providers.firstOrNull { it.idPrefix == currentSubtitle.idPrefix }?.let { api -> ioSafe { - when (val apiResource = api.getResource(currentSubtitle)) { + when (val apiResource = + Resource.fromResult(api.resource(currentSubtitle))) { is Resource.Success -> { val subtitles = apiResource.value.getSubtitles().map { resource -> SubtitleData( @@ -950,8 +951,10 @@ class GeneratorPlayer : FullScreenPlayer() { // we might want to change it to prefer different sources when used multiple times, // however caching might make this random after the first click too subsProviders.toList().amap { provider -> - val success = when (val result = provider.search( - query = query + val success = when (val result = Resource.fromResult( + provider.search( + query = query + ) )) { is Resource.Failure -> { // scope might cancel, so we do an extra check @@ -977,21 +980,7 @@ class GeneratorPlayer : FullScreenPlayer() { break } - val subtitleResources = - when (val result = provider.getResource(subtitleEntry)) { - is Resource.Failure -> { - continue - } - - is Resource.Loading -> { - // unreachable - continue - } - - is Resource.Success -> { - result.value - } - } + val subtitleResources = provider.resource(subtitleEntry).getOrNull() ?: continue val subtitles = subtitleResources.getSubtitles().map { resource -> SubtitleData( diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt index 2d1257def..04f43dc40 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt @@ -55,6 +55,17 @@ sealed class Resource { ) : Resource() data class Loading(val url: String? = null) : Resource() + + companion object { + fun fromResult(result: Result) : Resource { + val value = result.getOrNull() + return if(value != null) { + Success(value) + } else { + throwAbleToResource(result.exceptionOrNull() ?: Exception("this should not be possible")) + } + } + } } fun logError(throwable: Throwable) { From 1a36bb45238f94ca0de5e0c0444a2ad63d5c6c94 Mon Sep 17 00:00:00 2001 From: int3debug <164035730+int3debug@users.noreply.github.com> Date: Tue, 12 Aug 2025 15:03:00 +0200 Subject: [PATCH 008/790] fix(setup): mark setup as completed when skipping (#1824) --- .../lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt index f57d4f159..a908db55a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt @@ -10,6 +10,7 @@ import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R @@ -103,6 +104,7 @@ class SetupFragmentLanguage : Fragment() { } skipBtt.setOnClickListener { + setKey(HAS_DONE_SETUP_KEY, true) findNavController().navigate(R.id.navigation_home) } } From 54366bb6c66e65e3b9cd24d140ba05a6f3f088d2 Mon Sep 17 00:00:00 2001 From: rockhero1234 <149141736+rockhero1234@users.noreply.github.com> Date: Wed, 13 Aug 2025 18:55:48 +0530 Subject: [PATCH 009/790] feat:added repo icon (#1825) * feat:added repo icon * fixes --- .../cloudstream3/plugins/RepositoryManager.kt | 1 + .../settings/extensions/ExtensionsFragment.kt | 3 +-- .../extensions/ExtensionsViewModel.kt | 1 + .../ui/settings/extensions/RepoAdapter.kt | 23 +++++++++++++++++++ .../cloudstream3/utils/AppContextUtils.kt | 1 + 5 files changed, 27 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt index d92e81acd..82537ccbc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -28,6 +28,7 @@ import java.io.OutputStream * */ data class Repository( + @JsonProperty("iconUrl") val iconUrl: String?, @JsonProperty("name") val name: String, @JsonProperty("description") val description: String?, @JsonProperty("manifestVersion") val manifestVersion: Int, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt index bd1e219d0..9c5229212 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt @@ -263,8 +263,7 @@ class ExtensionsFragment : Fragment() { val fixedName = if (!name.isNullOrBlank()) name else repository.name - - val newRepo = RepositoryData(fixedName, url) + val newRepo = RepositoryData(repository.iconUrl,fixedName, url) RepositoryManager.addRepository(newRepo) extensionViewModel.loadStats() extensionViewModel.loadRepositories() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt index ebe9fc888..c148ae35a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt @@ -17,6 +17,7 @@ import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe data class RepositoryData( + @JsonProperty("iconUrl") val iconUrl: String?, @JsonProperty("name") val name: String, @JsonProperty("url") val url: String ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt index 9d6333144..42550091a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt @@ -12,7 +12,9 @@ import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIE import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper +import com.lagradost.cloudstream3.utils.getImageFromDrawable class RepoAdapter( val isSetup: Boolean, @@ -61,6 +63,17 @@ class RepoAdapter( diffResult.dispatchUpdatesTo(this) } + // Clear coil image because setImageResource doesn't override + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + if (holder is RepoViewHolder) { + when(holder.binding){ + is RepositoryItemBinding -> holder.binding.entryIcon.loadImage(R.drawable.ic_github_logo) + is RepositoryItemTvBinding -> holder.binding.entryIcon.loadImage(R.drawable.ic_github_logo) + } + } + super.onViewRecycled(holder) + } + inner class RepoViewHolder( val binding: ViewBinding ) : @@ -89,6 +102,11 @@ class RepoAdapter( } mainText.text = repositoryData.name subText.text = repositoryData.url + if(!repositoryData.iconUrl.isNullOrEmpty()){ + entryIcon.loadImage(repositoryData.iconUrl){ + error(getImageFromDrawable(itemView.context,R.drawable.ic_github_logo)) + } + } } } @@ -116,6 +134,11 @@ class RepoAdapter( mainText.text = repositoryData.name subText.text = repositoryData.url + if(!repositoryData.iconUrl.isNullOrEmpty()){ + entryIcon.loadImage(repositoryData.iconUrl){ + error(getImageFromDrawable(itemView.context,R.drawable.ic_github_logo)) + } + } } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt index cf4e20815..a451972f7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -543,6 +543,7 @@ object AppContextUtils { val repo = RepositoryManager.parseRepository(url) ?: return@ioSafe RepositoryManager.addRepository( RepositoryData( + repo.iconUrl ?: "", repo.name, url ) From f3ff6bb4a5f8637f2009fb0cc57e3839ecc41bfc Mon Sep 17 00:00:00 2001 From: rockhero1234 <149141736+rockhero1234@users.noreply.github.com> Date: Wed, 13 Aug 2025 19:10:04 +0530 Subject: [PATCH 010/790] feat:local profile photo (#1826) --- .../cloudstream3/ui/account/AccountHelper.kt | 54 +++++++++++++++++++ .../main/res/layout/account_edit_dialog.xml | 43 +++++++++------ app/src/main/res/values/strings.xml | 5 ++ 3 files changed, 86 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt index ce8d53609..0fd37e245 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt @@ -8,6 +8,7 @@ import android.text.Editable import android.view.LayoutInflater import android.view.inputmethod.EditorInfo import android.widget.TextView +import android.widget.Toast import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone @@ -16,12 +17,17 @@ import androidx.core.widget.doOnTextChanged import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import coil3.ImageLoader +import coil3.request.ImageRequest +import coil3.request.allowHardware import com.google.android.material.bottomsheet.BottomSheetDialog import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity +import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.AccountEditDialogBinding import com.lagradost.cloudstream3.databinding.AccountSelectLinearBinding +import com.lagradost.cloudstream3.databinding.BottomInputDialogBinding import com.lagradost.cloudstream3.databinding.LockPinDialogBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe @@ -94,6 +100,7 @@ object AccountHelper { binding.accountImage.loadImage(account.image) binding.accountImage.setOnClickListener { // Roll the image forwards once + currentEditAccount = currentEditAccount.copy(customImage = null) currentEditAccount = currentEditAccount.copy(defaultImageIndex = (currentEditAccount.defaultImageIndex + 1) % DataStoreHelper.profileImages.size) binding.accountImage.loadImage(currentEditAccount.image) @@ -156,6 +163,53 @@ object AccountHelper { } canSetPin = true + + binding.editProfilePhotoButton.setOnClickListener({ + val bottomSheetDialog = BottomSheetDialog(context) + val sheetBinding = BottomInputDialogBinding.inflate(LayoutInflater.from(context)) + bottomSheetDialog.setContentView(sheetBinding.root) + bottomSheetDialog.show() + + sheetBinding.apply { + text1.text = context.getString(R.string.edit_profile_image_title) + nginxTextInput.hint = context.getString(R.string.edit_profile_image_hint) + + applyBtt.setOnClickListener({ + val url = sheetBinding.nginxTextInput.text.toString() + if (url.isNotEmpty()) { + val imageLoader = ImageLoader(context) + val request = ImageRequest.Builder(context) + .data(url) + .allowHardware(false) + .listener( + onSuccess = { _, _ -> + currentEditAccount = currentEditAccount.copy(customImage = url) + binding.accountImage.loadImage(url) + showToast( + R.string.edit_profile_image_success, + Toast.LENGTH_SHORT + ) + bottomSheetDialog.dismiss() + }, + onError = { _, _ -> + showToast( + R.string.edit_profile_image_error_invalid, + Toast.LENGTH_SHORT + ) + } + ) + .build() + imageLoader.enqueue(request) + } else { + showToast(R.string.edit_profile_image_error_empty, Toast.LENGTH_SHORT) + } + bottomSheetDialog.dismissSafe() + }) + sheetBinding.cancelBtt.setOnClickListener({ + bottomSheetDialog.dismissSafe() + }) + } + }) } fun showPinInputDialog( diff --git a/app/src/main/res/layout/account_edit_dialog.xml b/app/src/main/res/layout/account_edit_dialog.xml index 9d39425a4..066b94342 100644 --- a/app/src/main/res/layout/account_edit_dialog.xml +++ b/app/src/main/res/layout/account_edit_dialog.xml @@ -37,6 +37,33 @@ android:layout_marginBottom="60dp" android:orientation="vertical"> + + + + + - - - - speedup_key LongPress Speed Toggle Hold to get 2x speed + Edit Profile Image + Enter Profile Image URL + No URL Found + Invalid URL or Image + Successfully Image Updated From b2ce0f81f2963c1651a82320e13818188e01e7c3 Mon Sep 17 00:00:00 2001 From: KingLucius Date: Sat, 16 Aug 2025 00:58:25 +0300 Subject: [PATCH 011/790] Hide PIP settings option on TV (#1829) --- .../com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt index 5c6acdd9b..0f7a24d15 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt @@ -43,7 +43,8 @@ class SettingsPlayer : PreferenceFragmentCompat() { R.string.pref_category_gestures_key, R.string.rotate_video_key, R.string.auto_rotate_video_key, - R.string.speedup_key + R.string.speedup_key, + R.string.pip_enabled_key ), TV or EMULATOR ) From ddfaf7e701d0fc4e898f8f7310e228be9446e6e3 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Sat, 16 Aug 2025 22:09:56 +0200 Subject: [PATCH 012/790] Fix: Sync step size crashfix, Closes #1838 --- .../cloudstream3/ui/result/ResultFragmentPhone.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index 78031d5c8..9c39767a2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -946,7 +946,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultSyncHolder.isVisible = true val d = status.value - resultSyncRating.value = d.score?.toFloat(resultSyncRating.valueTo.roundToInt()) ?: 0.0f + val desiredScore = d.score?.toFloat(1) ?: 0.0f + val totalSteps = (resultSyncRating.valueTo / resultSyncRating.stepSize) + val desiredStep = (totalSteps * desiredScore).roundToInt() + resultSyncRating.value = desiredStep * resultSyncRating.stepSize + resultSyncCheck.setItemChecked(d.status.internalId + 1, true) val watchedEpisodes = d.watchedEpisodes ?: 0 currentSyncProgress = watchedEpisodes @@ -964,7 +968,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultSyncCurrentEpisodes.text = Editable.Factory.getInstance()?.newEditable(watchedEpisodes.toString()) safe { // format might fail - val text = d.score?.toInt(10)?.let { + val text = d.score?.toFloat(10)?.roundToInt()?.let { context?.getString(R.string.sync_score_format)?.format(it) } ?: "?" resultSyncScoreText.text = text From c4a111ef3afab4c842c0cfc61ee0e0641b809c7b Mon Sep 17 00:00:00 2001 From: rockhero1234 <149141736+rockhero1234@users.noreply.github.com> Date: Sun, 17 Aug 2025 18:22:04 +0530 Subject: [PATCH 013/790] fix:retain old RepositoryData constructor (#1841) --- .../ui/settings/extensions/ExtensionsViewModel.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt index c148ae35a..6d5e2ce27 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt @@ -20,7 +20,9 @@ data class RepositoryData( @JsonProperty("iconUrl") val iconUrl: String?, @JsonProperty("name") val name: String, @JsonProperty("url") val url: String -) +){ + constructor(name: String,url: String):this(null,name,url) +} const val REPOSITORIES_KEY = "REPOSITORIES_KEY" From ec093fb36ca1151904e0b7f6698a0d55d6c9b949 Mon Sep 17 00:00:00 2001 From: Phisher98 <153359846+phisher98@users.noreply.github.com> Date: Tue, 19 Aug 2025 00:24:42 +0530 Subject: [PATCH 014/790] DailyMotion Fix (#1844) --- .../cloudstream3/extractors/Dailymotion.kt | 73 ++++++------------- 1 file changed, 23 insertions(+), 50 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt index d4d9c606f..557910807 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt @@ -1,9 +1,7 @@ package com.lagradost.cloudstream3.extractors -import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 @@ -32,32 +30,22 @@ open class Dailymotion : ExtractorApi() { callback: (ExtractorLink) -> Unit ) { val embedUrl = getEmbedUrl(url) ?: return - val req = app.get(embedUrl) - val prefix = "window.__PLAYER_CONFIG__ = " - val configStr = req.document.selectFirst("script:containsData($prefix)")?.data() ?: return - val config = tryParseJson(configStr.substringAfter(prefix).substringBefore(";").trim()) ?: return val id = getVideoId(embedUrl) ?: return - val dmV1st = config.dmInternalData.v1st - val dmTs = config.dmInternalData.ts - val embedder = config.context.embedder - val metaDataUrl = "$baseUrl/player/metadata/video/$id?embedder=$embedder&locale=en-US&dmV1st=$dmV1st&dmTs=$dmTs&is_native_app=0" - val metaData = app.get(metaDataUrl, referer = embedUrl, cookies = req.cookies) - .parsedSafe() ?: return - metaData.qualities.forEach { (_, video) -> - video.forEach { - getStream(it.url, this.name, callback) + val metaDataUrl = "$baseUrl/player/metadata/video/$id" + val metaData = app.get(metaDataUrl, referer = embedUrl) + .parsedSafe() ?: return + metaData.qualities.forEach { (_, qualityList) -> + qualityList.forEach { video -> + getStream(video.url, this.name, callback) } } + metaData.subtitles.data.forEach { (_, subtitle) -> val subUrl = subtitle.urls.firstOrNull() ?: return@forEach - subtitleCallback.invoke( - SubtitleFile( - subtitle.label, - subUrl - ) + subtitleCallback( + SubtitleFile(subtitle.label, subUrl) ) } - } private fun getEmbedUrl(url: String): String? { @@ -91,39 +79,24 @@ open class Dailymotion : ExtractorApi() { "", ).forEach(callback) } - data class Config( - val context: Context, - val dmInternalData: InternalData + + data class VideoData( + val qualities: Map>, + val subtitles: SubtitlesData ) - data class InternalData( - val ts: Long, - val v1st: String - ) - - data class Context( - @JsonProperty("access_token") val accessToken: String?, - val embedder: String?, - ) - - data class MetaData( - val qualities: Map>, - val subtitles: Subtitles, - ) - - data class Subtitles( - val enable: Boolean, - val data: Map - ) - - data class Subtitle( - val label: String, - val urls: List, - ) - - data class VideoLink( + data class QualityVideo( val type: String, val url: String ) + data class SubtitlesData( + val data: Map + ) + + data class SubtitleItem( + val label: String, + val urls: List + ) + } \ No newline at end of file From 5122a72adfca1c0feec9718783c2407af4be225b Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Mon, 18 Aug 2025 20:55:33 +0200 Subject: [PATCH 015/790] Fix: popCurrentPage fix in player --- .../com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index ee4bad545..22cd22d3c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -1864,7 +1864,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } playerGoBack.setOnClickListener { - activity?.popCurrentPage() + activity?.popCurrentPage("FullScreenPlayer") } playerSourcesBtt.setOnClickListener { From b32a27b76f30cf8e738de5f98488406c40630b72 Mon Sep 17 00:00:00 2001 From: Phisher98 <153359846+phisher98@users.noreply.github.com> Date: Tue, 19 Aug 2025 20:39:28 +0530 Subject: [PATCH 016/790] Dailymotion Minor Fix (#1848) * Dailymotion * Dailymotion * Dailymotion --- .../cloudstream3/extractors/Dailymotion.kt | 73 +++++++------------ 1 file changed, 25 insertions(+), 48 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt index 557910807..aa5e60c32 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt @@ -7,6 +7,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 import java.net.URI + class Geodailymotion : Dailymotion() { override val name = "GeoDailymotion" override val mainUrl = "https://geo.dailymotion.com" @@ -18,11 +19,8 @@ open class Dailymotion : ExtractorApi() { override val requiresReferer = false private val baseUrl = "https://www.dailymotion.com" - @Suppress("RegExpSimplifiable") - private val videoIdRegex = "^[kx][a-zA-Z0-9]+\$".toRegex() + private val videoIdRegex = "^[kx][a-zA-Z0-9]+$".toRegex() - // https://www.dailymotion.com/video/k3JAHfletwk94ayCVIu - // https://www.dailymotion.com/embed/video/k3JAHfletwk94ayCVIu override suspend fun getUrl( url: String, referer: String?, @@ -32,26 +30,31 @@ open class Dailymotion : ExtractorApi() { val embedUrl = getEmbedUrl(url) ?: return val id = getVideoId(embedUrl) ?: return val metaDataUrl = "$baseUrl/player/metadata/video/$id" - val metaData = app.get(metaDataUrl, referer = embedUrl) - .parsedSafe() ?: return - metaData.qualities.forEach { (_, qualityList) -> - qualityList.forEach { video -> - getStream(video.url, this.name, callback) - } + val response = app.get(metaDataUrl, referer = embedUrl).text + val qualityUrlRegex = Regex(""""url"\s*:\s*"([^"]+)"""") + val subtitlesRegex = Regex(""""subtitles"\s*:\s*\{[^}]*"data"\s*:\s*(\[[^\]]*\])""") + + val urls = qualityUrlRegex.findAll(response) + .map { it.groupValues[1] } + .toList().filter { it.contains(".m3u8") } + + urls.forEach { videoUrl -> + getStream(videoUrl, this.name, callback) } - metaData.subtitles.data.forEach { (_, subtitle) -> - val subUrl = subtitle.urls.firstOrNull() ?: return@forEach - subtitleCallback( - SubtitleFile(subtitle.label, subUrl) - ) + val subtitlesMatches = subtitlesRegex.findAll(response).map { it.groupValues[1] }.toList() + subtitlesMatches.forEach { subtitleJson -> + val subRegex = Regex("""\{\s*"label"\s*:\s*"([^"]+)",\s*"urls"\s*:\s*\["([^"]+)"""") + subRegex.findAll(subtitleJson).forEach { match -> + val label = match.groupValues[1] + val subUrl = match.groupValues[2] + subtitleCallback(SubtitleFile(label, subUrl)) + } } } private fun getEmbedUrl(url: String): String? { - if (url.contains("/embed/") || url.contains("/video/")) { - return url - } + if (url.contains("/embed/") || url.contains("/video/")) return url if (url.contains("geo.dailymotion.com")) { val videoId = url.substringAfter("video=") return "$baseUrl/embed/video/$videoId" @@ -59,44 +62,18 @@ open class Dailymotion : ExtractorApi() { return null } + private fun getVideoId(url: String): String? { val path = URI(url).path val id = path.substringAfter("/video/") - if (id.matches(videoIdRegex)) { - return id - } - return null + return if (id.matches(videoIdRegex)) id else null } private suspend fun getStream( streamLink: String, name: String, callback: (ExtractorLink) -> Unit - ) { - return generateM3u8( - name, - streamLink, - "", - ).forEach(callback) + ) { + return generateM3u8(name, streamLink, "").forEach(callback) } - - data class VideoData( - val qualities: Map>, - val subtitles: SubtitlesData - ) - - data class QualityVideo( - val type: String, - val url: String - ) - - data class SubtitlesData( - val data: Map - ) - - data class SubtitleItem( - val label: String, - val urls: List - ) - } \ No newline at end of file From b87e85ba7de5d56296fd2d218c72259b8708125b Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Wed, 20 Aug 2025 19:20:14 +0200 Subject: [PATCH 017/790] Fix: Force reload account when loading homepage, Closes #1851 --- .../java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt index 60f2c1801..fccf1bb2c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt @@ -531,7 +531,7 @@ class HomeViewModel : ViewModel() { // if the api is found, then set it to it and save key if (fromUI) DataStoreHelper.currentHomePage = api.name loadAndCancel(api) - reloadAccount() } + reloadAccount() } } \ No newline at end of file From 6cc8b0111935c27e9cb58a6a01538fed71288cdb Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Thu, 21 Aug 2025 01:13:13 +0200 Subject: [PATCH 018/790] Feat(TV): Added network stream to downloads tab, and updated nextfocus for easier navigation. Closes #1283 --- .../lagradost/cloudstream3/MainActivity.kt | 30 ++ .../ui/download/DownloadFragment.kt | 27 +- .../res/layout/fragment_child_downloads.xml | 4 +- .../main/res/layout/fragment_downloads.xml | 277 ++++++++++++------ .../main/res/layout/fragment_home_head_tv.xml | 6 +- app/src/main/res/layout/fragment_home_tv.xml | 4 +- .../main/res/layout/fragment_library_tv.xml | 2 +- .../main/res/layout/fragment_search_tv.xml | 12 +- 8 files changed, 242 insertions(+), 120 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 10f8117eb..ed3db1493 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -699,6 +699,36 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa // Check if we are already at the selected destination if (navController.currentDestination?.id == destinationId) return false + // Make all nav buttons focus on this specific view when nextFocusRightId + val targetView = when (destinationId) { + // Please note that if R.id.navigation_home is readded, then it will only take affect when + // navigation to home for the second time as onNavDestinationSelected will not get called + // when first loading up the app + + // R.id.navigation_home -> R.id.home_preview_change_api + R.id.navigation_search -> R.id.main_search + R.id.navigation_library -> R.id.main_search + R.id.navigation_downloads -> R.id.download_appbar + else -> null + } + if (targetView != null && isLayout(TV or EMULATOR)) { + val fromView = binding?.navRailView + if (fromView != null) { + fromView.nextFocusRightId = targetView + + for (focusView in arrayOf( + R.id.navigation_downloads, + R.id.navigation_home, + R.id.navigation_search, + R.id.navigation_library, + R.id.navigation_settings, + )) { + fromView.findViewById(focusView)?.nextFocusRightId = targetView + } + } + } + + val builder = NavOptions.Builder().setLaunchSingleTop(true).setRestoreState(true) .setEnterAnim(R.anim.enter_anim) .setExitAnim(R.anim.exit_anim) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt index 7c1c5b769..2010fe7e3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt @@ -87,7 +87,7 @@ class DownloadFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) hideKeyboard() - binding?.downloadStorageAppbar?.setAppBarNoScrollFlagsOnTV() + binding?.downloadAppbar?.setAppBarNoScrollFlagsOnTV() binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV() /** @@ -136,12 +136,15 @@ class DownloadFragment : Fragment() { binding?.downloadUsed ) - // Prevent race condition and make sure - // we don't display it early - if ( - downloadsViewModel.isMultiDeleteState.value == null || - downloadsViewModel.isMultiDeleteState.value == false - ) binding?.downloadStorageAppbar?.isVisible = it > 0 + val hasBytes = it > 0 + if(hasBytes) { + binding?.downloadLoadingBytes?.stopShimmer() + } else { + binding?.downloadLoadingBytes?.startShimmer() + } + + binding?.downloadBytesBar?.isVisible = hasBytes + binding?.downloadLoadingBytes?.isGone = hasBytes } observe(downloadsViewModel.downloadBytes) { updateStorageInfo( @@ -165,7 +168,7 @@ class DownloadFragment : Fragment() { // Prevent race condition and make sure // we don't display it early if (downloadsViewModel.usedBytes.value?.let { it > 0 } == true) { - binding?.downloadStorageAppbar?.isVisible = true + binding?.downloadAppbar?.isVisible = true } } } @@ -218,6 +221,12 @@ class DownloadFragment : Fragment() { isGone = isLayout(TV) setOnClickListener { showStreamInputDialog(it.context) } } + + downloadStreamButtonTv.isFocusableInTouchMode = isLayout(TV) + downloadAppbar.isFocusableInTouchMode = isLayout(TV) + + downloadStreamButtonTv.setOnClickListener { showStreamInputDialog(it.context) } + steamImageviewHolder.isVisible = isLayout(TV) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -252,7 +261,7 @@ class DownloadFragment : Fragment() { private fun handleSelectedChange(selected: MutableSet) { if (selected.isNotEmpty()) { binding?.downloadDeleteAppbar?.isVisible = true - binding?.downloadStorageAppbar?.isVisible = false + binding?.downloadAppbar?.isVisible = false activity?.attachBackPressedCallback("Downloads") { downloadsViewModel.setIsMultiDeleteState(false) } diff --git a/app/src/main/res/layout/fragment_child_downloads.xml b/app/src/main/res/layout/fragment_child_downloads.xml index 64ed1d700..c3ab356c2 100644 --- a/app/src/main/res/layout/fragment_child_downloads.xml +++ b/app/src/main/res/layout/fragment_child_downloads.xml @@ -32,7 +32,7 @@ android:contentDescription="@string/cancel" android:padding="8dp" android:layout_gravity="center_vertical" - android:nextFocusLeft="@id/nav_rail_view" + android:nextFocusLeft="@id/navigation_downloads" app:tint="@android:color/white" />