refactor: Grab extension repo detail from repo.json and include in DB

Co-authored-by: Matthew Witman <mnwranger@gmail.com>
This commit is contained in:
Ahmad Ansori Palembani 2024-06-05 05:05:39 +07:00
parent e9a3facba8
commit 55455090d1
Signed by: null2264
GPG key ID: BA64F8B60AF3EFB6
24 changed files with 622 additions and 89 deletions

View file

@ -36,7 +36,7 @@ android {
minSdk = AndroidConfig.minSdk minSdk = AndroidConfig.minSdk
targetSdk = AndroidConfig.targetSdk targetSdk = AndroidConfig.targetSdk
applicationId = "eu.kanade.tachiyomi" applicationId = "eu.kanade.tachiyomi"
versionCode = 129 versionCode = 130
versionName = "1.8.2" versionName = "1.8.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
multiDexEnabled = true multiDexEnabled = true

View file

@ -1,15 +1,27 @@
package dev.yokai.core.di package dev.yokai.core.di
import android.app.Application import dev.yokai.domain.extension.repo.ExtensionRepoRepository
import dev.yokai.data.extension.repo.ExtensionRepoRepository import dev.yokai.data.extension.repo.ExtensionRepoRepositoryImpl
import dev.yokai.domain.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.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addFactory
import uy.kohesive.injekt.api.addSingletonFactory import uy.kohesive.injekt.api.addSingletonFactory
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
class DomainModule(val app: Application) : InjektModule { class DomainModule : InjektModule {
override fun InjektRegistrar.registerInjectables() { override fun InjektRegistrar.registerInjectables() {
addSingletonFactory<ExtensionRepoRepository> { ExtensionRepoRepositoryImpl(get()) } addSingletonFactory<ExtensionRepoRepository> { ExtensionRepoRepositoryImpl(get()) }
addFactory { CreateExtensionRepo(get()) }
addFactory { DeleteExtensionRepo(get()) }
addFactory { GetExtensionRepo(get()) }
addFactory { GetExtensionRepoCount(get()) }
addFactory { ReplaceExtensionRepo(get()) }
addFactory { UpdateExtensionRepo(get(), get()) }
} }
} }

View file

@ -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<Nothing>
fun deleteRepo(repo: String)
fun getRepoFlow(): Flow<List<String>>
}

View file

@ -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<Nothing> {
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<List<ExtensionRepo>> =
handler.subscribeToList { extension_reposQueries.findAll(::mapExtensionRepo) }
override suspend fun getAll(): List<ExtensionRepo> =
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<Int> =
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)
}

View file

@ -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<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)
/*
fun addRepo(url: String): Result<Nothing>
fun deleteRepo(repo: String)
fun getRepoFlow(): Flow<List<String>>
*/
}

View file

@ -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<Nothing> {
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()

View file

@ -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)

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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<List<ExtensionRepo>> = extensionRepoRepository.subscribeAll()
suspend fun getAll(): List<ExtensionRepo> = extensionRepoRepository.getAll()
}

View file

@ -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()
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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,
)

View file

@ -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<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
}
}
}

View file

