Downloading extensions from Github Repo. (#1101)

Downloading extensions from Github Repo.
This commit is contained in:
Carlos 2018-02-05 16:50:56 -05:00 committed by inorichi
parent a71c805959
commit 854112095b
46 changed files with 2319 additions and 615 deletions

View File

@ -141,9 +141,6 @@ dependencies {
implementation 'com.google.code.gson:gson:2.8.2'
implementation 'com.github.salomonbrys.kotson:kotson:2.5.0'
// YAML
implementation 'com.github.bmoliveira:snake-yaml:v1.18-android'
// JavaScript engine
implementation 'com.squareup.duktape:duktape-android:1.2.0'
@ -170,7 +167,7 @@ dependencies {
implementation "info.android15.nucleus:nucleus-support-v7:$nucleus_version"
// Dependency injection
implementation "uy.kohesive.injekt:injekt-core:1.16.1"
implementation "com.github.inorichi.injekt:injekt-core:65b0440"
// Image library
final glide_version = '4.3.1'

View File

@ -8,33 +8,49 @@ import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.SourceManager
import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addSingletonFactory
import kotlinx.coroutines.experimental.async
import uy.kohesive.injekt.api.*
class AppModule(val app: Application) : InjektModule {
override fun InjektRegistrar.registerInjectables() {
addSingletonFactory { PreferencesHelper(app) }
addSingleton(app)
addSingletonFactory { DatabaseHelper(app) }
addSingletonFactory { PreferencesHelper(app) }
addSingletonFactory { ChapterCache(app) }
addSingletonFactory { DatabaseHelper(app) }
addSingletonFactory { CoverCache(app) }
addSingletonFactory { ChapterCache(app) }
addSingletonFactory { NetworkHelper(app) }
addSingletonFactory { CoverCache(app) }
addSingletonFactory { SourceManager(app) }
addSingletonFactory { NetworkHelper(app) }
addSingletonFactory { DownloadManager(app) }
addSingletonFactory { SourceManager(app).also { get<ExtensionManager>().init(it) } }
addSingletonFactory { TrackManager(app) }
addSingletonFactory { ExtensionManager(app) }
addSingletonFactory { Gson() }
addSingletonFactory { DownloadManager(app) }
addSingletonFactory { TrackManager(app) }
addSingletonFactory { Gson() }
// Asynchronously init expensive components for a faster cold start
async { get<PreferencesHelper>() }
async { get<NetworkHelper>() }
async { get<SourceManager>() }
async { get<DatabaseHelper>() }
async { get<DownloadManager>() }
}

View File

@ -0,0 +1,48 @@
package eu.kanade.tachiyomi.data.preference
import android.support.v7.preference.PreferenceDataStore
class EmptyPreferenceDataStore : PreferenceDataStore() {
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
return false
}
override fun putBoolean(key: String?, value: Boolean) {
}
override fun getInt(key: String?, defValue: Int): Int {
return 0
}
override fun putInt(key: String?, value: Int) {
}
override fun getLong(key: String?, defValue: Long): Long {
return 0
}
override fun putLong(key: String?, value: Long) {
}
override fun getFloat(key: String?, defValue: Float): Float {
return 0f
}
override fun putFloat(key: String?, value: Float) {
}
override fun getString(key: String?, defValue: String?): String? {
return null
}
override fun putString(key: String?, value: String?) {
}
override fun getStringSet(key: String?, defValues: Set<String>?): Set<String>? {
return null
}
override fun putStringSet(key: String?, values: Set<String>?) {
}
}

View File

@ -109,10 +109,14 @@ object PreferenceKeys {
const val downloadBadge = "display_download_badge"
@Deprecated("Use the preferences of the source")
fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId"
@Deprecated("Use the preferences of the source")
fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId"
fun sourceSharedPref(sourceId: Long) = "source_$sourceId"
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"

View File

@ -169,4 +169,5 @@ class PreferencesHelper(val context: Context) {
fun migrateFlags() = rxPrefs.getInteger("migrate_flags", Int.MAX_VALUE)
fun trustedSignatures() = rxPrefs.getStringSet("trusted_signatures", emptySet())
}

View File

@ -0,0 +1,55 @@
package eu.kanade.tachiyomi.data.preference
import android.content.SharedPreferences
import android.support.v7.preference.PreferenceDataStore
class SharedPreferencesDataStore(private val prefs: SharedPreferences) : PreferenceDataStore() {
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
return prefs.getBoolean(key, defValue)
}
override fun putBoolean(key: String?, value: Boolean) {
prefs.edit().putBoolean(key, value).apply()
}
override fun getInt(key: String?, defValue: Int): Int {
return prefs.getInt(key, defValue)
}
override fun putInt(key: String?, value: Int) {
prefs.edit().putInt(key, value).apply()
}
override fun getLong(key: String?, defValue: Long): Long {
return prefs.getLong(key, defValue)
}
override fun putLong(key: String?, value: Long) {
prefs.edit().putLong(key, value).apply()
}
override fun getFloat(key: String?, defValue: Float): Float {
return prefs.getFloat(key, defValue)
}
override fun putFloat(key: String?, value: Float) {
prefs.edit().putFloat(key, value).apply()
}
override fun getString(key: String?, defValue: String?): String? {
return prefs.getString(key, defValue)
}
override fun putString(key: String?, value: String?) {
prefs.edit().putString(key, value).apply()
}
override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String> {
return prefs.getStringSet(key, defValues)
}
override fun putStringSet(key: String?, values: MutableSet<String>?) {
prefs.edit().putStringSet(key, values).apply()
}
}

View File

@ -0,0 +1,330 @@
package eu.kanade.tachiyomi.extension
import android.content.Context
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
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.extension.model.LoadResult
import eu.kanade.tachiyomi.extension.util.ExtensionInstallReceiver
import eu.kanade.tachiyomi.extension.util.ExtensionInstaller
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.util.launchNow
import kotlinx.coroutines.experimental.async
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* The manager of extensions installed as another apk which extend the available sources. It handles
* the retrieval of remotely available extensions as well as installing, updating and removing them.
* To avoid malicious distribution, every extension must be signed and it will only be loaded if its
* signature is trusted, otherwise the user will be prompted with a warning to trust it before being
* loaded.
*
* @param context The application context.
* @param preferences The application preferences.
*/
class ExtensionManager(
private val context: Context,
private val preferences: PreferencesHelper = Injekt.get()
) {
/**
* API where all the available extensions can be found.
*/
private val api = ExtensionGithubApi()
/**
* The installer which installs, updates and uninstalls the extensions.
*/
private val installer by lazy { ExtensionInstaller(context) }
/**
* Relay used to notify the installed extensions.
*/
private val installedExtensionsRelay = BehaviorRelay.create<List<Extension.Installed>>()
/**
* List of the currently installed extensions.
*/
var installedExtensions = emptyList<Extension.Installed>()
private set(value) {
field = value
installedExtensionsRelay.call(value)
}
/**
* Relay used to notify the available extensions.
*/
private val availableExtensionsRelay = BehaviorRelay.create<List<Extension.Available>>()
/**
* List of the currently available extensions.
*/
var availableExtensions = emptyList<Extension.Available>()
private set(value) {
field = value
availableExtensionsRelay.call(value)
setUpdateFieldOfInstalledExtensions(value)
}
/**
* Relay used to notify the untrusted extensions.
*/
private val untrustedExtensionsRelay = BehaviorRelay.create<List<Extension.Untrusted>>()
/**
* List of the currently untrusted extensions.
*/
var untrustedExtensions = emptyList<Extension.Untrusted>()
private set(value) {
field = value
untrustedExtensionsRelay.call(value)
}
/**
* The source manager where the sources of the extensions are added.
*/
private lateinit var sourceManager: SourceManager
/**
* Initializes this manager with the given source manager.
*/
fun init(sourceManager: SourceManager) {
this.sourceManager = sourceManager
initExtensions()
ExtensionInstallReceiver(InstallationListener()).register(context)
}
/**
* Loads and registers the installed extensions.
*/
private fun initExtensions() {
val extensions = ExtensionLoader.loadExtensions(context)
installedExtensions = extensions
.filterIsInstance<LoadResult.Success>()
.map { it.extension }
installedExtensions
.flatMap { it.sources }
// overwrite is needed until the bundled sources are removed
.forEach { sourceManager.registerSource(it, true) }
untrustedExtensions = extensions
.filterIsInstance<LoadResult.Untrusted>()
.map { it.extension }
}
/**
* Returns the relay of the installed extensions as an observable.
*/
fun getInstalledExtensionsObservable(): Observable<List<Extension.Installed>> {
return installedExtensionsRelay.asObservable()
}
/**
* Returns the relay of the available extensions as an observable.
*/
fun getAvailableExtensionsObservable(): Observable<List<Extension.Available>> {
if (!availableExtensionsRelay.hasValue()) {
findAvailableExtensions()
}
return availableExtensionsRelay.asObservable()
}
/**
* Returns the relay of the untrusted extensions as an observable.
*/
fun getUntrustedExtensionsObservable(): Observable<List<Extension.Untrusted>> {
return untrustedExtensionsRelay.asObservable()
}
/**
* Finds the available extensions in the [api] and updates [availableExtensions].
*/
fun findAvailableExtensions() {
api.findExtensions()
.onErrorReturn { emptyList() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { availableExtensions = it }
}
/**
* Sets the update field of the installed extensions with the given [availableExtensions].
*
* @param availableExtensions The list of extensions given by the [api].
*/
private fun setUpdateFieldOfInstalledExtensions(availableExtensions: List<Extension.Available>) {
val mutInstalledExtensions = installedExtensions.toMutableList()
var changed = false
for ((index, installedExt) in mutInstalledExtensions.withIndex()) {
val pkgName = installedExt.pkgName
val availableExt = availableExtensions.find { it.pkgName == pkgName } ?: continue
val hasUpdate = availableExt.versionCode > installedExt.versionCode
if (installedExt.hasUpdate != hasUpdate) {
mutInstalledExtensions[index] = installedExt.copy(hasUpdate = hasUpdate)
changed = true
}
}
if (changed) {
installedExtensions = mutInstalledExtensions
}
}
/**
* Returns an observable of the installation process for the given extension. It will complete
* once the extension is installed or throws an error. The process will be canceled if
* unsubscribed before its completion.
*
* @param extension The extension to be installed.
*/
fun installExtension(extension: Extension.Available): Observable<InstallStep> {
return installer.downloadAndInstall(api.getApkUrl(extension), extension)
}
/**
* Returns an observable of the installation process for the given extension. It will complete
* once the extension is updated or throws an error. The process will be canceled if
* unsubscribed before its completion.
*
* @param extension The extension to be updated.
*/
fun updateExtension(extension: Extension.Installed): Observable<InstallStep> {
val availableExt = availableExtensions.find { it.pkgName == extension.pkgName }
?: return Observable.empty()
return installExtension(availableExt)
}
/**
* Uninstalls the extension that matches the given package name.
*
* @param pkgName The package name of the application to uninstall.
*/
fun uninstallExtension(pkgName: String) {
installer.uninstallApk(pkgName)
}
/**
* Adds the given signature to the list of trusted signatures. It also loads in background the
* extensions that match this signature.
*
* @param signature The signature to whitelist.
*/
fun trustSignature(signature: String) {
val untrustedSignatures = untrustedExtensions.map { it.signatureHash }.toSet()
if (signature !in untrustedSignatures) return
ExtensionLoader.trustedSignatures += signature
val preference = preferences.trustedSignatures()
preference.set(preference.getOrDefault() + signature)
val nowTrustedExtensions = untrustedExtensions.filter { it.signatureHash == signature }
untrustedExtensions -= nowTrustedExtensions
val ctx = context
launchNow {
nowTrustedExtensions
.map { extension ->
async { ExtensionLoader.loadExtensionFromPkgName(ctx, extension.pkgName) }
}
.map { it.await() }
.forEach { result ->
if (result is LoadResult.Success) {
registerNewExtension(result.extension)
}
}
}
}
/**
* Registers the given extension in this and the source managers.
*
* @param extension The extension to be registered.
*/
private fun registerNewExtension(extension: Extension.Installed) {
installedExtensions += extension
extension.sources.forEach { sourceManager.registerSource(it) }
}
/**
* Registers the given updated extension in this and the source managers previously removing
* the outdated ones.
*
* @param extension The extension to be registered.
*/
private fun registerUpdatedExtension(extension: Extension.Installed) {
val mutInstalledExtensions = installedExtensions.toMutableList()
val oldExtension = mutInstalledExtensions.find { it.pkgName == extension.pkgName }
if (oldExtension != null) {
mutInstalledExtensions -= oldExtension
extension.sources.forEach { sourceManager.unregisterSource(it) }
}
mutInstalledExtensions += extension
installedExtensions = mutInstalledExtensions
extension.sources.forEach { sourceManager.registerSource(it) }
}
/**
* Unregisters the extension in this and the source managers given its package name. Note this
* method is called for every uninstalled application in the system.
*
* @param pkgName The package name of the uninstalled application.
*/
private fun unregisterExtension(pkgName: String) {
val installedExtension = installedExtensions.find { it.pkgName == pkgName }
if (installedExtension != null) {
installedExtensions -= installedExtension
installedExtension.sources.forEach { sourceManager.unregisterSource(it) }
}
val untrustedExtension = untrustedExtensions.find { it.pkgName == pkgName }
if (untrustedExtension != null) {
untrustedExtensions -= untrustedExtension
}
}
/**
* Listener which receives events of the extensions being installed, updated or removed.
*/
private inner class InstallationListener : ExtensionInstallReceiver.Listener {
override fun onExtensionInstalled(extension: Extension.Installed) {
registerNewExtension(extension.withUpdateCheck())
installer.onApkInstalled(extension.pkgName)
}
override fun onExtensionUpdated(extension: Extension.Installed) {
registerUpdatedExtension(extension.withUpdateCheck())
installer.onApkInstalled(extension.pkgName)
}
override fun onExtensionUntrusted(extension: Extension.Untrusted) {
untrustedExtensions += extension
installer.onApkInstalled(extension.pkgName)
}
override fun onPackageUninstalled(pkgName: String) {
unregisterExtension(pkgName)
}
}
/**
* Extension method to set the update field of an installed extension.
*/
private fun Extension.Installed.withUpdateCheck(): Extension.Installed {
val availableExt = availableExtensions.find { it.pkgName == pkgName }
if (availableExt != null && availableExt.versionCode > versionCode) {
return copy(hasUpdate = true)
}
return this
}
}

View File

@ -0,0 +1,55 @@
package eu.kanade.tachiyomi.extension.api
import com.github.salomonbrys.kotson.fromJson
import com.github.salomonbrys.kotson.get
import com.github.salomonbrys.kotson.int
import com.github.salomonbrys.kotson.string
import com.google.gson.Gson
import com.google.gson.JsonArray
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.injectLazy
internal class ExtensionGithubApi {
private val network: NetworkHelper by injectLazy()
private val client get() = network.client
private val gson: Gson by injectLazy()
private val repoUrl = "https://raw.githubusercontent.com/inorichi/tachiyomi-extensions/repo"
fun findExtensions(): Observable<List<Extension.Available>> {
val call = GET("$repoUrl/index.json")
return client.newCall(call).asObservableSuccess()
.map(::parseResponse)
}
private fun parseResponse(response: Response): List<Extension.Available> {
val text = response.body()?.use { it.string() } ?: return emptyList()
val json = gson.fromJson<JsonArray>(text)
return json.map { element ->
val name = element["name"].string.substringAfter("Tachiyomi: ")
val pkgName = element["pkg"].string
val apkName = element["apk"].string
val versionName = element["version"].string
val versionCode = element["code"].int
val lang = element["lang"].string
val icon = "$repoUrl/icon/${apkName.replace(".apk", ".png")}"
Extension.Available(name, pkgName, versionName, versionCode, lang, apkName, icon)
}
}
fun getApkUrl(extension: Extension.Available): String {
return "$repoUrl/apk/${extension.apkName}"
}
}

View File

@ -0,0 +1,36 @@
package eu.kanade.tachiyomi.extension.model
import eu.kanade.tachiyomi.source.Source
sealed class Extension {
abstract val name: String
abstract val pkgName: String
abstract val versionName: String
abstract val versionCode: Int
abstract val lang: String?
data class Installed(override val name: String,
override val pkgName: String,
override val versionName: String,
override val versionCode: Int,
val sources: List<Source>,
override val lang: String,
val hasUpdate: Boolean = false) : Extension()
data class Available(override val name: String,
override val pkgName: String,
override val versionName: String,
override val versionCode: Int,
override val lang: String,
val apkName: String,
val iconUrl: String) : Extension()
data class Untrusted(override val name: String,
override val pkgName: String,
override val versionName: String,
override val versionCode: Int,
val signatureHash: String,
override val lang: String? = null) : Extension()
}

View File

@ -0,0 +1,9 @@
package eu.kanade.tachiyomi.extension.model
enum class InstallStep {
Pending, Downloading, Installing, Installed, Error;
fun isCompleted(): Boolean {
return this == Installed || this == Error
}
}

View File

@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.extension.model
sealed class LoadResult {
class Success(val extension: Extension.Installed) : LoadResult()
class Untrusted(val extension: Extension.Untrusted) : LoadResult()
class Error(val message: String? = null) : LoadResult() {
constructor(exception: Throwable) : this(exception.message)
}
}

View File

@ -0,0 +1,114 @@
package eu.kanade.tachiyomi.extension.util
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult
import eu.kanade.tachiyomi.util.launchNow
import kotlinx.coroutines.experimental.async
/**
* Broadcast receiver that listens for the system's packages installed, updated or removed, and only
* notifies the given [listener] when the package is an extension.
*
* @param listener The listener that should be notified of extension installation events.
*/
internal class ExtensionInstallReceiver(private val listener: Listener) :
BroadcastReceiver() {
/**
* Registers this broadcast receiver
*/
fun register(context: Context) {
context.registerReceiver(this, filter)
}
/**
* Returns the intent filter this receiver should subscribe to.
*/
private val filter get() = IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
}
/**
* Called when one of the events of the [filter] is received. When the package is an extension,
* it's loaded in background and it notifies the [listener] when finished.
*/
override fun onReceive(context: Context, intent: Intent?) {
if (intent == null) return
when (intent.action) {
Intent.ACTION_PACKAGE_ADDED -> {
if (!isReplacing(intent)) launchNow {
val result = getExtensionFromIntent(context, intent)
when (result) {
is LoadResult.Success -> listener.onExtensionInstalled(result.extension)
is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension)
}
}
}
Intent.ACTION_PACKAGE_REPLACED -> {
launchNow {
val result = getExtensionFromIntent(context, intent)
when (result) {
is LoadResult.Success -> listener.onExtensionUpdated(result.extension)
// Not needed as a package can't be upgraded if the signature is different
is LoadResult.Untrusted -> {}
}
}
}
Intent.ACTION_PACKAGE_REMOVED -> {
if (!isReplacing(intent)) {
val pkgName = getPackageNameFromIntent(intent)
if (pkgName != null) {
listener.onPackageUninstalled(pkgName)
}
}
}
}
}
/**
* Returns true if this package is performing an update.
*
* @param intent The intent that triggered the event.
*/
private fun isReplacing(intent: Intent): Boolean {
return intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)
}
/**
* Returns the extension triggered by the given intent.
*
* @param context The application context.
* @param intent The intent containing the package name of the extension.
*/
private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): LoadResult {
val pkgName = getPackageNameFromIntent(intent) ?:
return LoadResult.Error("Package name not found")
return async { ExtensionLoader.loadExtensionFromPkgName(context, pkgName) }.await()
}
/**
* Returns the package name of the installed, updated or removed application.
*/
private fun getPackageNameFromIntent(intent: Intent?): String? {
return intent?.data?.encodedSchemeSpecificPart ?: return null
}
/**
* Listener that receives extension installation events.
*/
interface Listener {
fun onExtensionInstalled(extension: Extension.Installed)
fun onExtensionUpdated(extension: Extension.Installed)
fun onExtensionUntrusted(extension: Extension.Untrusted)
fun onPackageUninstalled(pkgName: String)
}
}

