Add external repo support

This commit is contained in:
Jobobby04 2020-12-18 16:24:17 -05:00
parent 85e30ef6ca
commit 96213900ac
14 changed files with 652 additions and 9 deletions

View File

@ -332,4 +332,6 @@ object PreferenceKeys {
const val createLegacyBackup = "create_legacy_backup"
const val dontDeleteFromCategories = "dont_delete_from_categories"
const val extensionRepos = "extension_repos"
}

View File

@ -445,4 +445,6 @@ class PreferencesHelper(val context: Context) {
fun sortTagsForLibrary() = flowPrefs.getStringSet(Keys.sortTagsForLibrary, mutableSetOf())
fun dontDeleteFromCategories() = flowPrefs.getStringSet(Keys.dontDeleteFromCategories, emptySet())
fun extensionRepos() = flowPrefs.getStringSet(Keys.dontDeleteFromCategories, emptySet())
}

View File

@ -25,7 +25,12 @@ internal class ExtensionGithubApi {
return withContext(Dispatchers.IO) {
val response = service.getRepo()
parseResponse(response)
} /* SY --> */ + preferences.extensionRepos().get().flatMap {
val url = "$BASE_URL$it/repo/"
val response = service.getRepo("${url}index.min.json")
parseResponse(response, url)
}
// SY <--
}
suspend fun checkForUpdates(context: Context): List<Extension.Installed> {
@ -58,7 +63,7 @@ internal class ExtensionGithubApi {
return extensionsWithUpdate
}
private fun parseResponse(json: JsonArray): List<Extension.Available> {
private fun parseResponse(json: JsonArray /* SY --> */, repoUrl: String = REPO_URL_PREFIX /* SY <-- */): List<Extension.Available> {
return json
.filter { element ->
val versionName = element.jsonObject["version"]!!.jsonPrimitive.content
@ -73,14 +78,16 @@ internal class ExtensionGithubApi {
val versionCode = element.jsonObject["code"]!!.jsonPrimitive.int
val lang = element.jsonObject["lang"]!!.jsonPrimitive.content
val nsfw = element.jsonObject["nsfw"]!!.jsonPrimitive.int == 1
val icon = "$REPO_URL_PREFIX/icon/${apkName.replace(".apk", ".png")}"
// SY -->
val icon = "$repoUrl/icon/${apkName.replace(".apk", ".png")}"
// SY <--
Extension.Available(name, pkgName, versionName, versionCode, lang, nsfw, apkName, icon)
Extension.Available(name, pkgName, versionName, versionCode, lang, nsfw, apkName, icon /* SY --> */, repoUrl /* SY <-- */)
}
}
fun getApkUrl(extension: Extension.Available): String {
return "$REPO_URL_PREFIX/apk/${extension.apkName}"
return /* SY --> */ "${extension.repoUrl}/apk/${extension.apkName}" /* SY <-- */
}
// SY -->

View File

@ -7,6 +7,7 @@ import kotlinx.serialization.json.JsonArray
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.http.GET
import retrofit2.http.Url
import uy.kohesive.injekt.injectLazy
/**
@ -27,6 +28,8 @@ interface ExtensionGithubService {
}
}
@GET("${ExtensionGithubApi.REPO_URL_PREFIX}index.min.json")
suspend fun getRepo(): JsonArray
// SY -->
@GET
suspend fun getRepo(@Url url: String = "${ExtensionGithubApi.REPO_URL_PREFIX}index.min.json"): JsonArray
// SY <--
}

View File

@ -35,7 +35,10 @@ sealed class Extension {
override val lang: String,
override val isNsfw: Boolean,
val apkName: String,
val iconUrl: String
val iconUrl: String,
// SY -->
val repoUrl: String
// SY <--
) : Extension()
data class Untrusted(

View File

@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.ExtensionCardItemBinding
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.source.ConfigurableSource
@ -41,9 +42,9 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
extension is Extension.Installed && extension.isUnofficial -> itemView.context.getString(R.string.ext_unofficial)
// SY -->
extension is Extension.Installed && extension.isRedundant -> itemView.context.getString(R.string.ext_redundant)
extension.isNsfw && shouldLabelNsfw -> itemView.context.getString(R.string.ext_nsfw_short).plusRepo(extension)
else -> "".plusRepo(extension)
// SY <--
extension.isNsfw && shouldLabelNsfw -> itemView.context.getString(R.string.ext_nsfw_short)
else -> ""
}.toUpperCase()
GlideApp.with(itemView.context).clear(binding.image)
@ -57,6 +58,23 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
bindButton(item)
}
// SY -->
private fun String.plusRepo(extension: Extension): String {
return if (extension is Extension.Available) {
when (extension.repoUrl) {
ExtensionGithubApi.REPO_URL_PREFIX -> this
else -> {
this + if (this.isEmpty()) {
""
} else {
""
} + itemView.context.getString(R.string.repo_source)
}
}
} else this
}
// SY <--
@Suppress("ResourceType")
fun bindButton(item: ExtensionItem) = with(binding.extButton) {
isEnabled = true

View File

@ -0,0 +1,30 @@
package eu.kanade.tachiyomi.ui.category.repos
import eu.davidea.flexibleadapter.FlexibleAdapter
/**
* Custom adapter for repos.
*
* @param controller The containing controller.
*/
class RepoAdapter(controller: RepoController) :
FlexibleAdapter<RepoItem>(null, controller, true) {
/**
* Clears the active selections from the list and the model.
*/
override fun clearSelection() {
super.clearSelection()
(0 until itemCount).forEach { getItem(it)?.isSelected = false }
}
/**
* Toggles the selection of the given position.
*
* @param position The position to toggle.
*/
override fun toggleSelection(position: Int) {
super.toggleSelection(position)
getItem(position)?.isSelected = isSelected(position)
}
}

View File

@ -0,0 +1,320 @@
package eu.kanade.tachiyomi.ui.category.repos
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import com.google.android.material.snackbar.Snackbar
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter
import eu.davidea.flexibleadapter.helpers.UndoHelper
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.CategoriesControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.FabController
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.shrinkOnScroll
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
/**
* Controller to manage the categories for the users' library.
*/
class RepoController :
NucleusController<CategoriesControllerBinding, RepoPresenter>(),
FabController,
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
RepoCreateDialog.Listener,
UndoHelper.OnActionListener {
/**
* Object used to show ActionMode toolbar.
*/
private var actionMode: ActionMode? = null
/**
* Adapter containing repo items.
*/
private var adapter: RepoAdapter? = null
private var actionFab: ExtendedFloatingActionButton? = null
private var actionFabScrollListener: RecyclerView.OnScrollListener? = null
/**
* Undo helper used for restoring a deleted repo.
*/
private var undoHelper: UndoHelper? = null
/**
* Creates the presenter for this controller. Not to be manually called.
*/
override fun createPresenter() = RepoPresenter()
/**
* Returns the toolbar title to show when this controller is attached.
*/
override fun getTitle(): String? {
return resources?.getString(R.string.action_edit_repos)
}
/**
* Returns the view of this controller.
*
* @param inflater The layout inflater to create the view from XML.
* @param container The parent view for this one.
*/
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
binding = CategoriesControllerBinding.inflate(inflater)
return binding.root
}
/**
* Called after view inflation. Used to initialize the view.
*
* @param view The view of this controller.
*/
override fun onViewCreated(view: View) {
super.onViewCreated(view)
adapter = RepoAdapter(this@RepoController)
binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.setHasFixedSize(true)
binding.recycler.adapter = adapter
adapter?.isPermanentDelete = false
actionFabScrollListener = actionFab?.shrinkOnScroll(binding.recycler)
}
override fun configureFab(fab: ExtendedFloatingActionButton) {
actionFab = fab
fab.setText(R.string.action_add)
fab.setIconResource(R.drawable.ic_add_24dp)
fab.clicks()
.onEach {
RepoCreateDialog(this@RepoController).showDialog(router, null)
}
.launchIn(scope)
}
override fun cleanupFab(fab: ExtendedFloatingActionButton) {
actionFabScrollListener?.let { binding.recycler.removeOnScrollListener(it) }
actionFab = null
}
/**
* Called when the view is being destroyed. Used to release references and remove callbacks.
*
* @param view The view of this controller.
*/
override fun onDestroyView(view: View) {
// Manually call callback to delete repos if required
undoHelper?.onDeleteConfirmed(Snackbar.Callback.DISMISS_EVENT_MANUAL)
undoHelper = null
actionMode = null
adapter = null
super.onDestroyView(view)
}
/**
* Called from the presenter when the repos are updated.
*
* @param repos The new list of repos to display.
*/
fun setRepos(repos: List<RepoItem>) {
actionMode?.finish()
adapter?.updateDataSet(repos)
if (repos.isNotEmpty()) {
binding.emptyView.hide()
val selected = repos.filter { it.isSelected }
if (selected.isNotEmpty()) {
selected.forEach { onItemLongClick(repos.indexOf(it)) }
}
} else {
binding.emptyView.show(R.string.information_empty_repos)
}
}
/**
* Called when action mode is first created. The menu supplied will be used to generate action
* buttons for the action mode.
*
* @param mode ActionMode being created.
* @param menu Menu used to populate action buttons.
* @return true if the action mode should be created, false if entering this mode should be
* aborted.
*/
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
// Inflate menu.
mode.menuInflater.inflate(R.menu.category_selection, menu)
// Enable adapter multi selection.
adapter?.mode = SelectableAdapter.Mode.MULTI
return true
}
/**
* Called to refresh an action mode's action menu whenever it is invalidated.
*
* @param mode ActionMode being prepared.
* @param menu Menu used to populate action buttons.
* @return true if the menu or action mode was updated, false otherwise.
*/
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val adapter = adapter ?: return false
val count = adapter.selectedItemCount
mode.title = count.toString()
// Show edit button only when one item is selected
val editItem = mode.menu.findItem(R.id.action_edit)
editItem.isVisible = false
return true
}
/**
* Called to report a user click on an action button.
*
* @param mode The current ActionMode.
* @param item The item that was clicked.
* @return true if this callback handled the event, false if the standard MenuItem invocation
* should continue.
*/
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
val adapter = adapter ?: return false
when (item.itemId) {
R.id.action_delete -> {
undoHelper = UndoHelper(adapter, this)
undoHelper?.start(
adapter.selectedPositions,
(activity as? MainActivity)?.binding?.rootCoordinator!!,
R.string.snack_repo_deleted,
R.string.action_undo,
3000
)
mode.finish()
}
else -> return false
}
return true
}
/**
* Called when an action mode is about to be exited and destroyed.
*
* @param mode The current ActionMode being destroyed.
*/
override fun onDestroyActionMode(mode: ActionMode) {
// Reset adapter to single selection
adapter?.mode = SelectableAdapter.Mode.IDLE
adapter?.clearSelection()
actionMode = null
}
/**
* Called when an item in the list is clicked.
*
* @param position The position of the clicked item.
* @return true if this click should enable selection mode.
*/
override fun onItemClick(view: View, position: Int): Boolean {
// Check if action mode is initialized and selected item exist.
return if (actionMode != null && position != RecyclerView.NO_POSITION) {
toggleSelection(position)
true
} else {
false
}
}
/**
* Called when an item in the list is long clicked.
*
* @param position The position of the clicked item.
*/
override fun onItemLongClick(position: Int) {
val activity = activity as? AppCompatActivity ?: return
// Check if action mode is initialized.
if (actionMode == null) {
// Initialize action mode
actionMode = activity.startSupportActionMode(this)
}
// Set item as selected
toggleSelection(position)
}
/**
* Toggle the selection state of an item.
* If the item was the last one in the selection and is unselected, the ActionMode is finished.
*
* @param position The position of the item to toggle.
*/
private fun toggleSelection(position: Int) {
val adapter = adapter ?: return
// Mark the position selected
adapter.toggleSelection(position)
if (adapter.selectedItemCount == 0) {
actionMode?.finish()
} else {
actionMode?.invalidate()
}
}
/**
* Called when the undo action is clicked in the snackbar.
*
* @param action The action performed.
*/
override fun onActionCanceled(action: Int, positions: MutableList<Int>?) {
adapter?.restoreDeletedItems()
undoHelper = null
}
/**
* Called when the time to restore the items expires.
*
* @param action The action performed.
* @param event The event that triggered the action
*/
override fun onActionConfirmed(action: Int, event: Int) {
val adapter = adapter ?: return
presenter.deleteRepos(adapter.deletedItems.map { it.repo })
undoHelper = null
}
/**
* Creates a new repo with the given name.
*
* @param name The name of the new repo.
*/
override fun createRepo(name: String) {
presenter.createRepo(name)
}
/**
* Called from the presenter when a repo already exists.
*/
fun onRepoExistsError() {
activity?.toast(R.string.error_repo_exists)
}
/**
* Called from the presenter when a invalid repo is made
*/
fun onRepoInvalidNameError() {
activity?.toast(R.string.invalid_repo_name)
}
}

