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
targetSdk = AndroidConfig.targetSdk
applicationId = "eu.kanade.tachiyomi"
versionCode = 129
versionCode = 130
versionName = "1.8.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
multiDexEnabled = true

View file

@ -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<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
class SourcePreferences(private val preferenceStore: PreferenceStore) {
fun extensionRepos() = preferenceStore.getStringSet("extension_repos", 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.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(

View file

@ -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<ExtensionRepoState> = MutableStateFlow(ExtensionRepoState.Loading)
val repoState: StateFlow<ExtensionRepoState> = 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<String>,
val repos: List<ExtensionRepo>,
) : ExtensionRepoState() {
val isEmpty: Boolean

View file

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

View file

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

View file

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

View file

@ -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<Extension.Available> {
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<Extension.Available> {
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)

View file

@ -945,9 +945,13 @@
<string name="label_add_repo">Add new repo</string>
<string name="action_add_repo">Add repo</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="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="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 -->
<string name="version">Version</string>
@ -1242,4 +1246,6 @@
<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_restart_application">Restart the application</string>
<string name="refresh">Refresh</string>
</resources>

View file

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