View File

@ -0,0 +1,221 @@
package eu.kanade.tachiyomi.extension.util
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import timber.log.Timber
import java.util.concurrent.TimeUnit
/**
* The installer which installs, updates and uninstalls the extensions.
*
* @param context The application context.
*/
internal class ExtensionInstaller(private val context: Context) {
/**
* The system's download manager
*/
private val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
/**
* The broadcast receiver which listens to download completion events.
*/
private val downloadReceiver = DownloadCompletionReceiver()
/**
* The currently requested downloads, with the package name (unique id) as key, and the id
* returned by the download manager.
*/
private val activeDownloads = hashMapOf<String, Long>()
/**
* Relay used to notify the installation step of every download.
*/
private val downloadsRelay = PublishRelay.create<Pair<Long, InstallStep>>()
/**
* Adds the given extension to the downloads queue and returns an observable containing its
* step in the installation process.
*
* @param url The url of the apk.
* @param extension The extension to install.
*/
fun downloadAndInstall(url: String, extension: Extension) = Observable.defer {
val pkgName = extension.pkgName
val oldDownload = activeDownloads[pkgName]
if (oldDownload != null) {
deleteDownload(pkgName)
}
// Register the receiver after removing (and unregistering) the previous download
downloadReceiver.register()
val request = DownloadManager.Request(Uri.parse(url))
.setTitle(extension.name)
.setMimeType(APK_MIME)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
val id = downloadManager.enqueue(request)
activeDownloads.put(pkgName, id)
downloadsRelay.filter { it.first == id }
.map { it.second }
// Poll download status
.mergeWith(pollStatus(id))
// Force an error if the download takes more than 3 minutes
.mergeWith(Observable.timer(3, TimeUnit.MINUTES).map { InstallStep.Error })
// Stop when the application is installed or errors
.takeUntil { it.isCompleted() }
// Always notify on main thread
.observeOn(AndroidSchedulers.mainThread())
// Always remove the download when unsubscribed
.doOnUnsubscribe { deleteDownload(pkgName) }
}
/**
* Returns an observable that polls the given download id for its status every second, as the
* manager doesn't have any notification system. It'll stop once the download finishes.
*
* @param id The id of the download to poll.
*/
private fun pollStatus(id: Long): Observable<InstallStep> {
val query = DownloadManager.Query().setFilterById(id)
return Observable.interval(0, 1, TimeUnit.SECONDS)
// Get the current download status
.map {
downloadManager.query(query).use { cursor ->
cursor.moveToFirst()
cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))
}
}
// Ignore duplicate results
.distinctUntilChanged()
// Stop polling when the download fails or finishes
.takeUntil { it == DownloadManager.STATUS_SUCCESSFUL || it == DownloadManager.STATUS_FAILED }
// Map to our model
.flatMap { status ->
when (status) {
DownloadManager.STATUS_PENDING -> Observable.just(InstallStep.Pending)
DownloadManager.STATUS_RUNNING -> Observable.just(InstallStep.Downloading)
else -> Observable.empty()
}
}
}
/**
* Starts an intent to install the extension at the given uri.
*
* @param uri The uri of the extension to install.
*/
fun installApk(uri: Uri) {
val intent = Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, APK_MIME)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
context.startActivity(intent)
}
/**
* Starts an intent to uninstall the extension by the given package name.
*
* @param pkgName The package name of the extension to uninstall
*/
fun uninstallApk(pkgName: String) {
val packageUri = Uri.parse("package:$pkgName")
val uninstallIntent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, packageUri)
context.startActivity(uninstallIntent)
}
/**
* Called when an extension is installed, allowing to update its installation step.
*
* @param pkgName The package name of the installed application.
*/
fun onApkInstalled(pkgName: String) {
val id = activeDownloads[pkgName] ?: return
downloadsRelay.call(id to InstallStep.Installed)
}
/**
* Deletes the download for the given package name.
*
* @param pkgName The package name of the download to delete.
*/
fun deleteDownload(pkgName: String) {
val downloadId = activeDownloads.remove(pkgName)
if (downloadId != null) {
downloadManager.remove(downloadId)
}
if (activeDownloads.isEmpty()) {
downloadReceiver.unregister()
}
}
/**
* Receiver that listens to download status events.
*/
private inner class DownloadCompletionReceiver : BroadcastReceiver() {
/**
* Whether this receiver is currently registered.
*/
private var isRegistered = false
/**
* Registers this receiver if it's not already.
*/
fun register() {
if (isRegistered) return
isRegistered = true
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
context.registerReceiver(this, filter)
}
/**
* Unregisters this receiver if it's not already.
*/
fun unregister() {
if (!isRegistered) return
isRegistered = false
context.unregisterReceiver(this)
}
/**
* Called when a download event is received. It looks for the download in the current active
* downloads and notifies its installation step.
*/
override fun onReceive(context: Context, intent: Intent?) {
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0) ?: return
// Avoid events for downloads we didn't request
if (id !in activeDownloads.values) return
val uri = downloadManager.getUriForDownloadedFile(id)
if (uri != null) {
downloadsRelay.call(id to InstallStep.Installing)
installApk(uri)
} else {
Timber.e("Couldn't locate downloaded APK")
downloadsRelay.call(id to InstallStep.Error)
}
}
}
private companion object {
const val APK_MIME = "application/vnd.android.package-archive"
}
}

