Move sources from java to kotlin dir
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
package org.koitharu.kotatsu.suggestions.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
abstract class SuggestionDao {
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM suggestions ORDER BY relevance DESC")
|
||||
abstract fun observeAll(): Flow<List<SuggestionWithManga>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM suggestions ORDER BY relevance DESC LIMIT :limit")
|
||||
abstract fun observeAll(limit: Int): Flow<List<SuggestionWithManga>>
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
childColumns = ["manga_id"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
]
|
||||
)
|
||||
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(),
|
||||
)
|
||||
@@ -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<TagEntity>,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.koitharu.kotatsu.suggestions.domain
|
||||
|
||||
import androidx.annotation.FloatRange
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
data class MangaSuggestion(
|
||||
val manga: Manga,
|
||||
@FloatRange(from = 0.0, to = 1.0)
|
||||
val relevance: Float,
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
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.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.util.ext.mapItems
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
|
||||
import javax.inject.Inject
|
||||
|
||||
class SuggestionRepository @Inject constructor(
|
||||
private val db: MangaDatabase,
|
||||
) {
|
||||
|
||||
fun observeAll(): Flow<List<Manga>> {
|
||||
return db.suggestionDao.observeAll().mapItems {
|
||||
it.manga.toManga(it.tags.toMangaTags())
|
||||
}
|
||||
}
|
||||
|
||||
fun observeAll(limit: Int): Flow<List<Manga>> {
|
||||
return db.suggestionDao.observeAll(limit).mapItems {
|
||||
it.manga.toManga(it.tags.toMangaTags())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
db.suggestionDao.deleteAll()
|
||||
}
|
||||
|
||||
suspend fun isEmpty(): Boolean {
|
||||
return db.suggestionDao.count() == 0
|
||||
}
|
||||
|
||||
suspend fun replace(suggestions: Iterable<MangaSuggestion>) {
|
||||
db.withTransaction {
|
||||
db.suggestionDao.deleteAll()
|
||||
suggestions.forEach { (manga, relevance) ->
|
||||
val tags = manga.tags.toEntities()
|
||||
db.tagsDao.upsert(tags)
|
||||
db.mangaDao.upsert(manga.toEntity(), tags)
|
||||
db.suggestionDao.upsert(
|
||||
SuggestionEntity(
|
||||
mangaId = manga.id,
|
||||
relevance = relevance,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.koitharu.kotatsu.suggestions.domain
|
||||
|
||||
import org.koitharu.kotatsu.core.util.ext.almostEquals
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
|
||||
class TagsBlacklist(
|
||||
private val tags: Set<String>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.koitharu.kotatsu.suggestions.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.commit
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SuggestionsActivity :
|
||||
BaseActivity<ActivityContainerBinding>(),
|
||||
AppBarOwner {
|
||||
|
||||
override val appBar: AppBarLayout
|
||||
get() = viewBinding.appbar
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityContainerBinding.inflate(layoutInflater))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
val fm = supportFragmentManager
|
||||
if (fm.findFragmentById(R.id.container) == null) {
|
||||
fm.commit {
|
||||
setReorderingAllowed(true)
|
||||
val fragment = SuggestionsFragment.newInstance()
|
||||
replace(R.id.container, fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newIntent(context: Context) = Intent(context, SuggestionsActivity::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.koitharu.kotatsu.suggestions.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.fragment.app.viewModels
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
|
||||
class SuggestionsFragment : MangaListFragment() {
|
||||
|
||||
override val viewModel by viewModels<SuggestionsViewModel>()
|
||||
override val isSwipeRefreshEnabled = false
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
addMenuProvider(SuggestionMenuProvider())
|
||||
}
|
||||
|
||||
override fun onScrolledToEnd() = Unit
|
||||
|
||||
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.mode_remote, menu)
|
||||
return super.onCreateActionMode(controller, mode, menu)
|
||||
}
|
||||
|
||||
private inner class SuggestionMenuProvider : MenuProvider {
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.opt_suggestions, menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
R.id.action_update -> {
|
||||
SuggestionsWorker.startNow(requireContext())
|
||||
Snackbar.make(
|
||||
requireViewBinding().recyclerView,
|
||||
R.string.feed_will_update_soon,
|
||||
Snackbar.LENGTH_LONG,
|
||||
).show()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_settings -> {
|
||||
startActivity(SettingsActivity.newSuggestionsSettingsIntent(requireContext()))
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newInstance() = SuggestionsFragment()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.koitharu.kotatsu.suggestions.ui
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.asFlowLiveData
|
||||
import org.koitharu.kotatsu.core.util.ext.onFirst
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.list.ui.model.toUi
|
||||
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SuggestionsViewModel @Inject constructor(
|
||||
repository: SuggestionRepository,
|
||||
settings: AppSettings,
|
||||
private val tagHighlighter: MangaTagHighlighter,
|
||||
downloadScheduler: DownloadWorker.Scheduler,
|
||||
) : MangaListViewModel(settings, downloadScheduler) {
|
||||
|
||||
override val content = combine(
|
||||
repository.observeAll(),
|
||||
listModeFlow,
|
||||
) { list, mode ->
|
||||
when {
|
||||
list.isEmpty() -> listOf(
|
||||
EmptyState(
|
||||
icon = R.drawable.ic_empty_common,
|
||||
textPrimary = R.string.nothing_found,
|
||||
textSecondary = R.string.text_suggestion_holder,
|
||||
actionStringRes = 0,
|
||||
),
|
||||
)
|
||||
|
||||
else -> list.toUi(mode, tagHighlighter)
|
||||
}
|
||||
}.onStart {
|
||||
loadingCounter.increment()
|
||||
}.onFirst {
|
||||
loadingCounter.decrement()
|
||||
}.catch {
|
||||
emit(listOf(it.toErrorState(canRetry = false)))
|
||||
}.asFlowLiveData(
|
||||
viewModelScope.coroutineContext + Dispatchers.Default,
|
||||
listOf(LoadingState),
|
||||
)
|
||||
|
||||
override fun onRefresh() = Unit
|
||||
|
||||
override fun onRetry() = Unit
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
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.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
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
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.CancellationException
|
||||
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.core.util.ext.almostEquals
|
||||
import org.koitharu.kotatsu.core.util.ext.asArrayList
|
||||
import org.koitharu.kotatsu.core.util.ext.flatten
|
||||
import org.koitharu.kotatsu.core.util.ext.takeMostFrequent
|
||||
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
||||
import org.koitharu.kotatsu.core.util.ext.trySetForeground
|
||||
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.parsers.util.runCatchingCancellable
|
||||
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.util.ext.printStackTraceDebug
|
||||
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 {
|
||||
if (!appSettings.isSuggestionsEnabled) {
|
||||
suggestionRepository.clear()
|
||||
return Result.success()
|
||||
}
|
||||
trySetForeground()
|
||||
val count = doWorkImpl()
|
||||
val outputData = workDataOf(DATA_COUNT to count)
|
||||
return Result.success(outputData)
|
||||
}
|
||||
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||
val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val title = applicationContext.getString(R.string.suggestions_updating)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
WORKER_CHANNEL_ID,
|
||||
title,
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
)
|
||||
channel.setShowBadge(false)
|
||||
channel.enableVibration(false)
|
||||
channel.setSound(null, null)
|
||||
channel.enableLights(false)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(applicationContext, WORKER_CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setDefaults(0)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.setProgress(0, 0, true)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
|
||||
.build()
|
||||
|
||||
return ForegroundInfo(WORKER_NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private suspend fun doWorkImpl(): Int {
|
||||
val seed = (
|
||||
historyRepository.getList(0, 20) +
|
||||
favouritesRepository.getLastManga(20)
|
||||
).distinctById()
|
||||
val sources = appSettings.getMangaSources(includeHidden = false)
|
||||
if (seed.isEmpty() || sources.isEmpty()) {
|
||||
return 0
|
||||
}
|
||||
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) {
|
||||
for (i in 0..3) {
|
||||
try {
|
||||
val manga = suggestions[Random.nextInt(0, suggestions.size / 3)]
|
||||
val details = mangaRepositoryFactory.create(manga.manga.source)
|
||||
.getDetails(manga.manga)
|
||||
if (details.rating > 0 && details.rating < RATING_MIN) {
|
||||
continue
|
||||
}
|
||||
if (details.isNsfw && appSettings.isSuggestionsExcludeNsfw) {
|
||||
continue
|
||||
}
|
||||
if (details in tagsBlacklist) {
|
||||
continue
|
||||
}
|
||||
showNotification(details)
|
||||
break
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
list.removeAll { it.isNsfw }
|
||||
}
|
||||
if (blacklist.isNotEmpty()) {
|
||||
list.removeAll { manga -> manga in blacklist }
|
||||
}
|
||||
list.shuffle()
|
||||
list.take(MAX_SOURCE_RESULTS)
|
||||
}.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<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.inexactIndexOf(tag.title, TAG_EQ_THRESHOLD)
|
||||
if (index < 0) 0 else allTags.size - index
|
||||
}
|
||||
return (weight / maxWeight).pow(2.0).toFloat()
|
||||
}
|
||||
|
||||
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 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_SOURCE_RESULTS = 14
|
||||
private const val MAX_RAW_RESULTS = 200
|
||||
private const val TAG_EQ_THRESHOLD = 0.4f
|
||||
private const val RATING_MIN = 0.5f
|
||||
|
||||
private val preferredSortOrders = listOf(
|
||||
SortOrder.UPDATED,
|
||||
SortOrder.NEWEST,
|
||||
SortOrder.POPULARITY,
|
||||
SortOrder.RATING,
|
||||
)
|
||||
|
||||
fun setup(context: Context) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.build()
|
||||
val request = PeriodicWorkRequestBuilder<SuggestionsWorker>(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<SuggestionsWorker>()
|
||||
.setConstraints(constraints)
|
||||
.addTag(TAG_ONESHOT)
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
.build()
|
||||
WorkManager.getInstance(context)
|
||||
.enqueue(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user