View File

@ -0,0 +1,51 @@
package eu.kanade.tachiyomi.ui.category.repos
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.input.input
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
/**
* Dialog to create a new repo for the library.
*/
class RepoCreateDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : RepoCreateDialog.Listener {
/**
* Name of the new repo. Value updated with each input from the user.
*/
private var currentName = ""
constructor(target: T) : this() {
targetController = target
}
/**
* Called when creating the dialog for this controller.
*
* @param savedViewState The saved state of this dialog.
* @return a new dialog instance.
*/
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialDialog(activity!!)
.title(R.string.action_add_repo)
.message(R.string.action_add_repo_message)
.negativeButton(android.R.string.cancel)
.input(
hint = resources?.getString(R.string.name),
prefill = currentName
) { _, input ->
currentName = input.toString()
}
.positiveButton(android.R.string.ok) {
(targetController as? Listener)?.createRepo(currentName)
}
}
interface Listener {
fun createRepo(name: String)
}
}

View File

@ -0,0 +1,28 @@
package eu.kanade.tachiyomi.ui.category.repos
import android.view.View
import androidx.core.view.isVisible
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.databinding.CategoriesItemBinding
/**
* Holder used to display repo items.
*
* @param view The view used by repo items.
* @param adapter The adapter containing this holder.
*/
class RepoHolder(view: View, val adapter: RepoAdapter) : FlexibleViewHolder(view, adapter) {
private val binding = CategoriesItemBinding.bind(view)
/**
* Binds this holder with the given category.
*
* @param category The category to bind.
*/
fun bind(category: String) {
// Set capitalized title.
binding.title.text = category
binding.reorder.isVisible = false
}
}

