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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 649 additions and 81 deletions

View file

@ -4,10 +4,7 @@ import eu.kanade.domain.chapter.interactor.GetAvailableScanlators
import eu.kanade.domain.chapter.interactor.SetReadStatus
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
import eu.kanade.domain.download.interactor.DeleteDownload
import eu.kanade.domain.extension.interactor.CreateExtensionRepo
import eu.kanade.domain.extension.interactor.DeleteExtensionRepo
import eu.kanade.domain.extension.interactor.GetExtensionLanguages
import eu.kanade.domain.extension.interactor.GetExtensionRepos
import eu.kanade.domain.extension.interactor.GetExtensionSources
import eu.kanade.domain.extension.interactor.GetExtensionsByType
import eu.kanade.domain.extension.interactor.TrustExtension
@ -26,6 +23,14 @@ import eu.kanade.domain.track.interactor.AddTracks
import eu.kanade.domain.track.interactor.RefreshTracks
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
import eu.kanade.domain.track.interactor.TrackChapter
import mihon.data.repository.ExtensionRepoRepositoryImpl
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo
import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount
import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import tachiyomi.data.category.CategoryRepositoryImpl
import tachiyomi.data.chapter.ChapterRepositoryImpl
import tachiyomi.data.history.HistoryRepositoryImpl
@ -173,8 +178,12 @@ class DomainModule : InjektModule {
addFactory { ToggleSourcePin(get()) }
addFactory { TrustExtension(get()) }
addSingletonFactory<ExtensionRepoRepository> { ExtensionRepoRepositoryImpl(get()) }
addFactory { GetExtensionRepo(get()) }
addFactory { GetExtensionRepoCount(get()) }
addFactory { CreateExtensionRepo(get()) }
addFactory { DeleteExtensionRepo(get()) }
addFactory { GetExtensionRepos(get()) }
addFactory { ReplaceExtensionRepo(get()) }
addFactory { UpdateExtensionRepo(get(), get()) }
}
}

View file

@ -1,25 +0,0 @@
package eu.kanade.domain.extension.interactor
import eu.kanade.domain.source.service.SourcePreferences
import tachiyomi.core.common.preference.plusAssign
class CreateExtensionRepo(private val preferences: SourcePreferences) {
fun await(name: String): Result {
// Do not allow invalid formats
if (!name.matches(repoRegex)) {
return Result.InvalidUrl
}
preferences.extensionRepos() += name.removeSuffix("/index.min.json")
return Result.Success
}
sealed interface Result {
data object InvalidUrl : Result
data object Success : Result
}
}
private val repoRegex = """^https://.*/index\.min\.json$""".toRegex()

View file

@ -1,11 +0,0 @@
package eu.kanade.domain.extension.interactor
import eu.kanade.domain.source.service.SourcePreferences
import tachiyomi.core.common.preference.minusAssign
class DeleteExtensionRepo(private val preferences: SourcePreferences) {
fun await(repo: String) {
preferences.extensionRepos() -= repo
}
}

View file

@ -1,11 +0,0 @@
package eu.kanade.domain.extension.interactor
import eu.kanade.domain.source.service.SourcePreferences
import kotlinx.coroutines.flow.Flow
class GetExtensionRepos(private val preferences: SourcePreferences) {
fun subscribe(): Flow<Set<String>> {
return preferences.extensionRepos().changes()
}
}

View file

@ -2,6 +2,7 @@ package eu.kanade.presentation.more.settings.screen
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
@ -13,11 +14,11 @@ import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
import kotlinx.collections.immutable.persistentListOf
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.pluralStringResource
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -33,7 +34,9 @@ object SettingsBrowseScreen : SearchableSettings {
val navigator = LocalNavigator.currentOrThrow
val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
val reposCount by sourcePreferences.extensionRepos().collectAsState()
val getExtensionRepoCount = remember { Injekt.get<GetExtensionRepoCount>() }
val reposCount by getExtensionRepoCount.subscribe().collectAsState(0)
return listOf(
Preference.PreferenceGroup(
@ -45,7 +48,7 @@ object SettingsBrowseScreen : SearchableSettings {
),
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.label_extension_repos),
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.size, reposCount.size),
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount, reposCount),
onClick = {
navigator.push(ExtensionReposScreen())
},