View File

@ -0,0 +1,172 @@
package eu.kanade.tachiyomi.extension.util
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import dalvik.system.PathClassLoader
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.util.Hash
import kotlinx.coroutines.experimental.async
import kotlinx.coroutines.experimental.runBlocking
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* Class that handles the loading of the extensions installed in the system.
*/
@SuppressLint("PackageManagerGetSignatures")
internal object ExtensionLoader {
private const val EXTENSION_FEATURE = "tachiyomi.extension"
private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
private const val LIB_VERSION_MIN = 1
private const val LIB_VERSION_MAX = 1
private const val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES
/**
* List of the trusted signatures.
*/
var trustedSignatures = mutableSetOf<String>() +
Injekt.get<PreferencesHelper>().trustedSignatures().getOrDefault() +
// inorichi's key
"7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23"
/**
* Return a list of all the installed extensions initialized concurrently.
*
* @param context The application context.
*/
fun loadExtensions(context: Context): List<LoadResult> {
val pkgManager = context.packageManager
val installedPkgs = pkgManager.getInstalledPackages(PACKAGE_FLAGS)
val extPkgs = installedPkgs.filter { isPackageAnExtension(it) }
if (extPkgs.isEmpty()) return emptyList()
// Load each extension concurrently and wait for completion
return runBlocking {
val deferred = extPkgs.map {
async { loadExtension(context, it.packageName, it) }
}
deferred.map { it.await() }
}
}
/**
* Attempts to load an extension from the given package name. It checks if the extension
* contains the required feature flag before trying to load it.
*/
fun loadExtensionFromPkgName(context: Context, pkgName: String): LoadResult {
val pkgInfo = context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS)
if (!isPackageAnExtension(pkgInfo)) {
return LoadResult.Error("Tried to load a package that wasn't a extension")
}
return loadExtension(context, pkgName, pkgInfo)
}
/**
* Loads an extension given its package name.
*
* @param context The application context.
* @param pkgName The package name of the extension to load.
* @param pkgInfo The package info of the extension.
*/
private fun loadExtension(context: Context, pkgName: String, pkgInfo: PackageInfo): LoadResult {
val pkgManager = context.packageManager
val appInfo = pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA)
val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ")
val versionName = pkgInfo.versionName
val versionCode = pkgInfo.versionCode
// Validate lib version
val majorLibVersion = versionName.substringBefore('.').toInt()
if (majorLibVersion < LIB_VERSION_MIN || majorLibVersion > LIB_VERSION_MAX) {
val exception = Exception("Lib version is $majorLibVersion, while only versions " +
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed")
Timber.w(exception)
return LoadResult.Error(exception)
}
val signatureHash = getSignatureHash(pkgInfo)
if (signatureHash == null) {
return LoadResult.Error("Package $pkgName isn't signed")
} else if (signatureHash !in trustedSignatures) {
val extension = Extension.Untrusted(extName, pkgName, versionName, versionCode, signatureHash)
Timber.w("Extension $pkgName isn't trusted")
return LoadResult.Untrusted(extension)
}
val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader)
val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)
.split(";")
.map {
val sourceClass = it.trim()
if (sourceClass.startsWith("."))
pkgInfo.packageName + sourceClass
else
sourceClass
}
.flatMap {
try {
val obj = Class.forName(it, false, classLoader).newInstance()
when (obj) {
is Source -> listOf(obj)
is SourceFactory -> obj.createSources()
else -> throw Exception("Unknown source class type! ${obj.javaClass}")
}
} catch (e: Throwable) {
Timber.e(e, "Extension load error: $extName.")
return LoadResult.Error(e)
}
}
val langs = sources.filterIsInstance<CatalogueSource>()
.map { it.lang }
.toSet()
val lang = when (langs.size) {
0 -> ""
1 -> langs.first()
else -> "all"
}
val extension = Extension.Installed(extName, pkgName, versionName, versionCode, sources, lang)
return LoadResult.Success(extension)
}
/**
* Returns true if the given package is an extension.
*
* @param pkgInfo The package info of the application.
*/
private fun isPackageAnExtension(pkgInfo: PackageInfo): Boolean {
return pkgInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }
}
/**
* Returns the signature hash of the package or null if it's not signed.
*
* @param pkgInfo The package info of the application.
*/
private fun getSignatureHash(pkgInfo: PackageInfo): String? {
val signatures = pkgInfo.signatures
return if (signatures != null && !signatures.isEmpty()) {
Hash.sha256(signatures.first().toByteArray())
} else {
null
}
}
}

View File

@ -0,0 +1,8 @@
package eu.kanade.tachiyomi.source
import android.support.v7.preference.PreferenceScreen
interface ConfigurableSource : Source {
fun setupPreferenceScreen(screen: PreferenceScreen)
}

View File

