From 743098d0b0f323ab50b464f9d48b66d7637a409d Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 25 Oct 2022 15:18:44 +0300 Subject: [PATCH 01/49] Basic AniList api implementation --- app/build.gradle | 2 + .../kotatsu/core/network/CommonHeaders.kt | 1 + .../kotatsu/core/prefs/AppSettings.kt | 1 + .../kotatsu/scrobbling/ScrobblingModule.kt | 24 +- .../anilist/data/AniListAuthenticator.kt | 53 ++++ .../anilist/data/AniListInterceptor.kt | 23 ++ .../anilist/data/AniListRepository.kt | 232 ++++++++++++++++++ .../scrobbling/anilist/data/AniListStorage.kt | 40 +++ .../anilist/data/model/AniListUser.kt | 42 ++++ .../anilist/domain/AniListScrobbler.kt | 71 ++++++ .../anilist/ui/AniListSettingsFragment.kt | 86 +++++++ .../anilist/ui/AniListSettingsViewModel.kt | 57 +++++ .../domain/model/ScrobblerService.kt | 5 +- .../settings/HistorySettingsFragment.kt | 32 +++ .../kotatsu/settings/SettingsActivity.kt | 7 + app/src/main/res/drawable/ic_anilist.xml | 10 + app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/pref_anilist.xml | 19 ++ app/src/main/res/xml/pref_history.xml | 7 + 19 files changed, 709 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListAuthenticator.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListInterceptor.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListRepository.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListStorage.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/model/AniListUser.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/domain/AniListScrobbler.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/ui/AniListSettingsFragment.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/ui/AniListSettingsViewModel.kt create mode 100644 app/src/main/res/drawable/ic_anilist.xml create mode 100644 app/src/main/res/xml/pref_anilist.xml diff --git a/app/build.gradle b/app/build.gradle index 3324502f5..a7c55a360 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -29,6 +29,8 @@ android { // define this values in your local.properties file buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\"" buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\"" + buildConfigField 'String', 'ANILIST_CLIENT_ID', "\"${localProperty('anilist.clientId')}\"" + buildConfigField 'String', 'ANILIST_CLIENT_SECRET', "\"${localProperty('anilist.clientSecret')}\"" resValue "string", "acra_login", "${localProperty('acra.login')}" resValue "string", "acra_password", "${localProperty('acra.password')}" } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt index 9b27ccfc2..dd0124f01 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt @@ -7,6 +7,7 @@ object CommonHeaders { const val REFERER = "Referer" const val USER_AGENT = "User-Agent" const val ACCEPT = "Accept" + const val CONTENT_TYPE = "Content-Type" const val CONTENT_DISPOSITION = "Content-Disposition" const val COOKIE = "Cookie" const val CONTENT_ENCODING = "Content-Encoding" diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 6fa3a8803..04ea9092a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -339,6 +339,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags" const val KEY_SHIKIMORI = "shikimori" + const val KEY_ANILIST = "anilist" const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism" const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ScrobblingModule.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ScrobblingModule.kt index 0cbd62f4a..9afbf1854 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ScrobblingModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ScrobblingModule.kt @@ -5,15 +5,20 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.ElementsIntoSet -import javax.inject.Singleton import okhttp3.OkHttpClient import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.scrobbling.anilist.data.AniListAuthenticator +import org.koitharu.kotatsu.scrobbling.anilist.data.AniListInterceptor +import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository +import org.koitharu.kotatsu.scrobbling.anilist.data.AniListStorage +import org.koitharu.kotatsu.scrobbling.anilist.domain.AniListScrobbler import org.koitharu.kotatsu.scrobbling.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriAuthenticator import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriInterceptor import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriStorage import org.koitharu.kotatsu.scrobbling.shikimori.domain.ShikimoriScrobbler +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) @@ -33,9 +38,24 @@ object ScrobblingModule { return ShikimoriRepository(okHttp, storage, database) } + @Provides + @Singleton + fun provideAniListRepository( + storage: AniListStorage, + database: MangaDatabase, + authenticator: AniListAuthenticator, + ): AniListRepository { + val okHttp = OkHttpClient.Builder().apply { + authenticator(authenticator) + addInterceptor(AniListInterceptor(storage)) + }.build() + return AniListRepository(okHttp, storage, database) + } + @Provides @ElementsIntoSet fun provideScrobblers( shikimoriScrobbler: ShikimoriScrobbler, - ): Set<@JvmSuppressWildcards Scrobbler> = setOf(shikimoriScrobbler) + aniListScrobbler: AniListScrobbler, + ): Set<@JvmSuppressWildcards Scrobbler> = setOf(shikimoriScrobbler, aniListScrobbler) } diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListAuthenticator.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListAuthenticator.kt new file mode 100644 index 000000000..5a32ee6e7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListAuthenticator.kt @@ -0,0 +1,53 @@ +package org.koitharu.kotatsu.scrobbling.anilist.data + +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.core.network.CommonHeaders +import javax.inject.Inject +import javax.inject.Provider + +class AniListAuthenticator @Inject constructor( + private val storage: AniListStorage, + private val repositoryProvider: Provider, +) : Authenticator { + + override fun authenticate(route: Route?, response: Response): Request? { + val accessToken = storage.accessToken ?: return null + if (!isRequestWithAccessToken(response)) { + return null + } + synchronized(this) { + val newAccessToken = storage.accessToken ?: return null + if (accessToken != newAccessToken) { + return newRequestWithAccessToken(response.request, newAccessToken) + } + val updatedAccessToken = refreshAccessToken() ?: return null + return newRequestWithAccessToken(response.request, updatedAccessToken) + } + } + + private fun isRequestWithAccessToken(response: Response): Boolean { + val header = response.request.header(CommonHeaders.AUTHORIZATION) + return header?.startsWith("Bearer") == true + } + + private fun newRequestWithAccessToken(request: Request, accessToken: String): Request { + return request.newBuilder() + .header(CommonHeaders.AUTHORIZATION, "Bearer $accessToken") + .build() + } + + private fun refreshAccessToken(): String? = runCatching { + val repository = repositoryProvider.get() + runBlocking { repository.authorize(null) } + return storage.accessToken + }.onFailure { + if (BuildConfig.DEBUG) { + it.printStackTrace() + } + }.getOrNull() +} diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListInterceptor.kt new file mode 100644 index 000000000..70f124ee6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListInterceptor.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.scrobbling.anilist.data + +import okhttp3.Interceptor +import okhttp3.Response +import org.koitharu.kotatsu.core.network.CommonHeaders + +private const val JSON = "application/json" + +class AniListInterceptor(private val storage: AniListStorage) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val sourceRequest = chain.request() + val request = sourceRequest.newBuilder() + request.header(CommonHeaders.CONTENT_TYPE, JSON) + request.header(CommonHeaders.ACCEPT, JSON) + if (!sourceRequest.url.pathSegments.contains("oauth")) { + storage.accessToken?.let { + request.header(CommonHeaders.AUTHORIZATION, "Bearer $it") + } + } + return chain.proceed(request.build()) + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListRepository.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListRepository.kt new file mode 100644 index 000000000..320fba9a8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListRepository.kt @@ -0,0 +1,232 @@ +package org.koitharu.kotatsu.scrobbling.anilist.data + +import okhttp3.FormBody +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.parsers.exception.GraphQLException +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.util.await +import org.koitharu.kotatsu.parsers.util.json.getStringOrNull +import org.koitharu.kotatsu.parsers.util.json.mapJSON +import org.koitharu.kotatsu.parsers.util.parseJson +import org.koitharu.kotatsu.parsers.util.parseJsonArray +import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl +import org.koitharu.kotatsu.scrobbling.anilist.data.model.AniListUser +import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService +import org.koitharu.kotatsu.utils.ext.toRequestBody + +private const val REDIRECT_URI = "kotatsu://anilist-auth" +private const val BASE_URL = "https://anilist.co/api/v2/" +private const val ENDPOINT = "https://graphql.anilist.co" +private const val MANGA_PAGE_SIZE = 10 + +class AniListRepository( + private val okHttp: OkHttpClient, + private val storage: AniListStorage, + private val db: MangaDatabase, +) { + + val oauthUrl: String + get() = "${BASE_URL}oauth/authorize?client_id=${BuildConfig.ANILIST_CLIENT_ID}&" + + "redirect_uri=${REDIRECT_URI}&response_type=code" + + val isAuthorized: Boolean + get() = storage.accessToken != null + + suspend fun authorize(code: String?) { + val body = FormBody.Builder() + body.add("client_id", BuildConfig.ANILIST_CLIENT_ID) + body.add("client_secret", BuildConfig.ANILIST_CLIENT_SECRET) + if (code != null) { + body.add("grant_type", "authorization_code") + body.add("redirect_uri", REDIRECT_URI) + body.add("code", code) + } else { + body.add("grant_type", "refresh_token") + body.add("refresh_token", checkNotNull(storage.refreshToken)) + } + val request = Request.Builder() + .post(body.build()) + .url("${BASE_URL}oauth/token") + val response = okHttp.newCall(request.build()).await().parseJson() + storage.accessToken = response.getString("access_token") + storage.refreshToken = response.getString("refresh_token") + } + + suspend fun loadUser(): AniListUser { + val response = query( + """ + AniChartUser { + user { + id + name + avatar { + medium + } + } + } + """.trimIndent(), + ) + val jo = response.getJSONObject("data").getJSONObject("AniChartUser").getJSONObject("user") + return AniListUser(jo).also { storage.user = it } + } + + fun getCachedUser(): AniListUser? { + return storage.user + } + + suspend fun unregister(mangaId: Long) { + return db.scrobblingDao.delete(ScrobblerService.SHIKIMORI.id, mangaId) + } + + fun logout() { + storage.clear() + } + + suspend fun findManga(query: String, offset: Int): List { + val page = offset / MANGA_PAGE_SIZE + val pageOffset = offset % MANGA_PAGE_SIZE + val url = BASE_URL.toHttpUrl().newBuilder() + .addPathSegment("api") + .addPathSegment("mangas") + .addEncodedQueryParameter("page", (page + 1).toString()) + .addEncodedQueryParameter("limit", MANGA_PAGE_SIZE.toString()) + .addEncodedQueryParameter("censored", false.toString()) + .addQueryParameter("search", query) + .build() + val request = Request.Builder().url(url).get().build() + val response = okHttp.newCall(request).await().parseJsonArray() + val list = response.mapJSON { ScrobblerManga(it) } + return if (pageOffset != 0) list.drop(pageOffset) else list + } + + suspend fun createRate(mangaId: Long, shikiMangaId: Long) { + val user = getCachedUser() ?: loadUser() + val payload = JSONObject() + payload.put( + "user_rate", + JSONObject().apply { + put("target_id", shikiMangaId) + put("target_type", "Manga") + put("user_id", user.id) + }, + ) + val url = BASE_URL.toHttpUrl().newBuilder() + .addPathSegment("api") + .addPathSegment("v2") + .addPathSegment("user_rates") + .build() + val request = Request.Builder().url(url).post(payload.toRequestBody()).build() + val response = okHttp.newCall(request).await().parseJson() + saveRate(response, mangaId) + } + + suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) { + val payload = JSONObject() + payload.put( + "user_rate", + JSONObject().apply { + put("chapters", chapter.number) + }, + ) + val url = BASE_URL.toHttpUrl().newBuilder() + .addPathSegment("api") + .addPathSegment("v2") + .addPathSegment("user_rates") + .addPathSegment(rateId.toString()) + .build() + val request = Request.Builder().url(url).patch(payload.toRequestBody()).build() + val response = okHttp.newCall(request).await().parseJson() + saveRate(response, mangaId) + } + + suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) { + val payload = JSONObject() + payload.put( + "user_rate", + JSONObject().apply { + put("score", rating.toString()) + if (comment != null) { + put("text", comment) + } + if (status != null) { + put("status", status) + } + }, + ) + val url = BASE_URL.toHttpUrl().newBuilder() + .addPathSegment("api") + .addPathSegment("v2") + .addPathSegment("user_rates") + .addPathSegment(rateId.toString()) + .build() + val request = Request.Builder().url(url).patch(payload.toRequestBody()).build() + val response = okHttp.newCall(request).await().parseJson() + saveRate(response, mangaId) + } + + suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { + val request = Request.Builder() + .get() + .url("${BASE_URL}api/mangas/$id") + val response = okHttp.newCall(request.build()).await().parseJson() + return ScrobblerMangaInfo(response) + } + + private suspend fun saveRate(json: JSONObject, mangaId: Long) { + val entity = ScrobblingEntity( + scrobbler = ScrobblerService.SHIKIMORI.id, + id = json.getInt("id"), + mangaId = mangaId, + targetId = json.getLong("target_id"), + status = json.getString("status"), + chapter = json.getInt("chapters"), + comment = json.getString("text"), + rating = json.getDouble("score").toFloat() / 10f, + ) + db.scrobblingDao.insert(entity) + } + + private fun ScrobblerManga(json: JSONObject) = ScrobblerManga( + id = json.getLong("id"), + name = json.getString("name"), + altName = json.getStringOrNull("russian"), + cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.one"), + url = json.getString("url").toAbsoluteUrl("shikimori.one"), + ) + + private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo( + id = json.getLong("id"), + name = json.getString("name"), + cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.one"), + url = json.getString("url").toAbsoluteUrl("shikimori.one"), + descriptionHtml = json.getString("description_html"), + ) + + private suspend fun query(query: String): JSONObject { + val body = JSONObject() + body.put("query", "{$query}") + body.put("variables", null) + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = body.toString().toRequestBody(mediaType) + val request = Request.Builder() + .post(requestBody) + .url(ENDPOINT) + val json = okHttp.newCall(request.build()).await().parseJson() + json.optJSONArray("errors")?.let { + if (it.length() != 0) { + throw GraphQLException(it) + } + } + return json + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListStorage.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListStorage.kt new file mode 100644 index 000000000..09c918d08 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListStorage.kt @@ -0,0 +1,40 @@ +package org.koitharu.kotatsu.scrobbling.anilist.data + +import android.content.Context +import androidx.core.content.edit +import dagger.hilt.android.qualifiers.ApplicationContext +import org.json.JSONObject +import org.koitharu.kotatsu.scrobbling.anilist.data.model.AniListUser +import javax.inject.Inject +import javax.inject.Singleton + +private const val PREF_NAME = "anilist" +private const val KEY_ACCESS_TOKEN = "access_token" +private const val KEY_REFRESH_TOKEN = "refresh_token" +private const val KEY_USER = "user" + +@Singleton +class AniListStorage @Inject constructor(@ApplicationContext context: Context) { + + private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + + var accessToken: String? + get() = prefs.getString(KEY_ACCESS_TOKEN, null) + set(value) = prefs.edit { putString(KEY_ACCESS_TOKEN, value) } + + var refreshToken: String? + get() = prefs.getString(KEY_REFRESH_TOKEN, null) + set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) } + + var user: AniListUser? + get() = prefs.getString(KEY_USER, null)?.let { + AniListUser(JSONObject(it)) + } + set(value) = prefs.edit { + putString(KEY_USER, value?.toJson()?.toString()) + } + + fun clear() = prefs.edit { + clear() + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/model/AniListUser.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/model/AniListUser.kt new file mode 100644 index 000000000..24897ee3f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/model/AniListUser.kt @@ -0,0 +1,42 @@ +package org.koitharu.kotatsu.scrobbling.anilist.data.model + +import org.json.JSONObject + +class AniListUser( + val id: Long, + val nickname: String, + val avatar: String, +) { + + constructor(json: JSONObject) : this( + id = json.getLong("id"), + nickname = json.getString("name"), + avatar = json.getJSONObject("avatar").getString("medium"), + ) + + fun toJson() = JSONObject().apply { + put("id", id) + put("name", nickname) + put("avatar", JSONObject().apply { put("medium", avatar) }) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AniListUser + + if (id != other.id) return false + if (nickname != other.nickname) return false + if (avatar != other.avatar) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + nickname.hashCode() + result = 31 * result + avatar.hashCode() + return result + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/domain/AniListScrobbler.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/domain/AniListScrobbler.kt new file mode 100644 index 000000000..ebb935c52 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/domain/AniListScrobbler.kt @@ -0,0 +1,71 @@ +package org.koitharu.kotatsu.scrobbling.anilist.domain + +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository +import org.koitharu.kotatsu.scrobbling.domain.Scrobbler +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus +import javax.inject.Inject +import javax.inject.Singleton + +private const val RATING_MAX = 10f + +@Singleton +class AniListScrobbler @Inject constructor( + private val repository: AniListRepository, + db: MangaDatabase, +) : Scrobbler(db, ScrobblerService.ANILIST) { + + init { + statuses[ScrobblingStatus.PLANNED] = "planned" + statuses[ScrobblingStatus.READING] = "watching" + statuses[ScrobblingStatus.RE_READING] = "rewatching" + statuses[ScrobblingStatus.COMPLETED] = "completed" + statuses[ScrobblingStatus.ON_HOLD] = "on_hold" + statuses[ScrobblingStatus.DROPPED] = "dropped" + } + + override val isAvailable: Boolean + get() = repository.isAuthorized + + override suspend fun findManga(query: String, offset: Int): List { + return repository.findManga(query, offset) + } + + override suspend fun linkManga(mangaId: Long, targetId: Long) { + repository.createRate(mangaId, targetId) + } + + override suspend fun scrobble(mangaId: Long, chapter: MangaChapter) { + val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return + repository.updateRate(entity.id, entity.mangaId, chapter) + } + + override suspend fun updateScrobblingInfo( + mangaId: Long, + rating: Float, + status: ScrobblingStatus?, + comment: String?, + ) { + val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) + requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" } + repository.updateRate( + rateId = entity.id, + mangaId = entity.mangaId, + rating = rating * RATING_MAX, + status = statuses[status], + comment = comment, + ) + } + + override suspend fun unregisterScrobbling(mangaId: Long) { + repository.unregister(mangaId) + } + + override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { + return repository.getMangaInfo(id) + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/ui/AniListSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/ui/AniListSettingsFragment.kt new file mode 100644 index 000000000..a062fddc5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/ui/AniListSettingsFragment.kt @@ -0,0 +1,86 @@ +package org.koitharu.kotatsu.scrobbling.anilist.ui + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import androidx.preference.Preference +import coil.ImageLoader +import coil.request.ImageRequest +import coil.transform.CircleCropTransformation +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BasePreferenceFragment +import org.koitharu.kotatsu.scrobbling.anilist.data.model.AniListUser +import org.koitharu.kotatsu.utils.PreferenceIconTarget +import org.koitharu.kotatsu.utils.ext.assistedViewModels +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.withArgs +import javax.inject.Inject + +@AndroidEntryPoint +class AniListSettingsFragment : BasePreferenceFragment(R.string.anilist) { + + @Inject + lateinit var coil: ImageLoader + + @Inject + lateinit var viewModelFactory: AniListSettingsViewModel.Factory + + private val viewModel by assistedViewModels { + viewModelFactory.create(arguments?.getString(ARG_AUTH_CODE)) + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_anilist) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.user.observe(viewLifecycleOwner, this::onUserChanged) + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean { + return when (preference.key) { + KEY_USER -> openAuthorization() + KEY_LOGOUT -> { + viewModel.logout() + true + } + + else -> super.onPreferenceTreeClick(preference) + } + } + + private fun onUserChanged(user: AniListUser?) { + val pref = findPreference(KEY_USER) ?: return + pref.isSelectable = user == null + pref.title = user?.nickname ?: getString(R.string.sign_in) + ImageRequest.Builder(requireContext()) + .data(user?.avatar) + .transformations(CircleCropTransformation()) + .target(PreferenceIconTarget(pref)) + .enqueueWith(coil) + findPreference(KEY_LOGOUT)?.isVisible = user != null + } + + private fun openAuthorization(): Boolean { + return runCatching { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(viewModel.authorizationUrl) + startActivity(intent) + }.isSuccess + } + + companion object { + + private const val KEY_USER = "al_user" + private const val KEY_LOGOUT = "al_logout" + + private const val ARG_AUTH_CODE = "auth_code" + + fun newInstance(authCode: String?) = AniListSettingsFragment().withArgs(1) { + putString(ARG_AUTH_CODE, authCode) + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/ui/AniListSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/ui/AniListSettingsViewModel.kt new file mode 100644 index 000000000..9bec3f34d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/ui/AniListSettingsViewModel.kt @@ -0,0 +1,57 @@ +package org.koitharu.kotatsu.scrobbling.anilist.ui + +import androidx.lifecycle.MutableLiveData +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository +import org.koitharu.kotatsu.scrobbling.anilist.data.model.AniListUser + +class AniListSettingsViewModel @AssistedInject constructor( + private val repository: AniListRepository, + @Assisted authCode: String?, +) : BaseViewModel() { + + val authorizationUrl: String + get() = repository.oauthUrl + + val user = MutableLiveData() + + init { + if (authCode != null) { + authorize(authCode) + } else { + loadUser() + } + } + + fun logout() { + launchJob(Dispatchers.Default) { + repository.logout() + user.postValue(null) + } + } + + private fun loadUser() = launchJob(Dispatchers.Default) { + val userModel = if (repository.isAuthorized) { + repository.getCachedUser()?.let(user::postValue) + repository.loadUser() + } else { + null + } + user.postValue(userModel) + } + + private fun authorize(code: String) = launchJob(Dispatchers.Default) { + repository.authorize(code) + user.postValue(repository.loadUser()) + } + + @AssistedFactory + interface Factory { + + fun create(authCode: String?): AniListSettingsViewModel + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt index 45038ed12..3679b8118 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt @@ -10,5 +10,6 @@ enum class ScrobblerService( @DrawableRes val iconResId: Int, ) { - SHIKIMORI(1, R.string.shikimori, R.drawable.ic_shikimori) -} \ No newline at end of file + SHIKIMORI(1, R.string.shikimori, R.drawable.ic_shikimori), + ANILIST(2, R.string.anilist, R.drawable.ic_anilist), +} diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt index 595d47caa..936aa2028 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt @@ -17,6 +17,7 @@ import org.koitharu.kotatsu.core.os.ShortcutsUpdater import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository @@ -40,6 +41,9 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach @Inject lateinit var shikimoriRepository: ShikimoriRepository + @Inject + lateinit var aniListRepository: AniListRepository + @Inject lateinit var cookieJar: AndroidCookieJar @@ -75,6 +79,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach override fun onResume() { super.onResume() bindShikimoriSummary() + bindAniListSummary() } override fun onPreferenceTreeClick(preference: Preference): Boolean { @@ -122,6 +127,15 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach } } + AppSettings.KEY_ANILIST -> { + if (!aniListRepository.isAuthorized) { + launchAniListAuth() + true + } else { + super.onPreferenceTreeClick(preference) + } + } + else -> super.onPreferenceTreeClick(preference) } } @@ -193,6 +207,14 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach } } + private fun bindAniListSummary() { + findPreference(AppSettings.KEY_ANILIST)?.summary = if (aniListRepository.isAuthorized) { + getString(R.string.logged_in_as, aniListRepository.getCachedUser()?.nickname) + } else { + getString(R.string.disabled) + } + } + private fun launchShikimoriAuth() { runCatching { val intent = Intent(Intent.ACTION_VIEW) @@ -202,4 +224,14 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach Snackbar.make(listView, it.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show() } } + + private fun launchAniListAuth() { + runCatching { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(aniListRepository.oauthUrl) + startActivity(intent) + }.onFailure { + Snackbar.make(listView, it.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show() + } + } } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt index f00be0080..e6f8880aa 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt @@ -24,6 +24,7 @@ import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.databinding.ActivitySettingsBinding import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.scrobbling.anilist.ui.AniListSettingsFragment import org.koitharu.kotatsu.scrobbling.shikimori.ui.ShikimoriSettingsFragment import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment @@ -78,6 +79,7 @@ class SettingsActivity : startActivity(intent) true } + else -> super.onOptionsItemSelected(item) } @@ -132,6 +134,7 @@ class SettingsActivity : ACTION_SOURCE -> SourceSettingsFragment.newInstance( intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL, ) + ACTION_MANAGE_SOURCES -> SourcesSettingsFragment() else -> SettingsHeadersFragment() } @@ -145,6 +148,9 @@ class SettingsActivity : when (uri?.host) { HOST_SHIKIMORI_AUTH -> return ShikimoriSettingsFragment.newInstance(authCode = uri.getQueryParameter("code")) + + HOST_ANILIST_AUTH -> + return AniListSettingsFragment.newInstance(authCode = uri.getQueryParameter("code")) } finishAfterTransition() return null @@ -162,6 +168,7 @@ class SettingsActivity : private const val EXTRA_SOURCE = "source" private const val HOST_SHIKIMORI_AUTH = "shikimori-auth" + private const val HOST_ANILIST_AUTH = "anilist-auth" fun newIntent(context: Context) = Intent(context, SettingsActivity::class.java) diff --git a/app/src/main/res/drawable/ic_anilist.xml b/app/src/main/res/drawable/ic_anilist.xml new file mode 100644 index 000000000..e9fa65813 --- /dev/null +++ b/app/src/main/res/drawable/ic_anilist.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b078c48ca..789436f9f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -273,6 +273,7 @@ Removal completed Download all selected manga and its chapters\? This can consume a lot of traffic and storage. Shikimori + AniList Parallel downloads Download slowdown Helps avoid blocking your IP address diff --git a/app/src/main/res/xml/pref_anilist.xml b/app/src/main/res/xml/pref_anilist.xml new file mode 100644 index 000000000..7cd04cada --- /dev/null +++ b/app/src/main/res/xml/pref_anilist.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/xml/pref_history.xml b/app/src/main/res/xml/pref_history.xml index 439694206..0f0549516 100644 --- a/app/src/main/res/xml/pref_history.xml +++ b/app/src/main/res/xml/pref_history.xml @@ -23,9 +23,16 @@ + + From 0c4c7489e911886a95b5685b667212a081d0d800 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sun, 30 Oct 2022 11:10:38 +0200 Subject: [PATCH 02/49] Unify scrobblers implementation --- .../kotatsu/scrobbling/ScrobblingModule.kt | 25 ++- .../anilist/data/AniListAuthenticator.kt | 5 +- .../anilist/data/AniListInterceptor.kt | 3 +- .../anilist/data/AniListRepository.kt | 161 ++++++++++-------- .../scrobbling/anilist/data/AniListStorage.kt | 40 ----- .../anilist/data/model/AniListUser.kt | 42 ----- .../anilist/domain/AniListScrobbler.kt | 29 +--- .../anilist/ui/AniListSettingsFragment.kt | 4 +- .../anilist/ui/AniListSettingsViewModel.kt | 6 +- .../scrobbling/data/ScrobblerRepository.kt | 33 ++++ .../scrobbling/data/ScrobblerStorage.kt | 54 ++++++ .../kotatsu/scrobbling/domain/Scrobbler.kt | 26 ++- .../scrobbling/domain/model/ScrobblerType.kt | 8 + .../model/ScrobblerUser.kt} | 25 +-- .../shikimori/data/ShikimoriAuthenticator.kt | 9 +- .../shikimori/data/ShikimoriInterceptor.kt | 3 +- .../shikimori/data/ShikimoriRepository.kt | 46 +++-- .../shikimori/data/ShikimoriStorage.kt | 40 ----- .../shikimori/domain/ShikimoriScrobbler.kt | 33 +--- .../shikimori/ui/ShikimoriSettingsFragment.kt | 7 +- .../ui/ShikimoriSettingsViewModel.kt | 6 +- .../settings/HistorySettingsFragment.kt | 4 +- 22 files changed, 296 insertions(+), 313 deletions(-) delete mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListStorage.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/model/AniListUser.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblerRepository.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblerStorage.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerType.kt rename app/src/main/java/org/koitharu/kotatsu/scrobbling/{shikimori/data/model/ShikimoriUser.kt => domain/model/ScrobblerUser.kt} (56%) delete mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriStorage.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ScrobblingModule.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ScrobblingModule.kt index 9afbf1854..87f75e515 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ScrobblingModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ScrobblingModule.kt @@ -1,8 +1,10 @@ package org.koitharu.kotatsu.scrobbling +import android.content.Context import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import dagger.multibindings.ElementsIntoSet import okhttp3.OkHttpClient @@ -10,13 +12,14 @@ import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.scrobbling.anilist.data.AniListAuthenticator import org.koitharu.kotatsu.scrobbling.anilist.data.AniListInterceptor import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository -import org.koitharu.kotatsu.scrobbling.anilist.data.AniListStorage import org.koitharu.kotatsu.scrobbling.anilist.domain.AniListScrobbler +import org.koitharu.kotatsu.scrobbling.data.ScrobblerStorage import org.koitharu.kotatsu.scrobbling.domain.Scrobbler +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerType import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriAuthenticator import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriInterceptor import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository -import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriStorage import org.koitharu.kotatsu.scrobbling.shikimori.domain.ShikimoriScrobbler import javax.inject.Singleton @@ -27,7 +30,7 @@ object ScrobblingModule { @Provides @Singleton fun provideShikimoriRepository( - storage: ShikimoriStorage, + @ScrobblerType(ScrobblerService.SHIKIMORI) storage: ScrobblerStorage, database: MangaDatabase, authenticator: ShikimoriAuthenticator, ): ShikimoriRepository { @@ -41,7 +44,7 @@ object ScrobblingModule { @Provides @Singleton fun provideAniListRepository( - storage: AniListStorage, + @ScrobblerType(ScrobblerService.ANILIST) storage: ScrobblerStorage, database: MangaDatabase, authenticator: AniListAuthenticator, ): AniListRepository { @@ -52,6 +55,20 @@ object ScrobblingModule { return AniListRepository(okHttp, storage, database) } + @Provides + @Singleton + @ScrobblerType(ScrobblerService.ANILIST) + fun provideAniListStorage( + @ApplicationContext context: Context, + ): ScrobblerStorage = ScrobblerStorage(context, ScrobblerService.ANILIST) + + @Provides + @Singleton + @ScrobblerType(ScrobblerService.SHIKIMORI) + fun provideShikimoriStorage( + @ApplicationContext context: Context, + ): ScrobblerStorage = ScrobblerStorage(context, ScrobblerService.SHIKIMORI) + @Provides @ElementsIntoSet fun provideScrobblers( diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListAuthenticator.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListAuthenticator.kt index 5a32ee6e7..5fb3040d5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListAuthenticator.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListAuthenticator.kt @@ -7,11 +7,14 @@ import okhttp3.Response import okhttp3.Route import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.scrobbling.data.ScrobblerStorage +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerType import javax.inject.Inject import javax.inject.Provider class AniListAuthenticator @Inject constructor( - private val storage: AniListStorage, + @ScrobblerType(ScrobblerService.ANILIST) private val storage: ScrobblerStorage, private val repositoryProvider: Provider, ) : Authenticator { diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListInterceptor.kt index 70f124ee6..9812283fb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListInterceptor.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListInterceptor.kt @@ -3,10 +3,11 @@ package org.koitharu.kotatsu.scrobbling.anilist.data import okhttp3.Interceptor import okhttp3.Response import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.scrobbling.data.ScrobblerStorage private const val JSON = "application/json" -class AniListInterceptor(private val storage: AniListStorage) : Interceptor { +class AniListInterceptor(private val storage: ScrobblerStorage) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val sourceRequest = chain.request() diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListRepository.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListRepository.kt index 320fba9a8..2bff9c655 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListRepository.kt @@ -15,13 +15,13 @@ import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.parseJson -import org.koitharu.kotatsu.parsers.util.parseJsonArray -import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl -import org.koitharu.kotatsu.scrobbling.anilist.data.model.AniListUser +import org.koitharu.kotatsu.scrobbling.data.ScrobblerRepository +import org.koitharu.kotatsu.scrobbling.data.ScrobblerStorage import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerUser import org.koitharu.kotatsu.utils.ext.toRequestBody private const val REDIRECT_URI = "kotatsu://anilist-auth" @@ -31,18 +31,18 @@ private const val MANGA_PAGE_SIZE = 10 class AniListRepository( private val okHttp: OkHttpClient, - private val storage: AniListStorage, + private val storage: ScrobblerStorage, private val db: MangaDatabase, -) { +) : ScrobblerRepository { - val oauthUrl: String + override val oauthUrl: String get() = "${BASE_URL}oauth/authorize?client_id=${BuildConfig.ANILIST_CLIENT_ID}&" + "redirect_uri=${REDIRECT_URI}&response_type=code" - val isAuthorized: Boolean + override val isAuthorized: Boolean get() = storage.accessToken != null - suspend fun authorize(code: String?) { + override suspend fun authorize(code: String?) { val body = FormBody.Builder() body.add("client_id", BuildConfig.ANILIST_CLIENT_ID) body.add("client_secret", BuildConfig.ANILIST_CLIENT_SECRET) @@ -62,7 +62,7 @@ class AniListRepository( storage.refreshToken = response.getString("refresh_token") } - suspend fun loadUser(): AniListUser { + override suspend fun loadUser(): ScrobblerUser { val response = query( """ AniChartUser { @@ -80,57 +80,61 @@ class AniListRepository( return AniListUser(jo).also { storage.user = it } } - fun getCachedUser(): AniListUser? { - return storage.user - } + override val cachedUser: ScrobblerUser? + get() { + return storage.user + } - suspend fun unregister(mangaId: Long) { + override suspend fun unregister(mangaId: Long) { return db.scrobblingDao.delete(ScrobblerService.SHIKIMORI.id, mangaId) } - fun logout() { + override fun logout() { storage.clear() } - suspend fun findManga(query: String, offset: Int): List { + override suspend fun findManga(query: String, offset: Int): List { val page = offset / MANGA_PAGE_SIZE - val pageOffset = offset % MANGA_PAGE_SIZE - val url = BASE_URL.toHttpUrl().newBuilder() - .addPathSegment("api") - .addPathSegment("mangas") - .addEncodedQueryParameter("page", (page + 1).toString()) - .addEncodedQueryParameter("limit", MANGA_PAGE_SIZE.toString()) - .addEncodedQueryParameter("censored", false.toString()) - .addQueryParameter("search", query) - .build() - val request = Request.Builder().url(url).get().build() - val response = okHttp.newCall(request).await().parseJsonArray() - val list = response.mapJSON { ScrobblerManga(it) } - return if (pageOffset != 0) list.drop(pageOffset) else list + val response = query( + """ + Page(page: $page, perPage: ${MANGA_PAGE_SIZE}) { + media(type: MANGA, isAdult: true, sort: SEARCH_MATCH, search: "${JSONObject.quote(query)}") { + id + title { + userPreferred + native + } + coverImage { + medium + } + siteUrl + } + } + """.trimIndent(), + ) + val data = response.getJSONObject("data").getJSONObject("Page").getJSONArray("media") + return data.mapJSON { ScrobblerManga(it) } } - suspend fun createRate(mangaId: Long, shikiMangaId: Long) { - val user = getCachedUser() ?: loadUser() - val payload = JSONObject() - payload.put( - "user_rate", - JSONObject().apply { - put("target_id", shikiMangaId) - put("target_type", "Manga") - put("user_id", user.id) - }, + override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) { + val response = query( + """ + mutation { + SaveMediaListEntry(mediaId: $scrobblerMangaId) { + id + mediaId + status + notes + scoreRaw + progress + } + } + """.trimIndent(), ) - val url = BASE_URL.toHttpUrl().newBuilder() - .addPathSegment("api") - .addPathSegment("v2") - .addPathSegment("user_rates") - .build() - val request = Request.Builder().url(url).post(payload.toRequestBody()).build() - val response = okHttp.newCall(request).await().parseJson() saveRate(response, mangaId) } - suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) { + override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) { val payload = JSONObject() payload.put( "user_rate", @@ -149,7 +153,7 @@ class AniListRepository( saveRate(response, mangaId) } - suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) { + override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) { val payload = JSONObject() payload.put( "user_rate", @@ -174,12 +178,23 @@ class AniListRepository( saveRate(response, mangaId) } - suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { - val request = Request.Builder() - .get() - .url("${BASE_URL}api/mangas/$id") - val response = okHttp.newCall(request.build()).await().parseJson() - return ScrobblerMangaInfo(response) + override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { + val response = query( + """ + Media(id: $id) { + id + title { + userPreferred + } + coverImage { + large + } + description + siteUrl + } + """.trimIndent(), + ) + return ScrobblerMangaInfo(response.getJSONObject("data").getJSONObject("Media")) } private suspend fun saveRate(json: JSONObject, mangaId: Long) { @@ -187,29 +202,39 @@ class AniListRepository( scrobbler = ScrobblerService.SHIKIMORI.id, id = json.getInt("id"), mangaId = mangaId, - targetId = json.getLong("target_id"), + targetId = json.getLong("mediaId"), status = json.getString("status"), - chapter = json.getInt("chapters"), - comment = json.getString("text"), - rating = json.getDouble("score").toFloat() / 10f, + chapter = json.getInt("progress"), + comment = json.getString("notes"), + rating = json.getDouble("scoreRaw").toFloat() / 100f, ) db.scrobblingDao.insert(entity) } - private fun ScrobblerManga(json: JSONObject) = ScrobblerManga( - id = json.getLong("id"), - name = json.getString("name"), - altName = json.getStringOrNull("russian"), - cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.one"), - url = json.getString("url").toAbsoluteUrl("shikimori.one"), - ) + private fun ScrobblerManga(json: JSONObject): ScrobblerManga { + val title = json.getJSONObject("title") + return ScrobblerManga( + id = json.getLong("id"), + name = title.getString("userPreferred"), + altName = title.getStringOrNull("native"), + cover = json.getJSONObject("coverImage").getString("medium"), + url = json.getString("siteUrl"), + ) + } private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo( id = json.getLong("id"), - name = json.getString("name"), - cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.one"), - url = json.getString("url").toAbsoluteUrl("shikimori.one"), - descriptionHtml = json.getString("description_html"), + name = json.getJSONObject("title").getString("userPreferred"), + cover = json.getJSONObject("coverImage").getString("large"), + url = json.getString("siteUrl"), + descriptionHtml = json.getString("description"), + ) + + private fun AniListUser(json: JSONObject) = ScrobblerUser( + id = json.getLong("id"), + nickname = json.getString("name"), + avatar = json.getJSONObject("avatar").getString("medium"), + service = ScrobblerService.ANILIST, ) private suspend fun query(query: String): JSONObject { diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListStorage.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListStorage.kt deleted file mode 100644 index 09c918d08..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListStorage.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.anilist.data - -import android.content.Context -import androidx.core.content.edit -import dagger.hilt.android.qualifiers.ApplicationContext -import org.json.JSONObject -import org.koitharu.kotatsu.scrobbling.anilist.data.model.AniListUser -import javax.inject.Inject -import javax.inject.Singleton - -private const val PREF_NAME = "anilist" -private const val KEY_ACCESS_TOKEN = "access_token" -private const val KEY_REFRESH_TOKEN = "refresh_token" -private const val KEY_USER = "user" - -@Singleton -class AniListStorage @Inject constructor(@ApplicationContext context: Context) { - - private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - - var accessToken: String? - get() = prefs.getString(KEY_ACCESS_TOKEN, null) - set(value) = prefs.edit { putString(KEY_ACCESS_TOKEN, value) } - - var refreshToken: String? - get() = prefs.getString(KEY_REFRESH_TOKEN, null) - set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) } - - var user: AniListUser? - get() = prefs.getString(KEY_USER, null)?.let { - AniListUser(JSONObject(it)) - } - set(value) = prefs.edit { - putString(KEY_USER, value?.toJson()?.toString()) - } - - fun clear() = prefs.edit { - clear() - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/model/AniListUser.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/model/AniListUser.kt deleted file mode 100644 index 24897ee3f..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/model/AniListUser.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.anilist.data.model - -import org.json.JSONObject - -class AniListUser( - val id: Long, - val nickname: String, - val avatar: String, -) { - - constructor(json: JSONObject) : this( - id = json.getLong("id"), - nickname = json.getString("name"), - avatar = json.getJSONObject("avatar").getString("medium"), - ) - - fun toJson() = JSONObject().apply { - put("id", id) - put("name", nickname) - put("avatar", JSONObject().apply { put("medium", avatar) }) - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as AniListUser - - if (id != other.id) return false - if (nickname != other.nickname) return false - if (avatar != other.avatar) return false - - return true - } - - override fun hashCode(): Int { - var result = id.hashCode() - result = 31 * result + nickname.hashCode() - result = 31 * result + avatar.hashCode() - return result - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/domain/AniListScrobbler.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/domain/AniListScrobbler.kt index ebb935c52..4c5da448f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/domain/AniListScrobbler.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/domain/AniListScrobbler.kt @@ -1,11 +1,8 @@ package org.koitharu.kotatsu.scrobbling.anilist.domain import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository import org.koitharu.kotatsu.scrobbling.domain.Scrobbler -import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga -import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus import javax.inject.Inject @@ -17,7 +14,7 @@ private const val RATING_MAX = 10f class AniListScrobbler @Inject constructor( private val repository: AniListRepository, db: MangaDatabase, -) : Scrobbler(db, ScrobblerService.ANILIST) { +) : Scrobbler(db, ScrobblerService.ANILIST, repository) { init { statuses[ScrobblingStatus.PLANNED] = "planned" @@ -28,22 +25,6 @@ class AniListScrobbler @Inject constructor( statuses[ScrobblingStatus.DROPPED] = "dropped" } - override val isAvailable: Boolean - get() = repository.isAuthorized - - override suspend fun findManga(query: String, offset: Int): List { - return repository.findManga(query, offset) - } - - override suspend fun linkManga(mangaId: Long, targetId: Long) { - repository.createRate(mangaId, targetId) - } - - override suspend fun scrobble(mangaId: Long, chapter: MangaChapter) { - val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return - repository.updateRate(entity.id, entity.mangaId, chapter) - } - override suspend fun updateScrobblingInfo( mangaId: Long, rating: Float, @@ -60,12 +41,4 @@ class AniListScrobbler @Inject constructor( comment = comment, ) } - - override suspend fun unregisterScrobbling(mangaId: Long) { - repository.unregister(mangaId) - } - - override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { - return repository.getMangaInfo(id) - } } diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/ui/AniListSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/ui/AniListSettingsFragment.kt index a062fddc5..5a98b7a81 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/ui/AniListSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/ui/AniListSettingsFragment.kt @@ -11,7 +11,7 @@ import coil.transform.CircleCropTransformation import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BasePreferenceFragment -import org.koitharu.kotatsu.scrobbling.anilist.data.model.AniListUser +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerUser import org.koitharu.kotatsu.utils.PreferenceIconTarget import org.koitharu.kotatsu.utils.ext.assistedViewModels import org.koitharu.kotatsu.utils.ext.enqueueWith @@ -52,7 +52,7 @@ class AniListSettingsFragment : BasePreferenceFragment(R.string.anilist) { } } - private fun onUserChanged(user: AniListUser?) { + private fun onUserChanged(user: ScrobblerUser?) { val pref = findPreference(KEY_USER) ?: return pref.isSelectable = user == null pref.title = user?.nickname ?: getString(R.string.sign_in) diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/ui/AniListSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/ui/AniListSettingsViewModel.kt index 9bec3f34d..554e5a219 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/ui/AniListSettingsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/ui/AniListSettingsViewModel.kt @@ -7,7 +7,7 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository -import org.koitharu.kotatsu.scrobbling.anilist.data.model.AniListUser +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerUser class AniListSettingsViewModel @AssistedInject constructor( private val repository: AniListRepository, @@ -17,7 +17,7 @@ class AniListSettingsViewModel @AssistedInject constructor( val authorizationUrl: String get() = repository.oauthUrl - val user = MutableLiveData() + val user = MutableLiveData() init { if (authCode != null) { @@ -36,7 +36,7 @@ class AniListSettingsViewModel @AssistedInject constructor( private fun loadUser() = launchJob(Dispatchers.Default) { val userModel = if (repository.isAuthorized) { - repository.getCachedUser()?.let(user::postValue) + repository.cachedUser?.let(user::postValue) repository.loadUser() } else { null diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblerRepository.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblerRepository.kt new file mode 100644 index 000000000..08310ae25 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblerRepository.kt @@ -0,0 +1,33 @@ +package org.koitharu.kotatsu.scrobbling.data + +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerUser + +interface ScrobblerRepository { + + val oauthUrl: String + + val isAuthorized: Boolean + + val cachedUser: ScrobblerUser? + + suspend fun authorize(code: String?) + + suspend fun loadUser(): ScrobblerUser + + fun logout() + + suspend fun unregister(mangaId: Long) + + suspend fun findManga(query: String, offset: Int): List + + suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo + + suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) + + suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) + + suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) +} diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblerStorage.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblerStorage.kt new file mode 100644 index 000000000..68b1c4efc --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblerStorage.kt @@ -0,0 +1,54 @@ +package org.koitharu.kotatsu.scrobbling.data + +import android.content.Context +import androidx.core.content.edit +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerUser + +private const val KEY_ACCESS_TOKEN = "access_token" +private const val KEY_REFRESH_TOKEN = "refresh_token" +private const val KEY_USER = "user" + +class ScrobblerStorage(context: Context, service: ScrobblerService) { + + private val prefs = context.getSharedPreferences(service.name, Context.MODE_PRIVATE) + + var accessToken: String? + get() = prefs.getString(KEY_ACCESS_TOKEN, null) + set(value) = prefs.edit { putString(KEY_ACCESS_TOKEN, value) } + + var refreshToken: String? + get() = prefs.getString(KEY_REFRESH_TOKEN, null) + set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) } + + var user: ScrobblerUser? + get() = prefs.getString(KEY_USER, null)?.let { + val lines = it.lines() + if (lines.size != 4) { + return@let null + } + ScrobblerUser( + id = lines[0].toLong(), + nickname = lines[1], + avatar = lines[2], + service = ScrobblerService.valueOf(lines[3]), + ) + } + set(value) = prefs.edit { + if (value == null) { + remove(KEY_USER) + return@edit + } + val str = buildString { + appendLine(value.id) + appendLine(value.nickname) + appendLine(value.avatar) + appendLine(value.service.name) + } + putString(KEY_USER, str) + } + + fun clear() = prefs.edit { + clear() + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/Scrobbler.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/Scrobbler.kt index 1db58e204..54f9ac504 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/Scrobbler.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/Scrobbler.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.scrobbling.data.ScrobblerRepository import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo @@ -21,18 +22,27 @@ import java.util.EnumMap abstract class Scrobbler( protected val db: MangaDatabase, val scrobblerService: ScrobblerService, + private val repository: ScrobblerRepository, ) { private val infoCache = LongSparseArray() protected val statuses = EnumMap(ScrobblingStatus::class.java) - abstract val isAvailable: Boolean + val isAvailable: Boolean + get() = repository.isAuthorized - abstract suspend fun findManga(query: String, offset: Int): List + suspend fun findManga(query: String, offset: Int): List { + return repository.findManga(query, offset) + } - abstract suspend fun linkManga(mangaId: Long, targetId: Long) + suspend fun linkManga(mangaId: Long, targetId: Long) { + repository.createRate(mangaId, targetId) + } - abstract suspend fun scrobble(mangaId: Long, chapter: MangaChapter) + suspend fun scrobble(mangaId: Long, chapter: MangaChapter) { + val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return + repository.updateRate(entity.id, entity.mangaId, chapter) + } suspend fun getScrobblingInfoOrNull(mangaId: Long): ScrobblingInfo? { val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return null @@ -46,9 +56,13 @@ abstract class Scrobbler( .map { it?.toScrobblingInfo(mangaId) } } - abstract suspend fun unregisterScrobbling(mangaId: Long) + suspend fun unregisterScrobbling(mangaId: Long) { + repository.unregister(mangaId) + } - protected abstract suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo + protected suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { + return repository.getMangaInfo(id) + } private suspend fun ScrobblingEntity.toScrobblingInfo(mangaId: Long): ScrobblingInfo? { val mangaInfo = infoCache.getOrElse(targetId) { diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerType.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerType.kt new file mode 100644 index 000000000..e2d372edb --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerType.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.scrobbling.domain.model + +import javax.inject.Qualifier + +@Qualifier +annotation class ScrobblerType( + val service: ScrobblerService +) diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/model/ShikimoriUser.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerUser.kt similarity index 56% rename from app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/model/ShikimoriUser.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerUser.kt index 79ecfa6c5..92ecce0df 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/model/ShikimoriUser.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerUser.kt @@ -1,34 +1,22 @@ -package org.koitharu.kotatsu.scrobbling.shikimori.data.model +package org.koitharu.kotatsu.scrobbling.domain.model -import org.json.JSONObject - -class ShikimoriUser( +class ScrobblerUser( val id: Long, val nickname: String, val avatar: String, + val service: ScrobblerService, ) { - constructor(json: JSONObject) : this( - id = json.getLong("id"), - nickname = json.getString("nickname"), - avatar = json.getString("avatar"), - ) - - fun toJson() = JSONObject().apply { - put("id", id) - put("nickname", nickname) - put("avatar", avatar) - } - override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false - other as ShikimoriUser + other as ScrobblerUser if (id != other.id) return false if (nickname != other.nickname) return false if (avatar != other.avatar) return false + if (service != other.service) return false return true } @@ -37,6 +25,7 @@ class ShikimoriUser( var result = id.hashCode() result = 31 * result + nickname.hashCode() result = 31 * result + avatar.hashCode() + result = 31 * result + service.hashCode() return result } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt index 7b683cf1d..dbaad2cc7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt @@ -1,7 +1,5 @@ package org.koitharu.kotatsu.scrobbling.shikimori.data -import javax.inject.Inject -import javax.inject.Provider import kotlinx.coroutines.runBlocking import okhttp3.Authenticator import okhttp3.Request @@ -9,9 +7,14 @@ import okhttp3.Response import okhttp3.Route import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.scrobbling.data.ScrobblerStorage +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerType +import javax.inject.Inject +import javax.inject.Provider class ShikimoriAuthenticator @Inject constructor( - private val storage: ShikimoriStorage, + @ScrobblerType(ScrobblerService.SHIKIMORI) private val storage: ScrobblerStorage, private val repositoryProvider: Provider, ) : Authenticator { diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt index d0be7d23c..466382960 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt @@ -4,10 +4,11 @@ import okhttp3.Interceptor import okhttp3.Response import okio.IOException import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.scrobbling.data.ScrobblerStorage private const val USER_AGENT_SHIKIMORI = "Kotatsu" -class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor { +class ShikimoriInterceptor(private val storage: ScrobblerStorage) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val sourceRequest = chain.request() diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt index 2267994ec..66de790c2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt @@ -14,11 +14,13 @@ import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.parseJson import org.koitharu.kotatsu.parsers.util.parseJsonArray import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl +import org.koitharu.kotatsu.scrobbling.data.ScrobblerRepository +import org.koitharu.kotatsu.scrobbling.data.ScrobblerStorage import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService -import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerUser import org.koitharu.kotatsu.utils.ext.toRequestBody private const val REDIRECT_URI = "kotatsu://shikimori-auth" @@ -27,18 +29,18 @@ private const val MANGA_PAGE_SIZE = 10 class ShikimoriRepository( private val okHttp: OkHttpClient, - private val storage: ShikimoriStorage, + private val storage: ScrobblerStorage, private val db: MangaDatabase, -) { +) : ScrobblerRepository { - val oauthUrl: String + override val oauthUrl: String get() = "${BASE_URL}oauth/authorize?client_id=${BuildConfig.SHIKIMORI_CLIENT_ID}&" + "redirect_uri=$REDIRECT_URI&response_type=code&scope=" - val isAuthorized: Boolean + override val isAuthorized: Boolean get() = storage.accessToken != null - suspend fun authorize(code: String?) { + override suspend fun authorize(code: String?) { val body = FormBody.Builder() body.add("client_id", BuildConfig.SHIKIMORI_CLIENT_ID) body.add("client_secret", BuildConfig.SHIKIMORI_CLIENT_SECRET) @@ -58,7 +60,7 @@ class ShikimoriRepository( storage.refreshToken = response.getString("refresh_token") } - suspend fun loadUser(): ShikimoriUser { + override suspend fun loadUser(): ScrobblerUser { val request = Request.Builder() .get() .url("${BASE_URL}api/users/whoami") @@ -66,19 +68,20 @@ class ShikimoriRepository( return ShikimoriUser(response).also { storage.user = it } } - fun getCachedUser(): ShikimoriUser? { - return storage.user - } + override val cachedUser: ScrobblerUser? + get() { + return storage.user + } - suspend fun unregister(mangaId: Long) { + override suspend fun unregister(mangaId: Long) { return db.scrobblingDao.delete(ScrobblerService.SHIKIMORI.id, mangaId) } - fun logout() { + override fun logout() { storage.clear() } - suspend fun findManga(query: String, offset: Int): List { + override suspend fun findManga(query: String, offset: Int): List { val page = offset / MANGA_PAGE_SIZE val pageOffset = offset % MANGA_PAGE_SIZE val url = BASE_URL.toHttpUrl().newBuilder() @@ -95,8 +98,8 @@ class ShikimoriRepository( return if (pageOffset != 0) list.drop(pageOffset) else list } - suspend fun createRate(mangaId: Long, shikiMangaId: Long) { - val user = getCachedUser() ?: loadUser() + override suspend fun createRate(mangaId: Long, shikiMangaId: Long) { + val user = cachedUser ?: loadUser() val payload = JSONObject() payload.put( "user_rate", @@ -116,7 +119,7 @@ class ShikimoriRepository( saveRate(response, mangaId) } - suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) { + override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) { val payload = JSONObject() payload.put( "user_rate", @@ -135,7 +138,7 @@ class ShikimoriRepository( saveRate(response, mangaId) } - suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) { + override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) { val payload = JSONObject() payload.put( "user_rate", @@ -160,7 +163,7 @@ class ShikimoriRepository( saveRate(response, mangaId) } - suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { + override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { val request = Request.Builder() .get() .url("${BASE_URL}api/mangas/$id") @@ -197,4 +200,11 @@ class ShikimoriRepository( url = json.getString("url").toAbsoluteUrl("shikimori.one"), descriptionHtml = json.getString("description_html"), ) + + private fun ShikimoriUser(json: JSONObject) = ScrobblerUser( + id = json.getLong("id"), + nickname = json.getString("nickname"), + avatar = json.getString("avatar"), + service = ScrobblerService.SHIKIMORI, + ) } diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriStorage.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriStorage.kt deleted file mode 100644 index a3cd84e85..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriStorage.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.shikimori.data - -import android.content.Context -import androidx.core.content.edit -import dagger.hilt.android.qualifiers.ApplicationContext -import org.json.JSONObject -import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser -import javax.inject.Inject -import javax.inject.Singleton - -private const val PREF_NAME = "shikimori" -private const val KEY_ACCESS_TOKEN = "access_token" -private const val KEY_REFRESH_TOKEN = "refresh_token" -private const val KEY_USER = "user" - -@Singleton -class ShikimoriStorage @Inject constructor(@ApplicationContext context: Context) { - - private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - - var accessToken: String? - get() = prefs.getString(KEY_ACCESS_TOKEN, null) - set(value) = prefs.edit { putString(KEY_ACCESS_TOKEN, value) } - - var refreshToken: String? - get() = prefs.getString(KEY_REFRESH_TOKEN, null) - set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) } - - var user: ShikimoriUser? - get() = prefs.getString(KEY_USER, null)?.let { - ShikimoriUser(JSONObject(it)) - } - set(value) = prefs.edit { - putString(KEY_USER, value?.toJson()?.toString()) - } - - fun clear() = prefs.edit { - clear() - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt index bf69e3fa4..5c73c0532 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt @@ -1,15 +1,12 @@ package org.koitharu.kotatsu.scrobbling.shikimori.domain -import javax.inject.Inject -import javax.inject.Singleton import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.scrobbling.domain.Scrobbler -import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga -import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository +import javax.inject.Inject +import javax.inject.Singleton private const val RATING_MAX = 10f @@ -17,7 +14,7 @@ private const val RATING_MAX = 10f class ShikimoriScrobbler @Inject constructor( private val repository: ShikimoriRepository, db: MangaDatabase, -) : Scrobbler(db, ScrobblerService.SHIKIMORI) { +) : Scrobbler(db, ScrobblerService.SHIKIMORI, repository) { init { statuses[ScrobblingStatus.PLANNED] = "planned" @@ -28,22 +25,6 @@ class ShikimoriScrobbler @Inject constructor( statuses[ScrobblingStatus.DROPPED] = "dropped" } - override val isAvailable: Boolean - get() = repository.isAuthorized - - override suspend fun findManga(query: String, offset: Int): List { - return repository.findManga(query, offset) - } - - override suspend fun linkManga(mangaId: Long, targetId: Long) { - repository.createRate(mangaId, targetId) - } - - override suspend fun scrobble(mangaId: Long, chapter: MangaChapter) { - val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return - repository.updateRate(entity.id, entity.mangaId, chapter) - } - override suspend fun updateScrobblingInfo( mangaId: Long, rating: Float, @@ -60,12 +41,4 @@ class ShikimoriScrobbler @Inject constructor( comment = comment, ) } - - override suspend fun unregisterScrobbling(mangaId: Long) { - repository.unregister(mangaId) - } - - override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { - return repository.getMangaInfo(id) - } } diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsFragment.kt index 03fd7d882..da45cf4ed 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsFragment.kt @@ -9,14 +9,14 @@ import coil.ImageLoader import coil.request.ImageRequest import coil.transform.CircleCropTransformation import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BasePreferenceFragment -import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerUser import org.koitharu.kotatsu.utils.PreferenceIconTarget import org.koitharu.kotatsu.utils.ext.assistedViewModels import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.withArgs +import javax.inject.Inject @AndroidEntryPoint class ShikimoriSettingsFragment : BasePreferenceFragment(R.string.shikimori) { @@ -47,11 +47,12 @@ class ShikimoriSettingsFragment : BasePreferenceFragment(R.string.shikimori) { viewModel.logout() true } + else -> super.onPreferenceTreeClick(preference) } } - private fun onUserChanged(user: ShikimoriUser?) { + private fun onUserChanged(user: ScrobblerUser?) { val pref = findPreference(KEY_USER) ?: return pref.isSelectable = user == null pref.title = user?.nickname ?: getString(R.string.sign_in) diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsViewModel.kt index 091bdcea3..2e689e673 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsViewModel.kt @@ -6,8 +6,8 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerUser import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository -import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser class ShikimoriSettingsViewModel @AssistedInject constructor( private val repository: ShikimoriRepository, @@ -17,7 +17,7 @@ class ShikimoriSettingsViewModel @AssistedInject constructor( val authorizationUrl: String get() = repository.oauthUrl - val user = MutableLiveData() + val user = MutableLiveData() init { if (authCode != null) { @@ -36,7 +36,7 @@ class ShikimoriSettingsViewModel @AssistedInject constructor( private fun loadUser() = launchJob(Dispatchers.Default) { val userModel = if (repository.isAuthorized) { - repository.getCachedUser()?.let(user::postValue) + repository.cachedUser?.let(user::postValue) repository.loadUser() } else { null diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt index 936aa2028..d625be3ea 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt @@ -201,7 +201,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach private fun bindShikimoriSummary() { findPreference(AppSettings.KEY_SHIKIMORI)?.summary = if (shikimoriRepository.isAuthorized) { - getString(R.string.logged_in_as, shikimoriRepository.getCachedUser()?.nickname) + getString(R.string.logged_in_as, shikimoriRepository.cachedUser?.nickname) } else { getString(R.string.disabled) } @@ -209,7 +209,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach private fun bindAniListSummary() { findPreference(AppSettings.KEY_ANILIST)?.summary = if (aniListRepository.isAuthorized) { - getString(R.string.logged_in_as, aniListRepository.getCachedUser()?.nickname) + getString(R.string.logged_in_as, aniListRepository.cachedUser?.nickname) } else { getString(R.string.disabled) } From bd692fc60cbae42eb18baa7f8210282d40c65abd Mon Sep 17 00:00:00 2001 From: Koitharu Date: Fri, 6 Jan 2023 08:04:05 +0200 Subject: [PATCH 03/49] Update dependencies --- .idea/kotlinc.xml | 2 +- app/build.gradle | 7 ++++--- build.gradle | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index dd185e22b..22dcb880f 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -4,6 +4,6 @@ \ No newline at end of file + diff --git a/app/src/main/res/xml/pref_reader.xml b/app/src/main/res/xml/pref_reader.xml index eedcddc3e..525a46202 100644 --- a/app/src/main/res/xml/pref_reader.xml +++ b/app/src/main/res/xml/pref_reader.xml @@ -61,20 +61,19 @@ android:title="@string/show_pages_numbers" /> From bdb2ae9c2f0a9afd6209b7cdc94f421b906922df Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 4 Jan 2023 08:28:46 +0200 Subject: [PATCH 11/49] Manage prefetch cache memory --- .../org/koitharu/kotatsu/core/AppModule.kt | 6 +-- .../kotatsu/core/cache/ContentCache.kt | 2 + .../kotatsu/core/cache/MemoryContentCache.kt | 38 +++++++++++++++++-- .../kotatsu/core/cache/StubContentCache.kt | 2 + .../details/service/MangaPrefetchService.kt | 6 +-- 5 files changed, 43 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt index 8a39c05a1..4ee31403a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt @@ -190,12 +190,12 @@ interface AppModule { @Provides @Singleton fun provideContentCache( - @ApplicationContext context: Context, + application: Application, ): ContentCache { - return if (context.activityManager?.isLowRamDevice == true) { + return if (application.activityManager?.isLowRamDevice == true) { StubContentCache() } else { - MemoryContentCache() + MemoryContentCache(application) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/cache/ContentCache.kt b/app/src/main/java/org/koitharu/kotatsu/core/cache/ContentCache.kt index 56a6f740e..f9c37b2c3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/cache/ContentCache.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/cache/ContentCache.kt @@ -7,6 +7,8 @@ import org.koitharu.kotatsu.parsers.model.MangaSource interface ContentCache { + val isCachingEnabled: Boolean + suspend fun getDetails(source: MangaSource, url: String): Manga? fun putDetails(source: MangaSource, url: String, details: Deferred) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt b/app/src/main/java/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt index ed1b46ac1..4fb146d22 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt @@ -1,15 +1,24 @@ package org.koitharu.kotatsu.core.cache +import android.app.Application +import android.content.ComponentCallbacks2 +import android.content.res.Configuration import kotlinx.coroutines.Deferred import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource @Suppress("DeferredResultUnused") -class MemoryContentCache : ContentCache { +class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 { - private val detailsCache = DeferredLruCache(10) - private val pagesCache = DeferredLruCache>(10) + init { + application.registerComponentCallbacks(this) + } + + private val detailsCache = DeferredLruCache(4) + private val pagesCache = DeferredLruCache>(4) + + override val isCachingEnabled: Boolean = true override suspend fun getDetails(source: MangaSource, url: String): Manga? { return detailsCache[ContentCache.Key(source, url)]?.await() @@ -26,4 +35,27 @@ class MemoryContentCache : ContentCache { override fun putPages(source: MangaSource, url: String, pages: Deferred>) { pagesCache.put(ContentCache.Key(source, url), pages) } + + override fun onConfigurationChanged(newConfig: Configuration) = Unit + + override fun onLowMemory() = Unit + + override fun onTrimMemory(level: Int) { + trimCache(detailsCache, level) + trimCache(pagesCache, level) + } + + private fun trimCache(cache: DeferredLruCache<*>, level: Int) { + when (level) { + ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL, + ComponentCallbacks2.TRIM_MEMORY_COMPLETE, + ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.evictAll() + + ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN, + ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW, + ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> cache.trimToSize(1) + + else -> cache.trimToSize(cache.maxSize() / 2) + } + } } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/cache/StubContentCache.kt b/app/src/main/java/org/koitharu/kotatsu/core/cache/StubContentCache.kt index cd3d632be..faf9a7237 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/cache/StubContentCache.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/cache/StubContentCache.kt @@ -7,6 +7,8 @@ import org.koitharu.kotatsu.parsers.model.MangaSource class StubContentCache : ContentCache { + override val isCachingEnabled: Boolean = false + override suspend fun getDetails(source: MangaSource, url: String): Manga? = null override fun putDetails(source: MangaSource, url: String, details: Deferred) = Unit diff --git a/app/src/main/java/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt b/app/src/main/java/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt index 225d1042c..6af5e3258 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import org.koitharu.kotatsu.base.ui.CoroutineIntentService import org.koitharu.kotatsu.core.cache.ContentCache -import org.koitharu.kotatsu.core.cache.StubContentCache import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters import org.koitharu.kotatsu.core.parser.MangaRepository @@ -87,10 +86,7 @@ class MangaPrefetchService : CoroutineIntentService() { return false } val entryPoint = EntryPointAccessors.fromApplication(context, PrefetchCompanionEntryPoint::class.java) - if (entryPoint.contentCache is StubContentCache) { - return false - } - return entryPoint.settings.isContentPrefetchEnabled() + return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled() } } } From c03dcf6d2efce251cb9ac9a2c1e67ba2f2a03f52 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 4 Jan 2023 12:38:49 +0200 Subject: [PATCH 12/49] Fix memory leaks --- .../kotatsu/core/cache/ContentCache.kt | 5 ++- .../kotatsu/core/cache/DeferredLruCache.kt | 7 ++-- .../kotatsu/core/cache/MemoryContentCache.kt | 10 +++--- .../kotatsu/core/cache/SafeDeferred.kt | 20 ++++++++++++ .../kotatsu/core/cache/StubContentCache.kt | 5 ++- .../core/parser/RemoteMangaRepository.kt | 32 ++++++++++++------- .../details/service/MangaPrefetchService.kt | 11 ++----- 7 files changed, 54 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/cache/SafeDeferred.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/core/cache/ContentCache.kt b/app/src/main/java/org/koitharu/kotatsu/core/cache/ContentCache.kt index f9c37b2c3..791ce9358 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/cache/ContentCache.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/cache/ContentCache.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.core.cache -import kotlinx.coroutines.Deferred import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource @@ -11,11 +10,11 @@ interface ContentCache { suspend fun getDetails(source: MangaSource, url: String): Manga? - fun putDetails(source: MangaSource, url: String, details: Deferred) + fun putDetails(source: MangaSource, url: String, details: SafeDeferred) suspend fun getPages(source: MangaSource, url: String): List? - fun putPages(source: MangaSource, url: String, pages: Deferred>) + fun putPages(source: MangaSource, url: String, pages: SafeDeferred>) data class Key( val source: MangaSource, diff --git a/app/src/main/java/org/koitharu/kotatsu/core/cache/DeferredLruCache.kt b/app/src/main/java/org/koitharu/kotatsu/core/cache/DeferredLruCache.kt index 7202543fd..f92561198 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/cache/DeferredLruCache.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/cache/DeferredLruCache.kt @@ -1,15 +1,14 @@ package org.koitharu.kotatsu.core.cache import androidx.collection.LruCache -import kotlinx.coroutines.Deferred -class DeferredLruCache(maxSize: Int) : LruCache>(maxSize) { +class DeferredLruCache(maxSize: Int) : LruCache>(maxSize) { override fun entryRemoved( evicted: Boolean, key: ContentCache.Key, - oldValue: Deferred, - newValue: Deferred?, + oldValue: SafeDeferred, + newValue: SafeDeferred?, ) { super.entryRemoved(evicted, key, oldValue, newValue) oldValue.cancel() diff --git a/app/src/main/java/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt b/app/src/main/java/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt index 4fb146d22..ffa9a904e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt @@ -3,12 +3,10 @@ package org.koitharu.kotatsu.core.cache import android.app.Application import android.content.ComponentCallbacks2 import android.content.res.Configuration -import kotlinx.coroutines.Deferred import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource -@Suppress("DeferredResultUnused") class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 { init { @@ -21,18 +19,18 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall override val isCachingEnabled: Boolean = true override suspend fun getDetails(source: MangaSource, url: String): Manga? { - return detailsCache[ContentCache.Key(source, url)]?.await() + return detailsCache[ContentCache.Key(source, url)]?.awaitOrNull() } - override fun putDetails(source: MangaSource, url: String, details: Deferred) { + override fun putDetails(source: MangaSource, url: String, details: SafeDeferred) { detailsCache.put(ContentCache.Key(source, url), details) } override suspend fun getPages(source: MangaSource, url: String): List? { - return pagesCache[ContentCache.Key(source, url)]?.await() + return pagesCache[ContentCache.Key(source, url)]?.awaitOrNull() } - override fun putPages(source: MangaSource, url: String, pages: Deferred>) { + override fun putPages(source: MangaSource, url: String, pages: SafeDeferred>) { pagesCache.put(ContentCache.Key(source, url), pages) } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/cache/SafeDeferred.kt b/app/src/main/java/org/koitharu/kotatsu/core/cache/SafeDeferred.kt new file mode 100644 index 000000000..24b2c48d4 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/cache/SafeDeferred.kt @@ -0,0 +1,20 @@ +package org.koitharu.kotatsu.core.cache + +import kotlinx.coroutines.Deferred + +class SafeDeferred( + private val delegate: Deferred>, +) { + + suspend fun await(): T { + return delegate.await().getOrThrow() + } + + suspend fun awaitOrNull(): T? { + return delegate.await().getOrNull() + } + + fun cancel() { + delegate.cancel() + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/cache/StubContentCache.kt b/app/src/main/java/org/koitharu/kotatsu/core/cache/StubContentCache.kt index faf9a7237..4750deb98 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/cache/StubContentCache.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/cache/StubContentCache.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.core.cache -import kotlinx.coroutines.Deferred import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource @@ -11,9 +10,9 @@ class StubContentCache : ContentCache { override suspend fun getDetails(source: MangaSource, url: String): Manga? = null - override fun putDetails(source: MangaSource, url: String, details: Deferred) = Unit + override fun putDetails(source: MangaSource, url: String, details: SafeDeferred) = Unit override suspend fun getPages(source: MangaSource, url: String): List? = null - override fun putPages(source: MangaSource, url: String, pages: Deferred>) = Unit + override fun putPages(source: MangaSource, url: String, pages: SafeDeferred>) = Unit } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt index be7b3b6a5..439d06494 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt @@ -1,8 +1,10 @@ package org.koitharu.kotatsu.core.parser +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope import org.koitharu.kotatsu.core.cache.ContentCache +import org.koitharu.kotatsu.core.cache.SafeDeferred import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParserAuthProvider @@ -14,6 +16,8 @@ import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.utils.ext.processLifecycleScope +import org.koitharu.kotatsu.utils.ext.runCatchingCancellable class RemoteMangaRepository( private val parser: MangaParser, @@ -42,20 +46,20 @@ class RemoteMangaRepository( override suspend fun getDetails(manga: Manga): Manga { cache.getDetails(source, manga.url)?.let { return it } - return coroutineScope { - val details = async { parser.getDetails(manga) } - cache.putDetails(source, manga.url, details) - details - }.await() + val details = asyncSafe { + parser.getDetails(manga) + } + cache.putDetails(source, manga.url, details) + return details.await() } override suspend fun getPages(chapter: MangaChapter): List { cache.getPages(source, chapter.url)?.let { return it } - return coroutineScope { - val pages = async { parser.getPages(chapter) } - cache.putPages(source, chapter.url, pages) - pages - }.await() + val pages = asyncSafe { + parser.getPages(chapter) + } + cache.putPages(source, chapter.url, pages) + return pages.await() } override suspend fun getPageUrl(page: MangaPage): String = parser.getPageUrl(page) @@ -71,4 +75,10 @@ class RemoteMangaRepository( } private fun getConfig() = parser.config as SourceSettings + + private fun asyncSafe(block: suspend CoroutineScope.() -> T) = SafeDeferred( + processLifecycleScope.async(Dispatchers.Default) { + runCatchingCancellable { block() } + }, + ) } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt b/app/src/main/java/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt index 6af5e3258..cc5f5b974 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt @@ -4,9 +4,7 @@ import android.content.Context import android.content.Intent import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.EntryPointAccessors -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch import org.koitharu.kotatsu.base.ui.CoroutineIntentService import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga @@ -16,7 +14,6 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat -import org.koitharu.kotatsu.utils.ext.processLifecycleScope import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import javax.inject.Inject @@ -46,16 +43,12 @@ class MangaPrefetchService : CoroutineIntentService() { private suspend fun prefetchDetails(manga: Manga) = coroutineScope { val source = mangaRepositoryFactory.create(manga.source) - processLifecycleScope.launch(Dispatchers.Default) { - runCatchingCancellable { source.getDetails(manga) } - }.join() + runCatchingCancellable { source.getDetails(manga) } } private suspend fun prefetchPages(chapter: MangaChapter) { val source = mangaRepositoryFactory.create(chapter.source) - processLifecycleScope.launch(Dispatchers.Default) { - runCatchingCancellable { source.getPages(chapter) } - }.join() + runCatchingCancellable { source.getPages(chapter) } } companion object { From 27f09480a0270c7758d77b08fe626c29f3da12e8 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 4 Jan 2023 13:11:11 +0200 Subject: [PATCH 13/49] Prefetch last manga --- .../details/service/MangaPrefetchService.kt | 34 ++++++++++++++++++- .../koitharu/kotatsu/main/ui/MainActivity.kt | 2 ++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt b/app/src/main/java/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt index cc5f5b974..06880b7cc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt @@ -10,6 +10,7 @@ import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaSource @@ -26,6 +27,9 @@ class MangaPrefetchService : CoroutineIntentService() { @Inject lateinit var cache: ContentCache + @Inject + lateinit var historyRepository: HistoryRepository + override suspend fun processIntent(startId: Int, intent: Intent) { when (intent.action) { ACTION_PREFETCH_DETAILS -> prefetchDetails( @@ -36,6 +40,8 @@ class MangaPrefetchService : CoroutineIntentService() { chapter = intent.getParcelableExtraCompat(EXTRA_CHAPTER) ?.chapters?.singleOrNull() ?: return, ) + + ACTION_PREFETCH_LAST -> prefetchLast() } } @@ -51,12 +57,31 @@ class MangaPrefetchService : CoroutineIntentService() { runCatchingCancellable { source.getPages(chapter) } } + private suspend fun prefetchLast() { + val last = historyRepository.getLastOrNull() ?: return + if (last.source == MangaSource.LOCAL) return + val repo = mangaRepositoryFactory.create(last.source) + val details = runCatchingCancellable { repo.getDetails(last) }.getOrNull() ?: return + val chapters = details.chapters + if (chapters.isNullOrEmpty()) { + return + } + val history = historyRepository.getOne(last) + val chapter = if (history == null) { + chapters.firstOrNull() + } else { + chapters.find { x -> x.id == history.chapterId } ?: chapters.firstOrNull() + } ?: return + runCatchingCancellable { repo.getPages(chapter) } + } + companion object { private const val EXTRA_MANGA = "manga" private const val EXTRA_CHAPTER = "manga" private const val ACTION_PREFETCH_DETAILS = "details" private const val ACTION_PREFETCH_PAGES = "pages" + private const val ACTION_PREFETCH_LAST = "last" fun prefetchDetails(context: Context, manga: Manga) { if (!isPrefetchAvailable(context, manga.source)) return @@ -74,7 +99,14 @@ class MangaPrefetchService : CoroutineIntentService() { context.startService(intent) } - private fun isPrefetchAvailable(context: Context, source: MangaSource): Boolean { + fun prefetchLast(context: Context) { + if (!isPrefetchAvailable(context, null)) return + val intent = Intent(context, MangaPrefetchService::class.java) + intent.action = ACTION_PREFETCH_LAST + context.startService(intent) + } + + private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean { if (source == MangaSource.LOCAL) { return false } diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt index 72fbd1a0f..c267a976f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -41,6 +41,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView import org.koitharu.kotatsu.databinding.ActivityMainBinding +import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner @@ -334,6 +335,7 @@ class MainActivity : TrackWorker.setup(applicationContext) SuggestionsWorker.setup(applicationContext) } + MangaPrefetchService.prefetchLast(this@MainActivity) requestNotificationsPermission() } } From 8a2706d70b2596b0f9a5b95e7dbd7831cedc1ea5 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 4 Jan 2023 13:16:43 +0200 Subject: [PATCH 14/49] Hide prefetch option if not available --- .../kotatsu/settings/ContentSettingsFragment.kt | 14 +++++++++++--- app/src/main/res/xml/pref_content.xml | 7 +++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt index 83a5f231a..f8aa998d8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt @@ -9,14 +9,13 @@ import androidx.preference.ListPreference import androidx.preference.Preference import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint -import java.io.File -import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog +import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.local.data.LocalStorageManager @@ -26,6 +25,8 @@ import org.koitharu.kotatsu.sync.ui.SyncSettingsIntent import org.koitharu.kotatsu.utils.ext.getStorageName import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat import org.koitharu.kotatsu.utils.ext.viewLifecycleScope +import java.io.File +import javax.inject.Inject @AndroidEntryPoint class ContentSettingsFragment : @@ -36,9 +37,12 @@ class ContentSettingsFragment : @Inject lateinit var storageManager: LocalStorageManager + @Inject + lateinit var contentCache: ContentCache + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_content) - + findPreference(AppSettings.KEY_PREFETCH_CONTENT)?.isVisible = contentCache.isCachingEnabled findPreference(AppSettings.KEY_DOWNLOADS_PARALLELISM)?.run { summary = value.toString() setOnPreferenceChangeListener { preference, newValue -> @@ -82,11 +86,13 @@ class ContentSettingsFragment : AppSettings.KEY_LOCAL_STORAGE -> { findPreference(key)?.bindStorageName() } + AppSettings.KEY_SUGGESTIONS -> { findPreference(AppSettings.KEY_SUGGESTIONS)?.setSummary( if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled, ) } + AppSettings.KEY_SOURCES_HIDDEN -> { bindRemoteSourcesSummary() } @@ -104,6 +110,7 @@ class ContentSettingsFragment : .show() true } + AppSettings.KEY_SYNC -> { val am = AccountManager.get(requireContext()) val accountType = getString(R.string.account_type_sync) @@ -119,6 +126,7 @@ class ContentSettingsFragment : } true } + else -> super.onPreferenceTreeClick(preference) } } diff --git a/app/src/main/res/xml/pref_content.xml b/app/src/main/res/xml/pref_content.xml index e060edc5d..1c94f2674 100644 --- a/app/src/main/res/xml/pref_content.xml +++ b/app/src/main/res/xml/pref_content.xml @@ -1,7 +1,8 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> + app:isPreferenceVisible="false" + app:useSimpleSummaryProvider="true" + tools:isPreferenceVisible="true" /> Date: Mon, 9 Jan 2023 11:19:17 +0200 Subject: [PATCH 15/49] Fix list mode changing --- .../ui/list/FavouritesListViewModel.kt | 2 +- .../kotatsu/history/ui/HistoryListViewModel.kt | 2 +- .../kotatsu/list/ui/ListModeBottomSheet.kt | 3 +++ .../kotatsu/list/ui/MangaListViewModel.kt | 18 +++++++----------- .../kotatsu/local/ui/LocalListViewModel.kt | 2 +- .../remotelist/ui/RemoteListViewModel.kt | 2 +- .../kotatsu/search/ui/SearchViewModel.kt | 2 +- .../suggestions/ui/SuggestionsViewModel.kt | 5 +++-- .../tracker/ui/updates/UpdatesViewModel.kt | 2 +- 9 files changed, 19 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt index f9842b9d1..a73142563 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt @@ -55,7 +55,7 @@ class FavouritesListViewModel @AssistedInject constructor( } else { repository.observeAll(categoryId) }, - createListModeFlow(), + listModeFlow, ) { list, mode -> when { list.isEmpty() -> listOf( diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt index bb7bb0e1d..8806ea2d2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt @@ -48,7 +48,7 @@ class HistoryListViewModel @Inject constructor( override val content = combine( repository.observeAllWithHistory(), historyGrouping, - createListModeFlow(), + listModeFlow, ) { list, grouped, mode -> when { list.isEmpty() -> listOf( diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/ListModeBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/ListModeBottomSheet.kt index 042d30bc3..37339aac2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/ListModeBottomSheet.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/ListModeBottomSheet.kt @@ -49,6 +49,9 @@ class ListModeBottomSheet : } override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) { + if (!isChecked) { + return + } val mode = when (checkedId) { R.id.button_list -> ListMode.LIST R.id.button_list_detailed -> ListMode.DETAILED_LIST diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt index f71ccac22..03ec95536 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt @@ -1,26 +1,29 @@ package org.koitharu.kotatsu.list.ui import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsLiveData import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.utils.SingleLiveEvent +import org.koitharu.kotatsu.utils.asFlowLiveData abstract class MangaListViewModel( private val settings: AppSettings, ) : BaseViewModel() { abstract val content: LiveData> - val listMode = MutableLiveData() + protected val listModeFlow = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode } + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, settings.listMode) + val listMode = listModeFlow.asFlowLiveData(viewModelScope.coroutineContext) val onActionDone = SingleLiveEvent() val gridScale = settings.observeAsLiveData( context = viewModelScope.coroutineContext + Dispatchers.Default, @@ -30,13 +33,6 @@ abstract class MangaListViewModel( open fun onUpdateFilter(tags: Set) = Unit - protected fun createListModeFlow() = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode } - .onEach { - if (listMode.value != it) { - listMode.postValue(it) - } - } - abstract fun onRefresh() abstract fun onRetry() diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 467f87f1b..499ed4b6d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -57,7 +57,7 @@ class LocalListViewModel @Inject constructor( override val content = combine( mangaList, - createListModeFlow(), + listModeFlow, sortOrder.asFlow(), selectedTags, listError, diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index 386808b28..aef575033 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -63,7 +63,7 @@ class RemoteListViewModel @AssistedInject constructor( override val content = combine( mangaList, - createListModeFlow(), + listModeFlow, createHeaderFlow(), listError, hasNextPage, diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt index 37301cd11..b959f65a7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt @@ -39,7 +39,7 @@ class SearchViewModel @AssistedInject constructor( override val content = combine( mangaList, - createListModeFlow(), + listModeFlow, listError, hasNextPage, ) { list, mode, error, hasNext -> diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt index 25941f2e3..2eca00df8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt @@ -2,7 +2,6 @@ package org.koitharu.kotatsu.suggestions.ui import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine @@ -17,6 +16,7 @@ import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.ext.onFirst +import javax.inject.Inject @HiltViewModel class SuggestionsViewModel @Inject constructor( @@ -26,7 +26,7 @@ class SuggestionsViewModel @Inject constructor( override val content = combine( repository.observeAll(), - createListModeFlow(), + listModeFlow, ) { list, mode -> when { list.isEmpty() -> listOf( @@ -37,6 +37,7 @@ class SuggestionsViewModel @Inject constructor( actionStringRes = 0, ), ) + else -> list.toUi(mode) } }.onStart { diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt index 586ef35c2..6a649cd38 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt @@ -34,7 +34,7 @@ class UpdatesViewModel @Inject constructor( override val content = combine( repository.observeUpdatedManga(), - createListModeFlow(), + listModeFlow, ) { mangaMap, mode -> when { mangaMap.isEmpty() -> listOf( From 516470e8aeec6e5735adc86eae165871a0bdb7b6 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 9 Jan 2023 13:36:45 +0200 Subject: [PATCH 16/49] Update manga details list item --- .../kotatsu/list/ui/MangaListFragment.kt | 41 ++++++- .../list/ui/MangaSelectionDecoration.kt | 11 +- .../ui/adapter/MangaDetailsClickListener.kt | 13 ++ .../ui/adapter/MangaListDetailedItemAD.kt | 41 +++++-- .../list/ui/adapter/MangaListListener.kt | 4 +- .../list/ui/model/ListModelConversionExt.kt | 11 +- .../list/ui/model/MangaListDetailedModel.kt | 6 +- .../search/ui/multi/MultiSearchActivity.kt | 22 +++- .../kotatsu/tracker/ui/feed/FeedFragment.kt | 4 + .../main/res/drawable/bg_circle_button.xml | 15 +++ app/src/main/res/drawable/ic_star.xml | 4 +- .../res/layout-w600dp/fragment_details.xml | 6 +- app/src/main/res/layout/fragment_details.xml | 6 +- app/src/main/res/layout/item_manga_grid.xml | 15 ++- .../res/layout/item_manga_list_details.xml | 113 +++++++++--------- app/src/main/res/layout/item_page_thumb.xml | 6 +- app/src/main/res/values/dimens.xml | 3 + app/src/main/res/values/styles.xml | 5 + 18 files changed, 222 insertions(+), 104 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaDetailsClickListener.kt create mode 100644 app/src/main/res/drawable/bg_circle_button.xml diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index 70013233c..01c09394e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -1,7 +1,11 @@ package org.koitharu.kotatsu.list.ui import android.os.Bundle -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams import androidx.annotation.CallSuper import androidx.appcompat.view.ActionMode @@ -15,7 +19,6 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import coil.ImageLoader import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject import kotlinx.coroutines.launch import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.domain.reverseAsync @@ -42,13 +45,23 @@ import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaItemModel +import org.koitharu.kotatsu.main.ui.MainActivity import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner -import org.koitharu.kotatsu.main.ui.MainActivity import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.reader.ui.ReaderActivity +import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.utils.ShareHelper -import org.koitharu.kotatsu.utils.ext.* +import org.koitharu.kotatsu.utils.ext.addMenuProvider +import org.koitharu.kotatsu.utils.ext.clearItemDecorations +import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.getThemeColor +import org.koitharu.kotatsu.utils.ext.measureHeight +import org.koitharu.kotatsu.utils.ext.resolveDp +import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf +import org.koitharu.kotatsu.utils.ext.viewLifecycleScope +import javax.inject.Inject @AndroidEntryPoint abstract class MangaListFragment : @@ -138,6 +151,20 @@ abstract class MangaListFragment : return selectionController?.onItemLongClick(item.id) ?: false } + override fun onReadClick(manga: Manga, view: View) { + if (selectionController?.onItemClick(manga.id) != true) { + val intent = ReaderActivity.newIntent(context ?: return, manga) + startActivity(intent, scaleUpActivityOptionsOf(view).toBundle()) + } + } + + override fun onTagClick(manga: Manga, tag: MangaTag, view: View) { + if (selectionController?.onItemClick(manga.id) != true) { + val intent = MangaListActivity.newIntent(context ?: return, setOf(tag)) + startActivity(intent) + } + } + @CallSuper override fun onRefresh() { binding.swipeRefreshLayout.isRefreshing = true @@ -251,12 +278,14 @@ abstract class MangaListFragment : ) addItemDecoration(decoration) } + ListMode.DETAILED_LIST -> { layoutManager = FitHeightLinearLayoutManager(context) val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing) updatePadding(left = spacing, right = spacing) addItemDecoration(SpacingItemDecoration(spacing)) } + ListMode.GRID -> { layoutManager = FitHeightGridLayoutManager(context, checkNotNull(spanResolver).spanCount).also { it.spanSizeLookup = spanSizeLookup @@ -284,21 +313,25 @@ abstract class MangaListFragment : selectionController?.addAll(ids) true } + R.id.action_share -> { ShareHelper(requireContext()).shareMangaLinks(selectedItems) mode.finish() true } + R.id.action_favourite -> { FavouriteCategoriesBottomSheet.show(childFragmentManager, selectedItems) mode.finish() true } + R.id.action_save -> { DownloadService.confirmAndStart(requireContext(), selectedItems) mode.finish() true } + else -> false } } diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt index 8422d00ec..b8d5e03c9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt @@ -22,11 +22,12 @@ open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDec protected val paint = Paint(Paint.ANTI_ALIAS_FLAG) protected val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle) - protected val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer) + protected val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_offset) + protected val iconSize = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_size) protected val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED) protected val fillColor = ColorUtils.setAlphaComponent( ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), - 0x74 + 0x74, ) protected val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner) @@ -65,11 +66,11 @@ open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDec setBounds( (bounds.left + iconOffset).toInt(), (bounds.top + iconOffset).toInt(), - (bounds.left + iconOffset + intrinsicWidth).toInt(), - (bounds.top + iconOffset + intrinsicHeight).toInt(), + (bounds.left + iconOffset + iconSize).toInt(), + (bounds.top + iconOffset + iconSize).toInt(), ) draw(canvas) } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaDetailsClickListener.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaDetailsClickListener.kt new file mode 100644 index 000000000..9bb885da4 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaDetailsClickListener.kt @@ -0,0 +1,13 @@ +package org.koitharu.kotatsu.list.ui.adapter + +import android.view.View +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaTag + +interface MangaDetailsClickListener : OnListItemClickListener { + + fun onReadClick(manga: Manga, view: View) + + fun onTagClick(manga: Manga, tag: MangaTag, view: View) +} diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt index e5ac99e33..1caac32e5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt @@ -1,34 +1,52 @@ package org.koitharu.kotatsu.list.ui.adapter +import android.view.View +import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner import coil.ImageLoader import com.google.android.material.badge.BadgeDrawable +import com.google.android.material.chip.Chip import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.utils.ext.* +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.utils.ext.disposeImageRequest +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.newImageRequest +import org.koitharu.kotatsu.utils.ext.referer +import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.image.CoverSizeResolver fun mangaListDetailedItemAD( coil: ImageLoader, lifecycleOwner: LifecycleOwner, - clickListener: OnListItemClickListener, + clickListener: MangaDetailsClickListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) }, ) { var badge: BadgeDrawable? = null - itemView.setOnClickListener { - clickListener.onItemClick(item.manga, it) - } - itemView.setOnLongClickListener { - clickListener.onItemLongClick(item.manga, it) + val listenerAdapter = object : View.OnClickListener, View.OnLongClickListener, ChipsView.OnChipClickListener { + override fun onClick(v: View) = when (v.id) { + R.id.button_read -> clickListener.onReadClick(item.manga, v) + else -> clickListener.onItemClick(item.manga, v) + } + + override fun onLongClick(v: View): Boolean = clickListener.onItemLongClick(item.manga, v) + + override fun onChipClick(chip: Chip, data: Any?) { + val tag = data as? MangaTag ?: return + clickListener.onTagClick(item.manga, tag, chip) + } } + itemView.setOnClickListener(listenerAdapter) + itemView.setOnLongClickListener(listenerAdapter) + binding.buttonRead.setOnClickListener(listenerAdapter) + binding.chipsTags.onChipClickListener = listenerAdapter bind { payloads -> binding.textViewTitle.text = item.title @@ -44,8 +62,9 @@ fun mangaListDetailedItemAD( lifecycle(lifecycleOwner) enqueueWith(coil) } - binding.textViewRating.textAndVisible = item.rating - binding.textViewTags.text = item.tags + binding.chipsTags.setChips(item.tags) + binding.ratingBar.isVisible = item.manga.hasRating + binding.ratingBar.rating = binding.ratingBar.numStars * item.manga.rating badge = itemView.bindBadge(badge, item.counter) } diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListListener.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListListener.kt index 516455c06..d445e34bb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListListener.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListListener.kt @@ -1,11 +1,9 @@ package org.koitharu.kotatsu.list.ui.adapter import android.view.View -import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag -interface MangaListListener : OnListItemClickListener, ListStateHolderListener, ListHeaderClickListener { +interface MangaListListener : MangaDetailsClickListener, ListStateHolderListener, ListHeaderClickListener { fun onUpdateFilter(tags: Set) diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt index 73e57d848..215b0c9d9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt @@ -1,8 +1,7 @@ package org.koitharu.kotatsu.list.ui.model -import java.net.SocketTimeoutException -import java.net.UnknownHostException import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.prefs.ListMode @@ -11,6 +10,8 @@ import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.utils.ext.ifZero +import java.net.SocketTimeoutException +import java.net.UnknownHostException fun Manga.toListModel(counter: Int, progress: Float) = MangaListModel( id = id, @@ -26,12 +27,11 @@ fun Manga.toListDetailedModel(counter: Int, progress: Float) = MangaListDetailed id = id, title = title, subtitle = altTitle, - rating = if (hasRating) String.format("%.1f", rating * 5) else null, - tags = tags.joinToString(", ") { it.title }, coverUrl = coverUrl, manga = this, counter = counter, progress = progress, + tags = tags.map { ChipsView.ChipModel(0, it.title, false, false, it) }, ) fun Manga.toGridModel(counter: Int, progress: Float) = MangaGridModel( @@ -69,9 +69,11 @@ suspend fun > List.toUi( ListMode.LIST -> mapTo(destination) { it.toListModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id)) } + ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id)) } + ListMode.GRID -> mapTo(destination) { it.toGridModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id)) } @@ -95,5 +97,6 @@ private fun getErrorIcon(error: Throwable) = when (error) { is UnknownHostException, is SocketTimeoutException, -> R.drawable.ic_plug_large + else -> R.drawable.ic_error_large } diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt index f9957f345..d1f40dc46 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt @@ -1,15 +1,15 @@ package org.koitharu.kotatsu.list.ui.model +import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.parsers.model.Manga data class MangaListDetailedModel( override val id: Long, override val title: String, val subtitle: String?, - val tags: String, override val coverUrl: String, - val rating: String?, override val manga: Manga, override val counter: Int, override val progress: Float, -) : MangaItemModel \ No newline at end of file + val tags: List, +) : MangaItemModel diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt index 0e595cfbe..72d2cf8b1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt @@ -12,7 +12,6 @@ import androidx.core.graphics.Insets import androidx.core.view.updatePadding import coil.ImageLoader import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.list.ListSelectionController @@ -27,11 +26,15 @@ import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.reader.ui.ReaderActivity +import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.multi.adapter.MultiSearchAdapter import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ext.assistedViewModels import org.koitharu.kotatsu.utils.ext.invalidateNestedItemDecorations +import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf +import javax.inject.Inject @AndroidEntryPoint class MultiSearchActivity : @@ -110,6 +113,20 @@ class MultiSearchActivity : return selectionController.onItemLongClick(item.id) } + override fun onReadClick(manga: Manga, view: View) { + if (!selectionController.onItemClick(manga.id)) { + val intent = ReaderActivity.newIntent(this, manga) + startActivity(intent, scaleUpActivityOptionsOf(view).toBundle()) + } + } + + override fun onTagClick(manga: Manga, tag: MangaTag, view: View) { + if (!selectionController.onItemClick(manga.id)) { + val intent = MangaListActivity.newIntent(this, setOf(tag)) + startActivity(intent) + } + } + override fun onRetryClick(error: Throwable) { viewModel.doSearch(viewModel.query.value.orEmpty()) } @@ -139,16 +156,19 @@ class MultiSearchActivity : mode.finish() true } + R.id.action_favourite -> { FavouriteCategoriesBottomSheet.show(supportFragmentManager, collectSelectedItems()) mode.finish() true } + R.id.action_save -> { DownloadService.confirmAndStart(this, collectSelectedItems()) mode.finish() true } + else -> false } } diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt index 9bdff5c5a..f4e7ed794 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt @@ -144,6 +144,10 @@ class FeedFragment : startActivity(DetailsActivity.newIntent(context ?: return, item)) } + override fun onReadClick(manga: Manga, view: View) = Unit + + override fun onTagClick(manga: Manga, tag: MangaTag, view: View) = Unit + companion object { fun newInstance() = FeedFragment() diff --git a/app/src/main/res/drawable/bg_circle_button.xml b/app/src/main/res/drawable/bg_circle_button.xml new file mode 100644 index 000000000..341baf3ae --- /dev/null +++ b/app/src/main/res/drawable/bg_circle_button.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_star.xml b/app/src/main/res/drawable/ic_star.xml index a1b2a8843..d2b46cc1c 100644 --- a/app/src/main/res/drawable/ic_star.xml +++ b/app/src/main/res/drawable/ic_star.xml @@ -1,7 +1,7 @@ diff --git a/app/src/main/res/layout-w600dp/fragment_details.xml b/app/src/main/res/layout-w600dp/fragment_details.xml index 0244030a2..40a7e1af6 100644 --- a/app/src/main/res/layout-w600dp/fragment_details.xml +++ b/app/src/main/res/layout-w600dp/fragment_details.xml @@ -34,9 +34,9 @@ diff --git a/app/src/main/res/layout/fragment_details.xml b/app/src/main/res/layout/fragment_details.xml index 342179654..f41461b13 100644 --- a/app/src/main/res/layout/fragment_details.xml +++ b/app/src/main/res/layout/fragment_details.xml @@ -34,9 +34,9 @@ diff --git a/app/src/main/res/layout/item_manga_grid.xml b/app/src/main/res/layout/item_manga_grid.xml index bd7d6ba36..364ce294a 100644 --- a/app/src/main/res/layout/item_manga_grid.xml +++ b/app/src/main/res/layout/item_manga_grid.xml @@ -7,13 +7,13 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:clipChildren="false" - app:cardCornerRadius="16dp"> + app:cardCornerRadius="16dp" + tools:layout_width="140dp"> + android:orientation="vertical"> + android:layout_margin="@dimen/card_indicator_offset" /> @@ -45,8 +45,7 @@ android:elegantTextHeight="false" android:ellipsize="end" android:lines="2" - android:paddingHorizontal="8dp" - android:paddingVertical="4dp" + android:padding="8dp" android:textAppearance="?attr/textAppearanceTitleSmall" android:textColor="?android:attr/textColorPrimary" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/layout/item_manga_list_details.xml b/app/src/main/res/layout/item_manga_list_details.xml index b23b6e245..fff6a9ef7 100644 --- a/app/src/main/res/layout/item_manga_list_details.xml +++ b/app/src/main/res/layout/item_manga_list_details.xml @@ -4,31 +4,29 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="@dimen/manga_list_details_item_height" + android:layout_height="wrap_content" app:cardCornerRadius="16dp"> + android:layout_height="match_parent"> - - + @@ -36,13 +34,13 @@ android:id="@+id/textView_title" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="8dp" - android:layout_marginEnd="8dp" + android:layout_marginStart="12dp" + android:layout_marginTop="12dp" + android:layout_marginEnd="12dp" android:ellipsize="end" android:maxLines="2" - android:textAppearance="?attr/textAppearanceTitleMedium" - app:layout_constraintBottom_toTopOf="@+id/textView_subtitle" + android:textAppearance="?attr/textAppearanceTitleLarge" + android:textSize="20sp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@+id/imageView_cover" app:layout_constraintTop_toTopOf="parent" @@ -52,61 +50,68 @@ android:id="@+id/textView_subtitle" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginEnd="8dp" + android:layout_marginStart="12dp" + android:layout_marginTop="8dp" + android:layout_marginEnd="12dp" android:ellipsize="none" android:gravity="center_vertical" android:maxLines="2" android:requiresFadingEdge="horizontal" - android:textAppearance="?attr/textAppearanceBodyMedium" - app:layout_constraintBottom_toTopOf="@+id/linearLayout" + android:textAppearance="?attr/textAppearanceSubtitle1" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@+id/imageView_cover" app:layout_constraintTop_toBottomOf="@+id/textView_title" tools:text="@tools:sample/lorem/random" /> - + app:layout_constraintTop_toBottomOf="@id/textView_subtitle" + app:layout_goneMarginTop="12dp"> - - - + app:chipSpacingHorizontal="6dp" + app:chipSpacingVertical="6dp" + app:singleLine="true" /> + - + + + diff --git a/app/src/main/res/layout/item_page_thumb.xml b/app/src/main/res/layout/item_page_thumb.xml index c19c532d5..48012df02 100644 --- a/app/src/main/res/layout/item_page_thumb.xml +++ b/app/src/main/res/layout/item_page_thumb.xml @@ -17,10 +17,10 @@ 24dp 8dp + 32dp + 8dp + 8dp diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index d6c92e121..1bd4f35b2 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -224,6 +224,11 @@ 6dp + + From 9cb59711820f54759558bd3904c84c43113d0fc3 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 19 Jan 2023 18:57:24 +0200 Subject: [PATCH 26/49] Option to change app language #282 --- .idea/gradle.xml | 2 +- app/build.gradle | 1 + .../java/org/koitharu/kotatsu/KotatsuApp.kt | 1 + .../kotatsu/core/prefs/AppSettings.kt | 13 +++ .../settings/AppearanceSettingsFragment.kt | 80 ++++++++++++++++++- .../settings/utils/ActivityListPreference.kt | 38 +++++++++ .../koitharu/kotatsu/utils/ext/AndroidExt.kt | 26 ++++++ app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/locales.xml | 8 +- app/src/main/res/xml/pref_appearance.xml | 6 +- 10 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/settings/utils/ActivityListPreference.kt diff --git a/.idea/gradle.xml b/.idea/gradle.xml index a0de2a152..ae388c2a5 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -7,7 +7,7 @@ \ No newline at end of file + From 6b08074a70ba0590a61c4472d332a46141fe8f78 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 19 Jan 2023 19:52:00 +0200 Subject: [PATCH 27/49] Fix changelog formatting --- .../core/github/AppUpdateRepository.kt | 6 +++++ .../kotatsu/settings/about/AppUpdateDialog.kt | 9 ++++---- .../kotatsu/settings/tools/ToolsFragment.kt | 14 +---------- app/src/main/res/layout/layout_app_update.xml | 23 +++---------------- 4 files changed, 15 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt index 7b935f4ac..24d28bb68 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt @@ -80,6 +80,12 @@ class AppUpdateRepository @Inject constructor( return BuildConfig.DEBUG || getCertificateSHA1Fingerprint() == CERT_SHA1 } + suspend fun getCurrentVersionChangelog(): String? { + val currentVersion = VersionId(BuildConfig.VERSION_NAME) + val available = getAvailableVersions() + return available.find { x -> x.versionId == currentVersion }?.description + } + @Suppress("DEPRECATION") @SuppressLint("PackageManagerGetSignatures") private fun getCertificateSHA1Fingerprint(): String? = runCatching { diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/about/AppUpdateDialog.kt b/app/src/main/java/org/koitharu/kotatsu/settings/about/AppUpdateDialog.kt index 94116493a..0043a5ab2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/about/AppUpdateDialog.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/about/AppUpdateDialog.kt @@ -3,30 +3,31 @@ package org.koitharu.kotatsu.settings.about import android.content.Context import android.content.Intent import androidx.core.net.toUri -import com.google.android.material.R as materialR +import androidx.core.text.buildSpannedString import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.noties.markwon.Markwon import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.github.AppVersion import org.koitharu.kotatsu.utils.FileSize +import com.google.android.material.R as materialR class AppUpdateDialog(private val context: Context) { fun show(version: AppVersion) { - val message = buildString { + val message = buildSpannedString { append(context.getString(R.string.new_version_s, version.name)) appendLine() append(context.getString(R.string.size_s, FileSize.BYTES.format(context, version.apkSize))) appendLine() appendLine() - append(version.description) + append(Markwon.create(context).toMarkdown(version.description)) } MaterialAlertDialogBuilder( context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered, ) .setTitle(R.string.app_update_available) - .setMessage(Markwon.create(context).toMarkdown(message)) + .setMessage(message) .setIcon(R.drawable.ic_app_update) .setPositiveButton(R.string.download) { _, _ -> val intent = Intent(Intent.ACTION_VIEW, version.apkUrl.toUri()) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt index 5c9dc2719..bd8b17e43 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt @@ -18,7 +18,6 @@ import androidx.core.widget.TextViewCompat import androidx.fragment.app.viewModels import com.google.android.material.color.MaterialColors import dagger.hilt.android.AndroidEntryPoint -import io.noties.markwon.Markwon import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.widgets.SegmentedBarView @@ -48,7 +47,6 @@ class ToolsFragment : super.onViewCreated(view, savedInstanceState) binding.buttonSettings.setOnClickListener(this) binding.buttonDownloads.setOnClickListener(this) - binding.cardUpdate.root.setOnClickListener(this) binding.cardUpdate.buttonChangelog.setOnClickListener(this) binding.cardUpdate.buttonDownload.setOnClickListener(this) binding.switchIncognito.setOnCheckedChangeListener(this) @@ -72,12 +70,10 @@ class ToolsFragment : startActivity(Intent.createChooser(intent, getString(R.string.open_in_browser))) } - R.id.card_update -> { + R.id.button_changelog -> { val version = viewModel.appUpdate.value ?: return AppUpdateDialog(v.context).show(version) } - - R.id.button_changelog -> showChangelog() } } @@ -97,7 +93,6 @@ class ToolsFragment : return } binding.cardUpdate.textSecondary.text = getString(R.string.new_version_s, version.name) - binding.cardUpdate.textChangelog.text = Markwon.create(requireActivity()).toMarkdown(version.description) binding.cardUpdate.root.isVisible = true } @@ -146,13 +141,6 @@ class ToolsFragment : return MaterialColors.harmonize(color, backgroundColor) } - private fun showChangelog() { - TransitionManager.beginDelayedTransition(binding.cardUpdate.root) - binding.cardUpdate.buttonChangelog.isVisible = false - binding.cardUpdate.textSecondary.isVisible = true - binding.cardUpdate.textChangelog.isVisible = true - } - companion object { fun newInstance() = ToolsFragment() diff --git a/app/src/main/res/layout/layout_app_update.xml b/app/src/main/res/layout/layout_app_update.xml index 536c83d82..02701ea39 100644 --- a/app/src/main/res/layout/layout_app_update.xml +++ b/app/src/main/res/layout/layout_app_update.xml @@ -2,7 +2,6 @@ - - + app:layout_constraintTop_toBottomOf="@id/textPrimary" />