View file

@ -8,11 +8,14 @@ import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoConflictDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoCreateDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoDeleteDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionReposScreen
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.flow.collectLatest
import tachiyomi.presentation.core.screens.LoadingScreen
@ -42,17 +45,19 @@ class ExtensionReposScreen(
ExtensionReposScreen(
state = successState,
onClickCreate = { screenModel.showDialog(RepoDialog.Create) },
onOpenWebsite = { context.openInBrowser(it.website) },
onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) },
onClickRefresh = { screenModel.refreshRepos() },
navigateUp = navigator::pop,
)
when (val dialog = successState.dialog) {
null -> {}
RepoDialog.Create -> {
is RepoDialog.Create -> {
ExtensionRepoCreateDialog(
onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createRepo(it) },
repos = successState.repos,
repoUrls = successState.repos.map { it.baseUrl }.toImmutableSet(),
)
}
is RepoDialog.Delete -> {
@ -62,6 +67,15 @@ class ExtensionReposScreen(
repo = dialog.repo,
)
}
is RepoDialog.Conflict -> {
ExtensionRepoConflictDialog(
onDismissRequest = screenModel::dismissDialog,
onMigrate = { screenModel.replaceRepo(dialog.newRepo) },
oldRepo = dialog.oldRepo,
newRepo = dialog.newRepo,
)
}
}
LaunchedEffect(Unit) {

View file

@ -4,24 +4,29 @@ import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.extension.interactor.CreateExtensionRepo
import eu.kanade.domain.extension.interactor.DeleteExtensionRepo
import eu.kanade.domain.extension.interactor.GetExtensionRepos
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo
import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionReposScreenModel(
private val getExtensionRepos: GetExtensionRepos = Injekt.get(),
private val getExtensionRepo: GetExtensionRepo = Injekt.get(),
private val createExtensionRepo: CreateExtensionRepo = Injekt.get(),
private val deleteExtensionRepo: DeleteExtensionRepo = Injekt.get(),
private val replaceExtensionRepo: ReplaceExtensionRepo = Injekt.get(),
private val updateExtensionRepo: UpdateExtensionRepo = Injekt.get(),
) : StateScreenModel<RepoScreenState>(RepoScreenState.Loading) {
private val _events: Channel<RepoEvent> = Channel(Int.MAX_VALUE)
@ -29,7 +34,7 @@ class ExtensionReposScreenModel(
init {
screenModelScope.launchIO {
getExtensionRepos.subscribe()
getExtensionRepo.subscribeAll()
.collectLatest { repos ->
mutableState.update {
RepoScreenState.Success(
@ -43,25 +48,51 @@ class ExtensionReposScreenModel(
/**
* Creates and adds a new repo to the database.
*
* @param name The name of the repo to create.
* @param baseUrl The baseUrl of the repo to create.
*/
fun createRepo(name: String) {
fun createRepo(baseUrl: String) {
screenModelScope.launchIO {
when (createExtensionRepo.await(name)) {
is CreateExtensionRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl)
when (val result = createExtensionRepo.await(baseUrl)) {
CreateExtensionRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl)
CreateExtensionRepo.Result.RepoAlreadyExists -> _events.send(RepoEvent.RepoAlreadyExists)
is CreateExtensionRepo.Result.DuplicateFingerprint -> {
showDialog(RepoDialog.Conflict(result.oldRepo, result.newRepo))
}
else -> {}
}
}
}
/**
* Deletes the given repo from the database.
* Inserts a repo to the database, replace a matching repo with the same signing key fingerprint if found.
*
* @param repo The repo to delete.
* @param newRepo The repo to insert
*/
fun deleteRepo(repo: String) {
fun replaceRepo(newRepo: ExtensionRepo) {
screenModelScope.launchIO {
deleteExtensionRepo.await(repo)
replaceExtensionRepo.await(newRepo)
}
}
/**
* Refreshes information for each repository.
*/
fun refreshRepos() {
val status = state.value
if (status is RepoScreenState.Success) {
screenModelScope.launchIO {
updateExtensionRepo.awaitAll()
}
}
}
/**
* Deletes the given repo from the database
*/
fun deleteRepo(baseUrl: String) {
screenModelScope.launchIO {
deleteExtensionRepo.await(baseUrl)
}
}
@ -87,11 +118,13 @@ class ExtensionReposScreenModel(
sealed class RepoEvent {
sealed class LocalizedMessage(val stringRes: StringResource) : RepoEvent()
data object InvalidUrl : LocalizedMessage(MR.strings.invalid_repo_name)
data object RepoAlreadyExists : LocalizedMessage(MR.strings.error_repo_exists)
}
sealed class RepoDialog {
data object Create : RepoDialog()
data class Delete(val repo: String) : RepoDialog()
data class Conflict(val oldRepo: ExtensionRepo, val newRepo: ExtensionRepo) : RepoDialog()
}
sealed class RepoScreenState {
@ -101,7 +134,8 @@ sealed class RepoScreenState {
@Immutable
data class Success(
val repos: ImmutableSet<String>,
val repos: ImmutableSet<ExtensionRepo>,
val oldRepos: ImmutableSet<String>? = null,
val dialog: RepoDialog? = null,
) : RepoScreenState() {

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Label
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.ElevatedCard
@ -22,15 +23,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import eu.kanade.tachiyomi.util.system.copyToClipboard
import kotlinx.collections.immutable.ImmutableSet
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun ExtensionReposContent(
repos: ImmutableSet<String>,
repos: ImmutableSet<ExtensionRepo>,
lazyListState: LazyListState,
paddingValues: PaddingValues,
onOpenWebsite: (ExtensionRepo) -> Unit,
onClickDelete: (String) -> Unit,
modifier: Modifier = Modifier,
) {
@ -44,8 +47,9 @@ fun ExtensionReposContent(
item {
ExtensionRepoListItem(
modifier = Modifier.animateItemPlacement(),
repo = it,
onDelete = { onClickDelete(it) },
repo = it.name,
onOpenWebsite = { onOpenWebsite(it) },
onDelete = { onClickDelete(it.baseUrl) },
)
}
}
@ -55,6 +59,7 @@ fun ExtensionReposContent(
@Composable
private fun ExtensionRepoListItem(
repo: String,
onOpenWebsite: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -74,13 +79,24 @@ private fun ExtensionRepoListItem(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null)
Text(text = repo, modifier = Modifier.padding(start = MaterialTheme.padding.medium))
Text(
text = repo,
modifier = Modifier.padding(start = MaterialTheme.padding.medium),
style = MaterialTheme.typography.titleMedium,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(onClick = onOpenWebsite) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
contentDescription = stringResource(MR.strings.action_open_in_browser),
)
}
IconButton(
onClick = {
val url = "$repo/index.min.json"

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.coroutines.delay
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import kotlin.time.Duration.Companion.seconds
@ -24,12 +25,12 @@ import kotlin.time.Duration.Companion.seconds
fun ExtensionRepoCreateDialog(
onDismissRequest: () -> Unit,
onCreate: (String) -> Unit,
repos: ImmutableSet<String>,
repoUrls: ImmutableSet<String>,
) {
var name by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val nameAlreadyExists = remember(name) { repos.contains(name) }
val nameAlreadyExists = remember(name) { repoUrls.contains(name) }
AlertDialog(
onDismissRequest = onDismissRequest,
@ -115,3 +116,36 @@ fun ExtensionRepoDeleteDialog(
},
)
}
@Composable
fun ExtensionRepoConflictDialog(
oldRepo: ExtensionRepo,
newRepo: ExtensionRepo,
onDismissRequest: () -> Unit,
onMigrate: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(
onClick = {
onMigrate()
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_replace_repo))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
title = {
Text(text = stringResource(MR.strings.action_replace_repo_title))
},
text = {
Text(text = stringResource(MR.strings.action_replace_repo_message, newRepo.name, oldRepo.name))
},
)
}

View file

@ -5,12 +5,17 @@ package eu.kanade.presentation.more.settings.screen.browse.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import eu.kanade.presentation.category.components.CategoryFloatingActionButton
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.more.settings.screen.browse.RepoScreenState
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding
@ -23,7 +28,9 @@ import tachiyomi.presentation.core.util.plus
fun ExtensionReposScreen(
state: RepoScreenState.Success,
onClickCreate: () -> Unit,
onOpenWebsite: (ExtensionRepo) -> Unit,
onClickDelete: (String) -> Unit,
onClickRefresh: () -> Unit,
navigateUp: () -> Unit,
) {
val lazyListState = rememberLazyListState()
@ -33,6 +40,14 @@ fun ExtensionReposScreen(
navigateUp = navigateUp,
title = stringResource(MR.strings.label_extension_repos),
scrollBehavior = scrollBehavior,
actions = {
IconButton(onClick = onClickRefresh) {
Icon(
imageVector = Icons.Outlined.Refresh,
contentDescription = stringResource(resource = MR.strings.action_webview_refresh),
)
}
},
)
},
floatingActionButton = {
@ -55,6 +70,7 @@ fun ExtensionReposScreen(
lazyListState = lazyListState,
paddingValues = paddingValues + topSmallPaddingValues +
PaddingValues(horizontal = MaterialTheme.padding.medium),
onOpenWebsite = onOpenWebsite,
onClickDelete = onClickDelete,
)
}

View file

@ -1,10 +1,18 @@
package eu.kanade.tachiyomi
import android.content.Context
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import logcat.LogPriority
import mihon.domain.extensionrepo.exception.SaveExtensionRepoException
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.system.logcat
object Migrations {
@ -13,10 +21,12 @@ object Migrations {
*
* @return true if a migration is performed, false otherwise.
*/
@Suppress("SameReturnValue")
@Suppress("SameReturnValue", "MagicNumber")
fun upgrade(
context: Context,
preferenceStore: PreferenceStore,
sourcePreferences: SourcePreferences,
extensionRepoRepository: ExtensionRepoRepository,
): Boolean {
val lastVersionCode = preferenceStore.getInt(Preference.appStateKey("last_version_code"), 0)
val oldVersion = lastVersionCode.get()
@ -31,6 +41,27 @@ object Migrations {
if (oldVersion == 0) {
return false
}
val coroutineScope = CoroutineScope(Dispatchers.IO)
if (oldVersion < 6) {
coroutineScope.launchIO {
for ((index, source) in sourcePreferences.extensionRepos().get().withIndex()) {
try {
extensionRepoRepository.upsertRepository(
source,
"Repo #${index + 1}",
null,
source,
"NOFINGERPRINT-${index + 1}",
)
} catch (e: SaveExtensionRepoException) {
logcat(LogPriority.ERROR, e) { "Error Migrating Extension Repo with baseUrl: $source" }
}
}
sourcePreferences.extensionRepos().delete()
}
}
}
return false

View file

@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.extension.api
import android.content.Context
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult
@ -10,9 +9,14 @@ import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import logcat.LogPriority
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.util.lang.withIOContext
@ -25,7 +29,8 @@ internal class ExtensionApi {
private val networkService: NetworkHelper by injectLazy()
private val preferenceStore: PreferenceStore by injectLazy()
private val sourcePreferences: SourcePreferences by injectLazy()
private val getExtensionRepo: GetExtensionRepo by injectLazy()
private val updateExtensionRepo: UpdateExtensionRepo by injectLazy()
private val extensionManager: ExtensionManager by injectLazy()
private val json: Json by injectLazy()
@ -35,11 +40,15 @@ internal class ExtensionApi {
suspend fun findExtensions(): List<Extension.Available> {
return withIOContext {
sourcePreferences.extensionRepos().get().flatMap { getExtensions(it) }
getExtensionRepo.getAll()
.map { async { getExtensions(it) } }
.awaitAll()
.flatten()
}
}
private suspend fun getExtensions(repoBaseUrl: String): List<Extension.Available> {
private suspend fun getExtensions(extRepo: ExtensionRepo): List<Extension.Available> {
val repoBaseUrl = extRepo.baseUrl
return try {
val response = networkService.client
.newCall(GET("$repoBaseUrl/index.min.json"))
@ -67,6 +76,9 @@ internal class ExtensionApi {
return null
}
// Update extension repo details
updateExtensionRepo.awaitAll()
val extensions = if (fromAvailableExtensionList) {
extensionManager.availableExtensionsFlow.value
} else {

View file

@ -134,6 +134,8 @@ class MainActivity : BaseActivity() {
Migrations.upgrade(
context = applicationContext,
preferenceStore = Injekt.get(),
sourcePreferences = Injekt.get(),
extensionRepoRepository = Injekt.get(),
)
} else {
false

View file

@ -0,0 +1,93 @@
package mihon.data.repository
import android.database.sqlite.SQLiteException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import mihon.domain.extensionrepo.exception.SaveExtensionRepoException
import mihon.domain.extensionrepo.model.ExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import tachiyomi.data.DatabaseHandler
class ExtensionRepoRepositoryImpl(
private val handler: DatabaseHandler,
) : ExtensionRepoRepository {
override fun subscribeAll(): Flow<List<ExtensionRepo>> {
return handler.subscribeToList { extension_reposQueries.findAll(::mapExtensionRepo) }
}
override suspend fun getAll(): List<ExtensionRepo> {
return handler.awaitList { extension_reposQueries.findAll(::mapExtensionRepo) }
}
override suspend fun getRepository(baseUrl: String): ExtensionRepo? {
return handler.awaitOneOrNull { extension_reposQueries.findOne(baseUrl, ::mapExtensionRepo) }
}
override suspend fun getRepositoryBySigningKeyFingerprint(fingerprint: String): ExtensionRepo? {
return handler.awaitOneOrNull {
extension_reposQueries.findOneBySigningKeyFingerprint(fingerprint, ::mapExtensionRepo)
}
}
override fun getCount(): Flow<Int> {
return 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 (ex: SQLiteException) {
throw SaveExtensionRepoException(ex)
}
}
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 (ex: SQLiteException) {
throw SaveExtensionRepoException(ex)
}
}
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) {
return handler.await { extension_reposQueries.delete(baseUrl) }
}
private fun mapExtensionRepo(
baseUrl: String,
name: String,
shortName: String?,
website: String,
signingKeyFingerprint: String,
): ExtensionRepo = ExtensionRepo(
baseUrl = baseUrl,
name = name,
shortName = shortName,
website = website,
signingKeyFingerprint = signingKeyFingerprint,
)
}

View file

@ -0,0 +1,57 @@
CREATE TABLE extension_repos (
base_url TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
short_name TEXT,
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;

View file

@ -0,0 +1,8 @@
-- Create ExtensionRepo table --
CREATE TABLE extension_repos (
base_url TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
short_name TEXT,
website TEXT NOT NULL,
signing_key_fingerprint TEXT UNIQUE NOT NULL
);

View file

@ -33,6 +33,7 @@ tasks {
withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
kotlinOptions.freeCompilerArgs += listOf(
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-Xcontext-receivers",
)
}
}

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

View file

@ -347,6 +347,9 @@
<string name="invalid_repo_name">Invalid repo URL</string>
<string name="delete_repo_confirmation">Do you wish to delete the repo \"%s\"?</string>
<string name="action_open_repo">Open source repo</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>
<!-- Reader section -->
<string name="pref_fullscreen">Fullscreen</string>