@ -3,6 +3,5 @@ package dev.yokai.domain.source
import eu.kanade.tachiyomi.core.preference.PreferenceStore import eu.kanade.tachiyomi.core.preference.PreferenceStore
class SourcePreferences(private val preferenceStore: PreferenceStore) { class SourcePreferences(private val preferenceStore: PreferenceStore) {
fun extensionRepos() = preferenceStore.getStringSet("extension_repos", emptySet())
fun trustedExtensions() = preferenceStore.getStringSet("trusted_extensions", emptySet()) fun trustedExtensions() = preferenceStore.getStringSet("trusted_extensions", emptySet())
} }

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExtensionOff import androidx.compose.material.icons.filled.ExtensionOff
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -25,9 +26,11 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import dev.yokai.domain.ComposableAlertDialog import dev.yokai.domain.ComposableAlertDialog
import dev.yokai.domain.extension.repo.model.ExtensionRepo
import dev.yokai.presentation.AppBarType import dev.yokai.presentation.AppBarType
import dev.yokai.presentation.YokaiScaffold import dev.yokai.presentation.YokaiScaffold
import dev.yokai.presentation.component.EmptyScreen 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.ExtensionRepoInput
import dev.yokai.presentation.extension.repo.component.ExtensionRepoItem import dev.yokai.presentation.extension.repo.component.ExtensionRepoItem
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
@ -58,6 +61,13 @@ fun ExtensionRepoScreen(
state = rememberTopAppBarState(), state = rememberTopAppBarState(),
canScroll = { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 }, canScroll = { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 },
), ),
actions = {
ToolTipButton(
toolTipLabel = stringResource(R.string.refresh),
icon = Icons.Outlined.Refresh,
buttonClicked = { viewModel.refreshRepos() },
)
},
) { innerPadding -> ) { innerPadding ->
if (repoState.value is ExtensionRepoState.Loading) return@YokaiScaffold if (repoState.value is ExtensionRepoState.Loading) return@YokaiScaffold
@ -94,7 +104,7 @@ fun ExtensionRepoScreen(
repos.forEach { repo -> repos.forEach { repo ->
item { item {
ExtensionRepoItem( ExtensionRepoItem(
repoUrl = repo, extensionRepo = repo,
onDeleteClick = { repoToDelete -> onDeleteClick = { repoToDelete ->
alertDialog.content = { ExtensionRepoDeletePrompt(repoToDelete, alertDialog, viewModel) } alertDialog.content = { ExtensionRepoDeletePrompt(repoToDelete, alertDialog, viewModel) }
}, },
@ -114,10 +124,54 @@ fun ExtensionRepoScreen(
context.toast(event.stringRes) context.toast(event.stringRes)
if (event is ExtensionRepoEvent.Success) if (event is ExtensionRepoEvent.Success)
inputText = "" 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 @Composable
fun ExtensionRepoDeletePrompt(repoToDelete: String, alertDialog: ComposableAlertDialog, viewModel: ExtensionRepoViewModel) { fun ExtensionRepoDeletePrompt(repoToDelete: String, alertDialog: ComposableAlertDialog, viewModel: ExtensionRepoViewModel) {
AlertDialog( AlertDialog(

View file

@ -4,8 +4,14 @@ import androidx.annotation.StringRes
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.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.R
import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.util.system.launchIO
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -19,7 +25,12 @@ import uy.kohesive.injekt.injectLazy
class ExtensionRepoViewModel : class ExtensionRepoViewModel :
ViewModel() { 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<ExtensionRepoState> = MutableStateFlow(ExtensionRepoState.Loading) private val mutableRepoState: MutableStateFlow<ExtensionRepoState> = MutableStateFlow(ExtensionRepoState.Loading)
val repoState: StateFlow<ExtensionRepoState> = mutableRepoState.asStateFlow() val repoState: StateFlow<ExtensionRepoState> = mutableRepoState.asStateFlow()
@ -28,7 +39,7 @@ class ExtensionRepoViewModel :
init { init {
viewModelScope.launchIO { viewModelScope.launchIO {
repository.getRepoFlow().collectLatest { repos -> getExtensionRepo.subscribeAll().collectLatest { repos ->
mutableRepoState.update { ExtensionRepoState.Success(repos = repos.toImmutableList()) } mutableRepoState.update { ExtensionRepoState.Success(repos = repos.toImmutableList()) }
} }
} }
@ -36,25 +47,50 @@ class ExtensionRepoViewModel :
fun addRepo(url: String) { fun addRepo(url: String) {
viewModelScope.launchIO { viewModelScope.launchIO {
val result = repository.addRepo(url) when (val result = createExtensionRepo.await(url)) {
when (result) { is CreateExtensionRepo.Result.Success -> internalEvent.value = ExtensionRepoEvent.Success
is Result.Error -> internalEvent.value = ExtensionRepoEvent.InvalidUrl is CreateExtensionRepo.Result.Error -> internalEvent.value = ExtensionRepoEvent.InvalidUrl
is Result.Success -> internalEvent.value = ExtensionRepoEvent.Success 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 else -> internalEvent.value = ExtensionRepoEvent.NoOp
} }
} }
} }
fun deleteRepo(repo: String) { fun replaceRepo(newRepo: ExtensionRepo) {
viewModelScope.launchIO { 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 ExtensionRepoEvent {
sealed class LocalizedMessage(@StringRes val stringRes: Int) : ExtensionRepoEvent() sealed class LocalizedMessage(@StringRes val stringRes: Int) : ExtensionRepoEvent()
data object InvalidUrl : LocalizedMessage(R.string.invalid_repo_url) 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 NoOp : ExtensionRepoEvent()
data object Success : ExtensionRepoEvent() data object Success : ExtensionRepoEvent()
} }
@ -66,7 +102,7 @@ sealed class ExtensionRepoState {
@Immutable @Immutable
data class Success( data class Success(
val repos: List<String>, val repos: List<ExtensionRepo>,
) : ExtensionRepoState() { ) : ExtensionRepoState() {
val isEmpty: Boolean val isEmpty: Boolean

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Label 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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 import eu.kanade.tachiyomi.util.compose.textHint
// TODO: Redesign // TODO: Redesign
// - Edit // - Edit
// - Show display name
@Composable @Composable
fun ExtensionRepoItem( fun ExtensionRepoItem(
repoUrl: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
extensionRepo: ExtensionRepo,
onDeleteClick: (String) -> Unit = {}, onDeleteClick: (String) -> Unit = {},
) { ) {
Row( Row(
@ -50,15 +53,28 @@ fun ExtensionRepoItem(
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
) )
Text( Column(
modifier = Modifier modifier = modifier.weight(1.0f),
.weight(1.0f) ) {
.basicMarquee(), Text(
text = repoUrl, modifier = Modifier
color = MaterialTheme.colorScheme.onBackground, .fillMaxWidth()
fontSize = 16.sp, .basicMarquee(),
) text = extensionRepo.name,
IconButton(onClick = { onDeleteClick(repoUrl) }) { 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( Icon(
imageVector = Icons.Filled.Delete, imageVector = Icons.Filled.Delete,
contentDescription = null, contentDescription = null,
@ -142,7 +158,7 @@ fun ExtensionRepoItemPreview() {
val input = "https://raw.githubusercontent.com/null2264/totally-real-extensions/repo/index.min.json" val input = "https://raw.githubusercontent.com/null2264/totally-real-extensions/repo/index.min.json"
Surface { Surface {
Column { Column {
ExtensionRepoItem(repoUrl = input) ExtensionRepoItem(extensionRepo = ExtensionRepo("", "", "", "", ""))
ExtensionRepoInput(inputHint = "Input") ExtensionRepoInput(inputHint = "Input")
ExtensionRepoInput(inputHint = "", inputText = input) ExtensionRepoInput(inputHint = "", inputText = input)
ExtensionRepoInput(inputHint = "", inputText = input, isLoading = true) ExtensionRepoInput(inputHint = "", inputText = input, isLoading = true)

View file

@ -91,7 +91,7 @@ open class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.F
Injekt.apply { Injekt.apply {
importModule(PreferenceModule(this@App)) importModule(PreferenceModule(this@App))
importModule(AppModule(this@App)) importModule(AppModule(this@App))
importModule(DomainModule(this@App)) importModule(DomainModule())
} }
setupNotificationChannels() setupNotificationChannels()

View file

@ -3,23 +3,24 @@ package eu.kanade.tachiyomi
import androidx.core.content.edit import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import dev.yokai.domain.base.BasePreferences 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
import dev.yokai.domain.ui.settings.ReaderPreferences.CutoutBehaviour import dev.yokai.domain.ui.settings.ReaderPreferences.CutoutBehaviour
import dev.yokai.domain.ui.settings.ReaderPreferences.LandscapeCutoutBehaviour 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.backup.BackupCreatorJob
import eu.kanade.tachiyomi.data.download.DownloadProvider import eu.kanade.tachiyomi.data.download.DownloadProvider
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob 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.data.preference.PreferenceKeys
import eu.kanade.tachiyomi.core.preference.PreferenceStore
import eu.kanade.tachiyomi.data.preference.PreferenceValues import eu.kanade.tachiyomi.data.preference.PreferenceValues
import eu.kanade.tachiyomi.data.preference.PreferencesHelper 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.track.TrackManager
import eu.kanade.tachiyomi.data.updater.AppDownloadInstallJob import eu.kanade.tachiyomi.data.updater.AppDownloadInstallJob
import eu.kanade.tachiyomi.data.updater.AppUpdateJob import eu.kanade.tachiyomi.data.updater.AppUpdateJob
import eu.kanade.tachiyomi.extension.ExtensionUpdateJob 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.network.PREF_DOH_CLOUDFLARE
import eu.kanade.tachiyomi.ui.library.LibraryPresenter import eu.kanade.tachiyomi.ui.library.LibraryPresenter
import eu.kanade.tachiyomi.ui.library.LibrarySort 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.launchIO
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import timber.log.Timber
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import kotlin.math.max import kotlin.math.max
@ -308,6 +312,28 @@ object Migrations {
readerPreferences.landscapeCutoutBehavior().set(LandscapeCutoutBehaviour.DEFAULT) readerPreferences.landscapeCutoutBehavior().set(LandscapeCutoutBehaviour.DEFAULT)
} }
} }
if (oldVersion < 130) {
val coroutineScope = CoroutineScope(Dispatchers.IO)
val extensionRepoRepository: ExtensionRepoRepository by injectLazy()
val extensionRepos: Preference<Set<String>> = 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 return true
} }

View file

@ -1,7 +1,9 @@
package eu.kanade.tachiyomi.extension.api package eu.kanade.tachiyomi.extension.api
import android.content.Context 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.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult 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.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.system.withIOContext import eu.kanade.tachiyomi.util.system.withIOContext
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import timber.log.Timber import timber.log.Timber
@ -22,27 +26,22 @@ internal class ExtensionApi {
private val json: Json by injectLazy() private val json: Json by injectLazy()
private val networkService: NetworkHelper 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<Extension.Available> { suspend fun findExtensions(): List<Extension.Available> {
return withIOContext { return withIOContext {
val repos = sourcePreferences.extensionRepos().get() getExtensionRepo.getAll()
if (repos.isEmpty()) { .map { async { getExtensions(it) } }
return@withIOContext emptyList() .awaitAll()
} .flatten()
val extensions = repos.flatMap { getExtensions(it) }
if (extensions.isEmpty()) {
throw Exception()
}
extensions
} }
} }
private suspend fun getExtensions( private suspend fun getExtensions(
repoBaseUrl: String, repo: ExtensionRepo,
): List<Extension.Available> { ): List<Extension.Available> {
val repoBaseUrl = repo.baseUrl
return try { return try {
val response = networkService.client val response = networkService.client
.newCall(GET("$repoBaseUrl/index.min.json")) .newCall(GET("$repoBaseUrl/index.min.json"))
@ -63,6 +62,9 @@ internal class ExtensionApi {
return withIOContext { return withIOContext {
val extensions = prefetchedExtensions ?: findExtensions() val extensions = prefetchedExtensions ?: findExtensions()
// Update extension repo details
updateExtensionRepo.awaitAll()
val extensionManager: ExtensionManager = Injekt.get() val extensionManager: ExtensionManager = Injekt.get()
val installedExtensions = extensionManager.installedExtensionsFlow.value.ifEmpty { val installedExtensions = extensionManager.installedExtensionsFlow.value.ifEmpty {
ExtensionLoader.loadExtensionAsync(context) ExtensionLoader.loadExtensionAsync(context)

View file

@ -945,9 +945,13 @@
<string name="label_add_repo">Add new repo</string> <string name="label_add_repo">Add new repo</string>
<string name="action_add_repo">Add repo</string> <string name="action_add_repo">Add repo</string>
<string name="invalid_repo_url">Invalid repo url</string> <string name="invalid_repo_url">Invalid repo url</string>
<string name="repo_already_exists">Repo already exists!</string>
<string name="information_empty_repos">You haven\'t added any repos yet.</string> <string name="information_empty_repos">You haven\'t added any repos yet.</string>
<string name="confirm_delete_repo_title">Delete repo?</string> <string name="confirm_delete_repo_title">Delete repo?</string>
<string name="confirm_delete_repo">Are you sure you wish to delete the repo \"%s\"?</string> <string name="confirm_delete_repo">Are you sure you wish to delete the repo \"%s\"?</string>
<string name="action_replace_repo">Replace</string>
<string name="action_replace_repo_title">Signing Key Fingerprint Already Exists</string>
<string name="action_replace_repo_message">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.</string>
<!-- About section --> <!-- About section -->
<string name="version">Version</string> <string name="version">Version</string>
@ -1242,4 +1246,6 @@
<string name="crash_screen_title">An Unexpected Error Occurred</string> <string name="crash_screen_title">An Unexpected Error Occurred</string>
<string name="crash_screen_description">%s ran into an unexpected error. We suggest you screenshot this message, dump the crash logs, and then share it to a GitHub Issue.</string> <string name="crash_screen_description">%s ran into an unexpected error. We suggest you screenshot this message, dump the crash logs, and then share it to a GitHub Issue.</string>
<string name="crash_screen_restart_application">Restart the application</string> <string name="crash_screen_restart_application">Restart the application</string>
<string name="refresh">Refresh</string>
</resources> </resources>

View file

@ -5,3 +5,53 @@ CREATE TABLE extension_repos (
website TEXT NOT NULL, website TEXT NOT NULL,
signing_key_fingerprint TEXT UNIQUE 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;