From d2d2e41fb31a7da70adf6ef080540833a7070657 Mon Sep 17 00:00:00 2001 From: self-similarity <137652432+self-similarity@users.noreply.github.com> Date: Sat, 12 Aug 2023 20:25:30 +0000 Subject: [PATCH] Added Simkl (#548) --- .github/workflows/build_to_archive.yml | 2 + .github/workflows/prerelease.yml | 2 + app/build.gradle.kts | 25 +- .../com/lagradost/cloudstream3/MainAPI.kt | 29 +- .../syncproviders/AccountManager.kt | 7 +- .../cloudstream3/syncproviders/SyncAPI.kt | 31 +- .../cloudstream3/syncproviders/SyncRepo.kt | 4 +- .../syncproviders/providers/AniListApi.kt | 4 +- .../syncproviders/providers/LocalList.kt | 4 +- .../syncproviders/providers/MALApi.kt | 2 +- .../syncproviders/providers/SimklApi.kt | 848 ++++++++++++++++++ .../cloudstream3/ui/result/SyncViewModel.kt | 38 +- .../ui/settings/SettingsAccount.kt | 2 + app/src/main/res/drawable/simkl_logo.xml | 9 + app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/settings_account.xml | 43 +- 16 files changed, 988 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt create mode 100644 app/src/main/res/drawable/simkl_logo.xml diff --git a/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml index 9cd2c523..3b7aa9ae 100644 --- a/.github/workflows/build_to_archive.yml +++ b/.github/workflows/build_to_archive.yml @@ -56,6 +56,8 @@ jobs: SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} + SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} + SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} - uses: actions/checkout@v3 with: repository: "recloudstream/cloudstream-archive" diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 856d267c..58009a7a 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -48,6 +48,8 @@ jobs: SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} + SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} + SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} - name: Create pre-release uses: "marvinpinto/action-automatic-releases@latest" with: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cfe89c05..3c12652a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,4 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties import org.jetbrains.dokka.gradle.DokkaTask import java.io.ByteArrayOutputStream import java.net.URL @@ -54,17 +55,27 @@ android { versionName = "4.1.3" resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") - resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "") - resValue("bool", "is_prerelease", "false") + // Reads local.properties + val localProperties = gradleLocalProperties(rootDir) + buildConfigField( "String", "BUILDDATE", "new java.text.SimpleDateFormat(\"yyyy-MM-dd HH:mm\").format(new java.util.Date(" + System.currentTimeMillis() + "L));" ) - + buildConfigField( + "String", + "SIMKL_CLIENT_ID", + "\"" + (System.getenv("SIMKL_CLIENT_ID") ?: localProperties["simkl.id"]) + "\"" + ) + buildConfigField( + "String", + "SIMKL_CLIENT_SECRET", + "\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\"" + ) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" kapt { @@ -108,9 +119,9 @@ android { } } //toolchain { - // languageVersion.set(JavaLanguageVersion.of(17)) - // } - // jvmToolchain(17) + // languageVersion.set(JavaLanguageVersion.of(17)) + // } + // jvmToolchain(17) compileOptions { isCoreLibraryDesugaringEnabled = true @@ -211,7 +222,7 @@ dependencies { // Networking // implementation("com.squareup.okhttp3:okhttp:4.9.2") // implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1") - implementation("com.github.Blatzar:NiceHttp:0.4.2") + implementation("com.github.Blatzar:NiceHttp:0.4.3") // To fix SSL fuckery on android 9 implementation("org.conscrypt:conscrypt-android:2.2.1") // Util to skip the URI file fuckery 🙏 diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt index 76abda97..7790f047 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt @@ -11,9 +11,12 @@ import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.json.JsonMapper import com.fasterxml.jackson.module.kotlin.KotlinModule import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.syncproviders.providers.SimklApi import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.AppUtils.toJson @@ -821,7 +824,8 @@ public enum class AutoDownloadMode(val value: Int) { ; companion object { - infix fun getEnum(value: Int): AutoDownloadMode? = AutoDownloadMode.values().firstOrNull { it.value == value } + infix fun getEnum(value: Int): AutoDownloadMode? = + AutoDownloadMode.values().firstOrNull { it.value == value } } } @@ -1143,6 +1147,7 @@ interface LoadResponse { companion object { private val malIdPrefix = malApi.idPrefix private val aniListIdPrefix = aniListApi.idPrefix + private val simklIdPrefix = simklApi.idPrefix var isTrailersEnabled = true fun LoadResponse.isMovie(): Boolean { @@ -1164,6 +1169,20 @@ interface LoadResponse { this.actors = actors?.map { (actor, role) -> ActorData(actor, role = role) } } + /** + * Internal helper function to add simkl ids from other databases. + */ + private fun LoadResponse.addSimklId( + database: SimklApi.Companion.SyncServices, + id: String? + ) { + normalSafeApiCall { + this.syncData[simklIdPrefix] = + SimklApi.addIdToString(this.syncData[simklIdPrefix], database, id.toString()) + ?: return@normalSafeApiCall + } + } + @JvmName("addActorsOnly") fun LoadResponse.addActors(actors: List?) { this.actors = actors?.map { actor -> ActorData(actor) } @@ -1179,10 +1198,16 @@ interface LoadResponse { fun LoadResponse.addMalId(id: Int?) { this.syncData[malIdPrefix] = (id ?: return).toString() + this.addSimklId(SimklApi.Companion.SyncServices.Mal, id.toString()) } fun LoadResponse.addAniListId(id: Int?) { this.syncData[aniListIdPrefix] = (id ?: return).toString() + this.addSimklId(SimklApi.Companion.SyncServices.AniList, id.toString()) + } + + fun LoadResponse.addSimklId(id: Int?) { + this.addSimklId(SimklApi.Companion.SyncServices.Simkl, id.toString()) } fun LoadResponse.addImdbUrl(url: String?) { @@ -1264,6 +1289,7 @@ interface LoadResponse { fun LoadResponse.addImdbId(id: String?) { // TODO add imdb sync + this.addSimklId(SimklApi.Companion.SyncServices.Imdb, id) } fun LoadResponse.addTrackId(id: String?) { @@ -1276,6 +1302,7 @@ interface LoadResponse { fun LoadResponse.addTMDbId(id: String?) { // TODO add TMDb sync + this.addSimklId(SimklApi.Companion.SyncServices.Tmdb, id) } fun LoadResponse.addRating(text: String?) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt index 8ce6bae2..8bf8dffa 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -11,6 +11,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI { val malApi = MALApi(0) val aniListApi = AniListApi(0) val openSubtitlesApi = OpenSubtitlesApi(0) + val simklApi = SimklApi(0) val indexSubtitlesApi = IndexSubtitleApi() val addic7ed = Addic7ed() val localListApi = LocalList() @@ -18,19 +19,19 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI { // used to login via app intent val OAuth2Apis get() = listOf( - malApi, aniListApi + malApi, aniListApi, simklApi ) // this needs init with context and can be accessed in settings val accountManagers get() = listOf( - malApi, aniListApi, openSubtitlesApi, //nginxApi + malApi, aniListApi, openSubtitlesApi, simklApi //nginxApi ) // used for active syncing val SyncApis get() = listOf( - SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi) + SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi) ) val inAppAuths diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt index 8c76c5bf..ed496326 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt @@ -10,7 +10,8 @@ enum class SyncIdName { MyAnimeList, Trakt, Imdb, - LocalList + Simkl, + LocalList, } interface SyncAPI : OAuth2API { @@ -35,9 +36,9 @@ interface SyncAPI : OAuth2API { 4 -> PlanToWatch 5 -> ReWatching */ - suspend fun score(id: String, status: SyncStatus): Boolean + suspend fun score(id: String, status: AbstractSyncStatus): Boolean - suspend fun getStatus(id: String): SyncStatus? + suspend fun getStatus(id: String): AbstractSyncStatus? suspend fun getResult(id: String): SyncResult? @@ -59,14 +60,24 @@ interface SyncAPI : OAuth2API { override var id: Int? = null, ) : SearchResponse - data class SyncStatus( - val status: Int, + abstract class AbstractSyncStatus { + abstract var status: Int + /** 1-10 */ - val score: Int?, - val watchedEpisodes: Int?, - var isFavorite: Boolean? = null, - var maxEpisodes: Int? = null, - ) + abstract var score: Int? + abstract var watchedEpisodes: Int? + abstract var isFavorite: Boolean? + abstract var maxEpisodes: Int? + } + + data class SyncStatus( + override var status: Int, + /** 1-10 */ + override var score: Int?, + override var watchedEpisodes: Int?, + override var isFavorite: Boolean? = null, + override var maxEpisodes: Int? = null, + ) : AbstractSyncStatus() data class SyncResult( /**Used to verify*/ diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt index 85b877e0..9363cb6f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt @@ -18,11 +18,11 @@ class SyncRepo(private val repo: SyncAPI) { repo.requireLibraryRefresh = value } - suspend fun score(id: String, status: SyncAPI.SyncStatus): Resource { + suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Resource { return safeApiCall { repo.score(id, status) } } - suspend fun getStatus(id: String): Resource { + suspend fun getStatus(id: String): Resource { return safeApiCall { repo.getStatus(id) ?: throw ErrorLoadingException("No data") } } 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 0010ce25..d0c88901 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 @@ -158,7 +158,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) } - override suspend fun getStatus(id: String): SyncAPI.SyncStatus? { + override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? { val internalId = id.toIntOrNull() ?: return null val data = getDataAboutId(internalId) ?: return null @@ -171,7 +171,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) } - override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean { + override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { return postDataAboutId( id.toIntOrNull() ?: return false, fromIntToAnimeStatus(status.status), 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 7dd43fe7..e6ca9711 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 @@ -45,11 +45,11 @@ class LocalList : SyncAPI { override val mainUrl = "" override val syncIdName = SyncIdName.LocalList - override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean { + override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { return true } - override suspend fun getStatus(id: String): SyncAPI.SyncStatus? { + override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? { return null } 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 5164b606..02826401 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 @@ -91,7 +91,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() } - override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean { + override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { return setScoreRequest( id.toIntOrNull() ?: return false, fromIntToAnimeStatus(status.status), 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 new file mode 100644 index 00000000..64afb0e2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt @@ -0,0 +1,848 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import androidx.annotation.StringRes +import androidx.core.net.toUri +import androidx.fragment.app.FragmentActivity +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.mvvm.debugPrint +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall +import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.ui.library.ListSorting +import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import okhttp3.Interceptor +import okhttp3.Response +import java.math.BigInteger +import java.security.SecureRandom +import java.text.SimpleDateFormat +import java.time.Instant +import java.util.Date +import java.util.TimeZone + +class SimklApi(index: Int) : AccountManager(index), SyncAPI { + override var name = "Simkl" + override val key = "simkl-key" + override val redirectUrl = "simkl" + override val idPrefix = "simkl" + override var requireLibraryRefresh = true + override var mainUrl = "https://api.simkl.com" + override val icon = R.drawable.simkl_logo + override val requiresLogin = false + override val createAccountUrl = "$mainUrl/signup" + override val syncIdName = SyncIdName.Simkl + private val token: String? + get() = getKey(accountId, SIMKL_TOKEN_KEY).also { + debugAssert({ it == null }) { "No ${this.name} token!" } + } + + /** Automatically adds simkl auth headers */ + private val interceptor = HeaderInterceptor() + + /** + * This is required to override the reported last activity as simkl activites + * may not always update based on testing. + */ + private var lastScoreTime = -1L + + companion object { + private const val clientId = BuildConfig.SIMKL_CLIENT_ID + private const val clientSecret = BuildConfig.SIMKL_CLIENT_SECRET + private var lastLoginState = "" + + const val SIMKL_TOKEN_KEY: String = "simkl_token" + const val SIMKL_USER_KEY: String = "simkl_user" + const val SIMKL_CACHED_LIST: String = "simkl_cached_list" + const val SIMKL_CACHED_LIST_TIME: String = "simkl_cached_time" + + /** 2014-09-01T09:10:11Z -> 1409562611 */ + private const val simklDateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" + fun getUnixTime(string: String?): Long? { + return try { + SimpleDateFormat(simklDateFormat).apply { + this.timeZone = TimeZone.getTimeZone("UTC") + }.parse( + string ?: return null + )?.toInstant()?.epochSecond + } catch (e: Exception) { + logError(e) + return null + } + } + + /** 1409562611 -> 2014-09-01T09:10:11Z */ + fun getDateTime(unixTime: Long?): String? { + return try { + SimpleDateFormat(simklDateFormat).apply { + this.timeZone = TimeZone.getTimeZone("UTC") + }.format( + Date.from( + Instant.ofEpochSecond( + unixTime ?: return null + ) + ) + ) + } catch (e: Exception) { + null + } + } + + /** + * Set of sync services simkl is compatible with. + * Add more as required: https://simkl.docs.apiary.io/#reference/search/id-lookup/get-items-by-id + */ + enum class SyncServices(val originalName: String) { + Simkl("simkl"), + Imdb("imdb"), + Tmdb("tmdb"), + AniList("anilist"), + Mal("mal"), + } + + /** + * The ID string is a way to keep a collection of services in one single ID using a map + * This adds a database service (like imdb) to the string and returns the new string. + */ + fun addIdToString(idString: String?, database: SyncServices, id: String?): String? { + if (id == null) return idString + return (readIdFromString(idString) + mapOf(database to id)).toJson() + } + + /** Read the id string to get all other ids */ + private fun readIdFromString(idString: String?): Map { + return tryParseJson(idString) ?: return emptyMap() + } + + fun getPosterUrl(poster: String): String { + return "https://wsrv.nl/?url=https://simkl.in/posters/${poster}_m.webp" + } + + private fun getUrlFromId(id: Int): String { + return "https://simkl.com/shows/$id" + } + + enum class SimklListStatusType( + var value: Int, + @StringRes val stringRes: Int, + val originalName: String? + ) { + Watching(0, R.string.type_watching, "watching"), + Completed(1, R.string.type_completed, "completed"), + Paused(2, R.string.type_on_hold, "hold"), + Dropped(3, R.string.type_dropped, "dropped"), + Planning(4, R.string.type_plan_to_watch, "plantowatch"), + ReWatching(5, R.string.type_re_watching, "watching"), + None(-1, R.string.none, null); + + companion object { + fun fromString(string: String): SimklListStatusType? { + return SimklListStatusType.values().firstOrNull { + it.originalName == string + } + } + } + } + + // ------------------- + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class TokenRequest( + @JsonProperty("code") val code: String, + @JsonProperty("client_id") val client_id: String = clientId, + @JsonProperty("client_secret") val client_secret: String = clientSecret, + @JsonProperty("redirect_uri") val redirect_uri: String = "$appString://simkl", + @JsonProperty("grant_type") val grant_type: String = "authorization_code" + ) + + data class TokenResponse( + /** No expiration date */ + val access_token: String, + val token_type: String, + val scope: String + ) + // ------------------- + + /** https://simkl.docs.apiary.io/#reference/users/settings/receive-settings */ + data class SettingsResponse( + val user: User + ) { + data class User( + val name: String, + /** Url */ + val avatar: String + ) + } + + // ------------------- + data class ActivitiesResponse( + val all: String?, + val tv_shows: UpdatedAt, + val anime: UpdatedAt, + val movies: UpdatedAt, + ) { + data class UpdatedAt( + val all: String?, + val removed_from_list: String?, + val rated_at: String?, + ) + } + + /** https://simkl.docs.apiary.io/#reference/tv/episodes/get-tv-show-episodes */ + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class EpisodeMetadata( + @JsonProperty("title") val title: String?, + @JsonProperty("description") val description: String?, + @JsonProperty("season") val season: Int?, + @JsonProperty("episode") val episode: Int, + @JsonProperty("img") val img: String? + ) { + companion object { + fun convertToEpisodes(list: List?): List { + return list?.map { + MediaObject.Season.Episode(it.episode) + } ?: emptyList() + } + + fun convertToSeasons(list: List?): List { + return list?.filter { it.season != null }?.groupBy { + it.season + }?.map { (season, episodes) -> + MediaObject.Season(season!!, convertToEpisodes(episodes)) + } ?: emptyList() + } + } + } + + /** + * https://simkl.docs.apiary.io/#introduction/about-simkl-api/standard-media-objects + * Useful for finding shows from metadata + */ + @JsonInclude(JsonInclude.Include.NON_EMPTY) + open class MediaObject( + @JsonProperty("title") val title: String?, + @JsonProperty("year") val year: Int?, + @JsonProperty("ids") val ids: Ids?, + @JsonProperty("poster") val poster: String? = null, + @JsonProperty("type") val type: String? = null, + @JsonProperty("seasons") val seasons: List? = null, + @JsonProperty("episodes") val episodes: List? = null + ) { + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class Season( + @JsonProperty("number") val number: Int, + @JsonProperty("episodes") val episodes: List + ) { + data class Episode(@JsonProperty("number") val number: Int) + } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class Ids( + @JsonProperty("simkl") val simkl: Int?, + @JsonProperty("imdb") val imdb: String? = null, + @JsonProperty("tmdb") val tmdb: String? = null, + @JsonProperty("mal") val mal: String? = null, + @JsonProperty("anilist") val anilist: String? = null, + ) { + companion object { + fun fromMap(map: Map): Ids { + return Ids( + simkl = map[SyncServices.Simkl]?.toIntOrNull(), + imdb = map[SyncServices.Imdb], + tmdb = map[SyncServices.Tmdb], + mal = map[SyncServices.Mal], + anilist = map[SyncServices.AniList] + ) + } + } + } + + fun toSyncSearchResult(): SyncAPI.SyncSearchResult? { + return SyncAPI.SyncSearchResult( + this.title ?: return null, + "Simkl", + this.ids?.simkl?.toString() ?: return null, + getUrlFromId(this.ids.simkl), + this.poster?.let { getPosterUrl(it) }, + if (this.type == "movie") TvType.Movie else TvType.TvSeries + ) + } + } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + class RatingMediaObject( + @JsonProperty("title") title: String?, + @JsonProperty("year") year: Int?, + @JsonProperty("ids") ids: Ids?, + @JsonProperty("rating") val rating: Int, + @JsonProperty("rated_at") val rated_at: String? = getDateTime(unixTime) + ) : MediaObject(title, year, ids) + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + class StatusMediaObject( + @JsonProperty("title") title: String?, + @JsonProperty("year") year: Int?, + @JsonProperty("ids") ids: Ids?, + @JsonProperty("to") val to: String, + @JsonProperty("watched_at") val watched_at: String? = getDateTime(unixTime) + ) : MediaObject(title, year, ids) + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + class HistoryMediaObject( + @JsonProperty("title") title: String?, + @JsonProperty("year") year: Int?, + @JsonProperty("ids") ids: Ids?, + @JsonProperty("seasons") seasons: List?, + @JsonProperty("episodes") episodes: List?, + ) : MediaObject(title, year, ids, seasons = seasons, episodes = episodes) + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class StatusRequest( + @JsonProperty("movies") val movies: List, + @JsonProperty("shows") val shows: List + ) + + /** https://simkl.docs.apiary.io/#reference/sync/get-all-items/get-all-items-in-the-user's-watchlist */ + data class AllItemsResponse( + val shows: List, + val anime: List, + val movies: List, + ) { + companion object { + fun merge(first: AllItemsResponse?, second: AllItemsResponse?): AllItemsResponse { + + // Replace the first item with the same id, or add the new item + fun MutableList.replaceOrAddItem(newItem: T, predicate: (T) -> Boolean) { + for (i in this.indices) { + if (predicate(this[i])) { + this[i] = newItem + return + } + } + this.add(newItem) + } + + // + fun merge( + first: List?, + second: List? + ): List { + return (first?.toMutableList() ?: mutableListOf()).apply { + second?.forEach { secondShow -> + this.replaceOrAddItem(secondShow) { + it.getIds().simkl == secondShow.getIds().simkl + } + } + } + } + + return AllItemsResponse( + merge(first?.shows, second?.shows), + merge(first?.anime, second?.anime), + merge(first?.movies, second?.movies), + ) + } + } + + interface Metadata { + val last_watched_at: String? + val status: String? + val user_rating: Int? + val last_watched: String? + val watched_episodes_count: Int? + val total_episodes_count: Int? + + fun getIds(): ShowMetadata.Show.Ids + fun toLibraryItem(): SyncAPI.LibraryItem + } + + data class MovieMetadata( + override val last_watched_at: String?, + override val status: String, + override val user_rating: Int?, + override val last_watched: String?, + override val watched_episodes_count: Int?, + override val total_episodes_count: Int?, + val movie: ShowMetadata.Show + ) : Metadata { + override fun getIds(): ShowMetadata.Show.Ids { + return this.movie.ids + } + + override fun toLibraryItem(): SyncAPI.LibraryItem { + return SyncAPI.LibraryItem( + this.movie.title, + "https://simkl.com/tv/${movie.ids.simkl}", + movie.ids.simkl.toString(), + this.watched_episodes_count, + this.total_episodes_count, + this.user_rating?.times(10), + getUnixTime(last_watched_at) ?: 0, + "Simkl", + TvType.Movie, + this.movie.poster?.let { getPosterUrl(it) }, + null, + null, + movie.ids.simkl + ) + } + } + + data class ShowMetadata( + override val last_watched_at: String?, + override val status: String, + override val user_rating: Int?, + override val last_watched: String?, + override val watched_episodes_count: Int?, + override val total_episodes_count: Int?, + val show: Show + ) : Metadata { + override fun getIds(): Show.Ids { + return this.show.ids + } + + override fun toLibraryItem(): SyncAPI.LibraryItem { + return SyncAPI.LibraryItem( + this.show.title, + "https://simkl.com/tv/${show.ids.simkl}", + show.ids.simkl.toString(), + this.watched_episodes_count, + this.total_episodes_count, + this.user_rating?.times(10), + getUnixTime(last_watched_at) ?: 0, + "Simkl", + TvType.Anime, + this.show.poster?.let { getPosterUrl(it) }, + null, + null, + show.ids.simkl + ) + } + + data class Show( + val title: String, + val poster: String?, + val year: Int?, + val ids: Ids, + ) { + data class Ids( + val simkl: Int, + val slug: String?, + val imdb: String?, + val zap2it: String?, + val tmdb: String?, + val offen: String?, + val tvdb: String?, + val mal: String?, + val anidb: String?, + val anilist: String?, + val traktslug: String? + ) { + fun matchesId(database: SyncServices, id: String): Boolean { + return when (database) { + SyncServices.Simkl -> this.simkl == id.toIntOrNull() + SyncServices.AniList -> this.anilist == id + SyncServices.Mal -> this.mal == id + SyncServices.Tmdb -> this.tmdb == id + SyncServices.Imdb -> this.imdb == id + } + } + } + } + } + } + } + + /** + * Appends api keys to the requests + **/ + private inner class HeaderInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + debugPrint { "${this@SimklApi.name} made request to ${chain.request().url}" } + return chain.proceed( + chain.request() + .newBuilder() + .addHeader("Authorization", "Bearer $token") + .addHeader("simkl-api-key", clientId) + .build() + ) + } + } + + private suspend fun getUser(): SettingsResponse.User? { + return suspendSafeApiCall { + app.post("$mainUrl/users/settings", interceptor = interceptor) + .parsedSafe()?.user + } + } + + class SimklSyncStatus( + override var status: Int, + override var score: Int?, + override var watchedEpisodes: Int?, + val episodes: Array?, + override var isFavorite: Boolean? = null, + override var maxEpisodes: Int? = null, + ) : SyncAPI.AbstractSyncStatus() + + override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? { + val realIds = readIdFromString(id) + val foundItem = getSyncListSmart()?.let { list -> + listOf(list.shows, list.anime, list.movies).flatten().firstOrNull { show -> + realIds.any { (database, id) -> + show.getIds().matchesId(database, id) + } + } + } + + // Search to get episodes + val searchResult = searchByIds(realIds)?.firstOrNull() + val episodes = getEpisodes(searchResult?.ids?.simkl, searchResult?.type) + + if (foundItem != null) { + return SimklSyncStatus( + status = foundItem.status?.let { SimklListStatusType.fromString(it)?.value } + ?: return null, + score = foundItem.user_rating, + watchedEpisodes = foundItem.watched_episodes_count, + maxEpisodes = foundItem.total_episodes_count, + episodes = episodes + ) + } else { + return if (searchResult != null) { + SimklSyncStatus( + status = SimklListStatusType.None.value, + score = 0, + watchedEpisodes = 0, + maxEpisodes = if (searchResult.type == "movie") 0 else null, + episodes = episodes + ) + } else { + null + } + } + } + + override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { + val parsedId = readIdFromString(id) + lastScoreTime = unixTime + + if (status.status == SimklListStatusType.None.value) { + return app.post( + "$mainUrl/sync/history/remove", + json = StatusRequest( + shows = listOf( + HistoryMediaObject( + null, + null, + MediaObject.Ids.fromMap(parsedId), + emptyList(), + emptyList() + ) + ), + movies = emptyList() + ), + interceptor = interceptor + ).isSuccessful + } + + val realScore = status.score + val ratingResponseSuccess = if (realScore != null) { + // Remove rating if score is 0 + val ratingsSuffix = if (realScore == 0) "/remove" else "" + debugPrint { "Rate ${this.name} item: rating=$realScore" } + app.post( + "$mainUrl/sync/ratings$ratingsSuffix", + json = StatusRequest( + // Not possible to know if TV or Movie + shows = listOf( + RatingMediaObject( + null, + null, + MediaObject.Ids.fromMap(parsedId), + realScore + ) + ), + movies = emptyList() + ), + interceptor = interceptor + ).isSuccessful + } else { + true + } + + val simklStatus = status as? SimklSyncStatus + // All episodes if marked as completed + val watchedEpisodes = if (status.status == SimklListStatusType.Completed.value) { + simklStatus?.episodes?.size + } else { + status.watchedEpisodes + } + + // Only post episodes if available episodes and the status is correct + val episodeResponseSuccess = + if (simklStatus != null && watchedEpisodes != null && !simklStatus.episodes.isNullOrEmpty() && listOf( + SimklListStatusType.Paused.value, + SimklListStatusType.Dropped.value, + SimklListStatusType.Watching.value, + SimklListStatusType.Completed.value, + SimklListStatusType.ReWatching.value + ).contains(status.status) + ) { + val cutEpisodes = simklStatus.episodes.take(watchedEpisodes) + + val (seasons, episodes) = if (cutEpisodes.any { it.season != null }) { + EpisodeMetadata.convertToSeasons(cutEpisodes) to null + } else { + null to EpisodeMetadata.convertToEpisodes(cutEpisodes) + } + + debugPrint { "Synced history for ${status.watchedEpisodes} given size of ${simklStatus.episodes.size}: seasons=${seasons?.toList()}, episodes=${episodes?.toList()}" } + val episodeResponse = app.post( + "$mainUrl/sync/history", + json = StatusRequest( + shows = listOf( + HistoryMediaObject( + null, + null, + MediaObject.Ids.fromMap(parsedId), + seasons, + episodes + ) + ), + movies = emptyList() + ), + interceptor = interceptor + ) + episodeResponse.isSuccessful + } else true + + val newStatus = + SimklListStatusType.values().firstOrNull { it.value == status.status }?.originalName + ?: SimklListStatusType.Watching.originalName + + val statusResponseSuccess = if (newStatus != null) { + debugPrint { "Add to ${this.name} list: status=$newStatus" } + app.post( + "$mainUrl/sync/add-to-list", + json = StatusRequest( + shows = listOf( + StatusMediaObject( + null, + null, + MediaObject.Ids.fromMap(parsedId), + newStatus + ) + ), + movies = emptyList() + ), + interceptor = interceptor + ).isSuccessful + } else { + true + } + + debugPrint { "All scoring complete: rating=$ratingResponseSuccess, status=$statusResponseSuccess, episode=$episodeResponseSuccess" } + requireLibraryRefresh = true + return ratingResponseSuccess && statusResponseSuccess && episodeResponseSuccess + } + + + /** See https://simkl.docs.apiary.io/#reference/search/id-lookup/get-items-by-id */ + suspend fun searchByIds(serviceMap: Map): Array? { + if (serviceMap.isEmpty()) return emptyArray() + + return app.get( + "$mainUrl/search/id", + params = mapOf("client_id" to clientId) + serviceMap.map { (service, id) -> + service.originalName to id + } + ).parsedSafe() + } + + suspend fun getEpisodes(simklId: Int?, type: String?): Array? { + if (simklId == null) return null + val url = when (type) { + "anime" -> "https://api.simkl.com/anime/episodes/$simklId" + "tv" -> "https://api.simkl.com/tv/episodes/$simklId" + "movie" -> return null + else -> return null + } + return app.get(url, params = mapOf("client_id" to clientId)).parsedSafe() + } + + override suspend fun search(name: String): List? { + return app.get( + "$mainUrl/search/", params = mapOf("client_id" to clientId, "q" to name) + ).parsedSafe>()?.mapNotNull { it.toSyncSearchResult() } + } + + override fun authenticate(activity: FragmentActivity?) { + lastLoginState = BigInteger(130, SecureRandom()).toString(32) + val url = + "https://simkl.com/oauth/authorize?response_type=code&client_id=$clientId&redirect_uri=$appString://${redirectUrl}&state=$lastLoginState" + openBrowser(url, activity) + } + + override fun loginInfo(): AuthAPI.LoginInfo? { + return getKey(accountId, SIMKL_USER_KEY)?.let { user -> + AuthAPI.LoginInfo( + name = user.name, + profilePicture = user.avatar, + accountIndex = accountIndex + ) + } + } + + override fun logOut() { + requireLibraryRefresh = true + removeAccountKeys() + } + + override suspend fun getResult(id: String): SyncAPI.SyncResult? { + return null + } + + private suspend fun getSyncListSince(since: Long?): AllItemsResponse { + val params = getDateTime(since)?.let { + mapOf("date_from" to it) + } ?: emptyMap() + + return app.get( + "$mainUrl/sync/all-items/", + params = params, + interceptor = interceptor + ).parsed() + } + + private suspend fun getActivities(): ActivitiesResponse? { + return app.post("$mainUrl/sync/activities", interceptor = interceptor).parsedSafe() + } + + private fun getSyncListCached(): AllItemsResponse? { + return getKey(accountId, SIMKL_CACHED_LIST) + } + + private suspend fun getSyncListSmart(): AllItemsResponse? { + if (token == null) return null + + val activities = getActivities() + val lastCacheUpdate = getKey(accountId, SIMKL_CACHED_LIST_TIME) + val lastRemoval = listOf( + activities?.tv_shows?.removed_from_list, + activities?.anime?.removed_from_list, + activities?.movies?.removed_from_list + ).maxOf { + getUnixTime(it) ?: -1 + } + val lastRealUpdate = + listOf( + activities?.tv_shows?.all, + activities?.anime?.all, + activities?.movies?.all, + ).maxOf { + getUnixTime(it) ?: -1 + } + + debugPrint { "Cache times: lastCacheUpdate=$lastCacheUpdate, lastRemoval=$lastRemoval, lastRealUpdate=$lastRealUpdate" } + val list = if (lastCacheUpdate == null || lastCacheUpdate < lastRemoval) { + debugPrint { "Full list update in ${this.name}." } + setKey(accountId, SIMKL_CACHED_LIST_TIME, lastRemoval) + getSyncListSince(null) + } else if (lastCacheUpdate < lastRealUpdate || lastCacheUpdate < lastScoreTime) { + debugPrint { "Partial list update in ${this.name}." } + setKey(accountId, SIMKL_CACHED_LIST_TIME, lastCacheUpdate) + AllItemsResponse.merge(getSyncListCached(), getSyncListSince(lastCacheUpdate)) + } else { + debugPrint { "Cached list update in ${this.name}." } + getSyncListCached() + } + debugPrint { "List sizes: movies=${list?.movies?.size}, shows=${list?.shows?.size}, anime=${list?.anime?.size}" } + + setKey(accountId, SIMKL_CACHED_LIST, list) + + return list + } + + + override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? { + val list = getSyncListSmart() ?: return null + + val baseMap = + SimklListStatusType.values() + .filter { it.value >= 0 && it.value != SimklListStatusType.ReWatching.value } + .associate { + it.stringRes to emptyList() + } + + val syncMap = listOf(list.anime, list.movies, list.shows) + .flatten() + .groupBy { + it.status + } + .mapNotNull { (status, list) -> + val stringRes = + status?.let { SimklListStatusType.fromString(it)?.stringRes } + ?: return@mapNotNull null + val libraryList = list.map { it.toLibraryItem() } + stringRes to libraryList + }.toMap() + + return SyncAPI.LibraryMetadata( + (baseMap + syncMap).map { SyncAPI.LibraryList(txt(it.key), it.value) }, setOf( + ListSorting.AlphabeticalA, + ListSorting.AlphabeticalZ, + ListSorting.UpdatedNew, + ListSorting.UpdatedOld, + ListSorting.RatingHigh, + ListSorting.RatingLow, + ) + ) + } + + override fun getIdFromUrl(url: String): String { + val simklUrlRegex = Regex("""https://simkl\.com/[^/]*/(\d+).*""") + return simklUrlRegex.find(url)?.groupValues?.get(1) ?: "" + } + + override suspend fun handleRedirect(url: String): Boolean { + val uri = url.toUri() + val state = uri.getQueryParameter("state") + // Ensure consistent state + if (state != lastLoginState) return false + lastLoginState = "" + + val code = uri.getQueryParameter("code") ?: return false + val token = app.post( + "$mainUrl/oauth/token", json = TokenRequest(code) + ).parsedSafe() ?: return false + + switchToNewAccount() + setKey(accountId, SIMKL_TOKEN_KEY, token.access_token) + + val user = getUser() + if (user == null) { + removeKey(accountId, SIMKL_TOKEN_KEY) + switchToOldAccount() + return false + } + + setKey(accountId, SIMKL_USER_KEY, user) + registerAccount() + requireLibraryRefresh = true + + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt index 91415d26..a3e2ed87 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt @@ -36,18 +36,18 @@ class SyncViewModel : ViewModel() { val metadata: LiveData> get() = _metaResponse - private val _userDataResponse: MutableLiveData?> = + private val _userDataResponse: MutableLiveData?> = MutableLiveData(null) - val userData: LiveData?> get() = _userDataResponse + val userData: LiveData?> get() = _userDataResponse // prefix, id - private var syncs = mutableMapOf() + private val syncs = mutableMapOf() //private val _syncIds: MutableLiveData> = // MutableLiveData(mutableMapOf()) //val syncIds: LiveData> get() = _syncIds - fun getSyncs() : Map { + fun getSyncs(): Map { return syncs } @@ -106,7 +106,7 @@ class SyncViewModel : ViewModel() { Log.i(TAG, "addFromUrl = $url") if (url == null || hasAddedFromUrl.contains(url)) return@ioSafe - if(!url.startsWith("http")) return@ioSafe + if (!url.startsWith("http")) return@ioSafe SyncUtil.getIdsFromUrl(url)?.let { (malId, aniListId) -> hasAddedFromUrl.add(url) @@ -150,7 +150,8 @@ class SyncViewModel : ViewModel() { val user = userData.value if (user is Resource.Success) { - _userDataResponse.postValue(Resource.Success(user.value.copy(watchedEpisodes = episodes))) + user.value.watchedEpisodes = episodes + _userDataResponse.postValue(Resource.Success(user.value)) } } @@ -158,7 +159,8 @@ class SyncViewModel : ViewModel() { Log.i(TAG, "setScore = $score") val user = userData.value if (user is Resource.Success) { - _userDataResponse.postValue(Resource.Success(user.value.copy(score = score))) + user.value.score = score + _userDataResponse.postValue(Resource.Success(user.value)) } } @@ -167,7 +169,8 @@ class SyncViewModel : ViewModel() { if (which < -1 || which > 5) return // validate input val user = userData.value if (user is Resource.Success) { - _userDataResponse.postValue(Resource.Success(user.value.copy(status = which))) + user.value.status = which + _userDataResponse.postValue(Resource.Success(user.value)) } } @@ -185,17 +188,16 @@ class SyncViewModel : ViewModel() { fun modifyMaxEpisode(episodeNum: Int) { Log.i(TAG, "modifyMaxEpisode = $episodeNum") modifyData { status -> - status.copy( - watchedEpisodes = maxOf( - episodeNum, - status.watchedEpisodes ?: return@modifyData null - ) + status.watchedEpisodes = maxOf( + episodeNum, + status.watchedEpisodes ?: return@modifyData null ) + status } } /// modifies the current sync data, return null if you don't want to change it - private fun modifyData(update: ((SyncAPI.SyncStatus) -> (SyncAPI.SyncStatus?))) = + private fun modifyData(update: ((SyncAPI.AbstractSyncStatus) -> (SyncAPI.AbstractSyncStatus?))) = ioSafe { syncs.amap { (prefix, id) -> repos.firstOrNull { it.idPrefix == prefix }?.let { repo -> @@ -245,8 +247,12 @@ class SyncViewModel : ViewModel() { // shitty way to sort anilist first, as it has trailers while mal does not if (syncs.containsKey(aniListApi.idPrefix)) { try { // swap can throw error - Collections.swap(current, current.indexOfFirst { it.first == aniListApi.idPrefix }, 0) - } catch (t : Throwable) { + Collections.swap( + current, + current.indexOfFirst { it.first == aniListApi.idPrefix }, + 0 + ) + } catch (t: Throwable) { logError(t) } } 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 33316020..b3225d5c 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 @@ -23,6 +23,7 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi 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.AuthAPI import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI import com.lagradost.cloudstream3.syncproviders.OAuth2API @@ -257,6 +258,7 @@ class SettingsAccount : PreferenceFragmentCompat() { listOf( R.string.mal_key to malApi, R.string.anilist_key to aniListApi, + R.string.simkl_key to simklApi, R.string.opensubtitles_key to openSubtitlesApi, ) diff --git a/app/src/main/res/drawable/simkl_logo.xml b/app/src/main/res/drawable/simkl_logo.xml new file mode 100644 index 00000000..eb29fb5b --- /dev/null +++ b/app/src/main/res/drawable/simkl_logo.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ded7366b..13251c7c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -449,6 +449,7 @@ Put the title under the poster anilist_key + simkl_key mal_key opensubtitles_key nginx_key diff --git a/app/src/main/res/xml/settings_account.xml b/app/src/main/res/xml/settings_account.xml index d4bae8c4..d3dbcb31 100644 --- a/app/src/main/res/xml/settings_account.xml +++ b/app/src/main/res/xml/settings_account.xml @@ -1,27 +1,32 @@ + xmlns:app="http://schemas.android.com/apk/res-auto"> + android:icon="@drawable/mal_logo" + android:key="@string/mal_key" /> - - - - + android:icon="@drawable/ic_anilist_icon" + android:key="@string/anilist_key" /> - - - - - - - - + + + + + + + + + + + + + + + \ No newline at end of file