From 55455090d1d5491471c2d7ad22b8c7ef1528e3fa Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Wed, 5 Jun 2024 05:05:39 +0700 Subject: [PATCH] refactor: Grab extension repo detail from `repo.json` and include in DB Co-authored-by: Matthew Witman --- app/build.gradle.kts | 2 +- .../java/dev/yokai/core/di/DomainModule.kt | 20 +++- .../extension/repo/ExtensionRepoRepository.kt | 12 --- .../repo/ExtensionRepoRepositoryImpl.kt | 96 +++++++++++++++++++ .../extension/repo/ExtensionRepoRepository.kt | 44 +++++++++ .../repo/ExtensionRepoRepositoryImpl.kt | 29 ------ .../exception/SaveExtensionRepoException.kt | 10 ++ .../repo/interactor/CreateExtensionRepo.kt | 80 ++++++++++++++++ .../repo/interactor/DeleteExtensionRepo.kt | 11 +++ .../repo/interactor/GetExtensionRepo.kt | 13 +++ .../repo/interactor/GetExtensionRepoCount.kt | 9 ++ .../repo/interactor/ReplaceExtensionRepo.kt | 12 +++ .../repo/interactor/UpdateExtensionRepo.kt | 32 +++++++ .../extension/repo/model/ExtensionRepo.kt | 9 ++ .../repo/service/ExtensionRepoService.kt | 57 +++++++++++ .../yokai/domain/source/SourcePreferences.kt | 1 - .../extension/repo/ExtensionRepoScreen.kt | 56 ++++++++++- .../extension/repo/ExtensionRepoViewModel.kt | 56 +++++++++-- .../repo/component/ExtensionRepoItem.kt | 40 +++++--- app/src/main/java/eu/kanade/tachiyomi/App.kt | 2 +- .../java/eu/kanade/tachiyomi/Migrations.kt | 34 ++++++- .../tachiyomi/extension/api/ExtensionApi.kt | 30 +++--- app/src/main/res/values/strings.xml | 6 ++ .../tachiyomi/data/extension_repos.sq | 50 ++++++++++ 24 files changed, 622 insertions(+), 89 deletions(-) delete mode 100644 app/src/main/java/dev/yokai/data/extension/repo/ExtensionRepoRepository.kt create mode 100644 app/src/main/java/dev/yokai/data/extension/repo/ExtensionRepoRepositoryImpl.kt create mode 100644 app/src/main/java/dev/yokai/domain/extension/repo/ExtensionRepoRepository.kt delete mode 100644 app/src/main/java/dev/yokai/domain/extension/repo/ExtensionRepoRepositoryImpl.kt create mode 100644 app/src/main/java/dev/yokai/domain/extension/repo/exception/SaveExtensionRepoException.kt create mode 100644 app/src/main/java/dev/yokai/domain/extension/repo/interactor/CreateExtensionRepo.kt create mode 100644 app/src/main/java/dev/yokai/domain/extension/repo/interactor/DeleteExtensionRepo.kt create mode 100644 app/src/main/java/dev/yokai/domain/extension/repo/interactor/GetExtensionRepo.kt create mode 100644 app/src/main/java/dev/yokai/domain/extension/repo/interactor/GetExtensionRepoCount.kt create mode 100644 app/src/main/java/dev/yokai/domain/extension/repo/interactor/ReplaceExtensionRepo.kt create mode 100644 app/src/main/java/dev/yokai/domain/extension/repo/interactor/UpdateExtensionRepo.kt create mode 100644 app/src/main/java/dev/yokai/domain/extension/repo/model/ExtensionRepo.kt create mode 100644 app/src/main/java/dev/yokai/domain/extension/repo/service/ExtensionRepoService.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cc1dd45c37..8af6d8357f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -36,7 +36,7 @@ android { minSdk = AndroidConfig.minSdk targetSdk = AndroidConfig.targetSdk applicationId = "eu.kanade.tachiyomi" - versionCode = 129 + versionCode = 130 versionName = "1.8.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" multiDexEnabled = true diff --git a/app/src/main/java/dev/yokai/core/di/DomainModule.kt b/app/src/main/java/dev/yokai/core/di/DomainModule.kt index 06c3dd5db8..29c556211c 100644 --- a/app/src/main/java/dev/yokai/core/di/DomainModule.kt +++ b/app/src/main/java/dev/yokai/core/di/DomainModule.kt @@ -1,15 +1,27 @@ package dev.yokai.core.di -import android.app.Application -import dev.yokai.data.extension.repo.ExtensionRepoRepository -import dev.yokai.domain.extension.repo.ExtensionRepoRepositoryImpl +import dev.yokai.domain.extension.repo.ExtensionRepoRepository +import dev.yokai.data.extension.repo.ExtensionRepoRepositoryImpl +import dev.yokai.domain.extension.repo.interactor.CreateExtensionRepo +import dev.yokai.domain.extension.repo.interactor.DeleteExtensionRepo +import dev.yokai.domain.extension.repo.interactor.GetExtensionRepo +import dev.yokai.domain.extension.repo.interactor.GetExtensionRepoCount +import dev.yokai.domain.extension.repo.interactor.ReplaceExtensionRepo +import dev.yokai.domain.extension.repo.interactor.UpdateExtensionRepo import uy.kohesive.injekt.api.InjektModule import uy.kohesive.injekt.api.InjektRegistrar +import uy.kohesive.injekt.api.addFactory import uy.kohesive.injekt.api.addSingletonFactory import uy.kohesive.injekt.api.get -class DomainModule(val app: Application) : InjektModule { +class DomainModule : InjektModule { override fun InjektRegistrar.registerInjectables() { addSingletonFactory { ExtensionRepoRepositoryImpl(get()) } + addFactory { CreateExtensionRepo(get()) } + addFactory { DeleteExtensionRepo(get()) } + addFactory { GetExtensionRepo(get()) } + addFactory { GetExtensionRepoCount(get()) } + addFactory { ReplaceExtensionRepo(get()) } + addFactory { UpdateExtensionRepo(get(), get()) } } } diff --git a/app/src/main/java/dev/yokai/data/extension/repo/ExtensionRepoRepository.kt b/app/src/main/java/dev/yokai/data/extension/repo/ExtensionRepoRepository.kt deleted file mode 100644 index 6fd5e3a2c6..0000000000 --- a/app/src/main/java/dev/yokai/data/extension/repo/ExtensionRepoRepository.kt +++ /dev/null @@ -1,12 +0,0 @@ -package dev.yokai.data.extension.repo - -import dev.yokai.domain.Result -import kotlinx.coroutines.flow.Flow - -interface ExtensionRepoRepository { - fun addRepo(url: String): Result - - fun deleteRepo(repo: String) - - fun getRepoFlow(): Flow> -} diff --git a/app/src/main/java/dev/yokai/data/extension/repo/ExtensionRepoRepositoryImpl.kt b/app/src/main/java/dev/yokai/data/extension/repo/ExtensionRepoRepositoryImpl.kt new file mode 100644 index 0000000000..df8680fc66 --- /dev/null +++ b/app/src/main/java/dev/yokai/data/extension/repo/ExtensionRepoRepositoryImpl.kt @@ -0,0 +1,96 @@ +package dev.yokai.data.extension.repo + +import android.database.sqlite.SQLiteException +import dev.yokai.data.DatabaseHandler +import dev.yokai.domain.extension.repo.ExtensionRepoRepository +import dev.yokai.domain.extension.repo.exception.SaveExtensionRepoException +import dev.yokai.domain.extension.repo.model.ExtensionRepo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class ExtensionRepoRepositoryImpl(private val handler: DatabaseHandler): ExtensionRepoRepository { + /* + override fun addRepo(url: String): Result { + if (!url.matches(repoRegex)) + return Result.Error("Invalid URL") + + sourcePreferences.extensionRepos() += url.substringBeforeLast("/index.min.json") + + return Result.Success() + } + + override fun deleteRepo(repo: String) { + sourcePreferences.extensionRepos() -= repo + } + + override fun getRepoFlow() = + sourcePreferences.extensionRepos().changes() + .map { it.sortedWith(String.CASE_INSENSITIVE_ORDER) } + */ + override fun subscribeAll(): Flow> = + handler.subscribeToList { extension_reposQueries.findAll(::mapExtensionRepo) } + + override suspend fun getAll(): List = + handler.awaitList { extension_reposQueries.findAll(::mapExtensionRepo) } + + override suspend fun getRepository(baseUrl: String): ExtensionRepo? = + handler.awaitOneOrNull { extension_reposQueries.findOne(baseUrl, ::mapExtensionRepo) } + + override suspend fun getRepositoryBySigningKeyFingerprint(fingerprint: String): ExtensionRepo? = + handler.awaitOneOrNull { extension_reposQueries.findOneBySigningKeyFingerprint(fingerprint, ::mapExtensionRepo) } + + override fun getCount(): Flow = + handler.subscribeToOne { extension_reposQueries.count() }.map { it.toInt() } + + override suspend fun insertRepository( + baseUrl: String, + name: String, + shortName: String?, + website: String, + signingKeyFingerprint: String + ) { + try { + handler.await { extension_reposQueries.insert(baseUrl, name, shortName, website, signingKeyFingerprint) } + } catch (exc: SQLiteException) { + throw SaveExtensionRepoException(exc) + } + } + + override suspend fun upsertRepository( + baseUrl: String, + name: String, + shortName: String?, + website: String, + signingKeyFingerprint: String + ) { + try { + handler.await { extension_reposQueries.upsert(baseUrl, name, shortName, website, signingKeyFingerprint) } + } catch (exc: SQLiteException) { + throw SaveExtensionRepoException(exc) + } + } + + override suspend fun replaceRepository(newRepo: ExtensionRepo) { + handler.await { + extension_reposQueries.replace( + newRepo.baseUrl, + newRepo.name, + newRepo.shortName, + newRepo.website, + newRepo.signingKeyFingerprint, + ) + } + } + + override suspend fun deleteRepository(baseUrl: String) { + handler.await { extension_reposQueries.delete(baseUrl) } + } + + private fun mapExtensionRepo( + baseUrl: String, + name: String, + shortName: String?, + website: String, + signingKeyFingerprint: String, + ): ExtensionRepo = ExtensionRepo(baseUrl, name, shortName, website, signingKeyFingerprint) +} diff --git a/app/src/main/java/dev/yokai/domain/extension/repo/ExtensionRepoRepository.kt b/app/src/main/java/dev/yokai/domain/extension/repo/ExtensionRepoRepository.kt new file mode 100644 index 0000000000..8858825571 --- /dev/null +++ b/app/src/main/java/dev/yokai/domain/extension/repo/ExtensionRepoRepository.kt @@ -0,0 +1,44 @@ +package dev.yokai.domain.extension.repo + +import dev.yokai.domain.extension.repo.model.ExtensionRepo +import kotlinx.coroutines.flow.Flow + +interface ExtensionRepoRepository { + fun subscribeAll(): Flow> + suspend fun getAll(): List + suspend fun getRepository(baseUrl: String): ExtensionRepo? + suspend fun getRepositoryBySigningKeyFingerprint(fingerprint: String): ExtensionRepo? + fun getCount(): Flow + suspend fun insertRepository( + baseUrl: String, + name: String, + shortName: String?, + website: String, + signingKeyFingerprint: String, + ) + suspend fun upsertRepository( + baseUrl: String, + name: String, + shortName: String?, + website: String, + signingKeyFingerprint: String, + ) + suspend fun upsertRepository(repo: ExtensionRepo) { + upsertRepository( + baseUrl = repo.baseUrl, + name = repo.name, + shortName = repo.shortName, + website = repo.website, + signingKeyFingerprint = repo.signingKeyFingerprint, + ) + } + suspend fun replaceRepository(newRepo: ExtensionRepo) + suspend fun deleteRepository(baseUrl: String) + /* + fun addRepo(url: String): Result + + fun deleteRepo(repo: String) + + fun getRepoFlow(): Flow> + */ +} diff --git a/app/src/main/java/dev/yokai/domain/extension/repo/ExtensionRepoRepositoryImpl.kt b/app/src/main/java/dev/yokai/domain/extension/repo/ExtensionRepoRepositoryImpl.kt deleted file mode 100644 index 6ba1df8402..0000000000 --- a/app/src/main/java/dev/yokai/domain/extension/repo/ExtensionRepoRepositoryImpl.kt +++ /dev/null @@ -1,29 +0,0 @@ -package dev.yokai.domain.extension.repo - -import dev.yokai.data.extension.repo.ExtensionRepoRepository -import dev.yokai.domain.source.SourcePreferences -import dev.yokai.domain.Result -import eu.kanade.tachiyomi.data.preference.minusAssign -import eu.kanade.tachiyomi.data.preference.plusAssign -import kotlinx.coroutines.flow.map - -class ExtensionRepoRepositoryImpl(private val sourcePreferences: SourcePreferences): ExtensionRepoRepository { - override fun addRepo(url: String): Result { - if (!url.matches(repoRegex)) - return Result.Error("Invalid URL") - - sourcePreferences.extensionRepos() += url.substringBeforeLast("/index.min.json") - - return Result.Success() - } - - override fun deleteRepo(repo: String) { - sourcePreferences.extensionRepos() -= repo - } - - override fun getRepoFlow() = - sourcePreferences.extensionRepos().changes() - .map { it.sortedWith(String.CASE_INSENSITIVE_ORDER) } -} - -private val repoRegex = """^https://.*/index\.min\.json$""".toRegex() diff --git a/app/src/main/java/dev/yokai/domain/extension/repo/exception/SaveExtensionRepoException.kt b/app/src/main/java/dev/yokai/domain/extension/repo/exception/SaveExtensionRepoException.kt new file mode 100644 index 0000000000..bf7762a556 --- /dev/null +++ b/app/src/main/java/dev/yokai/domain/extension/repo/exception/SaveExtensionRepoException.kt @@ -0,0 +1,10 @@ +package dev.yokai.domain.extension.repo.exception + +import java.io.IOException + +/** + * Exception to abstract over SQLiteException and SQLiteConstraintException for multiplatform. + * + * @param throwable the source throwable to include for tracing. + */ +class SaveExtensionRepoException(throwable: Throwable) : IOException("Error Saving Repository to Database", throwable) diff --git a/app/src/main/java/dev/yokai/domain/extension/repo/interactor/CreateExtensionRepo.kt b/app/src/main/java/dev/yokai/domain/extension/repo/interactor/CreateExtensionRepo.kt new file mode 100644 index 0000000000..a4dffbc317 --- /dev/null +++ b/app/src/main/java/dev/yokai/domain/extension/repo/interactor/CreateExtensionRepo.kt @@ -0,0 +1,80 @@ +package dev.yokai.domain.extension.repo.interactor + +import dev.yokai.domain.extension.repo.ExtensionRepoRepository +import dev.yokai.domain.extension.repo.exception.SaveExtensionRepoException +import dev.yokai.domain.extension.repo.model.ExtensionRepo +import dev.yokai.domain.extension.repo.service.ExtensionRepoService +import eu.kanade.tachiyomi.network.NetworkHelper +import okhttp3.OkHttpClient +import timber.log.Timber +import uy.kohesive.injekt.injectLazy + +class CreateExtensionRepo( + private val extensionRepoRepository: ExtensionRepoRepository +) { + private val repoRegex = """^https://.*/index\.min\.json$""".toRegex() + + private val networkService: NetworkHelper by injectLazy() + + private val client: OkHttpClient + get() = networkService.client + + private val extensionRepoService = ExtensionRepoService(client) + + suspend fun await(repoUrl: String): Result { + if (!repoUrl.matches(repoRegex)) { + return Result.InvalidUrl + } + + val baseUrl = repoUrl.removeSuffix("/index.min.json") + return extensionRepoService.fetchRepoDetails(baseUrl)?.let { insert(it) } ?: Result.InvalidUrl + } + + private suspend fun insert(repo: ExtensionRepo): Result { + return try { + extensionRepoRepository.insertRepository( + repo.baseUrl, + repo.name, + repo.shortName, + repo.website, + repo.signingKeyFingerprint, + ) + Result.Success + } catch (e: SaveExtensionRepoException) { + Timber.e(e, "SQL Conflict attempting to add new repository ${repo.baseUrl}") + return handleInsertionError(repo) + } + } + + /** + * Error Handler for insert when there are trying to create new repositories + * + * SaveExtensionRepoException doesn't provide constraint info in exceptions. + * First check if the conflict was on primary key. if so return RepoAlreadyExists + * Then check if the conflict was on fingerprint. if so Return DuplicateFingerprint + * If neither are found, there was some other Error, and return Result.Error + * + * @param repo Extension Repo holder for passing to DB/Error Dialog + */ + @Suppress("ReturnCount") + private suspend fun handleInsertionError(repo: ExtensionRepo): Result { + val repoExists = extensionRepoRepository.getRepository(repo.baseUrl) + if (repoExists != null) { + return Result.RepoAlreadyExists + } + val matchingFingerprintRepo = + extensionRepoRepository.getRepositoryBySigningKeyFingerprint(repo.signingKeyFingerprint) + if (matchingFingerprintRepo != null) { + return Result.DuplicateFingerprint(matchingFingerprintRepo, repo) + } + return Result.Error + } + + sealed interface Result { + data class DuplicateFingerprint(val oldRepo: ExtensionRepo, val newRepo: ExtensionRepo) : Result + data object InvalidUrl : Result + data object RepoAlreadyExists : Result + data object Success : Result + data object Error : Result + } +} diff --git a/app/src/main/java/dev/yokai/domain/extension/repo/interactor/DeleteExtensionRepo.kt b/app/src/main/java/dev/yokai/domain/extension/repo/interactor/DeleteExtensionRepo.kt new file mode 100644 index 0000000000..b0882df664 --- /dev/null +++ b/app/src/main/java/dev/yokai/domain/extension/repo/interactor/DeleteExtensionRepo.kt @@ -0,0 +1,11 @@ +package dev.yokai.domain.extension.repo.interactor + +import dev.yokai.domain.extension.repo.ExtensionRepoRepository + +class DeleteExtensionRepo( + private val extensionRepoRepository: ExtensionRepoRepository +) { + suspend fun await(baseUrl: String) { + extensionRepoRepository.deleteRepository(baseUrl) + } +} diff --git a/app/src/main/java/dev/yokai/domain/extension/repo/interactor/GetExtensionRepo.kt b/app/src/main/java/dev/yokai/domain/extension/repo/interactor/GetExtensionRepo.kt new file mode 100644 index 0000000000..52be3f55c8 --- /dev/null +++ b/app/src/main/java/dev/yokai/domain/extension/repo/interactor/GetExtensionRepo.kt @@ -0,0 +1,13 @@ +package dev.yokai.domain.extension.repo.interactor + +import dev.yokai.domain.extension.repo.ExtensionRepoRepository +import dev.yokai.domain.extension.repo.model.ExtensionRepo +import kotlinx.coroutines.flow.Flow + +class GetExtensionRepo( + private val extensionRepoRepository: ExtensionRepoRepository +) { + fun subscribeAll(): Flow> = extensionRepoRepository.subscribeAll() + + suspend fun getAll(): List = extensionRepoRepository.getAll() +} diff --git a/app/src/main/java/dev/yokai/domain/extension/repo/interactor/GetExtensionRepoCount.kt b/app/src/main/java/dev/yokai/domain/extension/repo/interactor/GetExtensionRepoCount.kt new file mode 100644 index 0000000000..e41f63498b --- /dev/null +++ b/app/src/main/java/dev/yokai/domain/extension/repo/interactor/GetExtensionRepoCount.kt @@ -0,0 +1,9 @@ +package dev.yokai.domain.extension.repo.interactor + +import dev.yokai.domain.extension.repo.ExtensionRepoRepository + +class GetExtensionRepoCount( + private val extensionRepoRepository: ExtensionRepoRepository +) { + fun subscribe() = extensionRepoRepository.getCount() +} diff --git a/app/src/main/java/dev/yokai/domain/extension/repo/interactor/ReplaceExtensionRepo.kt b/app/src/main/java/dev/yokai/domain/extension/repo/interactor/ReplaceExtensionRepo.kt new file mode 100644 index 0000000000..37f5fcea7b --- /dev/null +++ b/app/src/main/java/dev/yokai/domain/extension/repo/interactor/ReplaceExtensionRepo.kt @@ -0,0 +1,12 @@ +package dev.yokai.domain.extension.repo.interactor + +import dev.yokai.domain.extension.repo.ExtensionRepoRepository +import dev.yokai.domain.extension.repo.model.ExtensionRepo + +class ReplaceExtensionRepo( + private val extensionRepoRepository: ExtensionRepoRepository +) { + suspend fun await(repo: ExtensionRepo) { + extensionRepoRepository.replaceRepository(repo) + } +} diff --git a/app/src/main/java/dev/yokai/domain/extension/repo/interactor/UpdateExtensionRepo.kt b/app/src/main/java/dev/yokai/domain/extension/repo/interactor/UpdateExtensionRepo.kt new file mode 100644 index 0000000000..19bcbe4e02 --- /dev/null +++ b/app/src/main/java/dev/yokai/domain/extension/repo/interactor/UpdateExtensionRepo.kt @@ -0,0 +1,32 @@ +package dev.yokai.domain.extension.repo.interactor + +import dev.yokai.domain.extension.repo.ExtensionRepoRepository +import dev.yokai.domain.extension.repo.model.ExtensionRepo +import dev.yokai.domain.extension.repo.service.ExtensionRepoService +import eu.kanade.tachiyomi.network.NetworkHelper +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope + +class UpdateExtensionRepo( + private val extensionRepoRepository: ExtensionRepoRepository, + networkService: NetworkHelper, +) { + private val extensionRepoService = ExtensionRepoService(networkService.client) + + suspend fun awaitAll() = coroutineScope { + extensionRepoRepository.getAll() + .map { async { await(it) } } + .awaitAll() + } + + suspend fun await(repo: ExtensionRepo) { + val newRepo = extensionRepoService.fetchRepoDetails(repo.baseUrl) ?: return + if ( + repo.signingKeyFingerprint.startsWith("NOFINGERPRINT") || + repo.signingKeyFingerprint == newRepo.signingKeyFingerprint + ) { + extensionRepoRepository.upsertRepository(newRepo) + } + } +} diff --git a/app/src/main/java/dev/yokai/domain/extension/repo/model/ExtensionRepo.kt b/app/src/main/java/dev/yokai/domain/extension/repo/model/ExtensionRepo.kt new file mode 100644 index 0000000000..fc2eaa7fb5 --- /dev/null +++ b/app/src/main/java/dev/yokai/domain/extension/repo/model/ExtensionRepo.kt @@ -0,0 +1,9 @@ +package dev.yokai.domain.extension.repo.model + +data class ExtensionRepo( + val baseUrl: String, + val name: String, + val shortName: String?, + val website: String, + val signingKeyFingerprint: String, +) diff --git a/app/src/main/java/dev/yokai/domain/extension/repo/service/ExtensionRepoService.kt b/app/src/main/java/dev/yokai/domain/extension/repo/service/ExtensionRepoService.kt new file mode 100644 index 0000000000..7af640dc3c --- /dev/null +++ b/app/src/main/java/dev/yokai/domain/extension/repo/service/ExtensionRepoService.kt @@ -0,0 +1,57 @@ +package dev.yokai.domain.extension.repo.service + +import androidx.core.net.toUri +import dev.yokai.domain.extension.repo.model.ExtensionRepo +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.HttpException +import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.network.parseAs +import eu.kanade.tachiyomi.util.system.withIOContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.OkHttpClient +import uy.kohesive.injekt.injectLazy + +class ExtensionRepoService( + private val client: OkHttpClient, +) { + + private val json: Json by injectLazy() + + suspend fun fetchRepoDetails( + repo: String, + ): ExtensionRepo? { + return withIOContext { + val url = "$repo/repo.json".toUri() + + try { + val response = with(json) { + client.newCall(GET(url.toString())) + .awaitSuccess() + .parseAs() + } + response["meta"] + ?.jsonObject + ?.let { jsonToExtensionRepo(baseUrl = repo, it) } + } catch (_: HttpException) { + null + } + } + } + + private fun jsonToExtensionRepo(baseUrl: String, obj: JsonObject): ExtensionRepo? { + return try { + ExtensionRepo( + baseUrl = baseUrl, + name = obj["name"]!!.jsonPrimitive.content, + shortName = obj["shortName"]?.jsonPrimitive?.content, + website = obj["website"]!!.jsonPrimitive.content, + signingKeyFingerprint = obj["signingKeyFingerprint"]!!.jsonPrimitive.content, + ) + } catch (_: NullPointerException) { + null + } + } +} diff --git a/app/src/main/java/dev/yokai/domain/source/SourcePreferences.kt b/app/src/main/java/dev/yokai/domain/source/SourcePreferences.kt index d5adad7457..ee9158ad05 100644 --- a/app/src/main/java/dev/yokai/domain/source/SourcePreferences.kt +++ b/app/src/main/java/dev/yokai/domain/source/SourcePreferences.kt @@ -3,6 +3,5 @@ package dev.yokai.domain.source import eu.kanade.tachiyomi.core.preference.PreferenceStore class SourcePreferences(private val preferenceStore: PreferenceStore) { - fun extensionRepos() = preferenceStore.getStringSet("extension_repos", emptySet()) fun trustedExtensions() = preferenceStore.getStringSet("trusted_extensions", emptySet()) } diff --git a/app/src/main/java/dev/yokai/presentation/extension/repo/ExtensionRepoScreen.kt b/app/src/main/java/dev/yokai/presentation/extension/repo/ExtensionRepoScreen.kt index cc716e7ae3..8ac229be5b 100644 --- a/app/src/main/java/dev/yokai/presentation/extension/repo/ExtensionRepoScreen.kt +++ b/app/src/main/java/dev/yokai/presentation/extension/repo/ExtensionRepoScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExtensionOff +import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -25,9 +26,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import dev.yokai.domain.ComposableAlertDialog +import dev.yokai.domain.extension.repo.model.ExtensionRepo import dev.yokai.presentation.AppBarType import dev.yokai.presentation.YokaiScaffold import dev.yokai.presentation.component.EmptyScreen +import dev.yokai.presentation.component.ToolTipButton import dev.yokai.presentation.extension.repo.component.ExtensionRepoInput import dev.yokai.presentation.extension.repo.component.ExtensionRepoItem import eu.kanade.tachiyomi.R @@ -58,6 +61,13 @@ fun ExtensionRepoScreen( state = rememberTopAppBarState(), canScroll = { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 }, ), + actions = { + ToolTipButton( + toolTipLabel = stringResource(R.string.refresh), + icon = Icons.Outlined.Refresh, + buttonClicked = { viewModel.refreshRepos() }, + ) + }, ) { innerPadding -> if (repoState.value is ExtensionRepoState.Loading) return@YokaiScaffold @@ -94,7 +104,7 @@ fun ExtensionRepoScreen( repos.forEach { repo -> item { ExtensionRepoItem( - repoUrl = repo, + extensionRepo = repo, onDeleteClick = { repoToDelete -> alertDialog.content = { ExtensionRepoDeletePrompt(repoToDelete, alertDialog, viewModel) } }, @@ -114,10 +124,54 @@ fun ExtensionRepoScreen( context.toast(event.stringRes) if (event is ExtensionRepoEvent.Success) inputText = "" + if (event is ExtensionRepoEvent.ShowDialog) + alertDialog.content = { + if (event.dialog is RepoDialog.Conflict) { + ExtensionRepoReplacePrompt( + oldRepo = event.dialog.oldRepo, + newRepo = event.dialog.newRepo, + onDismissRequest = { alertDialog.content = null }, + onMigrate = { viewModel.replaceRepo(event.dialog.newRepo) }, + ) + } + } } } } +@Composable +fun ExtensionRepoReplacePrompt( + oldRepo: ExtensionRepo, + newRepo: ExtensionRepo, + onDismissRequest: () -> Unit, + onMigrate: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton( + onClick = { + onMigrate() + onDismissRequest() + }, + ) { + Text(text = stringResource(R.string.action_replace_repo)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + title = { + Text(text = stringResource(R.string.action_replace_repo_title)) + }, + text = { + Text(text = stringResource(R.string.action_replace_repo_message, newRepo.name, oldRepo.name)) + }, + ) +} + @Composable fun ExtensionRepoDeletePrompt(repoToDelete: String, alertDialog: ComposableAlertDialog, viewModel: ExtensionRepoViewModel) { AlertDialog( diff --git a/app/src/main/java/dev/yokai/presentation/extension/repo/ExtensionRepoViewModel.kt b/app/src/main/java/dev/yokai/presentation/extension/repo/ExtensionRepoViewModel.kt index 09d19cf3e0..3a624706c8 100644 --- a/app/src/main/java/dev/yokai/presentation/extension/repo/ExtensionRepoViewModel.kt +++ b/app/src/main/java/dev/yokai/presentation/extension/repo/ExtensionRepoViewModel.kt @@ -4,8 +4,14 @@ import androidx.annotation.StringRes import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.yokai.data.extension.repo.ExtensionRepoRepository +import dev.yokai.domain.extension.repo.ExtensionRepoRepository import dev.yokai.domain.Result +import dev.yokai.domain.extension.repo.interactor.CreateExtensionRepo +import dev.yokai.domain.extension.repo.interactor.DeleteExtensionRepo +import dev.yokai.domain.extension.repo.interactor.GetExtensionRepo +import dev.yokai.domain.extension.repo.interactor.ReplaceExtensionRepo +import dev.yokai.domain.extension.repo.interactor.UpdateExtensionRepo +import dev.yokai.domain.extension.repo.model.ExtensionRepo import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.util.system.launchIO import kotlinx.coroutines.flow.MutableStateFlow @@ -19,7 +25,12 @@ import uy.kohesive.injekt.injectLazy class ExtensionRepoViewModel : ViewModel() { - private val repository: ExtensionRepoRepository by injectLazy() + private val getExtensionRepo: GetExtensionRepo by injectLazy() + private val createExtensionRepo: CreateExtensionRepo by injectLazy() + private val deleteExtensionRepo: DeleteExtensionRepo by injectLazy() + private val replaceExtensionRepo: ReplaceExtensionRepo by injectLazy() + private val updateExtensionRepo: UpdateExtensionRepo by injectLazy() + private val mutableRepoState: MutableStateFlow = MutableStateFlow(ExtensionRepoState.Loading) val repoState: StateFlow = mutableRepoState.asStateFlow() @@ -28,7 +39,7 @@ class ExtensionRepoViewModel : init { viewModelScope.launchIO { - repository.getRepoFlow().collectLatest { repos -> + getExtensionRepo.subscribeAll().collectLatest { repos -> mutableRepoState.update { ExtensionRepoState.Success(repos = repos.toImmutableList()) } } } @@ -36,25 +47,50 @@ class ExtensionRepoViewModel : fun addRepo(url: String) { viewModelScope.launchIO { - val result = repository.addRepo(url) - when (result) { - is Result.Error -> internalEvent.value = ExtensionRepoEvent.InvalidUrl - is Result.Success -> internalEvent.value = ExtensionRepoEvent.Success + when (val result = createExtensionRepo.await(url)) { + is CreateExtensionRepo.Result.Success -> internalEvent.value = ExtensionRepoEvent.Success + is CreateExtensionRepo.Result.Error -> internalEvent.value = ExtensionRepoEvent.InvalidUrl + is CreateExtensionRepo.Result.RepoAlreadyExists -> internalEvent.value = ExtensionRepoEvent.RepoAlreadyExists + is CreateExtensionRepo.Result.DuplicateFingerprint -> { + internalEvent.value = ExtensionRepoEvent.ShowDialog(RepoDialog.Conflict(result.oldRepo, result.newRepo)) + } else -> internalEvent.value = ExtensionRepoEvent.NoOp } } } - fun deleteRepo(repo: String) { + fun replaceRepo(newRepo: ExtensionRepo) { viewModelScope.launchIO { - repository.deleteRepo(repo) + replaceExtensionRepo.await(newRepo) } } + + fun refreshRepos() { + val status = repoState.value + + if (status is ExtensionRepoState.Success) { + viewModelScope.launchIO { + updateExtensionRepo.awaitAll() + } + } + } + + fun deleteRepo(url: String) { + viewModelScope.launchIO { + deleteExtensionRepo.await(url) + } + } +} + +sealed class RepoDialog { + data class Conflict(val oldRepo: ExtensionRepo, val newRepo: ExtensionRepo) : RepoDialog() } sealed class ExtensionRepoEvent { sealed class LocalizedMessage(@StringRes val stringRes: Int) : ExtensionRepoEvent() data object InvalidUrl : LocalizedMessage(R.string.invalid_repo_url) + data object RepoAlreadyExists : LocalizedMessage(R.string.repo_already_exists) + data class ShowDialog(val dialog: RepoDialog) : ExtensionRepoEvent() data object NoOp : ExtensionRepoEvent() data object Success : ExtensionRepoEvent() } @@ -66,7 +102,7 @@ sealed class ExtensionRepoState { @Immutable data class Success( - val repos: List, + val repos: List, ) : ExtensionRepoState() { val isEmpty: Boolean diff --git a/app/src/main/java/dev/yokai/presentation/extension/repo/component/ExtensionRepoItem.kt b/app/src/main/java/dev/yokai/presentation/extension/repo/component/ExtensionRepoItem.kt index bbf529a573..7e6fb4a5b6 100644 --- a/app/src/main/java/dev/yokai/presentation/extension/repo/component/ExtensionRepoItem.kt +++ b/app/src/main/java/dev/yokai/presentation/extension/repo/component/ExtensionRepoItem.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Label @@ -29,15 +30,17 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import dev.yokai.domain.extension.repo.model.ExtensionRepo +import dev.yokai.presentation.component.Gap +import dev.yokai.presentation.theme.Size import eu.kanade.tachiyomi.util.compose.textHint // TODO: Redesign // - Edit -// - Show display name @Composable fun ExtensionRepoItem( - repoUrl: String, modifier: Modifier = Modifier, + extensionRepo: ExtensionRepo, onDeleteClick: (String) -> Unit = {}, ) { Row( @@ -50,15 +53,28 @@ fun ExtensionRepoItem( contentDescription = null, tint = MaterialTheme.colorScheme.onBackground, ) - Text( - modifier = Modifier - .weight(1.0f) - .basicMarquee(), - text = repoUrl, - color = MaterialTheme.colorScheme.onBackground, - fontSize = 16.sp, - ) - IconButton(onClick = { onDeleteClick(repoUrl) }) { + Column( + modifier = modifier.weight(1.0f), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .basicMarquee(), + text = extensionRepo.name, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 16.sp, + ) + Gap(Size.tiny) + Text( + modifier = Modifier + .fillMaxWidth() + .basicMarquee(), + text = extensionRepo.baseUrl, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 16.sp, + ) + } + IconButton(onClick = { onDeleteClick(extensionRepo.baseUrl) }) { Icon( imageVector = Icons.Filled.Delete, contentDescription = null, @@ -142,7 +158,7 @@ fun ExtensionRepoItemPreview() { val input = "https://raw.githubusercontent.com/null2264/totally-real-extensions/repo/index.min.json" Surface { Column { - ExtensionRepoItem(repoUrl = input) + ExtensionRepoItem(extensionRepo = ExtensionRepo("", "", "", "", "")) ExtensionRepoInput(inputHint = "Input") ExtensionRepoInput(inputHint = "", inputText = input) ExtensionRepoInput(inputHint = "", inputText = input, isLoading = true) diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index c92a144587..baba99bd8e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -91,7 +91,7 @@ open class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.F Injekt.apply { importModule(PreferenceModule(this@App)) importModule(AppModule(this@App)) - importModule(DomainModule(this@App)) + importModule(DomainModule()) } setupNotificationChannels() diff --git a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt index 53675bfb75..72bd9d6913 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt @@ -3,23 +3,24 @@ package eu.kanade.tachiyomi import androidx.core.content.edit import androidx.preference.PreferenceManager import dev.yokai.domain.base.BasePreferences +import dev.yokai.domain.extension.repo.ExtensionRepoRepository +import dev.yokai.domain.extension.repo.exception.SaveExtensionRepoException import dev.yokai.domain.ui.settings.ReaderPreferences import dev.yokai.domain.ui.settings.ReaderPreferences.CutoutBehaviour import dev.yokai.domain.ui.settings.ReaderPreferences.LandscapeCutoutBehaviour +import eu.kanade.tachiyomi.core.preference.Preference +import eu.kanade.tachiyomi.core.preference.PreferenceStore +import eu.kanade.tachiyomi.core.preference.plusAssign import eu.kanade.tachiyomi.data.backup.BackupCreatorJob import eu.kanade.tachiyomi.data.download.DownloadProvider import eu.kanade.tachiyomi.data.library.LibraryUpdateJob -import eu.kanade.tachiyomi.core.preference.Preference import eu.kanade.tachiyomi.data.preference.PreferenceKeys -import eu.kanade.tachiyomi.core.preference.PreferenceStore import eu.kanade.tachiyomi.data.preference.PreferenceValues import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.core.preference.plusAssign import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.updater.AppDownloadInstallJob import eu.kanade.tachiyomi.data.updater.AppUpdateJob import eu.kanade.tachiyomi.extension.ExtensionUpdateJob -import eu.kanade.tachiyomi.extension.util.ExtensionInstaller import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE import eu.kanade.tachiyomi.ui.library.LibraryPresenter import eu.kanade.tachiyomi.ui.library.LibrarySort @@ -29,8 +30,11 @@ import eu.kanade.tachiyomi.ui.recents.RecentsPresenter import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.util.system.toast import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import timber.log.Timber import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy import java.io.File import kotlin.math.max @@ -308,6 +312,28 @@ object Migrations { readerPreferences.landscapeCutoutBehavior().set(LandscapeCutoutBehaviour.DEFAULT) } } + if (oldVersion < 130) { + val coroutineScope = CoroutineScope(Dispatchers.IO) + val extensionRepoRepository: ExtensionRepoRepository by injectLazy() + val extensionRepos: Preference> = preferenceStore.getStringSet("extension_repos", emptySet()) + + coroutineScope.launchIO { + for ((index, source) in extensionRepos.get().withIndex()) { + try { + extensionRepoRepository.upsertRepository( + source, + "Repo #${index + 1}", + null, + source, + "NOFINGERPRINT-${index + 1}", + ) + } catch (e: SaveExtensionRepoException) { + Timber.e(e, "Error Migrating Extension Repo with baseUrl: $source") + } + } + extensionRepos.delete() + } + } return true } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt index 668af0bcce..1fc8866a43 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt @@ -1,7 +1,9 @@ package eu.kanade.tachiyomi.extension.api import android.content.Context -import dev.yokai.domain.source.SourcePreferences +import dev.yokai.domain.extension.repo.interactor.GetExtensionRepo +import dev.yokai.domain.extension.repo.interactor.UpdateExtensionRepo +import dev.yokai.domain.extension.repo.model.ExtensionRepo import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.LoadResult @@ -11,6 +13,8 @@ import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.util.system.withIOContext +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import timber.log.Timber @@ -22,27 +26,22 @@ internal class ExtensionApi { private val json: Json by injectLazy() private val networkService: NetworkHelper by injectLazy() - private val sourcePreferences: SourcePreferences by injectLazy() + private val getExtensionRepo: GetExtensionRepo by injectLazy() + private val updateExtensionRepo: UpdateExtensionRepo by injectLazy() suspend fun findExtensions(): List { return withIOContext { - val repos = sourcePreferences.extensionRepos().get() - if (repos.isEmpty()) { - return@withIOContext emptyList() - } - val extensions = repos.flatMap { getExtensions(it) } - - if (extensions.isEmpty()) { - throw Exception() - } - - extensions + getExtensionRepo.getAll() + .map { async { getExtensions(it) } } + .awaitAll() + .flatten() } } private suspend fun getExtensions( - repoBaseUrl: String, + repo: ExtensionRepo, ): List { + val repoBaseUrl = repo.baseUrl return try { val response = networkService.client .newCall(GET("$repoBaseUrl/index.min.json")) @@ -63,6 +62,9 @@ internal class ExtensionApi { return withIOContext { val extensions = prefetchedExtensions ?: findExtensions() + // Update extension repo details + updateExtensionRepo.awaitAll() + val extensionManager: ExtensionManager = Injekt.get() val installedExtensions = extensionManager.installedExtensionsFlow.value.ifEmpty { ExtensionLoader.loadExtensionAsync(context) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 640c3ec9c5..11e1d74418 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -945,9 +945,13 @@ Add new repo Add repo Invalid repo url + Repo already exists! You haven\'t added any repos yet. Delete repo? Are you sure you wish to delete the repo \"%s\"? + Replace + Signing Key Fingerprint Already Exists + Repository %1$s has the same Signing Key Fingerprint as %2$s.\nIf this is expected, %2$s will be replaced, otherwise contact your repo maintainer. Version @@ -1242,4 +1246,6 @@ An Unexpected Error Occurred %s ran into an unexpected error. We suggest you screenshot this message, dump the crash logs, and then share it to a GitHub Issue. Restart the application + + Refresh diff --git a/app/src/main/sqldelight/tachiyomi/data/extension_repos.sq b/app/src/main/sqldelight/tachiyomi/data/extension_repos.sq index 127a238752..6db69132a0 100644 --- a/app/src/main/sqldelight/tachiyomi/data/extension_repos.sq +++ b/app/src/main/sqldelight/tachiyomi/data/extension_repos.sq @@ -5,3 +5,53 @@ CREATE TABLE extension_repos ( website TEXT NOT NULL, signing_key_fingerprint TEXT UNIQUE NOT NULL ); + +findOne: +SELECT * +FROM extension_repos +WHERE base_url = :base_url; + +findOneBySigningKeyFingerprint: +SELECT * +FROM extension_repos +WHERE signing_key_fingerprint = :fingerprint; + +findAll: +SELECT * +FROM extension_repos; + +count: +SELECT COUNT(*) +FROM extension_repos; + +insert: +INSERT INTO extension_repos(base_url, name, short_name, website, signing_key_fingerprint) +VALUES (:base_url, :name, :short_name, :website, :fingerprint); + +upsert: +INSERT INTO extension_repos(base_url, name, short_name, website, signing_key_fingerprint) +VALUES (:base_url, :name, :short_name, :website, :fingerprint) +ON CONFLICT(base_url) +DO UPDATE +SET + name = :name, + short_name = :short_name, + website =: website, + signing_key_fingerprint = :fingerprint +WHERE base_url = base_url; + +replace: +INSERT INTO extension_repos(base_url, name, short_name, website, signing_key_fingerprint) +VALUES (:base_url, :name, :short_name, :website, :fingerprint) +ON CONFLICT(signing_key_fingerprint) +DO UPDATE +SET + base_url = :base_url, + name = :name, + short_name = :short_name, + website =: website +WHERE signing_key_fingerprint = signing_key_fingerprint; + +delete: +DELETE FROM extension_repos +WHERE base_url = :base_url;