@ -1,30 +1,19 @@
package eu.kanade.tachiyomi.source
import android.Manifest.permission.READ_EXTERNAL_STORAGE
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.os.Environment
import dalvik.system.PathClassLoader
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.YamlHttpSource
import eu.kanade.tachiyomi.source.online.english.*
import eu.kanade.tachiyomi.source.online.german.WieManga
import eu.kanade.tachiyomi.source.online.russian.Mangachan
import eu.kanade.tachiyomi.source.online.russian.Mintmanga
import eu.kanade.tachiyomi.source.online.russian.Readmanga
import eu.kanade.tachiyomi.util.hasPermission
import org.yaml.snakeyaml.Yaml
import timber.log.Timber
import java.io.File
open class SourceManager(private val context: Context) {
private val sourcesMap = mutableMapOf<Long, Source>()
init {
createSources()
createInternalSources().forEach { registerSource(it) }
}
open fun get(sourceKey: Long): Source? {
@ -35,18 +24,16 @@ open class SourceManager(private val context: Context) {
fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>()
private fun createSources() {
createExtensionSources().forEach { registerSource(it) }
createYamlSources().forEach { registerSource(it) }
createInternalSources().forEach { registerSource(it) }
}
private fun registerSource(source: Source, overwrite: Boolean = false) {
internal fun registerSource(source: Source, overwrite: Boolean = false) {
if (overwrite || !sourcesMap.containsKey(source.id)) {
sourcesMap.put(source.id, source)
}
}
internal fun unregisterSource(source: Source) {
sourcesMap.remove(source.id)
}
private fun createInternalSources(): List<Source> = listOf(
LocalSource(context),
Batoto(),
@ -60,92 +47,4 @@ open class SourceManager(private val context: Context) {
Mangasee(),
WieManga()
)
private fun createYamlSources(): List<Source> {
val sources = mutableListOf<Source>()
val parsersDir = File(Environment.getExternalStorageDirectory().absolutePath +
File.separator + context.getString(R.string.app_name), "parsers")
if (parsersDir.exists() && context.hasPermission(READ_EXTERNAL_STORAGE)) {
val yaml = Yaml()
for (file in parsersDir.listFiles().filter { it.extension == "yml" }) {
try {
val map = file.inputStream().use { yaml.loadAs(it, Map::class.java) }
sources.add(YamlHttpSource(map))
} catch (e: Exception) {
Timber.e("Error loading source from file. Bad format?", e)
}
}
}
return sources
}
private fun createExtensionSources(): List<Source> {
val pkgManager = context.packageManager
val flags = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES
val installedPkgs = pkgManager.getInstalledPackages(flags)
val extPkgs = installedPkgs.filter { it.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE } }
val sources = mutableListOf<Source>()
for (pkgInfo in extPkgs) {
val appInfo = pkgManager.getApplicationInfo(pkgInfo.packageName,
PackageManager.GET_META_DATA) ?: continue
val extName = pkgManager.getApplicationLabel(appInfo).toString()
.substringAfter("Tachiyomi: ")
val version = pkgInfo.versionName
val sourceClasses = appInfo.metaData.getString(METADATA_SOURCE_CLASS)
.split(";")
.map {
val sourceClass = it.trim()
if(sourceClass.startsWith("."))
pkgInfo.packageName + sourceClass
else
sourceClass
}
val extension = Extension(extName, appInfo, version, sourceClasses)
try {
sources += loadExtension(extension)
} catch (e: Exception) {
Timber.e("Extension load error: $extName.", e)
} catch (e: LinkageError) {
Timber.e("Extension load error: $extName.", e)
}
}
return sources
}
private fun loadExtension(ext: Extension): List<Source> {
// Validate lib version
val majorLibVersion = ext.version.substringBefore('.').toInt()
if (majorLibVersion < LIB_VERSION_MIN || majorLibVersion > LIB_VERSION_MAX) {
throw Exception("Lib version is $majorLibVersion, while only versions "
+ "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed")
}
val classLoader = PathClassLoader(ext.appInfo.sourceDir, null, context.classLoader)
return ext.sourceClasses.flatMap {
val obj = Class.forName(it, false, classLoader).newInstance()
when(obj) {
is Source -> listOf(obj)
is SourceFactory -> obj.createSources()
else -> throw Exception("Unknown source class type!")
}
}
}
class Extension(val name: String,
val appInfo: ApplicationInfo,
val version: String,
val sourceClasses: List<String>)
private companion object {
const val EXTENSION_FEATURE = "tachiyomi.extension"
const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
const val LIB_VERSION_MIN = 1
const val LIB_VERSION_MAX = 1
}
}

View File

@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess
@ -28,10 +27,12 @@ abstract class HttpSource : CatalogueSource {
*/
protected val network: NetworkHelper by injectLazy()
/**
* Preferences helper.
*/
protected val preferences: PreferencesHelper by injectLazy()
// /**
// * Preferences that a source may need.
// */
// val preferences: SharedPreferences by lazy {
// Injekt.get<Application>().getSharedPreferences("source_$id", Context.MODE_PRIVATE)
// }
/**
* Base url of the website without the trailing slash, like: http://mysite.com

View File

@ -1,231 +0,0 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.util.asJsoup
import eu.kanade.tachiyomi.util.attrOrText
import okhttp3.Request
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import java.text.SimpleDateFormat
import java.util.*
class YamlHttpSource(mappings: Map<*, *>) : HttpSource() {
val map = YamlSourceNode(mappings)
override val name: String
get() = map.name
override val baseUrl = map.host.let {
if (it.endsWith("/")) it.dropLast(1) else it
}
override val lang = map.lang.toLowerCase()
override val supportsLatest = map.latestupdates != null
override val client = when (map.client) {
"cloudflare" -> network.cloudflareClient
else -> network.client
}
override val id = map.id.let {
(it as? Int ?: (lang.toUpperCase().hashCode() + 31 * it.hashCode()) and 0x7fffffff).toLong()
}
// Ugly, but needed after the changes
var popularNextPage: String? = null
var searchNextPage: String? = null
var latestNextPage: String? = null
override fun popularMangaRequest(page: Int): Request {
val url = if (page == 1) {
popularNextPage = null
map.popular.url
} else {
popularNextPage!!
}
return when (map.popular.method?.toLowerCase()) {
"post" -> POST(url, headers, map.popular.createForm())
else -> GET(url, headers)
}
}
override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(map.popular.manga_css).map { element ->
SManga.create().apply {
title = element.text()
setUrlWithoutDomain(element.attr("href"))
}
}
popularNextPage = map.popular.next_url_css?.let { selector ->
document.select(selector).first()?.absUrl("href")
}
return MangasPage(mangas, popularNextPage != null)
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = if (page == 1) {
searchNextPage = null
map.search.url.replace("\$query", query)
} else {
searchNextPage!!
}
return when (map.search.method?.toLowerCase()) {
"post" -> POST(url, headers, map.search.createForm())
else -> GET(url, headers)
}
}
override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(map.search.manga_css).map { element ->
SManga.create().apply {
title = element.text()
setUrlWithoutDomain(element.attr("href"))
}
}
searchNextPage = map.search.next_url_css?.let { selector ->
document.select(selector).first()?.absUrl("href")
}
return MangasPage(mangas, searchNextPage != null)
}
override fun latestUpdatesRequest(page: Int): Request {
val url = if (page == 1) {
latestNextPage = null
map.latestupdates!!.url
} else {
latestNextPage!!
}
return when (map.latestupdates!!.method?.toLowerCase()) {
"post" -> POST(url, headers, map.latestupdates.createForm())
else -> GET(url, headers)
}
}
override fun latestUpdatesParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(map.latestupdates!!.manga_css).map { element ->
SManga.create().apply {
title = element.text()
setUrlWithoutDomain(element.attr("href"))
}
}
popularNextPage = map.latestupdates.next_url_css?.let { selector ->
document.select(selector).first()?.absUrl("href")
}
return MangasPage(mangas, popularNextPage != null)
}
override fun mangaDetailsParse(response: Response): SManga {
val document = response.asJsoup()
val manga = SManga.create()
with(map.manga) {
val pool = parts.get(document)
manga.author = author?.process(document, pool)
manga.artist = artist?.process(document, pool)
manga.description = summary?.process(document, pool)
manga.thumbnail_url = cover?.process(document, pool)
manga.genre = genres?.process(document, pool)
manga.status = status?.getStatus(document, pool) ?: SManga.UNKNOWN
}
return manga
}
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
val chapters = mutableListOf<SChapter>()
with(map.chapters) {
val pool = emptyMap<String, Element>()
val dateFormat = SimpleDateFormat(date?.format, Locale.ENGLISH)
for (element in document.select(chapter_css)) {
val chapter = SChapter.create()
element.select(title).first().let {
chapter.name = it.text()
chapter.setUrlWithoutDomain(it.attr("href"))
}
val dateElement = element.select(date?.select).first()
chapter.date_upload = date?.getDate(dateElement, pool, dateFormat)?.time ?: 0
chapters.add(chapter)
}
}
return chapters
}
override fun pageListParse(response: Response): List<Page> {
val body = response.body()!!.string()
val url = response.request().url().toString()
val pages = mutableListOf<Page>()
val document by lazy { Jsoup.parse(body, url) }
with(map.pages) {
// Capture a list of values where page urls will be resolved.
val capturedPages = if (pages_regex != null)
pages_regex!!.toRegex().findAll(body).map { it.value }.toList()
else if (pages_css != null)
document.select(pages_css).map { it.attrOrText(pages_attr!!) }
else
null
// For each captured value, obtain the url and create a new page.
capturedPages?.forEach { value ->
// If the captured value isn't an url, we have to use replaces with the chapter url.
val pageUrl = if (replace != null && replacement != null)
url.replace(replace!!.toRegex(), replacement!!.replace("\$value", value))
else
value
pages.add(Page(pages.size, pageUrl))
}
// Capture a list of images.
val capturedImages = if (image_regex != null)
image_regex!!.toRegex().findAll(body).map { it.groups[1]?.value }.toList()
else if (image_css != null)
document.select(image_css).map { it.absUrl(image_attr) }
else
null
// Assign the image url to each page
capturedImages?.forEachIndexed { i, url ->
val page = pages.getOrElse(i) { Page(i, "").apply { pages.add(this) } }
page.imageUrl = url
}
}
return pages
}
override fun imageUrlParse(response: Response): String {
val body = response.body()!!.string()
val url = response.request().url().toString()
with(map.pages) {
return if (image_regex != null)
image_regex!!.toRegex().find(body)!!.groups[1]!!.value
else if (image_css != null)
Jsoup.parse(body, url).select(image_css).first().absUrl(image_attr)
else
throw Exception("image_regex and image_css are null")
}
}
}

View File

@ -1,234 +0,0 @@
@file:Suppress("UNCHECKED_CAST")
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.model.SManga
import okhttp3.FormBody
import okhttp3.RequestBody
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
private fun toMap(map: Any?) = map as? Map<String, Any?>
class YamlSourceNode(uncheckedMap: Map<*, *>) {
val map = toMap(uncheckedMap)!!
val id: Any by map
val name: String by map
val host: String by map
val lang: String by map
val client: String?
get() = map["client"] as? String
val popular = PopularNode(toMap(map["popular"])!!)
val latestupdates = toMap(map["latest_updates"])?.let { LatestUpdatesNode(it) }
val search = SearchNode(toMap(map["search"])!!)
val manga = MangaNode(toMap(map["manga"])!!)
val chapters = ChaptersNode(toMap(map["chapters"])!!)
val pages = PagesNode(toMap(map["pages"])!!)
}
interface RequestableNode {
val map: Map<String, Any?>
val url: String
get() = map["url"] as String
val method: String?
get() = map["method"] as? String
val payload: Map<String, String>?
get() = map["payload"] as? Map<String, String>
fun createForm(): RequestBody {
return FormBody.Builder().apply {
payload?.let {
for ((key, value) in it) {
add(key, value)
}
}
}.build()
}
}
class PopularNode(override val map: Map<String, Any?>): RequestableNode {
val manga_css: String by map
val next_url_css: String?
get() = map["next_url_css"] as? String
}
class LatestUpdatesNode(override val map: Map<String, Any?>): RequestableNode {
val manga_css: String by map
val next_url_css: String?
get() = map["next_url_css"] as? String
}
class SearchNode(override val map: Map<String, Any?>): RequestableNode {
val manga_css: String by map
val next_url_css: String?
get() = map["next_url_css"] as? String
}
class MangaNode(private val map: Map<String, Any?>) {
val parts = CacheNode(toMap(map["parts"]) ?: emptyMap())
val artist = toMap(map["artist"])?.let { SelectableNode(it) }
val author = toMap(map["author"])?.let { SelectableNode(it) }
val summary = toMap(map["summary"])?.let { SelectableNode(it) }
val status = toMap(map["status"])?.let { StatusNode(it) }
val genres = toMap(map["genres"])?.let { SelectableNode(it) }
val cover = toMap(map["cover"])?.let { CoverNode(it) }
}
class ChaptersNode(private val map: Map<String, Any?>) {
val chapter_css: String by map
val title: String by map
val date = toMap(toMap(map["date"]))?.let { DateNode(it) }
}
class CacheNode(private val map: Map<String, Any?>) {
fun get(document: Document) = map.mapValues { document.select(it.value as String).first() }
}
open class SelectableNode(private val map: Map<String, Any?>) {
val select: String by map
val from: String?
get() = map["from"] as? String
open val attr: String?
get() = map["attr"] as? String
val capture: String?
get() = map["capture"] as? String
fun process(document: Element, cache: Map<String, Element>): String {
val parent = from?.let { cache[it] } ?: document
val node = parent.select(select).first()
var text = attr?.let { node.attr(it) } ?: node.text()
capture?.let {
text = Regex(it).find(text)?.groupValues?.get(1) ?: text
}
return text
}
}
class StatusNode(private val map: Map<String, Any?>) : SelectableNode(map) {
val complete: String?
get() = map["complete"] as? String
val ongoing: String?
get() = map["ongoing"] as? String
val licensed: String?
get() = map["licensed"] as? String
fun getStatus(document: Element, cache: Map<String, Element>): Int {
val text = process(document, cache)
complete?.let {
if (text.contains(it)) return SManga.COMPLETED
}
ongoing?.let {
if (text.contains(it)) return SManga.ONGOING
}
licensed?.let {
if (text.contains(it)) return SManga.LICENSED
}
return SManga.UNKNOWN
}
}
class CoverNode(private val map: Map<String, Any?>) : SelectableNode(map) {
override val attr: String?
get() = map["attr"] as? String ?: "src"
}
class DateNode(private val map: Map<String, Any?>) : SelectableNode(map) {
val format: String by map
fun getDate(document: Element, cache: Map<String, Element>, formatter: SimpleDateFormat): Date {
val text = process(document, cache)
try {
return formatter.parse(text)
} catch (exception: ParseException) {}
for (i in 0..7) {
(map["day$i"] as? List<String>)?.let {
it.find { it.toRegex().containsMatchIn(text) }?.let {
return Calendar.getInstance().apply { add(Calendar.DATE, -i) }.time
}
}
}
return Date(0)
}
}
class PagesNode(private val map: Map<String, Any?>) {
val pages_regex: String?
get() = map["pages_regex"] as? String
val pages_css: String?
get() = map["pages_css"] as? String
val pages_attr: String?
get() = map["pages_attr"] as? String ?: "value"
val replace: String?
get() = map["url_replace"] as? String
val replacement: String?
get() = map["url_replacement"] as? String
val image_regex: String?
get() = map["image_regex"] as? String
val image_css: String?
get() = map["image_css"] as? String
val image_attr: String
get() = map["image_attr"] as? String ?: "src"
}

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.source.online.english
import android.text.Html
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservable
@ -9,14 +10,11 @@ import eu.kanade.tachiyomi.source.online.LoginSource
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup
import eu.kanade.tachiyomi.util.selectText
import okhttp3.FormBody
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.*
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.net.URI
import java.text.ParseException
import java.text.SimpleDateFormat
@ -25,6 +23,9 @@ import java.util.regex.Pattern
class Batoto : ParsedHttpSource(), LoginSource {
// TODO remove
private val preferences: PreferencesHelper by injectLazy()
override val id: Long = 1
override val name = "Batoto"

View File

@ -28,7 +28,7 @@ class SourceDividerItemDecoration(context: Context) : RecyclerView.ItemDecoratio
val top = child.bottom + params.bottomMargin
val bottom = top + divider.intrinsicHeight
val left = parent.paddingLeft + holder.margin
val right = parent.paddingRight + holder.margin
val right = parent.width - parent.paddingRight - holder.margin
divider.setBounds(left, top, right, bottom)
divider.draw(c)

View File

@ -0,0 +1,30 @@
package eu.kanade.tachiyomi.ui.extension
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.getResourceColor
/**
* Adapter that holds the catalogue cards.
*
* @param controller instance of [ExtensionController].
*/
class ExtensionAdapter(val controller: ExtensionController) :
FlexibleAdapter<IFlexible<*>>(null, controller, true) {
val cardBackground = controller.activity!!.getResourceColor(R.attr.background_card)
init {
setDisplayHeadersAtStartUp(true)
}
/**
* Listener for browse item clicks.
*/
val buttonClickListener: ExtensionAdapter.OnButtonClickListener = controller
interface OnButtonClickListener {
fun onButtonClick(position: Int)
}
}

View File

@ -0,0 +1,132 @@
package eu.kanade.tachiyomi.ui.extension
import android.support.v7.widget.LinearLayoutManager
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.jakewharton.rxbinding.support.v4.widget.refreshes
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import kotlinx.android.synthetic.main.extension_controller.*
/**
* Controller to manage the catalogues available in the app.
*/
open class ExtensionController : NucleusController<ExtensionPresenter>(),
ExtensionAdapter.OnButtonClickListener,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
ExtensionTrustDialog.Listener {
/**
* Adapter containing the list of manga from the catalogue.
*/
private var adapter: FlexibleAdapter<IFlexible<*>>? = null
init {
setHasOptionsMenu(true)
}
override fun getTitle(): String? {
return applicationContext?.getString(R.string.label_extensions)
}
override fun createPresenter(): ExtensionPresenter {
return ExtensionPresenter()
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.extension_controller, container, false)
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
ext_swipe_refresh.isRefreshing = true
ext_swipe_refresh.refreshes().subscribeUntilDestroy {
presenter.findAvailableExtensions()
}
// Initialize adapter, scroll listener and recycler views
adapter = ExtensionAdapter(this)
// Create recycler and set adapter.
ext_recycler.layoutManager = LinearLayoutManager(view.context)
ext_recycler.adapter = adapter
ext_recycler.addItemDecoration(ExtensionDividerItemDecoration(view.context))
}
override fun onDestroyView(view: View) {
adapter = null
super.onDestroyView(view)
}
override fun onButtonClick(position: Int) {
val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return
when (extension) {
is Extension.Installed -> {
if (!extension.hasUpdate) {
openDetails(extension)
} else {
presenter.updateExtension(extension)
}
}
is Extension.Available -> {
presenter.installExtension(extension)
}
is Extension.Untrusted -> {
openTrustDialog(extension)
}
}
}
override fun onItemClick(position: Int): Boolean {
val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return false
if (extension is Extension.Installed) {
openDetails(extension)
} else if (extension is Extension.Untrusted) {
openTrustDialog(extension)
}
return false
}
override fun onItemLongClick(position: Int) {
val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return
if (extension is Extension.Installed || extension is Extension.Untrusted) {
uninstallExtension(extension.pkgName)
}
}
private fun openDetails(extension: Extension.Installed) {
val controller = ExtensionDetailsController(extension.pkgName)
router.pushController(controller.withFadeTransaction())
}
private fun openTrustDialog(extension: Extension.Untrusted) {
ExtensionTrustDialog(this, extension.signatureHash, extension.pkgName)
.showDialog(router)
}
fun setExtensions(extensions: List<ExtensionItem>) {
ext_swipe_refresh?.isRefreshing = false
adapter?.updateDataSet(extensions)
}
fun downloadUpdate(item: ExtensionItem) {
adapter?.updateItem(item, item.installStep)
}
override fun trustSignature(signatureHash: String) {
presenter.trustSignature(signatureHash)
}
override fun uninstallExtension(pkgName: String) {
presenter.uninstallExtension(pkgName)
}
}

View File

@ -0,0 +1,190 @@
package eu.kanade.tachiyomi.ui.extension
import android.annotation.SuppressLint
import android.content.Context
import android.os.Bundle
import android.support.v7.preference.*
import android.support.v7.preference.internal.AbstractMultiSelectListPreference
import android.support.v7.widget.DividerItemDecoration
import android.support.v7.widget.DividerItemDecoration.VERTICAL
import android.support.v7.widget.LinearLayoutManager
import android.util.TypedValue
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.jakewharton.rxbinding.view.clicks
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.EmptyPreferenceDataStore
import eu.kanade.tachiyomi.data.preference.SharedPreferencesDataStore
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.LoginSource
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.setting.preferenceCategory
import eu.kanade.tachiyomi.widget.preference.LoginPreference
import eu.kanade.tachiyomi.widget.preference.SourceLoginDialog
import kotlinx.android.synthetic.main.extension_detail_controller.*
@SuppressLint("RestrictedApi")
class ExtensionDetailsController(bundle: Bundle? = null) :
NucleusController<ExtensionDetailsPresenter>(bundle),
PreferenceManager.OnDisplayPreferenceDialogListener,
DialogPreference.TargetFragment,
SourceLoginDialog.Listener {
private var lastOpenPreferencePosition: Int? = null
private var preferenceScreen: PreferenceScreen? = null
constructor(pkgName: String) : this(Bundle().apply {
putString(PKGNAME_KEY, pkgName)
})
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.extension_detail_controller, container, false)
}
override fun createPresenter(): ExtensionDetailsPresenter {
return ExtensionDetailsPresenter(args.getString(PKGNAME_KEY))
}
override fun getTitle(): String? {
return resources?.getString(R.string.label_extension_info)
}
@SuppressLint("PrivateResource")
override fun onViewCreated(view: View) {
super.onViewCreated(view)
val extension = presenter.extension
val context = view.context
extension_title.text = extension.name
extension_version.text = context.getString(R.string.ext_version_info, extension.versionName)
extension_lang.text = context.getString(R.string.ext_language_info, extension.getLocalizedLang(context))
extension_pkg.text = extension.pkgName
extension.getApplicationIcon(context)?.let { extension_icon.setImageDrawable(it) }
extension_uninstall_button.clicks().subscribeUntilDestroy {
presenter.uninstallExtension()
}
val themedContext by lazy { getPreferenceThemeContext() }
val manager = PreferenceManager(themedContext)
manager.preferenceDataStore = EmptyPreferenceDataStore()
manager.onDisplayPreferenceDialogListener = this
val screen = manager.createPreferenceScreen(themedContext)
preferenceScreen = screen
val multiSource = extension.sources.size > 1
for (source in extension.sources) {
if (source is ConfigurableSource) {
addPreferencesForSource(screen, source, multiSource)
}
}
manager.setPreferences(screen)
extension_prefs_recycler.layoutManager = LinearLayoutManager(context)
extension_prefs_recycler.adapter = PreferenceGroupAdapter(screen)
extension_prefs_recycler.addItemDecoration(DividerItemDecoration(context, VERTICAL))
if (screen.preferenceCount == 0) {
extension_prefs_empty_view.show(R.drawable.ic_no_settings,
R.string.ext_empty_preferences)
}
}
override fun onDestroyView(view: View) {
preferenceScreen = null
super.onDestroyView(view)
}
fun onExtensionUninstalled() {
router.popCurrentController()
}
override fun onSaveInstanceState(outState: Bundle) {
lastOpenPreferencePosition?.let { outState.putInt(LASTOPENPREFERENCE_KEY, it) }
super.onSaveInstanceState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
lastOpenPreferencePosition = savedInstanceState.get(LASTOPENPREFERENCE_KEY) as? Int
}
private fun addPreferencesForSource(screen: PreferenceScreen, source: Source, multiSource: Boolean) {
val context = screen.context
// TODO
val dataStore = SharedPreferencesDataStore(/*if (source is HttpSource) {
source.preferences
} else {*/
context.getSharedPreferences("source_${source.id}", Context.MODE_PRIVATE)
/*}*/)
if (source is ConfigurableSource) {
if (multiSource) {
screen.preferenceCategory {
title = source.toString()
}
}
val newScreen = screen.preferenceManager.createPreferenceScreen(context)
source.setupPreferenceScreen(newScreen)
for (i in 0 until newScreen.preferenceCount) {
val pref = newScreen.getPreference(i)
pref.preferenceDataStore = dataStore
pref.order = Int.MAX_VALUE // reset to default order
screen.addPreference(pref)
}
}
}
private fun getPreferenceThemeContext(): Context {
val tv = TypedValue()
activity!!.theme.resolveAttribute(R.attr.preferenceTheme, tv, true)
return ContextThemeWrapper(activity, tv.resourceId)
}
override fun onDisplayPreferenceDialog(preference: Preference) {
if (!isAttached) return
val screen = preference.parent!!
lastOpenPreferencePosition = (0 until screen.preferenceCount).indexOfFirst {
screen.getPreference(it) === preference
}
val f = when (preference) {
is EditTextPreference -> EditTextPreferenceDialogController
.newInstance(preference.getKey())
is ListPreference -> ListPreferenceDialogController
.newInstance(preference.getKey())
is AbstractMultiSelectListPreference -> MultiSelectListPreferenceDialogController
.newInstance(preference.getKey())
else -> throw IllegalArgumentException("Tried to display dialog for unknown " +
"preference type. Did you forget to override onDisplayPreferenceDialog()?")
}
f.targetController = this
f.showDialog(router)
}
override fun findPreference(key: CharSequence?): Preference {
return preferenceScreen!!.getPreference(lastOpenPreferencePosition!!)
}
override fun loginDialogClosed(source: LoginSource) {
val lastOpen = lastOpenPreferencePosition ?: return
(preferenceScreen?.getPreference(lastOpen) as? LoginPreference)?.notifyChanged()
}
private companion object {
const val PKGNAME_KEY = "pkg_name"
const val LASTOPENPREFERENCE_KEY = "last_open_preference"
}
}

View File

@ -0,0 +1,38 @@
package eu.kanade.tachiyomi.ui.extension
import android.os.Bundle
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import rx.android.schedulers.AndroidSchedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionDetailsPresenter(
val pkgName: String,
private val extensionManager: ExtensionManager = Injekt.get()
) : BasePresenter<ExtensionDetailsController>() {
val extension = extensionManager.installedExtensions.first { it.pkgName == pkgName }
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
bindToUninstalledExtension()
}
private fun bindToUninstalledExtension() {
extensionManager.getInstalledExtensionsObservable()
.skip(1)
.filter { extensions -> extensions.none { it.pkgName == pkgName } }
.map { Unit }
.take(1)
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ ->
view.onExtensionUninstalled()
})
}
fun uninstallExtension() {
extensionManager.uninstallExtension(extension.pkgName)
}
}

