|
|
|
|
@ -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 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))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
}.onFailure {
|
|
|
|
|
it.printStackTraceDebug()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}.awaitAll().flatten().asArrayList()
|
|
|
|
|
return suggestions.size
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private suspend fun getList(
|
|
|
|
|
source: MangaSource,
|
|
|
|
|
tags: List<String>,
|
|
|
|
|
blacklist: TagsBlacklist,
|
|
|
|
|
): List<Manga> = 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) {
|
|
|
|
|
rawResults.removeAll { it.isNsfw }
|
|
|
|
|
list.removeAll { it.isNsfw }
|
|
|
|
|
}
|
|
|
|
|
if (blacklistTagRegex != null) {
|
|
|
|
|
rawResults.removeAll {
|
|
|
|
|
it.tags.any { x -> blacklistTagRegex.containsMatchIn(x.title) }
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
if (rawResults.isEmpty()) {
|
|
|
|
|
return 0
|
|
|
|
|
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 suggestions = rawResults.distinctBy { manga ->
|
|
|
|
|
manga.id
|
|
|
|
|
}.map { manga ->
|
|
|
|
|
MangaSuggestion(
|
|
|
|
|
manga = manga,
|
|
|
|
|
relevance = computeRelevance(manga.tags, allTags),
|
|
|
|
|
val intent = DetailsActivity.newIntent(applicationContext, manga)
|
|
|
|
|
setContentIntent(
|
|
|
|
|
PendingIntentCompat.getActivity(
|
|
|
|
|
applicationContext,
|
|
|
|
|
id,
|
|
|
|
|
intent,
|
|
|
|
|
PendingIntent.FLAG_UPDATE_CURRENT,
|
|
|
|
|
false,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
}.sortedBy { it.relevance }.take(LIMIT)
|
|
|
|
|
suggestionRepository.replace(suggestions)
|
|
|
|
|
return suggestions.size
|
|
|
|
|
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<MangaTag>, allTags: List<MangaTag>): Float {
|
|
|
|
|
private fun computeRelevance(mangaTags: Set<MangaTag>, allTags: List<String>): 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<String>.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()
|
|
|
|
|
|