diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt index d1c3f849e..f9720bb13 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt @@ -9,6 +9,8 @@ import org.koitharu.kotatsu.utils.ext.iterator fun Collection.ids() = mapToSet { it.id } +fun Collection.distinctById() = distinctBy { it.id } + fun Collection.countChaptersByBranch(): Int { if (size <= 1) { return size 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 01b79b675..6dab7e362 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 @@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.shelf.domain.ShelfSection import org.koitharu.kotatsu.utils.ext.connectivityManager import org.koitharu.kotatsu.utils.ext.filterToSet @@ -244,12 +245,25 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val isDownloadsWiFiOnly: Boolean get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false) - val isSuggestionsEnabled: Boolean + var isSuggestionsEnabled: Boolean get() = prefs.getBoolean(KEY_SUGGESTIONS, false) + set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) } val isSuggestionsExcludeNsfw: Boolean get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false) + val isSuggestionsNotificationAvailable: Boolean + get() = prefs.getBoolean(KEY_SUGGESTIONS_NOTIFICATIONS, true) + + val suggestionsTagsBlacklist: Set + get() { + val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',') + if (string.isNullOrEmpty()) { + return emptySet() + } + return string.split(',').mapToSet { it.trim() } + } + val isReaderBarEnabled: Boolean get() = prefs.getBoolean(KEY_READER_BAR, true) @@ -279,18 +293,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { return policy.isNetworkAllowed(connectivityManager) } - fun getSuggestionsTagsBlacklistRegex(): Regex? { - val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',') - if (string.isNullOrEmpty()) { - return null - } - val tags = string.split(',') - val regex = tags.joinToString(prefix = "(", separator = "|", postfix = ")") { tag -> - Regex.escape(tag.trim()) - } - return Regex(regex, RegexOption.IGNORE_CASE) - } - fun getMangaSources(includeHidden: Boolean): List { val list = remoteSources.toMutableList() val order = sourcesOrder @@ -381,6 +383,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_SUGGESTIONS = "suggestions" const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags" + const val KEY_SUGGESTIONS_NOTIFICATIONS = "suggestions_notifications" const val KEY_SHIKIMORI = "shikimori" const val KEY_ANILIST = "anilist" const val KEY_MAL = "mal" diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt b/app/src/main/java/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt index f8bc28e98..5fbce6f56 100644 --- a/app/src/main/java/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt @@ -1,11 +1,16 @@ package org.koitharu.kotatsu.explore.domain -import javax.inject.Inject import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist +import org.koitharu.kotatsu.utils.ext.almostEquals +import org.koitharu.kotatsu.utils.ext.asArrayList +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug +import org.koitharu.kotatsu.utils.ext.runCatchingCancellable +import javax.inject.Inject class ExploreRepository @Inject constructor( private val settings: AppSettings, @@ -14,29 +19,47 @@ class ExploreRepository @Inject constructor( ) { suspend fun findRandomManga(tagsLimit: Int): Manga { - val blacklistTagRegex = settings.getSuggestionsTagsBlacklistRegex() - val allTags = historyRepository.getPopularTags(tagsLimit).filterNot { - blacklistTagRegex?.containsMatchIn(it.title) ?: false + val blacklistTagRegex = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f) + val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull { + if (it in blacklistTagRegex) null else it.title } - val tag = allTags.randomOrNull() - val source = checkNotNull(tag?.source ?: settings.getMangaSources(includeHidden = false).randomOrNull()) { - "No sources found" - } - val repo = mangaRepositoryFactory.create(source) - val list = repo.getList( - offset = 0, - sortOrder = if (SortOrder.UPDATED in repo.sortOrders) SortOrder.UPDATED else null, - tags = setOfNotNull(tag), - ).shuffled() - for (item in list) { - if (settings.isSuggestionsExcludeNsfw && item.isNsfw) { + val sources = settings.getMangaSources(includeHidden = false) + check(sources.isNotEmpty()) { "No sources available" } + for (i in 0..4) { + val list = getList(sources.random(), tags, blacklistTagRegex) + val manga = list.randomOrNull() ?: continue + val details = runCatchingCancellable { + mangaRepositoryFactory.create(manga.source).getDetails(manga) + }.getOrNull() ?: continue + if ((settings.isSuggestionsExcludeNsfw && details.isNsfw) || details in blacklistTagRegex) { continue } - if (blacklistTagRegex != null && item.tags.any { x -> blacklistTagRegex.containsMatchIn(x.title) }) { - continue - } - return item + return details } - return list.random() + throw NoSuchElementException() } + + private suspend fun getList( + source: MangaSource, + tags: List, + blacklist: TagsBlacklist, + ): List = runCatchingCancellable { + val repository = mangaRepositoryFactory.create(source) + val order = repository.sortOrders.random() + val availableTags = repository.getTags() + val tag = tags.firstNotNullOfOrNull { title -> + availableTags.find { x -> x.title.almostEquals(title, 0.4f) } + } + val list = repository.getList(0, setOfNotNull(tag), order).asArrayList() + if (settings.isSuggestionsExcludeNsfw) { + list.removeAll { it.isNsfw } + } + if (blacklist.isNotEmpty()) { + list.removeAll { manga -> manga in blacklist } + } + list.shuffle() + list + }.onFailure { + it.printStackTraceDebug() + }.getOrDefault(emptyList()) } diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt index 7d8e326b8..618dc4763 100644 --- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.explore.ui +import android.content.DialogInterface import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem @@ -16,6 +17,7 @@ import coil.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseFragment +import org.koitharu.kotatsu.base.ui.dialog.TwoButtonsAlertDialog import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.base.ui.util.ReversibleActionObserver @@ -76,6 +78,9 @@ class ExploreFragment : viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga) viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged) + viewModel.onShowSuggestionsTip.observe(viewLifecycleOwner) { + showSuggestionsTip() + } } override fun onDestroyView() { @@ -143,6 +148,19 @@ class ExploreFragment : activity?.invalidateOptionsMenu() } + private fun showSuggestionsTip() { + val listener = DialogInterface.OnClickListener { _, which -> + viewModel.respondSuggestionTip(which == DialogInterface.BUTTON_POSITIVE) + } + TwoButtonsAlertDialog.Builder(requireContext()) + .setIcon(R.drawable.ic_suggestion) + .setTitle(R.string.suggestions_enable_prompt) + .setPositiveButton(R.string.enable, listener) + .setNegativeButton(R.string.no_thanks, listener) + .create() + .show() + } + private inner class SourceMenuListener( private val sourceItem: ExploreItem.Source, ) : PopupMenu.OnMenuItemClickListener { diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt index 4b87979f7..d81472ba3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @@ -27,6 +26,8 @@ import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.asFlowLiveData import javax.inject.Inject +private const val TIP_SUGGESTIONS = "suggestions" + @HiltViewModel class ExploreViewModel @Inject constructor( private val settings: AppSettings, @@ -41,6 +42,7 @@ class ExploreViewModel @Inject constructor( val onOpenManga = SingleLiveEvent() val onActionDone = SingleLiveEvent() + val onShowSuggestionsTip = SingleLiveEvent() val isGrid = gridMode.asFlowLiveData(viewModelScope.coroutineContext) val content: LiveData> = isLoading.asFlow().flatMapLatest { loading -> @@ -51,6 +53,14 @@ class ExploreViewModel @Inject constructor( } }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(ExploreItem.Loading)) + init { + launchJob(Dispatchers.Default) { + if (!settings.isSuggestionsEnabled && settings.isTipEnabled(TIP_SUGGESTIONS)) { + onShowSuggestionsTip.emitCall(Unit) + } + } + } + fun openRandom() { launchLoadingJob(Dispatchers.Default) { val manga = exploreRepository.findRandomManga(tagsLimit = 8) @@ -72,6 +82,11 @@ class ExploreViewModel @Inject constructor( settings.isSourcesGridMode = value } + fun respondSuggestionTip(isAccepted: Boolean) { + settings.isSuggestionsEnabled = isAccepted + settings.closeTip(TIP_SUGGESTIONS) + } + private fun createContentFlow() = settings.observe() .filter { it == AppSettings.KEY_SOURCES_HIDDEN || @@ -80,7 +95,6 @@ class ExploreViewModel @Inject constructor( } .onStart { emit("") } .map { settings.getMangaSources(includeHidden = false) } - .distinctUntilChanged() .combine(gridMode) { content, grid -> buildList(content, grid) } private fun buildList(sources: List, isGrid: Boolean): List { diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt index 36308d4c6..af468c3e8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt @@ -1,9 +1,11 @@ package org.koitharu.kotatsu.favourites.data import org.koitharu.kotatsu.core.db.entity.SortOrder +import org.koitharu.kotatsu.core.db.entity.toManga +import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.parsers.model.SortOrder -import java.util.* +import java.util.Date fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory( id = id, @@ -13,4 +15,8 @@ fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) createdAt = Date(createdAt), isTrackingEnabled = track, isVisibleInLibrary = isVisibleInLibrary, -) \ No newline at end of file +) + +fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags()) + +fun Collection.toMangaList() = map { it.toManga() } diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt index 0e0ee05de..590b66436 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt @@ -17,6 +17,10 @@ abstract class FavouritesDao { @Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC") abstract suspend fun findAll(): List + @Transaction + @Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit") + abstract suspend fun findLast(limit: Int): List + fun observeAll(order: SortOrder): Flow> { val orderBy = getOrderBy(order) diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt index 3875eb2e5..72fb8d48c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt @@ -12,12 +12,12 @@ import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.SortOrder import org.koitharu.kotatsu.core.db.entity.toEntities import org.koitharu.kotatsu.core.db.entity.toEntity -import org.koitharu.kotatsu.core.db.entity.toManga -import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.toFavouriteCategory +import org.koitharu.kotatsu.favourites.data.toManga +import org.koitharu.kotatsu.favourites.data.toMangaList import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels @@ -32,22 +32,27 @@ class FavouritesRepository @Inject constructor( suspend fun getAllManga(): List { val entities = db.favouritesDao.findAll() - return entities.map { it.manga.toManga(it.tags.toMangaTags()) } + return entities.toMangaList() + } + + suspend fun getLastManga(limit: Int): List { + val entities = db.favouritesDao.findLast(limit) + return entities.toMangaList() } fun observeAll(order: SortOrder): Flow> { return db.favouritesDao.observeAll(order) - .mapItems { it.manga.toManga(it.tags.toMangaTags()) } + .mapItems { it.toManga() } } suspend fun getManga(categoryId: Long): List { val entities = db.favouritesDao.findAll(categoryId) - return entities.map { it.manga.toManga(it.tags.toMangaTags()) } + return entities.toMangaList() } fun observeAll(categoryId: Long, order: SortOrder): Flow> { return db.favouritesDao.observeAll(categoryId, order) - .mapItems { it.manga.toManga(it.tags.toMangaTags()) } + .mapItems { it.toManga() } } fun observeAll(categoryId: Long): Flow> { 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 index f0afaf429..50cd7c7f9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt @@ -1,7 +1,6 @@ package org.koitharu.kotatsu.suggestions.domain import androidx.room.withTransaction -import javax.inject.Inject import kotlinx.coroutines.flow.Flow import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toEntities @@ -11,6 +10,7 @@ import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.suggestions.data.SuggestionEntity import org.koitharu.kotatsu.utils.ext.mapItems +import javax.inject.Inject class SuggestionRepository @Inject constructor( private val db: MangaDatabase, diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/TagsBlacklist.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/TagsBlacklist.kt new file mode 100644 index 000000000..691928fa8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/TagsBlacklist.kt @@ -0,0 +1,31 @@ +package org.koitharu.kotatsu.suggestions.domain + +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.utils.ext.almostEquals + +class TagsBlacklist( + private val tags: Set, + private val threshold: Float, +) { + + fun isNotEmpty() = tags.isNotEmpty() + + operator fun contains(manga: Manga): Boolean { + if (tags.isEmpty()) { + return false + } + for (mangaTag in manga.tags) { + for (tagTitle in tags) { + if (mangaTag.title.almostEquals(tagTitle, threshold)) { + return true + } + } + } + return false + } + + operator fun contains(tag: MangaTag): Boolean = tags.any { + it.almostEquals(tag.title, threshold) + } +} 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 index e0a2e2d57..77d1c8bd1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt @@ -2,11 +2,15 @@ package org.koitharu.kotatsu.suggestions.ui import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context import android.os.Build import androidx.annotation.FloatRange import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat +import androidx.core.app.PendingIntentCompat +import androidx.core.text.HtmlCompat +import androidx.core.text.buildSpannedString +import androidx.core.text.parseAsHtml import androidx.hilt.work.HiltWorker import androidx.work.BackoffPolicy import androidx.work.Constraints @@ -20,39 +24,56 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf +import coil.ImageLoader +import coil.request.ImageRequest import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.distinctById import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository +import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist +import org.koitharu.kotatsu.utils.ext.almostEquals import org.koitharu.kotatsu.utils.ext.asArrayList +import org.koitharu.kotatsu.utils.ext.flatten import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.runCatchingCancellable +import org.koitharu.kotatsu.utils.ext.takeMostFrequent +import org.koitharu.kotatsu.utils.ext.toBitmapOrNull import org.koitharu.kotatsu.utils.ext.trySetForeground import java.util.concurrent.TimeUnit import kotlin.math.pow +import kotlin.random.Random @HiltWorker class SuggestionsWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted params: WorkerParameters, + private val coil: ImageLoader, private val suggestionRepository: SuggestionRepository, private val historyRepository: HistoryRepository, + private val favouritesRepository: FavouritesRepository, private val appSettings: AppSettings, private val mangaRepositoryFactory: MangaRepository.Factory, ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { + trySetForeground() val count = doWorkImpl() val outputData = workDataOf(DATA_COUNT to count) return Result.success(outputData) @@ -93,83 +114,197 @@ class SuggestionsWorker @AssistedInject constructor( suggestionRepository.clear() return 0 } - val blacklistTagRegex = appSettings.getSuggestionsTagsBlacklistRegex() - val allTags = historyRepository.getPopularTags(TAGS_LIMIT).filterNot { - blacklistTagRegex?.containsMatchIn(it.title) ?: false - } - if (allTags.isEmpty()) { + val seed = ( + historyRepository.getList(0, 20) + + favouritesRepository.getLastManga(20) + ).distinctById() + val sources = appSettings.getMangaSources(includeHidden = false) + if (seed.isEmpty() || sources.isEmpty()) { return 0 } - if (TAG in tags) { // not expedited - trySetForeground() - } - val tagsBySources = allTags.groupBy { x -> x.source } - val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM) - val rawResults = coroutineScope { - tagsBySources.flatMap { (source, tags) -> - val repo = mangaRepositoryFactory.tryCreate(source) ?: return@flatMap emptyList() - tags.map { tag -> - async(dispatcher) { - repo.getListSafe(tag) - } + val tagsBlacklist = TagsBlacklist(appSettings.suggestionsTagsBlacklist, TAG_EQ_THRESHOLD) + val tags = seed.flatMap { it.tags.map { x -> x.title } }.takeMostFrequent(10) + + val producer = channelFlow { + for (it in sources.shuffled()) { + launch { + send(getList(it, tags, tagsBlacklist)) } - }.awaitAll().flatten().asArrayList() - } - if (appSettings.isSuggestionsExcludeNsfw) { - rawResults.removeAll { it.isNsfw } - } - if (blacklistTagRegex != null) { - rawResults.removeAll { - it.tags.any { x -> blacklistTagRegex.containsMatchIn(x.title) } } } - if (rawResults.isEmpty()) { - return 0 - } - val suggestions = rawResults.distinctBy { manga -> - manga.id - }.map { manga -> - MangaSuggestion( - manga = manga, - relevance = computeRelevance(manga.tags, allTags), - ) - }.sortedBy { it.relevance }.take(LIMIT) + val suggestions = producer + .flatten() + .take(MAX_RAW_RESULTS) + .map { manga -> + MangaSuggestion( + manga = manga, + relevance = computeRelevance(manga.tags, tags), + ) + }.toList() + .sortedBy { it.relevance } + .take(MAX_RESULTS) suggestionRepository.replace(suggestions) + if (appSettings.isSuggestionsNotificationAvailable) { + runCatchingCancellable { + val manga = suggestions[Random.nextInt(0, suggestions.size / 3)] + val details = mangaRepositoryFactory.create(manga.manga.source) + .getDetails(manga.manga) + if (details !in tagsBlacklist) { + showNotification(details) + } + }.onFailure { + it.printStackTraceDebug() + } + } return suggestions.size } + private suspend fun getList( + source: MangaSource, + tags: List, + blacklist: TagsBlacklist, + ): List = runCatchingCancellable { + val repository = mangaRepositoryFactory.create(source) + val availableOrders = repository.sortOrders + val order = preferredSortOrders.first { it in availableOrders } + val availableTags = repository.getTags() + val tag = tags.firstNotNullOfOrNull { title -> + availableTags.find { x -> x.title.almostEquals(title, TAG_EQ_THRESHOLD) } + } + val list = repository.getList(0, setOfNotNull(tag), order).asArrayList() + if (appSettings.isSuggestionsExcludeNsfw) { + list.removeAll { it.isNsfw } + } + if (blacklist.isNotEmpty()) { + list.removeAll { manga -> manga in blacklist } + } + list + }.onFailure { + it.printStackTraceDebug() + }.getOrDefault(emptyList()) + + private suspend fun showNotification(manga: Manga) { + val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + MANGA_CHANNEL_ID, + applicationContext.getString(R.string.suggestions), + NotificationManager.IMPORTANCE_DEFAULT, + ) + channel.description = applicationContext.getString(R.string.suggestions_summary) + channel.enableLights(true) + channel.setShowBadge(true) + manager.createNotificationChannel(channel) + } + val id = manga.url.hashCode() + val title = applicationContext.getString(R.string.suggestion_manga, manga.title) + val builder = NotificationCompat.Builder(applicationContext, MANGA_CHANNEL_ID) + val tagsText = manga.tags.joinToString(", ") { it.title } + with(builder) { + setContentText(tagsText) + setContentTitle(title) + setLargeIcon( + coil.execute( + ImageRequest.Builder(applicationContext) + .data(manga.coverUrl) + .tag(manga.source) + .build(), + ).toBitmapOrNull(), + ) + setSmallIcon(R.drawable.ic_stat_suggestion) + val description = manga.description?.parseAsHtml(HtmlCompat.FROM_HTML_MODE_COMPACT) + if (!description.isNullOrBlank()) { + val style = NotificationCompat.BigTextStyle() + style.bigText( + buildSpannedString { + append(tagsText) + appendLine() + append(description) + }, + ) + style.setBigContentTitle(title) + setStyle(style) + } + val intent = DetailsActivity.newIntent(applicationContext, manga) + setContentIntent( + PendingIntentCompat.getActivity( + applicationContext, + id, + intent, + PendingIntent.FLAG_UPDATE_CURRENT, + false, + ), + ) + setAutoCancel(true) + setCategory(NotificationCompat.CATEGORY_RECOMMENDATION) + setVisibility(if (manga.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC) + setShortcutId(manga.id.toString()) + priority = NotificationCompat.PRIORITY_DEFAULT + + addAction( + R.drawable.ic_read, + applicationContext.getString(R.string.read), + PendingIntentCompat.getActivity( + applicationContext, + id + 2, + ReaderActivity.newIntent(applicationContext, manga), + 0, + false, + ), + ) + + addAction( + R.drawable.ic_suggestion, + applicationContext.getString(R.string.more), + PendingIntentCompat.getActivity( + applicationContext, + 0, + SuggestionsActivity.newIntent(applicationContext), + 0, + false, + ), + ) + } + manager.notify(TAG, id, builder.build()) + } + @FloatRange(from = 0.0, to = 1.0) - private fun computeRelevance(mangaTags: Set, allTags: List): Float { + private fun computeRelevance(mangaTags: Set, allTags: List): Float { val maxWeight = (allTags.size + allTags.size + 1 - mangaTags.size) * mangaTags.size / 2.0 val weight = mangaTags.sumOf { tag -> - val index = allTags.indexOf(tag) + val index = allTags.inexactIndexOf(tag.title, TAG_EQ_THRESHOLD) if (index < 0) 0 else allTags.size - index } return (weight / maxWeight).pow(2.0).toFloat() } - private suspend fun MangaRepository.getListSafe(tag: MangaTag) = runCatchingCancellable { - getList(offset = 0, sortOrder = SortOrder.UPDATED, tags = setOf(tag)) - }.onFailure { error -> - error.printStackTraceDebug() - }.getOrDefault(emptyList()) - - private fun MangaRepository.Factory.tryCreate(source: MangaSource) = runCatching { - create(source) - }.onFailure { error -> - error.printStackTraceDebug() - }.getOrNull() + private fun Iterable.inexactIndexOf(element: String, threshold: Float): Int { + forEachIndexed { i, t -> + if (t.almostEquals(element, threshold)) { + return i + } + } + return -1 + } companion object { private const val TAG = "suggestions" private const val TAG_ONESHOT = "suggestions_oneshot" - private const val LIMIT = 140 - private const val TAGS_LIMIT = 20 - private const val MAX_PARALLELISM = 4 private const val DATA_COUNT = "count" private const val WORKER_CHANNEL_ID = "suggestion_worker" + private const val MANGA_CHANNEL_ID = "suggestions" private const val WORKER_NOTIFICATION_ID = 36 + private const val MAX_RESULTS = 80 + private const val MAX_RAW_RESULTS = 200 + private const val TAG_EQ_THRESHOLD = 0.4f + + private val preferredSortOrders = listOf( + SortOrder.UPDATED, + SortOrder.NEWEST, + SortOrder.POPULARITY, + SortOrder.RATING, + ) fun setup(context: Context) { val constraints = Constraints.Builder() diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt index 6f6513707..879704445 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.utils.ext +import androidx.collection.ArrayMap import androidx.collection.ArraySet import java.util.Collections @@ -45,3 +46,17 @@ inline fun Collection.filterToSet(predicate: (T) -> Boolean): Set { fun Sequence.toListSorted(comparator: Comparator): List { return toMutableList().apply { sortWith(comparator) } } + +fun List.takeMostFrequent(limit: Int): List { + val map = ArrayMap(size) + for (item in this) { + map[item] = map.getOrDefault(item, 0) + 1 + } + val entries = map.entries.sortedByDescending { it.value } + val count = minOf(limit, entries.size) + return buildList(count) { + repeat(count) { i -> + add(entries[i].key) + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt index 4c08eec67..0ed270991 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt @@ -4,6 +4,7 @@ import android.os.SystemClock import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach @@ -43,3 +44,11 @@ fun Flow.throttle(timeoutMillis: (T) -> Long): Flow { fun StateFlow.requireValue(): T = checkNotNull(value) { "StateFlow value is null" } + +fun Flow>.flatten(): Flow = flow { + collect { value -> + for (item in value) { + emit(item) + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt index a5054b5e0..5e4056a5d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt @@ -1,5 +1,7 @@ package org.koitharu.kotatsu.utils.ext +import androidx.annotation.FloatRange +import org.koitharu.kotatsu.parsers.util.levenshteinDistance import java.util.UUID inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String { @@ -21,3 +23,14 @@ fun String.toUUIDOrNull(): UUID? = try { e.printStackTraceDebug() null } + +/** + * @param threshold 0 = exact match + */ +fun String.almostEquals(other: String, @FloatRange(from = 0.0) threshold: Float): Boolean { + if (threshold == 0f) { + return equals(other, ignoreCase = true) + } + val diff = lowercase().levenshteinDistance(other.lowercase()) / ((length + other.length) / 2f) + return diff < threshold +} diff --git a/app/src/main/res/drawable-anydpi-v24/ic_stat_suggestion.xml b/app/src/main/res/drawable-anydpi-v24/ic_stat_suggestion.xml new file mode 100644 index 000000000..3da7d00e0 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v24/ic_stat_suggestion.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_stat_suggestion.png b/app/src/main/res/drawable-hdpi/ic_stat_suggestion.png new file mode 100644 index 000000000..76ab52d8a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stat_suggestion.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_stat_suggestion.png b/app/src/main/res/drawable-mdpi/ic_stat_suggestion.png new file mode 100644 index 000000000..7fe8da107 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_stat_suggestion.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png b/app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png new file mode 100644 index 000000000..e85724bb9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_suggestion.png b/app/src/main/res/drawable-xxhdpi/ic_stat_suggestion.png new file mode 100644 index 000000000..17f64644a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_stat_suggestion.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ca2718ff7..eac989723 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -448,6 +448,9 @@ Cancel all Download only via Wi-Fi Stop downloading when switching to a mobile network + Suggestion: %s + Sometimes show notifications with suggested manga + More Enable No thanks All active downloads will be cancelled, partially downloaded data will be lost @@ -457,4 +460,5 @@ Downloads have been paused Downloads have been removed Downloads have been cancelled + Do you want to receive personalized manga suggestions? diff --git a/app/src/main/res/xml/pref_suggestions.xml b/app/src/main/res/xml/pref_suggestions.xml index b9712a556..c754b220f 100644 --- a/app/src/main/res/xml/pref_suggestions.xml +++ b/app/src/main/res/xml/pref_suggestions.xml @@ -9,13 +9,22 @@ android:layout="@layout/preference_toggle_header" android:title="@string/suggestions_enable" /> + + + android:title="@string/suggestions_excluded_genres" + app:allowDividerAbove="true" /> @@ -28,4 +37,4 @@ android:summary="@string/suggestions_info" app:allowDividerAbove="true" /> - \ No newline at end of file +