Grab extension repo detail from repo.json and include in DB (#506)

* WIP Extension Repo DB Support

* Wired in to extension screen, browse settings screen

* Detekt changes

* Ui tweaks and open in browser

* Migrate ExtensionRepos on Update

* Migration Cleanup

* Slight cleanup / error handling

* Update ExtensionRepo from Repo.json during extension search.
Added Manual refresh in extension repos page.

* Split repo fetching into separate API module, major refactor work

* Removed development strings

* Moved migration to #3

* Fixed rebase

* Detekt changes

* Added Replace Repository Dialog

* Cleanup, removed platform specific code, PR comments

* Removed extra function, reverted small change

* Detekt cleanup

* Apply suggestions from code review

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>

* Fixed error introduced in cleanup

* Tweak for multiline when

* Moved getCount() to flow

* changed getCount to non-suspend, used property delegation

* Apply suggestions from code review

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>

* Fixed formatting with updated comment string

* Big wave of PR comments, renaming/other tweaks

* onOpenWebsite changes

* onOpenWebsite changes

* trying to make single line

* Renamed ExtensionRepoApi.kt to ExtensionRepoService.kt

---------

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
This commit is contained in:
Maddie Witman
2024-03-22 18:58:35 -04:00
committed by GitHub
parent e75488f5d9
commit 4b4e468510
28 changed files with 649 additions and 81 deletions

View File

@@ -0,0 +1,10 @@
package mihon.domain.extensionrepo.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)

View File

@@ -0,0 +1,81 @@
package mihon.domain.extensionrepo.interactor
import eu.kanade.tachiyomi.network.NetworkHelper
import logcat.LogPriority
import mihon.domain.extensionrepo.exception.SaveExtensionRepoException
import mihon.domain.extensionrepo.model.ExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import mihon.domain.extensionrepo.service.ExtensionRepoService
import okhttp3.OkHttpClient
import tachiyomi.core.common.util.system.logcat
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) {
logcat(LogPriority.WARN, 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
}
}

View File

@@ -0,0 +1,11 @@
package mihon.domain.extensionrepo.interactor
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
class DeleteExtensionRepo(
private val extensionRepoRepository: ExtensionRepoRepository,
) {
suspend fun await(baseUrl: String) {
extensionRepoRepository.deleteRepository(baseUrl)
}
}

View File

@@ -0,0 +1,13 @@
package mihon.domain.extensionrepo.interactor
import kotlinx.coroutines.flow.Flow
import mihon.domain.extensionrepo.model.ExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
class GetExtensionRepo(
private val extensionRepoRepository: ExtensionRepoRepository,
) {
fun subscribeAll(): Flow<List<ExtensionRepo>> = extensionRepoRepository.subscribeAll()
suspend fun getAll(): List<ExtensionRepo> = extensionRepoRepository.getAll()
}

View File

@@ -0,0 +1,9 @@
package mihon.domain.extensionrepo.interactor
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
class GetExtensionRepoCount(
private val extensionRepoRepository: ExtensionRepoRepository,
) {
fun subscribe() = extensionRepoRepository.getCount()
}

View File

@@ -0,0 +1,12 @@
package mihon.domain.extensionrepo.interactor
import mihon.domain.extensionrepo.model.ExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
class ReplaceExtensionRepo(
private val extensionRepoRepository: ExtensionRepoRepository,
) {
suspend fun await(repo: ExtensionRepo) {
extensionRepoRepository.replaceRepository(repo)
}
}

View File

@@ -0,0 +1,33 @@
package mihon.domain.extensionrepo.interactor
import eu.kanade.tachiyomi.network.NetworkHelper
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import mihon.domain.extensionrepo.model.ExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import mihon.domain.extensionrepo.service.ExtensionRepoService
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)
}
}
}

View File

@@ -0,0 +1,9 @@
package mihon.domain.extensionrepo.model
data class ExtensionRepo(
val baseUrl: String,
val name: String,
val shortName: String?,
val website: String,
val signingKeyFingerprint: String,
)

View File

@@ -0,0 +1,47 @@
package mihon.domain.extensionrepo.repository
import kotlinx.coroutines.flow.Flow
import mihon.domain.extensionrepo.model.ExtensionRepo
interface ExtensionRepoRepository {
fun subscribeAll(): Flow<List<ExtensionRepo>>
suspend fun getAll(): List<ExtensionRepo>
suspend fun getRepository(baseUrl: String): ExtensionRepo?
suspend fun getRepositoryBySigningKeyFingerprint(fingerprint: String): ExtensionRepo?
fun getCount(): Flow<Int>
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)
}

View File

@@ -0,0 +1,57 @@
package mihon.domain.extensionrepo.service
import androidx.core.net.toUri
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 kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import mihon.domain.extensionrepo.model.ExtensionRepo
import okhttp3.OkHttpClient
import tachiyomi.core.common.util.lang.withIOContext
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<JsonObject>()
}
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
}
}
}