View File

@ -0,0 +1,62 @@
package eu.kanade.tachiyomi.ui.category.repos
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
/**
* Repo item for a recycler view.
*/
class RepoItem(val repo: String) : AbstractFlexibleItem<RepoHolder>() {
/**
* Whether this item is currently selected.
*/
var isSelected = false
/**
* Returns the layout resource for this item.
*/
override fun getLayoutRes(): Int {
return R.layout.categories_item
}
/**
* Returns a new view holder for this item.
*
* @param view The view of this item.
* @param adapter The adapter of this item.
*/
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): RepoHolder {
return RepoHolder(view, adapter as RepoAdapter)
}
/**
* Binds the given view holder with this item.
*
* @param adapter The adapter of this item.
* @param holder The holder to bind.
* @param position The position of this item in the adapter.
* @param payloads List of partial changes.
*/
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: RepoHolder,
position: Int,
payloads: List<Any?>?
) {
holder.bind(repo)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
return false
}
override fun hashCode(): Int {
return repo.hashCode()
}
}

View File

@ -0,0 +1,89 @@
package eu.kanade.tachiyomi.ui.category.repos
import android.os.Bundle
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* Presenter of [RepoController]. Used to manage the repos for the extensions.
*/
class RepoPresenter(
private val preferences: PreferencesHelper = Injekt.get()
) : BasePresenter<RepoController>() {
val scope = CoroutineScope(Job() + Dispatchers.Main)
/**
* List containing repos.
*/
private var repos: List<String> = emptyList()
/**
* Called when the presenter is created.
*
* @param savedState The saved state of this presenter.
*/
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
preferences.extensionRepos().asFlow().onEach { repos ->
this.repos = repos.toList().sortedBy { it.toLowerCase() }
Observable.just(this.repos)
.map { it.map(::RepoItem) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(RepoController::setRepos)
}.launchIn(scope)
}
/**
* Creates and adds a new repo to the database.
*
* @param name The name of the repo to create.
*/
fun createRepo(name: String) {
// Do not allow duplicate repos.
if (repoExists(name)) {
Observable.just(Unit).subscribeFirst({ view, _ -> view.onRepoExistsError() })
return
}
// Do not allow invalid formats
if (!name.matches(repoRegex)) {
Observable.just(Unit).subscribeFirst({ view, _ -> view.onRepoInvalidNameError() })
return
}
preferences.extensionRepos().set((repos + name).toSet())
}
/**
* Deletes the given repos from the database.
*
* @param repos The list of repos to delete.
*/
fun deleteRepos(repos: List<String>) {
preferences.extensionRepos().set(
this.repos.filterNot { it in repos }.toSet()
)
}
/**
* Returns true if a repo with the given name already exists.
*/
private fun repoExists(name: String): Boolean {
return repos.any { it.equals(name, true) }
}
companion object {
val repoRegex = """^[a-zA-Z-_.]*?\/[a-zA-Z-_.]*?$""".toRegex()
}
}