View File

@ -0,0 +1,44 @@
package eu.kanade.tachiyomi.ui.extension
import android.content.Context
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.support.v7.widget.RecyclerView
import android.view.View
class ExtensionDividerItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
private val divider: Drawable
init {
val a = context.obtainStyledAttributes(intArrayOf(android.R.attr.listDivider))
divider = a.getDrawable(0)
a.recycle()
}
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val childCount = parent.childCount
for (i in 0 until childCount - 1) {
val child = parent.getChildAt(i)
val holder = parent.getChildViewHolder(child)
if (holder is ExtensionHolder &&
parent.getChildViewHolder(parent.getChildAt(i + 1)) is ExtensionHolder) {
val params = child.layoutParams as RecyclerView.LayoutParams
val top = child.bottom + params.bottomMargin
val bottom = top + divider.intrinsicHeight
val left = parent.paddingLeft + holder.margin
val right = parent.width - parent.paddingRight - holder.margin
divider.setBounds(left, top, right, bottom)
divider.draw(c)
}
}
}
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView,
state: RecyclerView.State) {
outRect.set(0, 0, 0, divider.intrinsicHeight)
}
}

View File

@ -0,0 +1,20 @@
package eu.kanade.tachiyomi.ui.extension
import android.annotation.SuppressLint
import android.view.View
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import kotlinx.android.synthetic.main.extension_card_header.*
class ExtensionGroupHolder(view: View, adapter: FlexibleAdapter<*>) :
BaseFlexibleViewHolder(view, adapter, true) {
@SuppressLint("SetTextI18n")
fun bind(item: ExtensionGroupItem) {
title.text = when {
item.installed -> itemView.context.getString(R.string.ext_installed)
else -> itemView.context.getString(R.string.ext_available)
} + " (" + item.size + ")"
}
}

