diff --git a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt index b86d1ab0b..aca66f9b9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt +++ b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt @@ -27,6 +27,7 @@ import org.koitharu.kotatsu.reader.readerModule import org.koitharu.kotatsu.remotelist.remoteListModule import org.koitharu.kotatsu.search.searchModule import org.koitharu.kotatsu.settings.settingsModule +import org.koitharu.kotatsu.suggestions.suggestionsModule import org.koitharu.kotatsu.tracker.trackerModule import org.koitharu.kotatsu.widget.WidgetUpdater import org.koitharu.kotatsu.widget.appWidgetModule @@ -67,6 +68,7 @@ class KotatsuApp : Application() { settingsModule, readerModule, appWidgetModule, + suggestionsModule, ) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt index 04b1ba764..89d90665e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt @@ -10,6 +10,8 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouritesDao import org.koitharu.kotatsu.history.data.HistoryDao import org.koitharu.kotatsu.history.data.HistoryEntity +import org.koitharu.kotatsu.suggestions.data.SuggestionDao +import org.koitharu.kotatsu.suggestions.data.SuggestionEntity @Database( entities = [ @@ -35,4 +37,6 @@ abstract class MangaDatabase : RoomDatabase() { abstract val tracksDao: TracksDao abstract val trackLogsDao: TrackLogsDao + + abstract val suggestionDao: SuggestionDao } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt index e71378eec..beb969daf 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt @@ -237,7 +237,6 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor when { c == '-' -> { builder.setCharAt(i, ' ') - capitalize = true } capitalize -> { builder.setCharAt(i, c.uppercaseChar()) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ChanRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ChanRepository.kt index 82a0a3268..ceb4ee024 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ChanRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ChanRepository.kt @@ -61,7 +61,7 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe tags = runCatching { row.selectFirst("div.genre")?.select("a")?.mapToSet { MangaTag( - title = it.text(), + title = it.text().toTitleCase(), key = it.attr("href").substringAfterLast('/').urlEncoded(), source = source ) @@ -136,7 +136,7 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe return root.select("li.sidetag").mapToSet { li -> val a = li.children().last() ?: throw ParseException("a is null") MangaTag( - title = a.text().toCamelCase(), + title = a.text().toTitleCase(), key = a.attr("href").substringAfterLast('/'), source = source ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt index 0b5fd9b65..308f209b0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt @@ -85,7 +85,7 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor tags = json.getJSONArray("genres").mapToSet { MangaTag( key = it.getString("text"), - title = it.getString("russian"), + title = it.getString("russian").toTitleCase(), source = manga.source ) }, @@ -133,7 +133,7 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor MangaTag( source = source, key = it.selectFirst("input")?.attr("data-genre") ?: parseFailed(), - title = it.selectFirst("label")?.text() ?: parseFailed() + title = it.selectFirst("label")?.text()?.toTitleCase() ?: parseFailed() ) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt index 41b86750e..00619c34f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt @@ -85,7 +85,7 @@ class ExHentaiRepository( val tagsDiv = glink.nextElementSibling() ?: parseFailed("tags div not found") val mainTag = td2.selectFirst("div.cn")?.let { div -> MangaTag( - title = div.text(), + title = div.text().toTitleCase(), key = tagIdByClass(div.classNames()) ?: return@let null, source = source, ) @@ -181,7 +181,7 @@ class ExHentaiRepository( val id = div.id().substringAfterLast('_').toIntOrNull() ?: return@mapNotNullToSet null MangaTag( - title = div.text(), + title = div.text().toTitleCase(), key = id.toString(), source = source ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt index 598a43bf0..c1f49d804 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt @@ -89,7 +89,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) : tileInfo?.select("a.element-link") ?.mapToSet { MangaTag( - title = it.text(), + title = it.text().toTitleCase(), key = it.attr("href").substringAfterLast('/'), source = source ) @@ -119,7 +119,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) : .mapNotNull { val a = it.selectFirst("a.element-link") ?: return@mapNotNull null MangaTag( - title = a.text(), + title = a.text().toTitleCase(), key = a.attr("href").substringAfterLast('/'), source = source ) @@ -183,7 +183,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) : ?.selectFirst("table.table") ?: parseFailed("Cannot find root") return root.select("a.element-link").mapToSet { a -> MangaTag( - title = a.text().toCamelCase(), + title = a.text().toTitleCase(), key = a.attr("href").substringAfterLast('/'), source = source ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt index 072c7611b..2c25870b9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt @@ -5,6 +5,7 @@ import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.utils.ext.mapToSet import org.koitharu.kotatsu.utils.ext.parseHtml +import org.koitharu.kotatsu.utils.ext.toTitleCase class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) { @@ -36,7 +37,7 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load tags = root.selectFirst("div.sidetags")?.select("li.sidetag")?.mapToSet { val a = it.children().last() ?: parseFailed("Invalid tag") MangaTag( - title = a.text(), + title = a.text().toTitleCase(), key = a.attr("href").substringAfterLast('/'), source = source ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaDexRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaDexRepository.kt index 2b289212b..57e57c025 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaDexRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaDexRepository.kt @@ -94,7 +94,8 @@ class MangaDexRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit MangaTag( title = tag.getJSONObject("attributes") .getJSONObject("name") - .firstStringValue(), + .firstStringValue() + .toTitleCase(), key = tag.getString("id"), source = source, ) @@ -194,7 +195,7 @@ class MangaDexRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit .getJSONArray("data") return tags.mapToSet { jo -> MangaTag( - title = jo.getJSONObject("attributes").getJSONObject("name").firstStringValue(), + title = jo.getJSONObject("attributes").getJSONObject("name").firstStringValue().toTitleCase(), key = jo.getString("id"), source = source, ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt index ed58f073c..8ebbf2c44 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt @@ -139,7 +139,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) : tags = info?.selectFirst("div.media-tags") ?.select("a.media-tag-item")?.mapToSet { a -> MangaTag( - title = a.text().toCamelCase(), + title = a.text().toTitleCase(), key = a.attr("href").substringAfterLast('='), source = source ) @@ -203,7 +203,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) : result += MangaTag( source = source, key = x.getInt("id").toString(), - title = x.getString("name").toCamelCase() + title = x.getString("name").toTitleCase(), ) } return result diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaOwlRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaOwlRepository.kt index 5e5429d95..a9c0030d2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaOwlRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaOwlRepository.kt @@ -91,7 +91,7 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit .mapNotNull { val a = it.selectFirst("a") ?: return@mapNotNull null MangaTag( - title = a.text(), + title = a.text().toTitleCase(), key = a.attr("href"), source = source ) @@ -144,7 +144,7 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit return root.mapToSet { p -> val a = p.selectFirst("a") ?: parseFailed("a is null") MangaTag( - title = a.text().toCamelCase(), + title = a.text().toTitleCase(), key = a.attr("href"), source = source ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt index afe3750c3..973bb77bb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt @@ -80,7 +80,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) : }, tags = li.selectFirst("p.keyWord")?.select("a")?.mapNotNullToSet tags@{ x -> MangaTag( - title = x.attr("title"), + title = x.attr("title").toTitleCase(), key = x.attr("href").parseTagKey() ?: return@tags null, source = MangaSource.MANGATOWN ) @@ -104,7 +104,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) : x.selectFirst("b")?.ownText() == "Genre(s):" }?.select("a")?.mapNotNull { a -> MangaTag( - title = a.attr("title"), + title = a.attr("title").toTitleCase(), key = a.attr("href").parseTagKey() ?: return@mapNotNull null, source = MangaSource.MANGATOWN ) @@ -172,7 +172,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) : MangaTag( source = MangaSource.MANGATOWN, key = key, - title = a.text() + title = a.text().toTitleCase() ) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt index 2b2f9b8cd..6aa94cb98 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt @@ -62,7 +62,7 @@ class MangareadRepository( tags = summary?.selectFirst(".mg_genres")?.select("a")?.mapToSet { a -> MangaTag( key = a.attr("href").removeSuffix("/").substringAfterLast('/'), - title = a.text(), + title = a.text().toTitleCase(), source = MangaSource.MANGAREAD ) }.orEmpty(), @@ -91,7 +91,7 @@ class MangareadRepository( } MangaTag( key = href, - title = a.text(), + title = a.text().toTitleCase(), source = MangaSource.MANGAREAD ) } @@ -113,7 +113,7 @@ class MangareadRepository( ?.mapNotNullToSet { a -> MangaTag( key = a.attr("href").removeSuffix("/").substringAfterLast('/'), - title = a.text(), + title = a.text().toTitleCase(), source = MangaSource.MANGAREAD ) } ?: manga.tags, diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt index 7b782ab1c..351467882 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt @@ -94,7 +94,7 @@ abstract class NineMangaRepository( tags = infoRoot.getElementsByAttributeValue("itemprop", "genre").first() ?.select("a")?.mapToSet { a -> MangaTag( - title = a.text(), + title = a.text().toTitleCase(), key = a.attr("href").substringBetween("/", "."), source = source, ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt index ebea9bc94..d3925d62a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt @@ -73,7 +73,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito author = null, tags = jo.optJSONArray("genres")?.mapToSet { g -> MangaTag( - title = g.getString("name"), + title = g.getString("name").toTitleCase(), key = g.getInt("id").toString(), source = MangaSource.REMANGA ) @@ -109,7 +109,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito }, tags = content.getJSONArray("genres").mapToSet { g -> MangaTag( - title = g.getString("name"), + title = g.getString("name").toTitleCase(), key = g.getInt("id").toString(), source = MangaSource.REMANGA ) @@ -175,7 +175,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito .parseJson().getJSONObject("content").getJSONArray("genres") return content.mapToSet { jo -> MangaTag( - title = jo.getString("name"), + title = jo.getString("name").toTitleCase(), key = jo.getInt("id").toString(), source = source ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSection.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSection.kt index 64ce67264..0efa45c92 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSection.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSection.kt @@ -2,5 +2,5 @@ package org.koitharu.kotatsu.core.prefs enum class AppSection { - LOCAL, FAVOURITES, HISTORY, FEED + LOCAL, FAVOURITES, HISTORY, FEED, SUGGESTIONS } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 29f565fc3..7509c5cae 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -141,6 +141,12 @@ class AppSettings(context: Context) { } } + val isSuggestionsEnabled: Boolean + get() = prefs.getBoolean(KEY_SUGGESTIONS, false) + + val isSuggestionsExcludeNsfw: Boolean + get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false) + fun getDateFormat(format: String = prefs.getString(KEY_DATE_FORMAT, "").orEmpty()): DateFormat = when (format) { "" -> DateFormat.getDateInstance(DateFormat.SHORT) @@ -231,6 +237,8 @@ class AppSettings(context: Context) { const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw" const val KEY_PAGES_NUMBERS = "pages_numbers" const val KEY_SCREENSHOTS_POLICY = "screenshots_policy" + const val KEY_SUGGESTIONS = "suggestions" + const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" // About const val KEY_APP_UPDATE = "app_update" diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt index 9ee2642fb..0b973aa64 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.history.data import androidx.room.* import kotlinx.coroutines.flow.Flow import org.koitharu.kotatsu.core.db.entity.MangaEntity +import org.koitharu.kotatsu.core.db.entity.TagEntity @Dao @@ -22,6 +23,9 @@ abstract class HistoryDao { @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history)") abstract suspend fun findAllManga(): List + @Query("SELECT * FROM tags WHERE tag_id IN (SELECT tag_id FROM manga_tags WHERE manga_id IN (SELECT manga_id FROM history))") + abstract suspend fun findAllTags(): List + @Query("SELECT * FROM history WHERE manga_id = :id") abstract suspend fun find(id: Long): HistoryEntity? diff --git a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt index 220f06dca..c492e0e0b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt @@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.MangaHistory +import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.tracker.domain.TrackingRepository @@ -89,4 +90,8 @@ class HistoryRepository( db.historyDao.delete(manga.id) } } + + suspend fun getAllTags(): Set { + return db.historyDao.findAllTags().mapToSet { x -> x.toMangaTag() } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt index f678b83b7..791e9985a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt @@ -7,10 +7,7 @@ import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.MangaChapter import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaTag -import org.koitharu.kotatsu.utils.ext.getBooleanOrDefault -import org.koitharu.kotatsu.utils.ext.getLongOrDefault -import org.koitharu.kotatsu.utils.ext.getStringOrNull -import org.koitharu.kotatsu.utils.ext.mapToSet +import org.koitharu.kotatsu.utils.ext.* class MangaIndex(source: String?) { @@ -61,7 +58,7 @@ class MangaIndex(source: String?) { description = json.getStringOrNull("description"), tags = json.getJSONArray("tags").mapToSet { x -> MangaTag( - title = x.getString("title"), + title = x.getString("title").toTitleCase(), key = x.getString("key"), source = source ) diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt index 380b4942b..2b258bb53 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -49,6 +49,8 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel import org.koitharu.kotatsu.settings.AppUpdateChecker import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment +import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment +import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker import org.koitharu.kotatsu.tracker.ui.FeedFragment import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.utils.ext.getDisplayMessage @@ -122,6 +124,7 @@ class MainActivity : BaseActivity(), viewModel.onError.observe(this, this::onError) viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.remoteSources.observe(this, this::updateSideMenu) + viewModel.isSuggestionsEnabled.observe(this, this::setSuggestionsEnabled) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { @@ -187,6 +190,10 @@ class MainActivity : BaseActivity(), viewModel.defaultSection = AppSection.LOCAL setPrimaryFragment(LocalListFragment.newInstance()) } + R.id.nav_suggestions -> { + viewModel.defaultSection = AppSection.SUGGESTIONS + setPrimaryFragment(SuggestionsFragment.newInstance()) + } R.id.nav_feed -> { viewModel.defaultSection = AppSection.FEED setPrimaryFragment(FeedFragment.newInstance()) @@ -303,6 +310,14 @@ class MainActivity : BaseActivity(), submenu.setGroupCheckable(R.id.group_remote_sources, true, true) } + private fun setSuggestionsEnabled(isEnabled: Boolean) { + val item = binding.navigationView.menu.findItem(R.id.nav_suggestions) ?: return + if (!isEnabled && item.isChecked) { + binding.navigationView.setCheckedItem(R.id.nav_history) + } + item.isVisible = isEnabled + } + private fun openDefaultSection() { when (viewModel.defaultSection) { AppSection.LOCAL -> { @@ -321,6 +336,10 @@ class MainActivity : BaseActivity(), binding.navigationView.setCheckedItem(R.id.nav_feed) setPrimaryFragment(FeedFragment.newInstance()) } + AppSection.SUGGESTIONS -> { + binding.navigationView.setCheckedItem(R.id.nav_suggestions) + setPrimaryFragment(SuggestionsFragment.newInstance()) + } } } @@ -344,6 +363,7 @@ class MainActivity : BaseActivity(), private fun onFirstStart() { lifecycleScope.launch(Dispatchers.Default) { TrackWorker.setup(applicationContext) + SuggestionsWorker.setup(applicationContext) AppUpdateChecker(this@MainActivity).checkIfNeeded() if (!get().isSourcesSelected) { withContext(Dispatchers.Main) { diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt index ec9566e68..f197e454c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt @@ -21,6 +21,12 @@ class MainViewModel( val onOpenReader = SingleLiveEvent() var defaultSection by settings::defaultSection + val isSuggestionsEnabled = settings.observe() + .filter { it == AppSettings.KEY_SUGGESTIONS } + .onStart { emit("") } + .map { settings.isSuggestionsEnabled } + .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) + val remoteSources = settings.observe() .filter { it == AppSettings.KEY_SOURCES_ORDER || it == AppSettings.KEY_SOURCES_HIDDEN } .onStart { emit("") } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/SimpleSettingsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/SimpleSettingsActivity.kt index 8efab6827..c32605b96 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/SimpleSettingsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/SimpleSettingsActivity.kt @@ -14,10 +14,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.databinding.ActivitySettingsSimpleBinding -import org.koitharu.kotatsu.settings.MainSettingsFragment -import org.koitharu.kotatsu.settings.NetworkSettingsFragment -import org.koitharu.kotatsu.settings.ReaderSettingsFragment -import org.koitharu.kotatsu.settings.SourceSettingsFragment +import org.koitharu.kotatsu.settings.* class SimpleSettingsActivity : BaseActivity() { @@ -27,9 +24,11 @@ class SimpleSettingsActivity : BaseActivity() { supportActionBar?.setDisplayHomeAsUpEnabled(true) supportFragmentManager.commit { replace( - R.id.container, when (intent?.action) { + R.id.container, + when (intent?.action) { Intent.ACTION_MANAGE_NETWORK_USAGE -> NetworkSettingsFragment() ACTION_READER -> ReaderSettingsFragment() + ACTION_SUGGESTIONS -> SuggestionsSettingsFragment() ACTION_SOURCE -> SourceSettingsFragment.newInstance( intent.getParcelableExtra(EXTRA_SOURCE) ?: MangaSource.LOCAL ) @@ -55,6 +54,8 @@ class SimpleSettingsActivity : BaseActivity() { private const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS" + private const val ACTION_SUGGESTIONS = + "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS" private const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS" private const val EXTRA_SOURCE = "source" @@ -63,6 +64,10 @@ class SimpleSettingsActivity : BaseActivity() { Intent(context, SimpleSettingsActivity::class.java) .setAction(ACTION_READER) + fun newSuggestionsSettingsIntent(context: Context) = + Intent(context, SimpleSettingsActivity::class.java) + .setAction(ACTION_SUGGESTIONS) + fun newSourceSettingsIntent(context: Context, source: MangaSource) = Intent(context, SimpleSettingsActivity::class.java) .setAction(ACTION_SOURCE) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt index 17e8b6a93..7a2b992ae 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt @@ -11,7 +11,7 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceScreen -import androidx.preference.SwitchPreference +import androidx.preference.TwoStatePreference import kotlinx.coroutines.launch import leakcanary.LeakCanary import org.koin.android.ext.android.inject @@ -56,7 +56,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings), entryValues = ListMode.values().names() setDefaultValueCompat(ListMode.GRID.name) } - findPreference(AppSettings.KEY_DYNAMIC_THEME)?.isVisible = + findPreference(AppSettings.KEY_DYNAMIC_THEME)?.isVisible = AppSettings.isDynamicColorAvailable findPreference(AppSettings.KEY_DATE_FORMAT)?.run { entryValues = arrayOf("", "MM/dd/yy", "dd/MM/yy", "yyyy-MM-dd", "dd MMM yyyy", "MMM dd, yyyy") @@ -72,12 +72,15 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings), setDefaultValueCompat("") summary = "%s" } + findPreference(AppSettings.KEY_SUGGESTIONS)?.setSummary( + if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled + ) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) findPreference(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName() - findPreference(AppSettings.KEY_PROTECT_APP)?.isChecked = + findPreference(AppSettings.KEY_PROTECT_APP)?.isChecked = !settings.appPassword.isNullOrEmpty() settings.subscribe(this) } @@ -114,15 +117,20 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings), findPreference(key)?.setSummary(R.string.restart_required) } AppSettings.KEY_HIDE_TOOLBAR -> { - findPreference(key)?.setSummary(R.string.restart_required) + findPreference(key)?.setSummary(R.string.restart_required) } AppSettings.KEY_LOCAL_STORAGE -> { findPreference(key)?.bindStorageName() } AppSettings.KEY_APP_PASSWORD -> { - findPreference(AppSettings.KEY_PROTECT_APP) + findPreference(AppSettings.KEY_PROTECT_APP) ?.isChecked = !settings.appPassword.isNullOrEmpty() } + AppSettings.KEY_SUGGESTIONS -> { + findPreference(AppSettings.KEY_SUGGESTIONS)?.setSummary( + if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled + ) + } } } @@ -148,7 +156,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings), true } AppSettings.KEY_PROTECT_APP -> { - val pref = (preference as? SwitchPreference ?: return false) + val pref = (preference as? TwoStatePreference ?: return false) if (pref.isChecked) { pref.isChecked = false startActivity(Intent(preference.context, ProtectSetupActivity::class.java)) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SuggestionsSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SuggestionsSettingsFragment.kt new file mode 100644 index 000000000..02467f1d6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SuggestionsSettingsFragment.kt @@ -0,0 +1,46 @@ +package org.koitharu.kotatsu.settings + +import android.content.SharedPreferences +import android.os.Bundle +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BasePreferenceFragment +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository +import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker + +class SuggestionsSettingsFragment : BasePreferenceFragment(R.string.suggestions), + SharedPreferences.OnSharedPreferenceChangeListener { + + private val repository by inject(mode = LazyThreadSafetyMode.NONE) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + settings.subscribe(this) + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_suggestions) + } + + override fun onDestroy() { + super.onDestroy() + settings.unsubscribe(this) + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + if (key == AppSettings.KEY_SUGGESTIONS && settings.isSuggestionsEnabled) { + onSuggestionsEnabled() + } + } + + private fun onSuggestionsEnabled() { + lifecycleScope.launch { + if (repository.isEmpty()) { + SuggestionsWorker.startNow(context ?: return@launch) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/SuggestionsModule.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/SuggestionsModule.kt new file mode 100644 index 000000000..df0a2c870 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/SuggestionsModule.kt @@ -0,0 +1,14 @@ +package org.koitharu.kotatsu.suggestions + +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module +import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository +import org.koitharu.kotatsu.suggestions.ui.SuggestionsViewModel + +val suggestionsModule + get() = module { + + factory { SuggestionRepository(get()) } + + viewModel { SuggestionsViewModel(get(), get()) } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt new file mode 100644 index 000000000..0f80321a0 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt @@ -0,0 +1,31 @@ +package org.koitharu.kotatsu.suggestions.data + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class SuggestionDao { + + @Transaction + @Query("SELECT * FROM suggestions ORDER BY relevance DESC") + abstract fun observeAll(): Flow> + + @Query("SELECT COUNT(*) FROM suggestions") + abstract suspend fun count(): Int + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract suspend fun insert(entity: SuggestionEntity): Long + + @Update + abstract suspend fun update(entity: SuggestionEntity): Int + + @Query("DELETE FROM suggestions") + abstract suspend fun deleteAll() + + @Transaction + open suspend fun upsert(entity: SuggestionEntity) { + if (update(entity) == 0) { + insert(entity) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/SuggestionEntity.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionEntity.kt similarity index 77% rename from app/src/main/java/org/koitharu/kotatsu/core/db/entity/SuggestionEntity.kt rename to app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionEntity.kt index 4459d56b8..97212bf2e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/SuggestionEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionEntity.kt @@ -1,9 +1,11 @@ -package org.koitharu.kotatsu.core.db.entity +package org.koitharu.kotatsu.suggestions.data +import androidx.annotation.FloatRange import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey +import org.koitharu.kotatsu.core.db.entity.MangaEntity @Entity( tableName = "suggestions", @@ -19,6 +21,7 @@ import androidx.room.PrimaryKey class SuggestionEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "manga_id", index = true) val mangaId: Long, + @FloatRange(from = 0.0, to = 1.0) @ColumnInfo(name = "relevance") val relevance: Float, @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(), ) diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionWithManga.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionWithManga.kt new file mode 100644 index 000000000..13aa11bec --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionWithManga.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.suggestions.data + +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation +import org.koitharu.kotatsu.core.db.entity.MangaEntity +import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity +import org.koitharu.kotatsu.core.db.entity.TagEntity + +data class SuggestionWithManga( + @Embedded val suggestion: SuggestionEntity, + @Relation( + parentColumn = "manga_id", + entityColumn = "manga_id" + ) + val manga: MangaEntity, + @Relation( + parentColumn = "manga_id", + entityColumn = "tag_id", + associateBy = Junction(MangaTagsEntity::class) + ) + val tags: List +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/MangaSuggestion.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/MangaSuggestion.kt new file mode 100644 index 000000000..689d8276a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/MangaSuggestion.kt @@ -0,0 +1,10 @@ +package org.koitharu.kotatsu.suggestions.domain + +import androidx.annotation.FloatRange +import org.koitharu.kotatsu.core.model.Manga + +data class MangaSuggestion( + val manga: Manga, + @FloatRange(from = 0.0, to = 1.0) + val relevance: Float, +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt new file mode 100644 index 000000000..aec0a948d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt @@ -0,0 +1,48 @@ +package org.koitharu.kotatsu.suggestions.domain + +import androidx.room.withTransaction +import kotlinx.coroutines.flow.Flow +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.core.db.entity.MangaEntity +import org.koitharu.kotatsu.core.db.entity.TagEntity +import org.koitharu.kotatsu.core.model.Manga +import org.koitharu.kotatsu.suggestions.data.SuggestionEntity +import org.koitharu.kotatsu.utils.ext.mapItems +import org.koitharu.kotatsu.utils.ext.mapToSet + +class SuggestionRepository( + private val db: MangaDatabase, +) { + + fun observeAll(): Flow> { + return db.suggestionDao.observeAll().mapItems { + it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) + } + } + + suspend fun clear() { + db.suggestionDao.deleteAll() + } + + suspend fun isEmpty(): Boolean { + return db.suggestionDao.count() == 0 + } + + suspend fun replace(suggestions: Iterable) { + db.withTransaction { + db.suggestionDao.deleteAll() + suggestions.forEach { x -> + val tags = x.manga.tags.map(TagEntity.Companion::fromMangaTag) + db.tagsDao.upsert(tags) + db.mangaDao.upsert(MangaEntity.from(x.manga), tags) + db.suggestionDao.upsert( + SuggestionEntity( + mangaId = x.manga.id, + relevance = x.relevance, + createdAt = System.currentTimeMillis(), + ) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt new file mode 100644 index 000000000..c70793405 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt @@ -0,0 +1,57 @@ +package org.koitharu.kotatsu.suggestions.ui + +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import com.google.android.material.snackbar.Snackbar +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.list.ui.MangaListFragment +import org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity + +class SuggestionsFragment : MangaListFragment() { + + override val viewModel by viewModel() + override val isSwipeRefreshEnabled = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.opt_suggestions, menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_update -> { + SuggestionsWorker.startNow(requireContext()) + Snackbar.make( + binding.recyclerView, + R.string.feed_will_update_soon, + Snackbar.LENGTH_LONG, + ).show() + true + } + R.id.action_settings -> { + startActivity(SimpleSettingsActivity.newSuggestionsSettingsIntent(requireContext())) + true + } + else -> super.onOptionsItemSelected(item) + } + } + + override fun onScrolledToEnd() = Unit + + override fun getTitle(): CharSequence? { + return context?.getString(R.string.suggestions) + } + + companion object { + + fun newInstance() = SuggestionsFragment() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt new file mode 100644 index 000000000..9d5190a93 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt @@ -0,0 +1,49 @@ +package org.koitharu.kotatsu.suggestions.ui + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.list.ui.MangaListViewModel +import org.koitharu.kotatsu.list.ui.model.* +import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct +import org.koitharu.kotatsu.utils.ext.onFirst + +class SuggestionsViewModel( + repository: SuggestionRepository, + settings: AppSettings, +) : MangaListViewModel(settings) { + + private val headerModel = ListHeader(null, R.string.suggestions) + + override val content = combine( + repository.observeAll(), + createListModeFlow() + ) { list, mode -> + when { + list.isEmpty() -> listOf(EmptyState( + icon = R.drawable.ic_book_cross, + textPrimary = R.string.nothing_found, + textSecondary = R.string.text_suggestion_holder, + )) + else -> buildList(list.size + 1) { + add(headerModel) + list.toUi(this, mode) + } + } + }.onFirst { + isLoading.postValue(false) + }.catch { + it.toErrorState(canRetry = false) + }.asLiveDataDistinct( + viewModelScope.coroutineContext + Dispatchers.Default, + listOf(LoadingState) + ) + + override fun onRefresh() = Unit + + override fun onRetry() = Unit +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt new file mode 100644 index 000000000..f602e534d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt @@ -0,0 +1,104 @@ +package org.koitharu.kotatsu.suggestions.ui + +import android.content.Context +import androidx.work.* +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.koitharu.kotatsu.core.model.Manga +import org.koitharu.kotatsu.core.model.SortOrder +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion +import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository +import org.koitharu.kotatsu.utils.ext.mangaRepositoryOf +import java.util.concurrent.TimeUnit +import kotlin.math.pow + +class SuggestionsWorker(appContext: Context, params: WorkerParameters) : + CoroutineWorker(appContext, params), KoinComponent { + + private val suggestionRepository by inject() + private val historyRepository by inject() + private val appSettings by inject() + + override suspend fun doWork(): Result = try { + val count = doWorkImpl() + Result.success(workDataOf(DATA_COUNT to count)) + } catch (t: Throwable) { + Result.failure() + } + + private suspend fun doWorkImpl(): Int { + if (!appSettings.isSuggestionsEnabled) { + suggestionRepository.clear() + return 0 + } + val rawResults = ArrayList() + val allTags = historyRepository.getAllTags() + if (allTags.isEmpty()) { + return 0 + } + val tagsBySources = allTags.groupBy { x -> x.source } + for ((source, tags) in tagsBySources) { + val repo = mangaRepositoryOf(source) + tags.flatMapTo(rawResults) { tag -> + repo.getList2( + offset = 0, + sortOrder = SortOrder.UPDATED, + tags = setOf(tag), + ) + } + } + if (appSettings.isSuggestionsExcludeNsfw) { + rawResults.removeAll { it.isNsfw } + } + if (rawResults.isEmpty()) { + return 0 + } + val suggestions = rawResults.distinctBy { manga -> + manga.id + }.map { manga -> + val jointTags = manga.tags intersect allTags + MangaSuggestion( + manga = manga, + relevance = (jointTags.size / manga.tags.size.toDouble()).pow(2.0).toFloat(), + ) + }.sortedBy { it.relevance }.take(LIMIT) + suggestionRepository.replace(suggestions) + return suggestions.size + } + + companion object { + + private const val TAG = "suggestions" + private const val TAG_ONESHOT = "suggestions_oneshot" + private const val LIMIT = 140 + private const val DATA_COUNT = "count" + + fun setup(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .setRequiresBatteryNotLow(true) + .build() + val request = PeriodicWorkRequestBuilder(6, TimeUnit.HOURS) + .setConstraints(constraints) + .addTag(TAG) + .setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES) + .build() + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request) + } + + fun startNow(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .addTag(TAG_ONESHOT) + .build() + WorkManager.getInstance(context) + .enqueue(request) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt index 7f487e3e4..3f5d5263a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt @@ -80,7 +80,7 @@ class FeedFragment : BaseFragment(), PaginationScrollListen Snackbar.make( binding.recyclerView, R.string.feed_will_update_soon, - Snackbar.LENGTH_SHORT + Snackbar.LENGTH_LONG, ).show() true } diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt index 7d20f6f43..7c982d601 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt @@ -236,6 +236,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : private const val DATA_PROGRESS = "progress" private const val DATA_TOTAL = "total" private const val TAG = "tracking" + private const val TAG_ONESHOT = "tracking_oneshot" @RequiresApi(Build.VERSION_CODES.O) private fun createNotificationChannel(context: Context) { @@ -276,7 +277,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : .build() val request = OneTimeWorkRequestBuilder() .setConstraints(constraints) - .addTag(TAG) + .addTag(TAG_ONESHOT) .build() WorkManager.getInstance(context) .enqueue(request) diff --git a/app/src/main/res/drawable/ic_suggestion.xml b/app/src/main/res/drawable/ic_suggestion.xml new file mode 100644 index 000000000..a93a75799 --- /dev/null +++ b/app/src/main/res/drawable/ic_suggestion.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/nav_drawer.xml b/app/src/main/res/menu/nav_drawer.xml index 75869c7a7..1e0db493c 100644 --- a/app/src/main/res/menu/nav_drawer.xml +++ b/app/src/main/res/menu/nav_drawer.xml @@ -14,6 +14,10 @@ android:id="@+id/nav_history" android:icon="@drawable/ic_history" android:title="@string/history" /> + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 3155e92f0..74bd60bc0 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -253,4 +253,12 @@ Разрешить Запретить для NSFW Запретить всегда + Рекомендации + Включить рекомендации + Предлагать мангу на основе Ваших предпочтений + Все данные анализируются локально на устройстве. Ваши персональные данные не передаются в какие-либо сервисы + Начните читать мангу, чтобы получать персональные предложения + Не предлагать NSFW мангу + Включено + Выключено \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6adb549de..43a21031c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -255,4 +255,12 @@ Allow Block on NSFW Block always + Suggestions + Enable suggestions + Suggest manga based on your preferences + All data is analyzed locally on this device. There is no transfer of your personal data to any services + Start reading manga and you will get personalized suggestions + Do not suggest NSFW manga + Enabled + Disabled \ No newline at end of file diff --git a/app/src/main/res/xml/pref_main.xml b/app/src/main/res/xml/pref_main.xml index 344a0cf14..0f4d7d6e3 100644 --- a/app/src/main/res/xml/pref_main.xml +++ b/app/src/main/res/xml/pref_main.xml @@ -61,6 +61,13 @@ app:allowDividerAbove="true" app:iconSpaceReserved="false" /> + + - + + + + + + + + + \ No newline at end of file