View File

@ -5,6 +5,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.asImmediateFlow
import eu.kanade.tachiyomi.extension.ExtensionUpdateJob
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.category.repos.RepoController
import eu.kanade.tachiyomi.ui.category.sources.SourceCategoryController
import eu.kanade.tachiyomi.util.preference.defaultValue
import eu.kanade.tachiyomi.util.preference.infoPreference
@ -84,6 +85,19 @@ class SettingsBrowseController : SettingsController() {
true
}
}
// SY -->
preference {
key = "pref_edit_extension_repos"
titleRes = R.string.action_edit_repos
val catCount = preferences.extensionRepos().get().count()
summary = context.resources.getQuantityString(R.plurals.num_repos, catCount, catCount)
onClick {
router.pushController(RepoController().withFadeTransaction())
}
}
// SY <--
}
preferenceCategory {

View File

@ -314,6 +314,20 @@
<string name="ext_redundant">Redundant</string>
<string name="redundant_extension_message">This extension is redundant and will not be used inside this version of Tachiyomi.</string>
<!-- Extension Repos -->
<string name="information_empty_repos">You have no additional repos. Tap the plus button to create one for adding external extensions.</string>
<string name="action_add_repo">Add repo</string>
<string name="action_add_repo_message">Add additional repos to Tachiyomi, the format of a repo is \'username/repo\' , with username being the repo owner, and repo being the repo name</string>
<string name="action_edit_repos">Edit repos</string>
<plurals name="num_repos">
<item quantity="one">%d additional repo</item>
<item quantity="other">%d additional repos</item>
</plurals>
<string name="error_repo_exists">This repo already exists!</string>
<string name="snack_repo_deleted">Repo deleted</string>
<string name="invalid_repo_name">Invalid category name</string>
<string name="repo_source">Repo source</string>
<!-- Migration -->
<string name="select_sources">Select sources</string>
<string name="select_none">Select none</string>