View File

@ -0,0 +1,50 @@
package eu.kanade.tachiyomi.ui.extension
import android.view.View
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractHeaderItem
import eu.kanade.tachiyomi.R
/**
* Item that contains the language header.
*
* @param code The lang code.
*/
data class ExtensionGroupItem(val installed: Boolean, val size: Int) : AbstractHeaderItem<ExtensionGroupHolder>() {
/**
* Returns the layout resource of this item.
*/
override fun getLayoutRes(): Int {
return R.layout.extension_card_header
}
/**
* Creates a new view holder for this item.
*/
override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): ExtensionGroupHolder {
return ExtensionGroupHolder(view, adapter)
}
/**
* Binds this item to the given view holder.
*/
override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: ExtensionGroupHolder,
position: Int, payloads: List<Any?>?) {
holder.bind(this)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is ExtensionGroupItem) {
return installed == other.installed
}
return false
}
override fun hashCode(): Int {
return installed.hashCode()
}
}

View File

@ -0,0 +1,88 @@
package eu.kanade.tachiyomi.ui.extension
import android.view.View
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import eu.kanade.tachiyomi.ui.base.holder.SlicedHolder
import io.github.mthli.slice.Slice
import kotlinx.android.synthetic.main.extension_card_item.*
class ExtensionHolder(view: View, override val adapter: ExtensionAdapter) :
BaseFlexibleViewHolder(view, adapter),
SlicedHolder {
override val slice = Slice(card).apply {
setColor(adapter.cardBackground)
}
override val viewToSlice: View
get() = card
init {
ext_button.setOnClickListener {
adapter.buttonClickListener.onButtonClick(adapterPosition)
}
}
fun bind(item: ExtensionItem) {
val extension = item.extension
setCardEdges(item)
// Set source name
ext_title.text = extension.name
version.text = extension.versionName
lang.text = if (extension !is Extension.Untrusted) {
extension.getLocalizedLang(itemView.context)
} else {
itemView.context.getString(R.string.ext_untrusted).toUpperCase()
}
GlideApp.with(itemView.context).clear(image)
if (extension is Extension.Available) {
GlideApp.with(itemView.context)
.load(extension.iconUrl)
.into(image)
} else {
extension.getApplicationIcon(itemView.context)?.let { image.setImageDrawable(it) }
}
bindButton(item)
}
fun bindButton(item: ExtensionItem) = with(ext_button) {
isEnabled = true
isClickable = true
isActivated = false
val extension = item.extension
val installStep = item.installStep
if (installStep != null) {
setText(when (installStep) {
InstallStep.Pending -> R.string.ext_pending
InstallStep.Downloading -> R.string.ext_downloading
InstallStep.Installing -> R.string.ext_installing
InstallStep.Installed -> R.string.ext_installed
InstallStep.Error -> R.string.action_retry
})
if (installStep != InstallStep.Error) {
isEnabled = false
isClickable = false
}
} else if (extension is Extension.Installed) {
if (extension.hasUpdate) {
isActivated = true
setText(R.string.ext_update)
} else {
setText(R.string.ext_details)
}
} else if (extension is Extension.Untrusted) {
setText(R.string.ext_trust)
} else {
setText(R.string.ext_install)
}
}
}

View File

@ -0,0 +1,59 @@
package eu.kanade.tachiyomi.ui.extension
import android.view.View
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractSectionableItem
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.source.CatalogueSource
/**
* Item that contains source information.
*
* @param source Instance of [CatalogueSource] containing source information.
* @param header The header for this item.
*/
data class ExtensionItem(val extension: Extension,
val header: ExtensionGroupItem? = null,
val installStep: InstallStep? = null) :
AbstractSectionableItem<ExtensionHolder, ExtensionGroupItem>(header) {
/**
* Returns the layout resource of this item.
*/
override fun getLayoutRes(): Int {
return R.layout.extension_card_item
}
/**
* Creates a new view holder for this item.
*/
override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): ExtensionHolder {
return ExtensionHolder(view, adapter as ExtensionAdapter)
}
/**
* Binds this item to the given view holder.
*/
override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: ExtensionHolder,
position: Int, payloads: List<Any?>?) {
if (payloads == null || payloads.isEmpty()) {
holder.bind(this)
} else {
holder.bindButton(this)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return extension.pkgName == (other as ExtensionItem).extension.pkgName
}
override fun hashCode(): Int {
return extension.pkgName.hashCode()
}
}

View File

@ -0,0 +1,130 @@
package eu.kanade.tachiyomi.ui.extension
import android.os.Bundle
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit
private typealias ExtensionTuple
= Triple<List<Extension.Installed>, List<Extension.Untrusted>, List<Extension.Available>>
/**
* Presenter of [ExtensionController].
*/
open class ExtensionPresenter(
private val extensionManager: ExtensionManager = Injekt.get()
) : BasePresenter<ExtensionController>() {
private var extensions = emptyList<ExtensionItem>()
private var currentDownloads = hashMapOf<String, InstallStep>()
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
bindToExtensionsObservable()
}
private fun bindToExtensionsObservable(): Subscription {
val installedObservable = extensionManager.getInstalledExtensionsObservable()
val untrustedObservable = extensionManager.getUntrustedExtensionsObservable()
val availableObservable = extensionManager.getAvailableExtensionsObservable()
.startWith(emptyList<Extension.Available>())
return Observable.combineLatest(installedObservable, untrustedObservable, availableObservable)
{ installed, untrusted, available -> Triple(installed, untrusted, available) }
.debounce(100, TimeUnit.MILLISECONDS)
.map(::toItems)
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache({ view, _ -> view.setExtensions(extensions) })
}
@Synchronized
private fun toItems(tuple: ExtensionTuple): List<ExtensionItem> {
val (installed, untrusted, available) = tuple
val items = mutableListOf<ExtensionItem>()
val installedSorted = installed.sortedWith(compareBy({ !it.hasUpdate }, { it.name }))
val untrustedSorted = untrusted.sortedBy { it.name }
val availableSorted = available
// Filter out already installed extensions
.filter { avail -> installed.none { it.pkgName == avail.pkgName }
&& untrusted.none { it.pkgName == avail.pkgName } }
.sortedBy { it.name }
if (installedSorted.isNotEmpty() || untrustedSorted.isNotEmpty()) {
val header = ExtensionGroupItem(true, installedSorted.size + untrustedSorted.size)
items += installedSorted.map { extension ->
ExtensionItem(extension, header, currentDownloads[extension.pkgName])
}
items += untrustedSorted.map { extension ->
ExtensionItem(extension, header)
}
}
if (availableSorted.isNotEmpty()) {
val header = ExtensionGroupItem(false, availableSorted.size)
items += availableSorted.map { extension ->
ExtensionItem(extension, header, currentDownloads[extension.pkgName])
}
}
this.extensions = items
return items
}
@Synchronized
private fun updateInstallStep(extension: Extension, state: InstallStep): ExtensionItem? {
val extensions = extensions.toMutableList()
val position = extensions.indexOfFirst { it.extension.pkgName == extension.pkgName }
return if (position != -1) {
val item = extensions[position].copy(installStep = state)
extensions[position] = item
this.extensions = extensions
item
} else {
null
}
}
fun installExtension(extension: Extension.Available) {
extensionManager.installExtension(extension).subscribeToInstallUpdate(extension)
}
fun updateExtension(extension: Extension.Installed) {
extensionManager.updateExtension(extension).subscribeToInstallUpdate(extension)
}
private fun Observable<InstallStep>.subscribeToInstallUpdate(extension: Extension) {
this.doOnNext { currentDownloads.put(extension.pkgName, it) }
.doOnUnsubscribe { currentDownloads.remove(extension.pkgName) }
.map { state -> updateInstallStep(extension, state) }
.subscribeWithView({ view, item ->
if (item != null) {
view.downloadUpdate(item)
}
})
}
fun uninstallExtension(pkgName: String) {
extensionManager.uninstallExtension(pkgName)
}
fun findAvailableExtensions() {
extensionManager.findAvailableExtensions()
}
fun trustSignature(signatureHash: String) {
extensionManager.trustSignature(signatureHash)
}
}

View File

@ -0,0 +1,44 @@
package eu.kanade.tachiyomi.ui.extension
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class ExtensionTrustDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T: ExtensionTrustDialog.Listener {
constructor(target: T, signatureHash: String, pkgName: String) : this(Bundle().apply {
putString(SIGNATURE_KEY, signatureHash)
putString(PKGNAME_KEY, pkgName)
}) {
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialDialog.Builder(activity!!)
.title(R.string.untrusted_extension)
.content(R.string.untrusted_extension_message)
.positiveText(R.string.ext_trust)
.negativeText(R.string.ext_uninstall)
.onPositive { _, _ ->
(targetController as? Listener)?.trustSignature(args.getString(SIGNATURE_KEY))
}
.onNegative { _, _ ->
(targetController as? Listener)?.uninstallExtension(args.getString(PKGNAME_KEY))
}
.build()
}
private companion object {
const val SIGNATURE_KEY = "signature_key"
const val PKGNAME_KEY = "pkgname_key"
}
interface Listener {
fun trustSignature(signatureHash: String)
fun uninstallExtension(pkgName: String)
}
}

View File

@ -0,0 +1,28 @@
package eu.kanade.tachiyomi.ui.extension
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.model.Extension
import java.util.*
fun Extension.getLocalizedLang(context: Context): String {
return when (lang) {
null -> ""
"" -> context.getString(R.string.other_source)
"all" -> context.getString(R.string.all_lang)
else -> {
val locale = Locale(lang)
locale.getDisplayName(locale).capitalize()
}
}
}
fun Extension.getApplicationIcon(context: Context): Drawable? {
return try {
context.packageManager.getApplicationIcon(pkgName)
} catch (e: PackageManager.NameNotFoundException) {
null
}
}

View File

