diff --git a/app/build.gradle b/app/build.gradle index a3b816a48d..4bb3789d73 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -141,9 +141,6 @@ dependencies { implementation 'com.google.code.gson:gson:2.8.2' implementation 'com.github.salomonbrys.kotson:kotson:2.5.0' - // YAML - implementation 'com.github.bmoliveira:snake-yaml:v1.18-android' - // JavaScript engine implementation 'com.squareup.duktape:duktape-android:1.2.0' @@ -170,7 +167,7 @@ dependencies { implementation "info.android15.nucleus:nucleus-support-v7:$nucleus_version" // Dependency injection - implementation "uy.kohesive.injekt:injekt-core:1.16.1" + implementation "com.github.inorichi.injekt:injekt-core:65b0440" // Image library final glide_version = '4.3.1' diff --git a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt index 71552f39f1..3b071fa2eb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt @@ -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().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() } + + async { get() } + + async { get() } + + async { get() } + + async { get() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/EmptyPreferenceDataStore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/EmptyPreferenceDataStore.kt new file mode 100644 index 0000000000..10e83b84eb --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/EmptyPreferenceDataStore.kt @@ -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?): Set? { + return null + } + + override fun putStringSet(key: String?, values: Set?) { + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt index 4ba22ee84a..d3d6e00372 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt @@ -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" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index c6f3e638cc..c2869510bd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -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()) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/SharedPreferencesDataStore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/SharedPreferencesDataStore.kt new file mode 100644 index 0000000000..bb07bb0a52 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/SharedPreferencesDataStore.kt @@ -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?): MutableSet { + return prefs.getStringSet(key, defValues) + } + + override fun putStringSet(key: String?, values: MutableSet?) { + prefs.edit().putStringSet(key, values).apply() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt new file mode 100644 index 0000000000..b2918a9725 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt @@ -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 of the currently installed extensions. + */ + var installedExtensions = emptyList() + private set(value) { + field = value + installedExtensionsRelay.call(value) + } + + /** + * Relay used to notify the available extensions. + */ + private val availableExtensionsRelay = BehaviorRelay.create>() + + /** + * List of the currently available extensions. + */ + var availableExtensions = emptyList() + private set(value) { + field = value + availableExtensionsRelay.call(value) + setUpdateFieldOfInstalledExtensions(value) + } + + /** + * Relay used to notify the untrusted extensions. + */ + private val untrustedExtensionsRelay = BehaviorRelay.create>() + + /** + * List of the currently untrusted extensions. + */ + var untrustedExtensions = emptyList() + 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() + .map { it.extension } + installedExtensions + .flatMap { it.sources } + // overwrite is needed until the bundled sources are removed + .forEach { sourceManager.registerSource(it, true) } + + untrustedExtensions = extensions + .filterIsInstance() + .map { it.extension } + } + + /** + * Returns the relay of the installed extensions as an observable. + */ + fun getInstalledExtensionsObservable(): Observable> { + return installedExtensionsRelay.asObservable() + } + + /** + * Returns the relay of the available extensions as an observable. + */ + fun getAvailableExtensionsObservable(): Observable> { + if (!availableExtensionsRelay.hasValue()) { + findAvailableExtensions() + } + return availableExtensionsRelay.asObservable() + } + + /** + * Returns the relay of the untrusted extensions as an observable. + */ + fun getUntrustedExtensionsObservable(): Observable> { + 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) { + 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 { + 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 { + 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 + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt new file mode 100644 index 0000000000..f8ef81b89b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt @@ -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> { + val call = GET("$repoUrl/index.json") + + return client.newCall(call).asObservableSuccess() + .map(::parseResponse) + } + + private fun parseResponse(response: Response): List { + val text = response.body()?.use { it.string() } ?: return emptyList() + + val json = gson.fromJson(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}" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt new file mode 100644 index 0000000000..ef4c245684 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt @@ -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, + 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() + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/InstallStep.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/model/InstallStep.kt new file mode 100644 index 0000000000..43bb5198d5 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/model/InstallStep.kt @@ -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 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/LoadResult.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/model/LoadResult.kt new file mode 100644 index 0000000000..0cf470fe85 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/model/LoadResult.kt @@ -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) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt new file mode 100644 index 0000000000..5067aa9362 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt @@ -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) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt new file mode 100644 index 0000000000..f445bbaf56 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt @@ -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() + + /** + * Relay used to notify the installation step of every download. + */ + private val downloadsRelay = PublishRelay.create>() + + /** + * 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 { + 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" + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt new file mode 100644 index 0000000000..06cfa58c57 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt @@ -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() + + Injekt.get().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 { + 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() + .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 + } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/ConfigurableSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/ConfigurableSource.kt new file mode 100644 index 0000000000..6b3f99aceb --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/ConfigurableSource.kt @@ -0,0 +1,8 @@ +package eu.kanade.tachiyomi.source + +import android.support.v7.preference.PreferenceScreen + +interface ConfigurableSource : Source { + + fun setupPreferenceScreen(screen: PreferenceScreen) +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt index 0b31a27a33..37c5ec40a8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt @@ -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() 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() - 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 = listOf( LocalSource(context), Batoto(), @@ -60,92 +47,4 @@ open class SourceManager(private val context: Context) { Mangasee(), WieManga() ) - - private fun createYamlSources(): List { - val sources = mutableListOf() - - 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 { - 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() - 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 { - // 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) - - 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 - } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt index 7b57d10c1c..cb76f1162f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt @@ -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().getSharedPreferences("source_$id", Context.MODE_PRIVATE) +// } /** * Base url of the website without the trailing slash, like: http://mysite.com diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSource.kt deleted file mode 100644 index 582a7c3d31..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSource.kt +++ /dev/null @@ -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 { - val document = response.asJsoup() - - val chapters = mutableListOf() - with(map.chapters) { - val pool = emptyMap() - 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 { - val body = response.body()!!.string() - val url = response.request().url().toString() - - val pages = mutableListOf() - - 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") - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSourceMappings.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSourceMappings.kt deleted file mode 100644 index ba07594c3b..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSourceMappings.kt +++ /dev/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 - -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 - - val url: String - get() = map["url"] as String - - val method: String? - get() = map["method"] as? String - - val payload: Map? - get() = map["payload"] as? Map - - fun createForm(): RequestBody { - return FormBody.Builder().apply { - payload?.let { - for ((key, value) in it) { - add(key, value) - } - } - }.build() - } - -} - -class PopularNode(override val map: Map): 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): 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): 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) { - - 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) { - - 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) { - - fun get(document: Document) = map.mapValues { document.select(it.value as String).first() } -} - -open class SelectableNode(private val map: Map) { - - 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 { - 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) : 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): 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) : SelectableNode(map) { - - override val attr: String? - get() = map["attr"] as? String ?: "src" -} - -class DateNode(private val map: Map) : SelectableNode(map) { - - val format: String by map - - fun getDate(document: Element, cache: Map, 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)?.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) { - - 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" - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Batoto.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Batoto.kt index 047393f99a..ce4a40ee6c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Batoto.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Batoto.kt @@ -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" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceDividerItemDecoration.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceDividerItemDecoration.kt index b5ac650186..a4c33beb75 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceDividerItemDecoration.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceDividerItemDecoration.kt @@ -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) } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt new file mode 100644 index 0000000000..e8addfbae0 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt @@ -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>(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) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionController.kt new file mode 100644 index 0000000000..0079c6be00 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionController.kt @@ -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(), + ExtensionAdapter.OnButtonClickListener, + FlexibleAdapter.OnItemClickListener, + FlexibleAdapter.OnItemLongClickListener, + ExtensionTrustDialog.Listener { + + /** + * Adapter containing the list of manga from the catalogue. + */ + private var adapter: FlexibleAdapter>? = 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) { + 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) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDetailsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDetailsController.kt new file mode 100644 index 0000000000..cd79f11d1a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDetailsController.kt @@ -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(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" + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDetailsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDetailsPresenter.kt new file mode 100644 index 0000000000..f6a6d4d9ac --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDetailsPresenter.kt @@ -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() { + + 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) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDividerItemDecoration.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDividerItemDecoration.kt new file mode 100644 index 0000000000..40fe44505d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDividerItemDecoration.kt @@ -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) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupHolder.kt new file mode 100644 index 0000000000..7edc3bd69a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupHolder.kt @@ -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 + ")" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupItem.kt new file mode 100644 index 0000000000..2c45f894e1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupItem.kt @@ -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() { + + /** + * 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?) { + + 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() + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt new file mode 100644 index 0000000000..889c84a9e5 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt @@ -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) + } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionItem.kt new file mode 100644 index 0000000000..2ed363e971 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionItem.kt @@ -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(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?) { + + 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() + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionPresenter.kt new file mode 100644 index 0000000000..2f0b81fda0 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionPresenter.kt @@ -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, List> + +/** + * Presenter of [ExtensionController]. + */ +open class ExtensionPresenter( + private val extensionManager: ExtensionManager = Injekt.get() +) : BasePresenter() { + + private var extensions = emptyList() + + private var currentDownloads = hashMapOf() + + 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()) + + 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 { + val (installed, untrusted, available) = tuple + + val items = mutableListOf() + + 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.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) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionTrustDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionTrustDialog.kt new file mode 100644 index 0000000000..3094e90620 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionTrustDialog.kt @@ -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(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) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionViewUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionViewUtils.kt new file mode 100644 index 0000000000..f05c9e5a96 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionViewUtils.kt @@ -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 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 53c557948c..f1f010f0e8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -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()) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt index ce231756c5..eb90b38a1c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt @@ -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 { diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/Hash.kt b/app/src/main/java/eu/kanade/tachiyomi/util/Hash.kt new file mode 100644 index 0000000000..eb89b04312 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/Hash.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/IntListPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/IntListPreference.kt index 2eb95e2ece..085cfd004c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/IntListPreference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/IntListPreference.kt @@ -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 } } } \ No newline at end of file diff --git a/app/src/main/res/drawable/button_bg_transparent.xml b/app/src/main/res/drawable/button_bg_transparent.xml new file mode 100644 index 0000000000..84577aa474 --- /dev/null +++ b/app/src/main/res/drawable/button_bg_transparent.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_extension_black_24dp.xml b/app/src/main/res/drawable/ic_extension_black_24dp.xml new file mode 100644 index 0000000000..d3dd094816 --- /dev/null +++ b/app/src/main/res/drawable/ic_extension_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_no_settings.xml b/app/src/main/res/drawable/ic_no_settings.xml new file mode 100644 index 0000000000..71acd27e8e --- /dev/null +++ b/app/src/main/res/drawable/ic_no_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/extension_card_header.xml b/app/src/main/res/layout/extension_card_header.xml new file mode 100644 index 0000000000..2c2e11fc91 --- /dev/null +++ b/app/src/main/res/layout/extension_card_header.xml @@ -0,0 +1,24 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/extension_card_item.xml b/app/src/main/res/layout/extension_card_item.xml new file mode 100644 index 0000000000..6acbe21bac --- /dev/null +++ b/app/src/main/res/layout/extension_card_item.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + +