From eb3a9878260324308bd46f866fb08f4695e48473 Mon Sep 17 00:00:00 2001 From: Jobobby04 Date: Mon, 26 Oct 2020 02:13:02 -0400 Subject: [PATCH] Implement Neko similar manga, Mangadex only recommendations --- app/build.gradle | 3 + app/src/main/AndroidManifest.xml | 4 + .../tachiyomi/data/database/DatabaseHelper.kt | 6 +- .../tachiyomi/data/database/DbOpenCallback.kt | 9 +- .../data/notification/NotificationReceiver.kt | 36 ++ .../data/notification/Notifications.kt | 16 + .../data/preference/PreferenceKeys.kt | 6 + .../data/preference/PreferencesHelper.kt | 8 + .../tachiyomi/source/online/all/MangaDex.kt | 6 + .../source/browse/BrowseSourceController.kt | 108 ++---- .../source/browse/BrowseSourcePresenter.kt | 11 +- .../tachiyomi/ui/manga/MangaController.kt | 34 +- .../ui/setting/SettingsMainController.kt | 2 +- .../ui/setting/SettingsMangaDexController.kt | 78 +++++ .../md/follows/MangaDexFollowsPresenter.kt | 4 +- .../java/exh/md/handlers/SimilarHandler.kt | 56 ++- .../java/exh/md/similar/SimilarHttpService.kt | 47 +++ .../java/exh/md/similar/SimilarUpdateJob.kt | 74 ++++ .../exh/md/similar/SimilarUpdateService.kt | 324 ++++++++++++++++++ .../similar/sql/mappers/SimilarTypeMapping.kt | 63 ++++ .../exh/md/similar/sql/models/MangaSimilar.kt | 31 ++ .../md/similar/sql/models/MangaSimilarImpl.kt | 32 ++ .../md/similar/sql/queries/SimilarQueries.kt | 42 +++ .../exh/md/similar/sql/tables/SimilarTable.kt | 27 ++ .../EnableMangaDexSimilarDialogController.kt | 27 ++ .../similar/ui/MangaDexSimilarController.kt | 55 +++ .../exh/md/similar/ui/MangaDexSimilarPager.kt | 29 ++ .../md/similar/ui/MangaDexSimilarPresenter.kt | 26 ++ .../java/exh/recs/RecommendsController.kt | 70 ++++ .../browse => exh/recs}/RecommendsPager.kt | 3 +- .../main/java/exh/recs/RecommendsPresenter.kt | 23 ++ app/src/main/res/values/strings_sy.xml | 34 ++ 32 files changed, 1155 insertions(+), 139 deletions(-) create mode 100644 app/src/main/java/exh/md/similar/SimilarHttpService.kt create mode 100644 app/src/main/java/exh/md/similar/SimilarUpdateJob.kt create mode 100644 app/src/main/java/exh/md/similar/SimilarUpdateService.kt create mode 100644 app/src/main/java/exh/md/similar/sql/mappers/SimilarTypeMapping.kt create mode 100644 app/src/main/java/exh/md/similar/sql/models/MangaSimilar.kt create mode 100644 app/src/main/java/exh/md/similar/sql/models/MangaSimilarImpl.kt create mode 100644 app/src/main/java/exh/md/similar/sql/queries/SimilarQueries.kt create mode 100644 app/src/main/java/exh/md/similar/sql/tables/SimilarTable.kt create mode 100644 app/src/main/java/exh/md/similar/ui/EnableMangaDexSimilarDialogController.kt create mode 100644 app/src/main/java/exh/md/similar/ui/MangaDexSimilarController.kt create mode 100644 app/src/main/java/exh/md/similar/ui/MangaDexSimilarPager.kt create mode 100644 app/src/main/java/exh/md/similar/ui/MangaDexSimilarPresenter.kt create mode 100644 app/src/main/java/exh/recs/RecommendsController.kt rename app/src/main/java/{eu/kanade/tachiyomi/ui/browse/source/browse => exh/recs}/RecommendsPager.kt (99%) create mode 100644 app/src/main/java/exh/recs/RecommendsPresenter.kt diff --git a/app/build.gradle b/app/build.gradle index f0138949c..11237c419 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -328,6 +328,9 @@ dependencies { // RatingBar (SY) implementation 'me.zhanghai.android.materialratingbar:library:1.4.0' + // JsonReader for similar manga + implementation 'com.squareup.moshi:moshi:1.11.0' + implementation 'androidx.gridlayout:gridlayout:1.0.0' implementation 'com.google.guava:guava:29.0-android' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 500556c11..2b500aeac 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -153,6 +153,10 @@ android:exported="false" /> + + */, SearchMetadataQueries, SearchTagQueries, SearchTitleQueries, MergedQueries /* SY <-- */ { + MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries /* SY --> */, SearchMetadataQueries, SearchTagQueries, SearchTitleQueries, MergedQueries, SimilarQueries /* SY <-- */ { private val configuration = SupportSQLiteOpenHelper.Configuration.builder(context) .name(DbOpenCallback.DATABASE_NAME) @@ -59,6 +62,7 @@ open class DatabaseHelper(context: Context) : .addTypeMapping(SearchTag::class.java, SearchTagTypeMapping()) .addTypeMapping(SearchTitle::class.java, SearchTitleTypeMapping()) .addTypeMapping(MergedMangaReference::class.java, MergedMangaTypeMapping()) + .addTypeMapping(MangaSimilar::class.java, SimilarTypeMapping()) // SY <-- .build() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt index ab0229260..784762063 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt @@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.data.database.tables.HistoryTable import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable import eu.kanade.tachiyomi.data.database.tables.MangaTable import eu.kanade.tachiyomi.data.database.tables.TrackTable +import exh.md.similar.sql.tables.SimilarTable import exh.merged.sql.tables.MergedTable import exh.metadata.sql.tables.SearchMetadataTable import exh.metadata.sql.tables.SearchTagTable @@ -24,7 +25,7 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) { /** * Version of the database. */ - const val DATABASE_VERSION = /* SY --> */ 4 /* SY <-- */ + const val DATABASE_VERSION = /* SY --> */ 5 /* SY <-- */ } override fun onCreate(db: SupportSQLiteDatabase) = with(db) { @@ -39,6 +40,7 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) { execSQL(SearchTagTable.createTableQuery) execSQL(SearchTitleTable.createTableQuery) execSQL(MergedTable.createTableQuery) + execSQL(SimilarTable.createTableQuery) // SY <-- // DB indexes @@ -55,6 +57,7 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) { execSQL(SearchTitleTable.createMangaIdIndexQuery) execSQL(SearchTitleTable.createTitleIndexQuery) execSQL(MergedTable.createIndexQuery) + execSQL(SimilarTable.createMangaIdIndexQuery) // SY <-- } @@ -71,6 +74,10 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) { db.execSQL(MergedTable.createTableQuery) db.execSQL(MergedTable.createIndexQuery) } + if (oldVersion < 5) { + db.execSQL(SimilarTable.createTableQuery) + db.execSQL(SimilarTable.createMangaIdIndexQuery) + } } override fun onConfigure(db: SupportSQLiteDatabase) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt index 4960327b9..4d3ae1845 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt @@ -25,6 +25,7 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.system.notificationManager import eu.kanade.tachiyomi.util.system.toast +import exh.md.similar.SimilarUpdateService import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy @@ -100,6 +101,9 @@ class NotificationReceiver : BroadcastReceiver() { markAsRead(urls, mangaId) } } + // SY --> + ACTION_CANCEL_SIMILAR_UPDATE -> cancelSimilarUpdate(context) + // SY <-- } } @@ -241,6 +245,18 @@ class NotificationReceiver : BroadcastReceiver() { } } + // SY --> + /** + * Method called when user wants to stop a similar manga update + * + * @param context context of application + */ + private fun cancelSimilarUpdate(context: Context) { + SimilarUpdateService.stop(context) + Handler().post { dismissNotification(context, Notifications.ID_SIMILAR_PROGRESS) } + } + // SY <-- + companion object { private const val NAME = "NotificationReceiver" @@ -298,6 +314,11 @@ class NotificationReceiver : BroadcastReceiver() { // Value containing chapter url. private const val EXTRA_CHAPTER_URL = "$ID.$NAME.EXTRA_CHAPTER_URL" + // Sy --> + // Called to cancel similar manga update. + private const val ACTION_CANCEL_SIMILAR_UPDATE = "$ID.$NAME.CANCEL_SIMILAR_UPDATE" + // SY <-- + /** * Returns a [PendingIntent] that resumes the download of a chapter * @@ -548,5 +569,20 @@ class NotificationReceiver : BroadcastReceiver() { } return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) } + + // SY --> + /** + * Returns [PendingIntent] that starts a service which stops the similar update + * + * @param context context of application + * @return [PendingIntent] + */ + internal fun cancelSimilarUpdatePendingBroadcast(context: Context): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_CANCEL_SIMILAR_UPDATE + } + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + // SY <-- } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt index b55b53fed..32c7ad2e1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt @@ -62,6 +62,15 @@ object Notifications { const val ID_BACKUP_COMPLETE = -502 const val ID_RESTORE_COMPLETE = -504 + // SY --> + /** + * Notification channel and ids used for backup and restore. + */ + const val CHANNEL_SIMILAR = "similar_channel" + const val ID_SIMILAR_PROGRESS = -601 + const val ID_SIMILAR_COMPLETE = -602 + // SY <-- + private val deprecatedChannels = listOf( "downloader_channel", "backup_restore_complete_channel" @@ -143,6 +152,13 @@ object Notifications { group = GROUP_BACKUP_RESTORE setShowBadge(false) setSound(null, null) + }, + NotificationChannel( + CHANNEL_SIMILAR, + context.getString(R.string.similar), + NotificationManager.IMPORTANCE_LOW + ).apply { + setShowBadge(false) } ).forEach(context.notificationManager::createNotificationChannel) 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 14a0ee3e0..3b0c40478 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt @@ -309,6 +309,12 @@ object PreferenceKeys { const val mangaDexForceLatestCovers = "manga_dex_force_latest_covers" + const val mangadexSimilarEnabled = "pref_related_show_tab_key" + + const val mangadexSimilarUpdateInterval = "related_update_interval" + + const val mangadexSimilarOnlyOverWifi = "pref_simular_only_over_wifi_key" + const val preferredMangaDexId = "preferred_mangaDex_id" const val dataSaver = "data_saver" 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 3303e1e2a..17de26ff4 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -431,6 +431,14 @@ class PreferencesHelper(val context: Context) { fun preferredMangaDexId() = flowPrefs.getString(Keys.preferredMangaDexId, "0") + fun mangadexSimilarEnabled() = flowPrefs.getBoolean(Keys.mangadexSimilarEnabled, false) + + fun shownMangaDexSimilarAskDialog() = flowPrefs.getBoolean("shown_similar_ask_dialog", false) + + fun mangadexSimilarOnlyOverWifi() = flowPrefs.getBoolean(Keys.mangadexSimilarOnlyOverWifi, true) + + fun mangadexSimilarUpdateInterval() = flowPrefs.getInt(Keys.mangadexSimilarUpdateInterval, 2) + fun dataSaver() = flowPrefs.getBoolean(Keys.dataSaver, false) fun ignoreJpeg() = flowPrefs.getBoolean(Keys.ignoreJpeg, false) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt index 86dea0cd6..b39d18316 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt @@ -6,6 +6,7 @@ import android.content.SharedPreferences import android.net.Uri import androidx.core.text.HtmlCompat import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.track.TrackManager @@ -35,6 +36,7 @@ import exh.md.handlers.ApiMangaParser import exh.md.handlers.FollowsHandler import exh.md.handlers.MangaHandler import exh.md.handlers.MangaPlusHandler +import exh.md.handlers.SimilarHandler import exh.md.utils.FollowStatus import exh.md.utils.MdLang import exh.md.utils.MdUtil @@ -257,6 +259,10 @@ class MangaDex(delegate: HttpSource, val context: Context) : return MangaHandler(client, headers, listOf(mdLang)).fetchRandomMangaId() } + fun fetchMangaSimilar(manga: Manga): Observable { + return SimilarHandler(preferences, useLowQualityThumbnail()).fetchSimilar(manga) + } + private fun importIdToMdId(query: String, fail: () -> Observable): Observable = when { query.toIntOrNull() != null -> { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt index 1ce14be35..805a0cd99 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt @@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.ui.browse.source.browse import android.content.res.Configuration import android.os.Bundle -import android.os.Parcelable import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater @@ -10,7 +9,6 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.SearchView -import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager @@ -37,6 +35,7 @@ import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.LoginSource +import eu.kanade.tachiyomi.source.online.all.MangaDex import eu.kanade.tachiyomi.ui.base.controller.FabController import eu.kanade.tachiyomi.ui.base.controller.NucleusController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction @@ -55,9 +54,9 @@ import eu.kanade.tachiyomi.util.view.snack import eu.kanade.tachiyomi.widget.AutofitRecyclerView import eu.kanade.tachiyomi.widget.EmptyView import exh.isEhBasedSource +import exh.md.similar.ui.EnableMangaDexSimilarDialogController import exh.savedsearches.EXHSavedSearch import exh.source.EnhancedHttpSource.Companion.getMainSource -import kotlinx.android.parcel.Parcelize import kotlinx.android.synthetic.main.main_activity.root_coordinator import kotlinx.coroutines.Job import kotlinx.coroutines.flow.drop @@ -109,17 +108,10 @@ open class BrowseSourceController(bundle: Bundle) : private val preferences: PreferencesHelper by injectLazy() - // SY --> - private val recommendsConfig: RecommendsConfig? = args.getParcelable(RECOMMENDS_CONFIG) - // SY <-- - - // AZ --> - private val mode = if (recommendsConfig == null) Mode.CATALOGUE else Mode.RECOMMENDS - // AZ <-- /** * Adapter containing the list of manga from the catalogue. */ - private var adapter: FlexibleAdapter>? = null + /* SY --> */ protected /* SY <-- */ var adapter: FlexibleAdapter>? = null private var actionFab: ExtendedFloatingActionButton? = null private var actionFabScrollListener: RecyclerView.OnScrollListener? = null @@ -154,12 +146,7 @@ open class BrowseSourceController(bundle: Bundle) : } override fun getTitle(): String? { - // SY --> - return when (mode) { - Mode.CATALOGUE -> presenter.source.name - Mode.RECOMMENDS -> recommendsConfig!!.title - } - // SY <-- + return presenter.source.name } override fun createPresenter(): BrowseSourcePresenter { @@ -167,7 +154,6 @@ open class BrowseSourceController(bundle: Bundle) : return BrowseSourcePresenter( args.getLong(SOURCE_ID_KEY), args.getString(SEARCH_QUERY_KEY), - recommendsMangaId = if (mode == Mode.RECOMMENDS) recommendsConfig?.mangaId else null, filters = args.getString(FILTERS_CONFIG_KEY) ) // SY <-- @@ -192,6 +178,11 @@ open class BrowseSourceController(bundle: Bundle) : // SY --> val mainSource = presenter.source.getMainSource() + if (mainSource is MangaDex && !preferences.mangadexSimilarEnabled().get() && !preferences.shownMangaDexSimilarAskDialog().get()) { + EnableMangaDexSimilarDialogController().showDialog(router) + preferences.shownMangaDexSimilarAskDialog().set(true) + } + if (mainSource is LoginSource && mainSource.needsLogin && !mainSource.isLogged()) { val dialog = mainSource.getLoginDialog(mainSource, activity!!) dialog.showDialog(router) @@ -200,12 +191,6 @@ open class BrowseSourceController(bundle: Bundle) : } open fun initFilterSheet() { - // SY --> - if (mode == Mode.RECOMMENDS) { - return - } - // SY <-- - if (presenter.sourceFilters.isEmpty()) { // SY --> actionFab?.text = activity!!.getString(R.string.saved_searches) @@ -455,10 +440,6 @@ open class BrowseSourceController(bundle: Bundle) : } menu.findItem(displayItem).isChecked = true // SY --> - if (mode == Mode.RECOMMENDS) { - menu.findItem(R.id.action_search).isVisible = false - } - if (preferences.enhancedEHentaiView().get() && presenter.source.isEhBasedSource()) { menu.findItem(R.id.action_display_mode).isVisible = false } @@ -472,9 +453,9 @@ open class BrowseSourceController(bundle: Bundle) : menu.findItem(R.id.action_open_in_web_view).isVisible = isHttpSource val isLocalSource = presenter.source is LocalSource - // SY --> - menu.findItem(R.id.action_local_source_help).isVisible = isLocalSource && mode == Mode.CATALOGUE + menu.findItem(R.id.action_local_source_help).isVisible = isLocalSource + // SY --> menu.findItem(R.id.action_settings).isVisible = presenter.source is ConfigurableSource // SY <-- } @@ -555,19 +536,14 @@ open class BrowseSourceController(bundle: Bundle) : * * @param error the error received. */ - fun onAddPageError(error: Throwable) { + /* SY --> */ open /* SY <-- */fun onAddPageError(error: Throwable) { // SY --> XLog.w("> Failed to load next catalogue page!", error) - - if (mode == Mode.CATALOGUE) { - XLog.w( - "> (source.id: %s, source.name: %s)", - presenter.source.id, - presenter.source.name - ) - } else { - XLog.w("> Recommendations") - } + XLog.w( + "> (source.id: %s, source.name: %s)", + presenter.source.id, + presenter.source.name + ) // SY <-- val adapter = adapter ?: return @@ -590,7 +566,7 @@ open class BrowseSourceController(bundle: Bundle) : if (adapter.isEmpty) { val actions = emptyList().toMutableList() - if (presenter.source is LocalSource /* SY --> */ && mode == Mode.CATALOGUE /* SY <-- */) { + if (presenter.source is LocalSource) { actions += EmptyView.Action(R.string.local_source_help_guide, View.OnClickListener { openLocalSourceHelpGuide() }) } else { actions += EmptyView.Action(R.string.action_retry, retryAction) @@ -734,36 +710,16 @@ open class BrowseSourceController(bundle: Bundle) : */ override fun onItemClick(view: View, position: Int): Boolean { val item = adapter?.getItem(position) as? SourceItem ?: return false - // SY --> - when (mode) { - Mode.CATALOGUE -> { - router.pushController( - MangaController( - item.manga, - true, - args.getParcelable(MangaController.SMART_SEARCH_CONFIG_EXTRA) - ).withFadeTransaction() - ) - } - Mode.RECOMMENDS -> openSmartSearch(item.manga.originalTitle) - } - // SY <-- + router.pushController( + MangaController( + item.manga, + true, + args.getParcelable(MangaController.SMART_SEARCH_CONFIG_EXTRA) + ).withFadeTransaction() + ) return false } - // AZ --> - private fun openSmartSearch(title: String) { - val smartSearchConfig = SourceController.SmartSearchConfig(title) - router.pushController( - SourceController( - bundleOf( - SourceController.SMART_SEARCH_CONFIG to smartSearchConfig - ) - ).withFadeTransaction() - ) - } - - // AZ <-- /** * Called when a manga is long clicked. * @@ -774,9 +730,6 @@ open class BrowseSourceController(bundle: Bundle) : * @param position the position of the element clicked. */ override fun onItemLongClick(position: Int) { - // SY --> - if (mode == Mode.RECOMMENDS) return - // SY <-- val activity = activity ?: return val manga = (adapter?.getItem(position) as? SourceItem?)?.manga ?: return @@ -852,16 +805,6 @@ open class BrowseSourceController(bundle: Bundle) : activity?.toast(activity?.getString(R.string.manga_added_library)) } - // SY --> - @Parcelize - data class RecommendsConfig(val title: String, val mangaId: Long?) : Parcelable - - enum class Mode { - CATALOGUE, - RECOMMENDS - } - // SY <-- - companion object { const val SOURCE_ID_KEY = "sourceId" const val SEARCH_QUERY_KEY = "searchQuery" @@ -869,7 +812,6 @@ open class BrowseSourceController(bundle: Bundle) : // SY --> const val SMART_SEARCH_CONFIG_KEY = "smartSearchConfig" const val FILTERS_CONFIG_KEY = "filters" - const val RECOMMENDS_CONFIG = "RECOMMENDS_CONFIG" // SY <-- } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt index 9184a92b3..526a0f461 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt @@ -59,7 +59,6 @@ open class BrowseSourcePresenter( private val sourceId: Long, private val searchQuery: String? = null, // SY --> - private val recommendsMangaId: Long? = null, private val filters: String? = null, // SY <-- private val sourceManager: SourceManager = Injekt.get(), @@ -145,10 +144,6 @@ open class BrowseSourcePresenter( query = savedState.getString(::query.name, "") } - if (recommendsMangaId != null) { - manga = db.getManga(recommendsMangaId).executeAsBlocking() - } - restartPager(/* SY -->*/ filters = if (allDefault) this.appliedFilters else sourceFilters /* SY <--*/) } @@ -170,11 +165,7 @@ open class BrowseSourcePresenter( subscribeToMangaInitializer() // Create a new pager. - // SY --> - pager = if (recommendsMangaId != null) RecommendsPager( - manga ?: throw Exception("Could not get Manga") - ) else createPager(query, filters) - // SY <-- + pager = createPager(query, filters) val sourceId = source.id diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt index 54186e462..2250e0150 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt @@ -31,6 +31,8 @@ import androidx.core.view.isVisible import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.list.listItemsSingleChoice import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeType import com.bumptech.glide.load.engine.DiskCacheStrategy @@ -50,11 +52,13 @@ import eu.kanade.tachiyomi.data.glide.GlideApp import eu.kanade.tachiyomi.data.glide.toMangaThumbnail import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.databinding.MangaControllerBinding +import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.MetadataSource +import eu.kanade.tachiyomi.source.online.all.MangaDex import eu.kanade.tachiyomi.ui.base.controller.FabController import eu.kanade.tachiyomi.ui.base.controller.NucleusController import eu.kanade.tachiyomi.ui.base.controller.ToolbarLiftOnScrollController @@ -96,7 +100,9 @@ import eu.kanade.tachiyomi.util.view.shrinkOnScroll import eu.kanade.tachiyomi.util.view.snack import exh.MERGED_SOURCE_ID import exh.isEhBasedSource +import exh.md.similar.ui.MangaDexSimilarController import exh.metadata.metadata.base.FlatMetadata +import exh.recs.RecommendsController import exh.source.EnhancedHttpSource.Companion.getMainSource import kotlinx.android.synthetic.main.main_activity.root_coordinator import kotlinx.android.synthetic.main.main_activity.toolbar @@ -748,15 +754,25 @@ class MangaController : // AZ --> fun openRecommends() { - val recommendsConfig = BrowseSourceController.RecommendsConfig(presenter.manga.originalTitle, presenter.manga.id) - - router?.pushController( - BrowseSourceController( - bundleOf( - BrowseSourceController.RECOMMENDS_CONFIG to recommendsConfig - ) - ).withFadeTransaction() - ) + val source = presenter.source.getMainSource() + if (source is MangaDex && preferences.mangadexSimilarEnabled().get()) { + MaterialDialog(activity!!) + .title(R.string.az_recommends) + .listItemsSingleChoice( + items = listOf( + "MangaDex similar", + "Community recommendations" + ) + ) { _, index, _ -> + when (index) { + 0 -> router.pushController(MangaDexSimilarController(presenter.manga, source).withFadeTransaction()) + 1 -> router.pushController(RecommendsController(presenter.manga, source).withFadeTransaction()) + } + } + .show() + } else if (source is CatalogueSource) { + router.pushController(RecommendsController(presenter.manga, source).withFadeTransaction()) + } } // AZ <-- diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt index 5484ba6be..c6e3d25f3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt @@ -90,7 +90,7 @@ class SettingsMainController : SettingsController() { preference { iconRes = R.drawable.ic_tracker_mangadex_logo_24dp iconTint = tintColor - titleRes = R.string.mangadex_specific_settings + titleRes = R.string.pref_category_mangadex onClick { navigateTo(SettingsMangaDexController()) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMangaDexController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMangaDexController.kt index 83e275244..aedee2a29 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMangaDexController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMangaDexController.kt @@ -1,17 +1,25 @@ package eu.kanade.tachiyomi.ui.setting +import android.content.Intent +import androidx.core.net.toUri import androidx.preference.PreferenceScreen import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.preference.PreferenceKeys import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.util.preference.defaultValue +import eu.kanade.tachiyomi.util.preference.entriesRes +import eu.kanade.tachiyomi.util.preference.intListPreference import eu.kanade.tachiyomi.util.preference.listPreference +import eu.kanade.tachiyomi.util.preference.onChange import eu.kanade.tachiyomi.util.preference.onClick import eu.kanade.tachiyomi.util.preference.preference +import eu.kanade.tachiyomi.util.preference.preferenceCategory import eu.kanade.tachiyomi.util.preference.summaryRes import eu.kanade.tachiyomi.util.preference.switchPreference import eu.kanade.tachiyomi.util.preference.titleRes +import eu.kanade.tachiyomi.util.system.toast +import exh.md.similar.SimilarUpdateJob import exh.md.utils.MdUtil import exh.widget.preference.MangaDexLoginPreference import exh.widget.preference.MangadexLoginDialog @@ -85,6 +93,76 @@ class SettingsMangaDexController : ) } } + + preferenceCategory { + titleRes = R.string.similar_settings + + preference { + key = "pref_similar_screen" + titleRes = R.string.similar_screen + summaryRes = R.string.similar_screen_summary_message + isIconSpaceReserved = true + } + + switchPreference { + key = PreferenceKeys.mangadexSimilarEnabled + titleRes = R.string.similar_screen + defaultValue = false + onClick { + SimilarUpdateJob.setupTask(context) + } + } + + switchPreference { + key = PreferenceKeys.mangadexSimilarOnlyOverWifi + titleRes = R.string.pref_download_only_over_wifi + defaultValue = true + onClick { + SimilarUpdateJob.setupTask(context, true) + } + } + + preference { + key = "pref_similar_manually_update" + titleRes = R.string.similar_manually_update + summaryRes = R.string.similar_manually_update_message + onClick { + SimilarUpdateJob.doWorkNow(context) + context.toast(R.string.similar_manually_toast) + } + } + + intListPreference { + key = PreferenceKeys.mangadexSimilarUpdateInterval + titleRes = R.string.similar_update_fequency + entriesRes = arrayOf( + R.string.update_never, + R.string.update_24hour, + R.string.update_48hour, + R.string.update_weekly, + R.string.update_monthly + ) + entryValues = arrayOf("0", "1", "2", "7", "30") + defaultValue = "2" + + onChange { + SimilarUpdateJob.setupTask(context, true) + true + } + } + + preference { + key = "similar_credits" + title = "Credits" + val url = "https://github.com/goldbattle/MangadexRecomendations" + summary = context.getString(R.string.similar_credit_message, url) + onClick { + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + startActivity(intent) + } + isIconSpaceReserved = true + } + } } override fun siteLoginDialogClosed(source: Source) { diff --git a/app/src/main/java/exh/md/follows/MangaDexFollowsPresenter.kt b/app/src/main/java/exh/md/follows/MangaDexFollowsPresenter.kt index 57ea7b4dc..23cf7fcc3 100644 --- a/app/src/main/java/exh/md/follows/MangaDexFollowsPresenter.kt +++ b/app/src/main/java/exh/md/follows/MangaDexFollowsPresenter.kt @@ -4,7 +4,7 @@ import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.online.all.MangaDex import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter import eu.kanade.tachiyomi.ui.browse.source.browse.Pager -import exh.source.EnhancedHttpSource +import exh.source.EnhancedHttpSource.Companion.getMainSource /** * Presenter of [MangaDexFollowsController]. Inherit BrowseCataloguePresenter. @@ -12,7 +12,7 @@ import exh.source.EnhancedHttpSource class MangaDexFollowsPresenter(sourceId: Long) : BrowseSourcePresenter(sourceId) { override fun createPager(query: String, filters: FilterList): Pager { - val sourceAsMangaDex = (source as EnhancedHttpSource).enhancedSource as MangaDex + val sourceAsMangaDex = source.getMainSource() as MangaDex return MangaDexFollowsPager(sourceAsMangaDex) } } diff --git a/app/src/main/java/exh/md/handlers/SimilarHandler.kt b/app/src/main/java/exh/md/handlers/SimilarHandler.kt index c245e974a..0288a8ad9 100644 --- a/app/src/main/java/exh/md/handlers/SimilarHandler.kt +++ b/app/src/main/java/exh/md/handlers/SimilarHandler.kt @@ -1,47 +1,41 @@ package exh.md.handlers -// todo make this work -/*import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.MangaSimilarImpl import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.SManga +import exh.md.similar.sql.models.MangaSimilar +import exh.md.similar.sql.models.MangaSimilarImpl import exh.md.utils.MdUtil import rx.Observable import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -class SimilarHandler(val preferences: PreferencesHelper) { +class SimilarHandler(val preferences: PreferencesHelper, private val useLowQualityCovers: Boolean) { - *//** - * fetch our similar mangas - *//* + /* + * fetch our similar mangas + */ fun fetchSimilar(manga: Manga): Observable { - // Parse the Mangadex id from the URL - val mangaid = MdUtil.getMangaId(manga.url).toLong() - - val lowQualityCovers = preferences.mangaDexLowQualityCovers().get() - - // Get our current database - val db = Injekt.get() - val similarMangaDb = db.getSimilar(mangaid).executeAsBlocking() ?: return Observable.just(MangasPage(mutableListOf(), false)) - - // Check if we have a result - - val similarMangaTitles = similarMangaDb.matched_titles.split(MangaSimilarImpl.DELIMITER) - val similarMangaIds = similarMangaDb.matched_ids.split(MangaSimilarImpl.DELIMITER) - - val similarMangas = similarMangaIds.mapIndexed { index, similarId -> - SManga.create().apply { - title = similarMangaTitles[index] - url = "/manga/$similarId/" - thumbnail_url = MdUtil.formThumbUrl(url, lowQualityCovers) + return Observable.just(MdUtil.getMangaId(manga.url).toLong()) + .flatMap { mangaId -> + val db = Injekt.get() + db.getSimilar(mangaId).asRxObservable() + }.map { similarMangaDb: MangaSimilar? -> + similarMangaDb?.let { mangaSimilar -> + val similarMangaTitles = mangaSimilar.matched_titles.split(MangaSimilarImpl.DELIMITER) + val similarMangaIds = mangaSimilar.matched_ids.split(MangaSimilarImpl.DELIMITER) + val similarMangas = similarMangaIds.mapIndexed { index, similarId -> + SManga.create().apply { + title = similarMangaTitles[index] + url = "/manga/$similarId/" + thumbnail_url = MdUtil.formThumbUrl(url, useLowQualityCovers) + } + } + MangasPage(similarMangas, false) + } ?: MangasPage(mutableListOf(), false) } - } - - // Return the matches - return Observable.just(MangasPage(similarMangas, false)) } -}*/ +} diff --git a/app/src/main/java/exh/md/similar/SimilarHttpService.kt b/app/src/main/java/exh/md/similar/SimilarHttpService.kt new file mode 100644 index 000000000..e36219e9f --- /dev/null +++ b/app/src/main/java/exh/md/similar/SimilarHttpService.kt @@ -0,0 +1,47 @@ +package exh.md.similar + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import eu.kanade.tachiyomi.network.NetworkHelper +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody +import retrofit2.Call +import retrofit2.Retrofit +import retrofit2.http.GET +import retrofit2.http.Streaming +import uy.kohesive.injekt.injectLazy + +interface SimilarHttpService { + companion object { + private val client by lazy { + val network: NetworkHelper by injectLazy() + network.client.newBuilder() + // unzip interceptor which will add the correct headers + .addNetworkInterceptor { chain -> + val originalResponse = chain.proceed(chain.request()) + originalResponse.newBuilder() + .header("Content-Encoding", "gzip") + .header("Content-Type", "application/json") + .build() + } + .build() + } + + @ExperimentalSerializationApi + fun create(): SimilarHttpService { + // actual builder, which will parse the underlying json file + val adapter = Retrofit.Builder() + .baseUrl("https://raw.githubusercontent.com") + .addConverterFactory(Json.asConverterFactory("application/json".toMediaType())) + .client(client) + .build() + + return adapter.create(SimilarHttpService::class.java) + } + } + + @Streaming + @GET("/goldbattle/MangadexRecomendations/master/output/mangas_compressed.json.gz") + fun getSimilarResults(): Call +} diff --git a/app/src/main/java/exh/md/similar/SimilarUpdateJob.kt b/app/src/main/java/exh/md/similar/SimilarUpdateJob.kt new file mode 100644 index 000000000..38b9983b6 --- /dev/null +++ b/app/src/main/java/exh/md/similar/SimilarUpdateJob.kt @@ -0,0 +1,74 @@ +package exh.md.similar + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.concurrent.TimeUnit + +class SimilarUpdateJob(private val context: Context, workerParams: WorkerParameters) : + Worker(context, workerParams) { + + override fun doWork(): Result { + SimilarUpdateService.start(context) + return Result.success() + } + + companion object { + const val TAG = "RelatedUpdate" + + fun setupTask(context: Context, skipInitial: Boolean = false) { + val preferences = Injekt.get() + val enabled = preferences.mangadexSimilarEnabled().get() + val interval = preferences.mangadexSimilarUpdateInterval().get() + if (enabled) { + // We are enabled, so construct the constraints + val wifiRestriction = if (preferences.mangadexSimilarOnlyOverWifi().get()) { + NetworkType.UNMETERED + } else { + NetworkType.CONNECTED + } + val constraints = Constraints.Builder() + .setRequiredNetworkType(wifiRestriction) + .build() + + // If we are not skipping the initial then run it right now + // Note that we won't run it if the constraints are not satisfied + if (!skipInitial) { + WorkManager.getInstance(context).enqueue(OneTimeWorkRequestBuilder().setConstraints(constraints).build()) + } + + // Finally build the periodic request + val request = PeriodicWorkRequestBuilder( + interval.toLong(), + TimeUnit.DAYS, + 1, + TimeUnit.HOURS + ) + .addTag(TAG) + .setConstraints(constraints) + .build() + + if (interval > 0) { + WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.REPLACE, request) + } else { + WorkManager.getInstance(context).cancelAllWorkByTag(TAG) + } + } else { + WorkManager.getInstance(context).cancelAllWorkByTag(TAG) + } + } + + fun doWorkNow(context: Context) { + WorkManager.getInstance(context).enqueue(OneTimeWorkRequestBuilder().build()) + } + } +} diff --git a/app/src/main/java/exh/md/similar/SimilarUpdateService.kt b/app/src/main/java/exh/md/similar/SimilarUpdateService.kt new file mode 100644 index 000000000..4091b0da7 --- /dev/null +++ b/app/src/main/java/exh/md/similar/SimilarUpdateService.kt @@ -0,0 +1,324 @@ +package exh.md.similar + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory +import android.os.PowerManager +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.text.isDigitsOnly +import com.elvishew.xlog.XLog +import com.squareup.moshi.JsonReader +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.notification.NotificationReceiver +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.util.system.isServiceRunning +import eu.kanade.tachiyomi.util.system.notificationManager +import exh.md.similar.sql.models.MangaSimilarImpl +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okio.buffer +import okio.sink +import okio.source +import retrofit2.awaitResponse +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.io.File +import java.util.concurrent.TimeUnit + +class SimilarUpdateService( + val db: DatabaseHelper = Injekt.get() +) : Service() { + + /** + * Wake lock that will be held until the service is destroyed. + */ + private lateinit var wakeLock: PowerManager.WakeLock + + var similarServiceScope = CoroutineScope(Dispatchers.IO + Job()) + + /** + * Subscription where the update is done. + */ + private var job: Job? = null + + /** + * Pending intent of action that cancels the library update + */ + private val cancelIntent by lazy { + NotificationReceiver.cancelSimilarUpdatePendingBroadcast(this) + } + + private val progressNotification by lazy { + NotificationCompat.Builder(this, Notifications.CHANNEL_SIMILAR) + .setLargeIcon(BitmapFactory.decodeResource(this.resources, R.mipmap.ic_launcher)) + .setSmallIcon(R.drawable.ic_tachi) + .setOngoing(true) + .setContentTitle(getString(R.string.similar_loading_progress_start)) + .setAutoCancel(true) + .addAction( + R.drawable.ic_close_24dp, + getString(android.R.string.cancel), + cancelIntent + ) + } + + /** + * Method called when the service is created. It injects dagger dependencies and acquire + * the wake lock. + */ + override fun onCreate() { + super.onCreate() + startForeground(Notifications.ID_SIMILAR_PROGRESS, progressNotification.build()) + wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "SimilarUpdateService:WakeLock" + ) + wakeLock.acquire(TimeUnit.MINUTES.toMillis(30)) + } + + /** + * Method called when the service is destroyed. It destroys subscriptions and releases the wake + * lock. + */ + override fun onDestroy() { + job?.cancel() + similarServiceScope.cancel() + if (wakeLock.isHeld) { + wakeLock.release() + } + super.onDestroy() + } + + /** + * This method needs to be implemented, but it's not used/needed. + */ + override fun onBind(intent: Intent) = null + + /** + * Method called when the service receives an intent. + * + * @param intent the start intent from. + * @param flags the flags of the command. + * @param startId the start id of this command. + * @return the start value of the command. + */ + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null) return Service.START_NOT_STICKY + + // Unsubscribe from any previous subscription if needed. + job?.cancel() + val handler = CoroutineExceptionHandler { _, exception -> + XLog.e(exception) + stopSelf(startId) + showResultNotification(true) + cancelProgressNotification() + } + job = similarServiceScope.launch(handler) { + updateSimilar() + } + job?.invokeOnCompletion { stopSelf(startId) } + + return START_REDELIVER_INTENT + } + + /** + * Method that updates the similar database for manga + */ + private suspend fun updateSimilar() = withContext(Dispatchers.IO) { + val response = SimilarHttpService.create().getSimilarResults().awaitResponse() + if (!response.isSuccessful) { + throw Exception("Error trying to download similar file") + } + val destinationFile = File(filesDir, "neko-similar.json") + val buffer = withContext(Dispatchers.IO) { destinationFile.sink().buffer() } + + // write json to file + response.body()?.byteStream()?.source()?.use { input -> + buffer.use { output -> + output.writeAll(input) + } + } + + val listSimilar = getSimilar(destinationFile) + + // Delete the old similar table + db.deleteAllSimilar().executeAsBlocking() + + val totalManga = listSimilar.size + + // Loop through each and insert into the database + + val dataToInsert = listSimilar.mapIndexed { index, similarFromJson -> + showProgressNotification(index, totalManga) + + if (similarFromJson.similarIds.size != similarFromJson.similarTitles.size) { + return@mapIndexed null + } + + val similar = MangaSimilarImpl() + similar.id = index.toLong() + similar.manga_id = similarFromJson.id.toLong() + similar.matched_ids = similarFromJson.similarIds.joinToString(MangaSimilarImpl.DELIMITER) + similar.matched_titles = similarFromJson.similarTitles.joinToString(MangaSimilarImpl.DELIMITER) + return@mapIndexed similar + }.filterNotNull() + + showProgressNotification(dataToInsert.size, totalManga) + + if (dataToInsert.isNotEmpty()) { + db.insertSimilar(dataToInsert).executeAsBlocking() + } + destinationFile.delete() + showResultNotification(!this.isActive) + cancelProgressNotification() + } + + private fun getSimilar(destinationFile: File): List { + val reader = JsonReader.of(destinationFile.source().buffer()) + + var processingManga = false + var processingTitles = false + var mangaId: String? = null + var similarIds = mutableListOf() + var similarTitles = mutableListOf() + val similars = mutableListOf() + + while (reader.peek() != JsonReader.Token.END_DOCUMENT) { + val nextToken = reader.peek() + + if (JsonReader.Token.BEGIN_OBJECT == nextToken) { + reader.beginObject() + } else if (JsonReader.Token.NAME == nextToken) { + val name = reader.nextName() + if (!processingManga && name.isDigitsOnly()) { + processingManga = true + // similar add id + mangaId = name + } else if (name == "m_titles") { + processingTitles = true + } + } else if (JsonReader.Token.BEGIN_ARRAY == nextToken) { + reader.beginArray() + } else if (JsonReader.Token.END_ARRAY == nextToken) { + reader.endArray() + if (processingTitles) { + processingManga = false + processingTitles = false + similars.add(SimilarFromJson(mangaId!!, similarIds.toList(), similarTitles.toList())) + mangaId = null + similarIds = mutableListOf() + similarTitles = mutableListOf() + } + } else if (JsonReader.Token.NUMBER == nextToken) { + similarIds.add(reader.nextInt().toString()) + } else if (JsonReader.Token.STRING == nextToken) { + if (processingTitles) { + similarTitles.add(reader.nextString()) + } + } else if (JsonReader.Token.END_OBJECT == nextToken) { + reader.endObject() + } + } + + return similars + } + + data class SimilarFromJson(val id: String, val similarIds: List, val similarTitles: List) + + /** + * Shows the notification containing the currently updating manga and the progress. + * + * @param current the current progress. + * @param total the total progress. + */ + private fun showProgressNotification(current: Int, total: Int) { + notificationManager.notify( + Notifications.ID_SIMILAR_PROGRESS, + progressNotification + .setContentTitle( + getString( + R.string.similar_loading_percent, + current, + total + ) + ) + .setProgress(total, current, false) + .build() + ) + } + + /** + * Shows the notification containing the result of the update done by the service. + * + * @param error if the result was a error. + */ + private fun showResultNotification(error: Boolean = false) { + val title = if (error) { + getString(R.string.similar_loading_complete_error) + } else { + getString( + R.string.similar_loading_complete + ) + } + + val result = NotificationCompat.Builder(this, Notifications.CHANNEL_SIMILAR) + .setContentTitle(title) + .setLargeIcon(BitmapFactory.decodeResource(this.resources, R.mipmap.ic_launcher)) + .setSmallIcon(R.drawable.ic_tachi) + .setAutoCancel(true) + NotificationManagerCompat.from(this) + .notify(Notifications.ID_SIMILAR_COMPLETE, result.build()) + } + + /** + * Cancels the progress notification. + */ + private fun cancelProgressNotification() { + notificationManager.cancel(Notifications.ID_SIMILAR_PROGRESS) + } + + companion object { + + /** + * Returns the status of the service. + * + * @param context the application context. + * @return true if the service is running, false otherwise. + */ + fun isRunning(context: Context): Boolean { + return context.isServiceRunning(SimilarUpdateService::class.java) + } + + /** + * Starts the service. It will be started only if there isn't another instance already + * running. + * + * @param context the application context. + */ + fun start(context: Context) { + if (!isRunning(context)) { + val intent = Intent(context, SimilarUpdateService::class.java) + ContextCompat.startForegroundService(context, intent) + } + } + + /** + * Stops the service. + * + * @param context the application context. + */ + fun stop(context: Context) { + context.stopService(Intent(context, SimilarUpdateService::class.java)) + } + } +} diff --git a/app/src/main/java/exh/md/similar/sql/mappers/SimilarTypeMapping.kt b/app/src/main/java/exh/md/similar/sql/mappers/SimilarTypeMapping.kt new file mode 100644 index 000000000..53283be6b --- /dev/null +++ b/app/src/main/java/exh/md/similar/sql/mappers/SimilarTypeMapping.kt @@ -0,0 +1,63 @@ +package exh.md.similar.sql.mappers + +import android.content.ContentValues +import android.database.Cursor +import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping +import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver +import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver +import com.pushtorefresh.storio.sqlite.operations.put.DefaultPutResolver +import com.pushtorefresh.storio.sqlite.queries.DeleteQuery +import com.pushtorefresh.storio.sqlite.queries.InsertQuery +import com.pushtorefresh.storio.sqlite.queries.UpdateQuery +import exh.md.similar.sql.models.MangaSimilar +import exh.md.similar.sql.models.MangaSimilarImpl +import exh.md.similar.sql.tables.SimilarTable.COL_ID +import exh.md.similar.sql.tables.SimilarTable.COL_MANGA_ID +import exh.md.similar.sql.tables.SimilarTable.COL_MANGA_SIMILAR_MATCHED_IDS +import exh.md.similar.sql.tables.SimilarTable.COL_MANGA_SIMILAR_MATCHED_TITLES +import exh.md.similar.sql.tables.SimilarTable.TABLE + +class SimilarTypeMapping : SQLiteTypeMapping( + SimilarPutResolver(), + SimilarGetResolver(), + SimilarDeleteResolver() +) + +class SimilarPutResolver : DefaultPutResolver() { + + override fun mapToInsertQuery(obj: MangaSimilar) = InsertQuery.builder() + .table(TABLE) + .build() + + override fun mapToUpdateQuery(obj: MangaSimilar) = UpdateQuery.builder() + .table(TABLE) + .where("$COL_ID = ?") + .whereArgs(obj.id) + .build() + + override fun mapToContentValues(obj: MangaSimilar) = ContentValues(4).apply { + put(COL_ID, obj.id) + put(COL_MANGA_ID, obj.manga_id) + put(COL_MANGA_SIMILAR_MATCHED_IDS, obj.matched_ids) + put(COL_MANGA_SIMILAR_MATCHED_TITLES, obj.matched_titles) + } +} + +class SimilarGetResolver : DefaultGetResolver() { + + override fun mapFromCursor(cursor: Cursor): MangaSimilar = MangaSimilarImpl().apply { + id = cursor.getLong(cursor.getColumnIndex(COL_ID)) + manga_id = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID)) + matched_ids = cursor.getString(cursor.getColumnIndex(COL_MANGA_SIMILAR_MATCHED_IDS)) + matched_titles = cursor.getString(cursor.getColumnIndex(COL_MANGA_SIMILAR_MATCHED_TITLES)) + } +} + +class SimilarDeleteResolver : DefaultDeleteResolver() { + + override fun mapToDeleteQuery(obj: MangaSimilar) = DeleteQuery.builder() + .table(TABLE) + .where("$COL_ID = ?") + .whereArgs(obj.id) + .build() +} diff --git a/app/src/main/java/exh/md/similar/sql/models/MangaSimilar.kt b/app/src/main/java/exh/md/similar/sql/models/MangaSimilar.kt new file mode 100644 index 000000000..03d050bc9 --- /dev/null +++ b/app/src/main/java/exh/md/similar/sql/models/MangaSimilar.kt @@ -0,0 +1,31 @@ +package exh.md.similar.sql.models + +import java.io.Serializable + +/** + * Object containing the history statistics of a chapter + */ +interface MangaSimilar : Serializable { + + /** + * Id of this similar manga object. + */ + var id: Long? + + /** + * Id of matching manga + */ + var manga_id: Long? + + /** + * JSONArray.toString() list of ids for this manga + * Example: [3467, 5907, 21052, 2141, 6139, 5602, 3999] + */ + var matched_ids: String + + /** + * JSONArray.toString() list of titles for this manga + * Example: [Title1, Title2, ..., Title10] + */ + var matched_titles: String +} diff --git a/app/src/main/java/exh/md/similar/sql/models/MangaSimilarImpl.kt b/app/src/main/java/exh/md/similar/sql/models/MangaSimilarImpl.kt new file mode 100644 index 000000000..07297b4c1 --- /dev/null +++ b/app/src/main/java/exh/md/similar/sql/models/MangaSimilarImpl.kt @@ -0,0 +1,32 @@ +package exh.md.similar.sql.models + +class MangaSimilarImpl : MangaSimilar { + + override var id: Long? = null + + override var manga_id: Long? = null + + override lateinit var matched_ids: String + + override lateinit var matched_titles: String + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + + other as MangaSimilar + + if (id != other.id) return false + if (manga_id != other.manga_id) return false + if (matched_ids != other.matched_ids) return false + return matched_titles != other.matched_titles + } + + override fun hashCode(): Int { + return id.hashCode() + manga_id.hashCode() + } + + companion object { + const val DELIMITER = "|*|" + } +} diff --git a/app/src/main/java/exh/md/similar/sql/queries/SimilarQueries.kt b/app/src/main/java/exh/md/similar/sql/queries/SimilarQueries.kt new file mode 100644 index 000000000..ad430078c --- /dev/null +++ b/app/src/main/java/exh/md/similar/sql/queries/SimilarQueries.kt @@ -0,0 +1,42 @@ +package exh.md.similar.sql.queries + +import com.pushtorefresh.storio.sqlite.queries.DeleteQuery +import com.pushtorefresh.storio.sqlite.queries.Query +import eu.kanade.tachiyomi.data.database.DbProvider +import exh.md.similar.sql.models.MangaSimilar +import exh.md.similar.sql.tables.SimilarTable + +interface SimilarQueries : DbProvider { + + fun getAllSimilar() = db.get() + .listOfObjects(MangaSimilar::class.java) + .withQuery( + Query.builder() + .table(SimilarTable.TABLE) + .build() + ) + .prepare() + + fun getSimilar(manga_id: Long) = db.get() + .`object`(MangaSimilar::class.java) + .withQuery( + Query.builder() + .table(SimilarTable.TABLE) + .where("${SimilarTable.COL_MANGA_ID} = ?") + .whereArgs(manga_id) + .build() + ) + .prepare() + + fun insertSimilar(similar: MangaSimilar) = db.put().`object`(similar).prepare() + + fun insertSimilar(similarList: List) = db.put().objects(similarList).prepare() + + fun deleteAllSimilar() = db.delete() + .byQuery( + DeleteQuery.builder() + .table(SimilarTable.TABLE) + .build() + ) + .prepare() +} diff --git a/app/src/main/java/exh/md/similar/sql/tables/SimilarTable.kt b/app/src/main/java/exh/md/similar/sql/tables/SimilarTable.kt new file mode 100644 index 000000000..937f8f1df --- /dev/null +++ b/app/src/main/java/exh/md/similar/sql/tables/SimilarTable.kt @@ -0,0 +1,27 @@ +package exh.md.similar.sql.tables + +object SimilarTable { + + const val TABLE = "manga_related" + + const val COL_ID = "_id" + + const val COL_MANGA_ID = "manga_id" + + const val COL_MANGA_SIMILAR_MATCHED_IDS = "matched_ids" + + const val COL_MANGA_SIMILAR_MATCHED_TITLES = "matched_titles" + + val createTableQuery: String + get() = + """CREATE TABLE $TABLE( + $COL_ID INTEGER NOT NULL PRIMARY KEY, + $COL_MANGA_ID INTEGER NOT NULL, + $COL_MANGA_SIMILAR_MATCHED_IDS TEXT NOT NULL, + $COL_MANGA_SIMILAR_MATCHED_TITLES TEXT NOT NULL, + UNIQUE ($COL_ID) ON CONFLICT REPLACE + )""" + + val createMangaIdIndexQuery: String + get() = "CREATE INDEX ${TABLE}_${COL_MANGA_SIMILAR_MATCHED_IDS}_index ON $TABLE($COL_MANGA_SIMILAR_MATCHED_IDS)" +} diff --git a/app/src/main/java/exh/md/similar/ui/EnableMangaDexSimilarDialogController.kt b/app/src/main/java/exh/md/similar/ui/EnableMangaDexSimilarDialogController.kt new file mode 100644 index 000000000..cc2d80e69 --- /dev/null +++ b/app/src/main/java/exh/md/similar/ui/EnableMangaDexSimilarDialogController.kt @@ -0,0 +1,27 @@ +package exh.md.similar.ui + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import exh.md.similar.SimilarUpdateJob +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class EnableMangaDexSimilarDialogController : DialogController() { + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val activity = activity!! + val preferences = Injekt.get() + return MaterialDialog(activity) + .title(text = activity.getString(R.string.similar_ask_to_enable_title)) + .message(R.string.similar_ask_to_enable) + .negativeButton(R.string.similar_ask_to_enable_no, activity.getString(R.string.similar_ask_to_enable_no)) + .positiveButton(R.string.similar_ask_to_enable_yes, activity.getString(R.string.similar_ask_to_enable_yes)) { + preferences.mangadexSimilarEnabled().set(true) + SimilarUpdateJob.setupTask(activity) + } + } +} diff --git a/app/src/main/java/exh/md/similar/ui/MangaDexSimilarController.kt b/app/src/main/java/exh/md/similar/ui/MangaDexSimilarController.kt new file mode 100644 index 000000000..2ebd94f08 --- /dev/null +++ b/app/src/main/java/exh/md/similar/ui/MangaDexSimilarController.kt @@ -0,0 +1,55 @@ +package exh.md.similar.ui + +import android.os.Bundle +import android.view.Menu +import androidx.core.os.bundleOf +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter + +/** + * Controller that shows the latest manga from the catalogue. Inherit [BrowseSourceController]. + */ +class MangaDexSimilarController(bundle: Bundle) : BrowseSourceController(bundle) { + + constructor(manga: Manga, source: CatalogueSource) : this( + bundleOf( + MANGA_ID to manga.id!!, + SOURCE_ID_KEY to source.id + ) + ) + + override fun getTitle(): String? { + return view?.context?.getString(R.string.similar) + } + + override fun createPresenter(): BrowseSourcePresenter { + return MangaDexSimilarPresenter(args.getLong(MANGA_ID), args.getLong(SOURCE_ID_KEY)) + } + + override fun onPrepareOptionsMenu(menu: Menu) { + super.onPrepareOptionsMenu(menu) + menu.findItem(R.id.action_search).isVisible = false + menu.findItem(R.id.action_open_in_web_view).isVisible = false + menu.findItem(R.id.action_settings).isVisible = false + } + + override fun initFilterSheet() { + // No-op: we don't allow filtering in similar + } + + override fun onItemLongClick(position: Int) { + return + } + + override fun onAddPageError(error: Throwable) { + super.onAddPageError(error) + binding.emptyView.show("No Similar Manga found") + } + + companion object { + const val MANGA_ID = "manga_id" + } +} diff --git a/app/src/main/java/exh/md/similar/ui/MangaDexSimilarPager.kt b/app/src/main/java/exh/md/similar/ui/MangaDexSimilarPager.kt new file mode 100644 index 000000000..190d2e0bf --- /dev/null +++ b/app/src/main/java/exh/md/similar/ui/MangaDexSimilarPager.kt @@ -0,0 +1,29 @@ +package exh.md.similar.ui + +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.online.all.MangaDex +import eu.kanade.tachiyomi.ui.browse.source.browse.NoResultsException +import eu.kanade.tachiyomi.ui.browse.source.browse.Pager +import rx.Observable +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers + +/** + * MangaDexSimilarPager inherited from the general Pager. + */ +class MangaDexSimilarPager(val manga: Manga, val source: MangaDex) : Pager() { + + override fun requestNext(): Observable { + return source.fetchMangaSimilar(manga) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { + if (it.mangas.isNotEmpty()) { + onPageReceived(it) + } else { + throw NoResultsException() + } + } + } +} diff --git a/app/src/main/java/exh/md/similar/ui/MangaDexSimilarPresenter.kt b/app/src/main/java/exh/md/similar/ui/MangaDexSimilarPresenter.kt new file mode 100644 index 000000000..dfffee18a --- /dev/null +++ b/app/src/main/java/exh/md/similar/ui/MangaDexSimilarPresenter.kt @@ -0,0 +1,26 @@ +package exh.md.similar.ui + +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.online.all.MangaDex +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter +import eu.kanade.tachiyomi.ui.browse.source.browse.Pager +import exh.source.EnhancedHttpSource.Companion.getMainSource +import uy.kohesive.injekt.injectLazy + +/** + * Presenter of [MangaDexSimilarController]. Inherit BrowseCataloguePresenter. + */ +class MangaDexSimilarPresenter(val mangaId: Long, sourceId: Long) : BrowseSourcePresenter(sourceId) { + + var manga: Manga? = null + + val db: DatabaseHelper by injectLazy() + + override fun createPager(query: String, filters: FilterList): Pager { + val sourceAsMangaDex = source.getMainSource() as MangaDex + this.manga = db.getManga(mangaId).executeAsBlocking() + return MangaDexSimilarPager(manga!!, sourceAsMangaDex) + } +} diff --git a/app/src/main/java/exh/recs/RecommendsController.kt b/app/src/main/java/exh/recs/RecommendsController.kt new file mode 100644 index 000000000..8f21d2b05 --- /dev/null +++ b/app/src/main/java/exh/recs/RecommendsController.kt @@ -0,0 +1,70 @@ +package exh.recs + +import android.os.Bundle +import android.view.Menu +import android.view.View +import androidx.core.os.bundleOf +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.browse.source.SourceController +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController +import eu.kanade.tachiyomi.ui.browse.source.browse.SourceItem + +/** + * Controller that shows the latest manga from the catalogue. Inherit [BrowseSourceController]. + */ +class RecommendsController(bundle: Bundle) : BrowseSourceController(bundle) { + + constructor(manga: Manga, source: CatalogueSource) : this( + bundleOf( + MANGA_ID to manga.id!!, + SOURCE_ID_KEY to source.id + ) + ) + + override fun getTitle(): String? { + return (presenter as? RecommendsPresenter)?.manga?.title + } + + override fun createPresenter(): RecommendsPresenter { + return RecommendsPresenter(args.getLong(MANGA_ID), args.getLong(SOURCE_ID_KEY)) + } + + override fun onPrepareOptionsMenu(menu: Menu) { + super.onPrepareOptionsMenu(menu) + menu.findItem(R.id.action_search).isVisible = false + menu.findItem(R.id.action_open_in_web_view).isVisible = false + menu.findItem(R.id.action_settings).isVisible = false + } + + override fun initFilterSheet() { + // No-op: we don't allow filtering in recs + } + + override fun onItemClick(view: View, position: Int): Boolean { + val item = adapter?.getItem(position) as? SourceItem ?: return false + openSmartSearch(item.manga.originalTitle) + return true + } + + private fun openSmartSearch(title: String) { + val smartSearchConfig = SourceController.SmartSearchConfig(title) + router.pushController( + SourceController( + bundleOf( + SourceController.SMART_SEARCH_CONFIG to smartSearchConfig + ) + ).withFadeTransaction() + ) + } + + override fun onItemLongClick(position: Int) { + return + } + + companion object { + const val MANGA_ID = "manga_id" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/RecommendsPager.kt b/app/src/main/java/exh/recs/RecommendsPager.kt similarity index 99% rename from app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/RecommendsPager.kt rename to app/src/main/java/exh/recs/RecommendsPager.kt index 0eac0104c..7cf539e4b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/RecommendsPager.kt +++ b/app/src/main/java/exh/recs/RecommendsPager.kt @@ -1,9 +1,10 @@ -package eu.kanade.tachiyomi.ui.browse.source.browse +package exh.recs import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.SMangaImpl +import eu.kanade.tachiyomi.ui.browse.source.browse.Pager import exh.util.MangaType import exh.util.mangaType import kotlinx.coroutines.CoroutineExceptionHandler diff --git a/app/src/main/java/exh/recs/RecommendsPresenter.kt b/app/src/main/java/exh/recs/RecommendsPresenter.kt new file mode 100644 index 000000000..586e9eff6 --- /dev/null +++ b/app/src/main/java/exh/recs/RecommendsPresenter.kt @@ -0,0 +1,23 @@ +package exh.recs + +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter +import eu.kanade.tachiyomi.ui.browse.source.browse.Pager +import uy.kohesive.injekt.injectLazy + +/** + * Presenter of [RecommendsController]. Inherit BrowseCataloguePresenter. + */ +class RecommendsPresenter(val mangaId: Long, sourceId: Long) : BrowseSourcePresenter(sourceId) { + + var manga: Manga? = null + + val db: DatabaseHelper by injectLazy() + + override fun createPager(query: String, filters: FilterList): Pager { + this.manga = db.getManga(mangaId).executeAsBlocking() + return RecommendsPager(manga!!) + } +} diff --git a/app/src/main/res/values/strings_sy.xml b/app/src/main/res/values/strings_sy.xml index 2d9470001..b7009d676 100644 --- a/app/src/main/res/values/strings_sy.xml +++ b/app/src/main/res/values/strings_sy.xml @@ -32,6 +32,7 @@ All Sources E-Hentai Fork Settings + MangaDex @@ -573,4 +574,37 @@ Metadata corrupted, please refresh the manga No scanlators available + + Similar manga + Updating similar manga (%1$d / %2$d updated) + Updating similar manga complete + Error trying to load/process similar manga + Downloading similar manga data file… + Enable Similar Manga? + + Would you like to enable similar manga recommendations? + This will download approximately 9 MB of data if enabled right now. + You can always enable it in the Settings / Mangadex menu. + + Enable + Skip + Similar Manga Settings + Show similar manga + Pull latest database + Starting manual update + + Download the latest similar manga database. + This is around 9MB in size and is updated daily. + + Similar update frequency + + This is a feature where one can get manga recommendations. + This is a recommendation system outside of MangaDex, and works by matching by genres, + demographics, content type, themes, and then using term frequency–inverse document frequency (tf–idf) to get the + similarity of two manga\'s descriptions. When enabled this file will download immediately!! The file is about 9 MB in size. + + + For more information and to view the source code:\n%s + +