@ -16,6 +16,7 @@ import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
import eu.kanade.tachiyomi.ui.base.controller.*
import eu.kanade.tachiyomi.ui.catalogue.CatalogueController
import eu.kanade.tachiyomi.ui.download.DownloadController
import eu.kanade.tachiyomi.ui.extension.ExtensionController
import eu.kanade.tachiyomi.ui.library.LibraryController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersController
@ -80,6 +81,7 @@ class MainActivity : BaseActivity() {
R.id.nav_drawer_recent_updates -> setRoot(RecentChaptersController(), id)
R.id.nav_drawer_recently_read -> setRoot(RecentlyReadController(), id)
R.id.nav_drawer_catalogues -> setRoot(CatalogueController(), id)
R.id.nav_drawer_extensions -> setRoot(ExtensionController(), id)
R.id.nav_drawer_downloads -> {
router.pushController(DownloadController().withFadeTransaction())
}

View File

@ -10,8 +10,6 @@ import android.support.v4.os.EnvironmentCompat
import java.io.File
import java.io.InputStream
import java.net.URLConnection
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
object DiskUtil {
@ -52,16 +50,7 @@ object DiskUtil {
}
fun hashKeyForDisk(key: String): String {
return try {
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
val sb = StringBuilder()
bytes.forEach { byte ->
sb.append(Integer.toHexString(byte.toInt() and 0xFF or 0x100).substring(1, 3))
}
sb.toString()
} catch (e: NoSuchAlgorithmException) {
key.hashCode().toString()
}
return Hash.md5(key)
}
fun getDirectorySize(f: File): Long {

View File

@ -0,0 +1,42 @@
package eu.kanade.tachiyomi.util
import java.security.MessageDigest
object Hash {
private val chars = charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'a', 'b', 'c', 'd', 'e', 'f')
private val MD5 get() = MessageDigest.getInstance("MD5")
private val SHA256 get() = MessageDigest.getInstance("SHA-256")
fun sha256(bytes: ByteArray): String {
return encodeHex(SHA256.digest(bytes))
}
fun sha256(string: String): String {
return sha256(string.toByteArray())
}
fun md5(bytes: ByteArray): String {
return encodeHex(MD5.digest(bytes))
}
fun md5(string: String): String {
return md5(string.toByteArray())
}
private fun encodeHex(data: ByteArray): String {
val l = data.size
val out = CharArray(l shl 1)
var i = 0
var j = 0
while (i < l) {
out[j++] = chars[(240 and data[i].toInt()).ushr(4)]
out[j++] = chars[15 and data[i].toInt()]
i++
}
return String(out)
}
}

View File

@ -12,10 +12,15 @@ class IntListPreference @JvmOverloads constructor(context: Context, attrs: Attri
}
override fun getPersistedString(defaultReturnValue: String?): String? {
if (sharedPreferences.contains(key)) {
return getPersistedInt(0).toString()
// When the underlying preference is using a PreferenceDataStore, there's no way (for now)
// to check if a value is in the store, so we use a most likely unused value as workaround
val defaultIntValue = Int.MIN_VALUE + 1
val value = getPersistedInt(defaultIntValue)
return if (value != defaultIntValue) {
value.toString()
} else {
return defaultReturnValue
defaultReturnValue
}
}
}

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_activated="true" android:color="@color/md_white_1000">
<shape android:shape="rectangle">
<corners android:radius="2dp" />
<solid android:color="@color/colorAccentLight" />
<padding android:left="8dp" android:right="8dp" />
</shape>
</item>
<item android:state_enabled="false" android:color="@color/textColorHintLight">
<shape android:shape="rectangle">
<corners android:radius="2dp" />
<solid android:color="@android:color/transparent" />
<stroke android:color="@color/textColorHintLight" android:width="1dp"/>
<padding android:left="8dp" android:right="8dp" />
</shape>
</item>
<item android:color="@color/colorAccentLight">
<shape android:shape="rectangle" android:color="@color/colorAccentLight">
<corners android:radius="2dp" />
<solid android:color="@android:color/transparent" />
<stroke android:color="@color/colorAccentLight" android:width="1dp"/>
<padding android:left="8dp" android:right="8dp" />
</shape>
</item>
</selector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M20.5,11H19V7c0,-1.1 -0.9,-2 -2,-2h-4V3.5C13,2.12 11.88,1 10.5,1S8,2.12 8,3.5V5H4c-1.1,0 -1.99,0.9 -1.99,2v3.8H3.5c1.49,0 2.7,1.21 2.7,2.7s-1.21,2.7 -2.7,2.7H2V20c0,1.1 0.9,2 2,2h3.8v-1.5c0,-1.49 1.21,-2.7 2.7,-2.7 1.49,0 2.7,1.21 2.7,2.7V22H17c1.1,0 2,-0.9 2,-2v-4h1.5c1.38,0 2.5,-1.12 2.5,-2.5S21.88,11 20.5,11z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M9.165,11.554C9.124,11.767 9.103,11.984 9.103,12.201 9.102,13.233 9.57,14.21 10.374,14.857 11.179,15.504 12.234,15.751 13.242,15.528ZM20.732,17.246L15.89,12.537C15.902,12.426 15.907,12.315 15.907,12.203 15.908,11.3 15.55,10.434 14.912,9.796 14.273,9.158 13.408,8.799 12.505,8.799c-0.142,0 -0.285,0.009 -0.426,0.026L8.866,5.698C9.157,5.523 9.462,5.372 9.778,5.247L10.137,2.671C10.175,2.432 10.381,2.256 10.623,2.256l3.881,0c0.24,-0.001 0.445,0.171 0.486,0.407L15.348,5.247c0.588,0.242 1.14,0.563 1.641,0.953l2.415,-0.972c0.221,-0.089 0.474,-0.001 0.593,0.206l1.94,3.365c0.118,0.209 0.065,0.473 -0.123,0.621L19.775,11.005c0.041,0.322 0.063,0.647 0.067,0.972 -0.004,0.313 -0.026,0.625 -0.067,0.935L21.814,14.534c0.186,0.15 0.235,0.413 0.116,0.621zM3.432,3.191L10.468,10.048 15.763,15.201 21.066,20.355 19.627,21.744 16.126,18.332c-0.251,0.142 -0.51,0.267 -0.778,0.374l-0.359,2.576c-0.041,0.236 -0.246,0.408 -0.486,0.407L10.623,21.689c-0.24,0.001 -0.445,-0.171 -0.486,-0.407L9.778,18.706C9.189,18.464 8.637,18.14 8.139,17.743L5.722,18.725C5.5,18.809 5.251,18.722 5.129,18.519L3.189,15.154C3.066,14.948 3.116,14.682 3.305,14.534l2.056,-1.615c-0.043,-0.31 -0.066,-0.622 -0.069,-0.935 0.004,-0.325 0.027,-0.65 0.069,-0.972l-2.056,-1.585C3.112,9.278 3.062,9.007 3.189,8.799l1.122,-1.961L2,4.58Z"/>
</vector>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<android.support.constraint.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/title"
style="@style/TextAppearance.Regular.SubHeading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingBottom="8dp"
android:paddingLeft="@dimen/material_component_text_fields_padding_above_and_below_label"
android:paddingTop="8dp"
tools:text="Title"/>
</android.support.constraint.ConstraintLayout>
</FrameLayout>

View File

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<android.support.constraint.ConstraintLayout
android:id="@+id/card"
android:layout_width="match_parent"
android:layout_height="64dp"
android:background="?attr/selectable_list_drawable">
<ImageView
android:id="@+id/image"
android:layout_width="64dp"
android:layout_height="match_parent"
android:padding="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@mipmap/ic_launcher_round"/>
<TextView
android:id="@+id/ext_title"
style="@style/TextAppearance.Regular"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.Regular.SubHeading"
android:textSize="14sp"
app:layout_constraintStart_toEndOf="@id/image"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/lang"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Batoto"/>
<TextView
android:id="@+id/lang"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
android:textSize="12sp"
app:layout_constraintStart_toEndOf="@id/image"
app:layout_constraintTop_toBottomOf="@+id/ext_title"
app:layout_constraintBottom_toBottomOf="parent"
tools:text="English"
tools:visibility="visible"/>
<TextView
android:id="@+id/version"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
android:textSize="12sp"
app:layout_constraintBaseline_toBaselineOf="@id/lang"
app:layout_constraintLeft_toRightOf="@id/lang"
android:layout_marginLeft="4dp"
tools:text="Version" />
<Button
android:id="@+id/ext_button"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:layout_marginEnd="16dp"
android:layout_marginRight="16dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:foreground="?attr/selectableItemBackground"
android:background="@drawable/button_bg_transparent"
android:textColor="@drawable/button_bg_transparent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Details"/>
</android.support.constraint.ConstraintLayout>
</FrameLayout>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.v4.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/ext_swipe_refresh">
<android.support.v7.widget.RecyclerView
android:id="@+id/ext_recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:listitem="@layout/extension_card_header"/>
</android.support.v4.widget.SwipeRefreshLayout>

View File

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/extension_icon"
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_marginLeft="16dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="@id/extension_title"
app:layout_constraintBottom_toBottomOf="@id/extension_pkg"
android:src="@mipmap/ic_launcher"/>
<TextView
android:id="@+id/extension_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginLeft="16dp"
style="@style/TextAppearance.Regular.SubHeading"
app:layout_constraintLeft_toRightOf="@id/extension_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="Tachiyomi: Extension"/>
<TextView
android:id="@+id/extension_version"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_weight="1"
android:gravity="center"
app:layout_constraintTop_toBottomOf="@id/extension_title"
app:layout_constraintLeft_toLeftOf="@id/extension_title"
tools:text="Version: 1.0.0" />
<TextView
android:id="@+id/extension_lang"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_weight="1"
android:gravity="center"
app:layout_constraintTop_toBottomOf="@id/extension_version"
app:layout_constraintLeft_toLeftOf="@id/extension_title"
tools:text="Language: English" />
<TextView
android:id="@+id/extension_pkg"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginRight="16dp"
android:singleLine="true"
android:ellipsize="middle"
app:layout_constraintTop_toBottomOf="@id/extension_lang"
app:layout_constraintLeft_toLeftOf="@id/extension_title"
app:layout_constraintRight_toRightOf="parent"
tools:text="eu.kanade.tachiyomi.extension.en.myext"/>
<Button
android:id="@+id/extension_uninstall_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:text="@string/ext_uninstall"
style="@style/Theme.Widget.Button.Colored"
app:layout_constraintLeft_toLeftOf="@id/guideline"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/extension_pkg" />
<android.support.v7.widget.RecyclerView
android:id="@+id/extension_prefs_recycler"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/extension_uninstall_button"/>
<eu.kanade.tachiyomi.widget.EmptyView
android:id="@+id/extension_prefs_empty_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone"
android:gravity="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/extension_uninstall_button"/>
<android.support.constraint.Guideline
android:id="@+id/guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.5" />
</android.support.constraint.ConstraintLayout>

View File

@ -19,6 +19,10 @@
android:id="@+id/nav_drawer_catalogues"
android:icon="@drawable/ic_explore_black_24dp"
android:title="@string/label_catalogues" />
<item
android:id="@+id/nav_drawer_extensions"
android:icon="@drawable/ic_extension_black_24dp"
android:title="@string/label_extensions"/>
<item
android:id="@+id/nav_drawer_downloads"
android:icon="@drawable/ic_file_download_black_24dp"

View File

@ -21,6 +21,9 @@
<string name="label_selected">Selected: %1$d</string>
<string name="label_backup">Backup</string>
<string name="label_migration">Source migration</string>
<string name="label_extensions">Extensions</string>
<string name="label_extension_info">Extension info</string>
<!-- Actions -->
<string name="action_settings">Settings</string>
@ -144,6 +147,26 @@
<string name="default_category">Default category</string>
<string name="default_category_summary">Always ask</string>
<!-- Extension section -->
<string name="all_lang">All</string>
<string name="ext_details">Details</string>
<string name="ext_update">Update</string>
<string name="ext_install">Install</string>
<string name="ext_pending">Pending</string>
<string name="ext_downloading">Downloading</string>
<string name="ext_installing">Installing</string>
<string name="ext_installed">Installed</string>
<string name="ext_trust">Trust</string>
<string name="ext_untrusted">Untrusted</string>
<string name="ext_uninstall">Uninstall</string>
<string name="ext_preferences">Preferences</string>
<string name="ext_available">Available</string>
<string name="untrusted_extension">Untrusted extension</string>
<string name="untrusted_extension_message">This extension was signed with an untrusted certificate and it wasn\'t activated.\n\nA malicious extension could read any login credentials stored in Tachiyomi or execute arbitrary code.\n\nBy trusting this certificate you accept these risks.</string>
<string name="ext_version_info">Version: %1$s</string>
<string name="ext_language_info">Language: %1$s</string>
<string name="ext_empty_preferences">There are no preferences to edit for this extension</string>
<!-- Reader section -->
<string name="pref_fullscreen">Fullscreen</string>
<string name="pref_lock_orientation">Lock orientation</string>