mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-30 22:07:57 +01:00 
			
		
		
		
	Downloading extensions from Github Repo. (#1101)
Downloading extensions from Github Repo.
This commit is contained in:
		| @@ -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>() } | ||||
|  | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -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>?) { | ||||
|     } | ||||
| } | ||||
| @@ -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" | ||||
|   | ||||
| @@ -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()) | ||||
| } | ||||
|   | ||||
| @@ -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() | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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}" | ||||
|     } | ||||
| } | ||||
| @@ -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() | ||||
|  | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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" | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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 | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,8 @@ | ||||
| package eu.kanade.tachiyomi.source | ||||
|  | ||||
| import android.support.v7.preference.PreferenceScreen | ||||
|  | ||||
| interface ConfigurableSource : Source { | ||||
|  | ||||
|     fun setupPreferenceScreen(screen: PreferenceScreen) | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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") | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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" | ||||
|  | ||||
| } | ||||
| @@ -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" | ||||
|   | ||||
| @@ -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) | ||||
| @@ -41,4 +41,4 @@ class SourceDividerItemDecoration(context: Context) : RecyclerView.ItemDecoratio | ||||
|         outRect.set(0, 0, 0, divider.intrinsicHeight) | ||||
|     } | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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" | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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 + ")" | ||||
|     } | ||||
| } | ||||
| @@ -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() | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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) | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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() | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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) | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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()) | ||||
|                     } | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
							
								
								
									
										42
									
								
								app/src/main/java/eu/kanade/tachiyomi/util/Hash.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								app/src/main/java/eu/kanade/tachiyomi/util/Hash.kt
									
									
									
									
									
										Normal 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) | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user