Suggestions
parent
2746b71575
commit
9e269a9e03
@ -0,0 +1,49 @@
|
||||
package org.xtimms.tokusho.core.database.dao
|
||||
|
||||
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
|
||||
import org.xtimms.tokusho.core.database.entity.SuggestionEntity
|
||||
import org.xtimms.tokusho.core.database.entity.SuggestionWithManga
|
||||
|
||||
@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>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM suggestions ORDER BY RANDOM() LIMIT 1")
|
||||
abstract suspend fun getRandom(): SuggestionWithManga?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM suggestions")
|
||||
abstract suspend fun count(): Int
|
||||
|
||||
@Query("SELECT manga.title FROM suggestions LEFT JOIN manga ON suggestions.manga_id = manga.manga_id WHERE manga.title LIKE :query")
|
||||
abstract suspend fun getTitles(query: String): List<String>
|
||||
|
||||
@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,26 @@
|
||||
package org.xtimms.tokusho.core.database.entity
|
||||
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@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,20 @@
|
||||
package org.xtimms.tokusho.core.database.entity
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Junction
|
||||
import androidx.room.Relation
|
||||
|
||||
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.xtimms.tokusho.core.model
|
||||
|
||||
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,31 @@
|
||||
package org.xtimms.tokusho.core.model
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.util.almostEquals
|
||||
|
||||
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,63 @@
|
||||
package org.xtimms.tokusho.data.repository
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.xtimms.tokusho.core.database.TokushoDatabase
|
||||
import org.xtimms.tokusho.core.database.entity.SuggestionEntity
|
||||
import org.xtimms.tokusho.core.database.entity.toEntities
|
||||
import org.xtimms.tokusho.core.database.entity.toEntity
|
||||
import org.xtimms.tokusho.core.database.entity.toManga
|
||||
import org.xtimms.tokusho.core.database.entity.toMangaTags
|
||||
import org.xtimms.tokusho.core.model.MangaSuggestion
|
||||
import org.xtimms.tokusho.utils.lang.mapItems
|
||||
import javax.inject.Inject
|
||||
|
||||
class SuggestionRepository @Inject constructor(
|
||||
private val db: TokushoDatabase,
|
||||
) {
|
||||
|
||||
fun observeAll(): Flow<List<Manga>> {
|
||||
return db.getSuggestionDao().observeAll().mapItems {
|
||||
it.manga.toManga(it.tags.toMangaTags())
|
||||
}
|
||||
}
|
||||
|
||||
fun observeAll(limit: Int): Flow<List<Manga>> {
|
||||
return db.getSuggestionDao().observeAll(limit).mapItems {
|
||||
it.manga.toManga(it.tags.toMangaTags())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getRandom(): Manga? {
|
||||
return db.getSuggestionDao().getRandom()?.let {
|
||||
it.manga.toManga(it.tags.toMangaTags())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
db.getSuggestionDao().deleteAll()
|
||||
}
|
||||
|
||||
suspend fun isEmpty(): Boolean {
|
||||
return db.getSuggestionDao().count() == 0
|
||||
}
|
||||
|
||||
suspend fun replace(suggestions: Iterable<MangaSuggestion>) {
|
||||
db.withTransaction {
|
||||
db.getSuggestionDao().deleteAll()
|
||||
suggestions.forEach { (manga, relevance) ->
|
||||
val tags = manga.tags.toEntities()
|
||||
db.getTagsDao().upsert(tags)
|
||||
db.getMangaDao().upsert(manga.toEntity(), tags)
|
||||
db.getSuggestionDao().upsert(
|
||||
SuggestionEntity(
|
||||
mangaId = manga.id,
|
||||
relevance = relevance,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package org.xtimms.tokusho.sections.suggestions
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.xtimms.tokusho.core.model.ListModel
|
||||
|
||||
data class SuggestionMangaModel(
|
||||
val manga: Manga
|
||||
) : ListModel {
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
return other is SuggestionMangaModel && other.manga.id == manga.id
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,116 @@
|
||||
package org.xtimms.tokusho.sections.suggestions
|
||||
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.xtimms.tokusho.R
|
||||
import org.xtimms.tokusho.core.components.MangaGridItem
|
||||
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
||||
|
||||
const val SUGGESTIONS_DESTINATION = "suggestions"
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun SuggestionsView(
|
||||
viewModel: SuggestionsViewModel = hiltViewModel(),
|
||||
coil: ImageLoader,
|
||||
navigateBack: () -> Unit,
|
||||
navigateToDetails: (Long) -> Unit
|
||||
) {
|
||||
|
||||
val suggestions by viewModel.content.collectAsStateWithLifecycle(emptyList())
|
||||
|
||||
ScaffoldWithTopAppBar(
|
||||
title = stringResource(id = R.string.suggestions),
|
||||
navigateBack = navigateBack,
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.updateSuggestions() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = "Refresh"
|
||||
)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
val listState = rememberLazyGridState()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 100.dp),
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 8.dp,
|
||||
top = 8.dp,
|
||||
end = 8.dp,
|
||||
bottom = WindowInsets.navigationBars.asPaddingValues()
|
||||
.calculateBottomPadding()
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(
|
||||
8.dp,
|
||||
Alignment.CenterHorizontally
|
||||
),
|
||||
) {
|
||||
items(
|
||||
items = suggestions,
|
||||
key = { it.manga.id },
|
||||
contentType = { it }
|
||||
) { item ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().animateItemPlacement(
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMedium / 4,
|
||||
visibilityThreshold = IntOffset.VisibilityThreshold
|
||||
)
|
||||
),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
val onClickManga = { manga: Manga ->
|
||||
navigateToDetails(manga.id)
|
||||
}
|
||||
MangaGridItem(
|
||||
coil = coil,
|
||||
manga = item.manga,
|
||||
onClick = onClickManga,
|
||||
onLongClick = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package org.xtimms.tokusho.sections.suggestions
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel
|
||||
import org.xtimms.tokusho.data.repository.SuggestionRepository
|
||||
import org.xtimms.tokusho.sections.history.HistoryItemModel
|
||||
import org.xtimms.tokusho.utils.lang.mapItems
|
||||
import org.xtimms.tokusho.work.suggestions.SuggestionsWorker
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SuggestionsViewModel @Inject constructor(
|
||||
repository: SuggestionRepository,
|
||||
private val suggestionsScheduler: SuggestionsWorker.Scheduler,
|
||||
) : KotatsuBaseViewModel() {
|
||||
|
||||
private val suggestionsStateFlow = repository.observeAll()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
val content = suggestionsStateFlow
|
||||
.filterNotNull()
|
||||
.mapItems { SuggestionMangaModel(it) }
|
||||
.distinctUntilChanged()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
|
||||
fun updateSuggestions() {
|
||||
suggestionsScheduler.startNow()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package org.xtimms.tokusho.utils.lang
|
||||
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import coil.request.ErrorResult
|
||||
import coil.request.ImageResult
|
||||
import coil.request.SuccessResult
|
||||
|
||||
fun ImageResult.toBitmapOrNull() = when (this) {
|
||||
is SuccessResult -> try {
|
||||
drawable.toBitmap()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
is ErrorResult -> null
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
package org.xtimms.tokusho.utils.system
|
||||
|
||||
import android.Manifest
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.Context.POWER_SERVICE
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
fun <I> ActivityResultLauncher<I>.tryLaunch(
|
||||
input: I,
|
||||
options: ActivityOptionsCompat? = null,
|
||||
): Boolean = runCatching {
|
||||
launch(input, options)
|
||||
}.onFailure { e ->
|
||||
e.printStackTrace()
|
||||
}.isSuccess
|
||||
|
||||
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
|
||||
|
||||
val Context.powerManager: PowerManager?
|
||||
get() = getSystemService(POWER_SERVICE) as? PowerManager
|
||||
|
||||
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable {
|
||||
val info = getForegroundInfo()
|
||||
setForeground(info)
|
||||
}.isSuccess
|
||||
|
||||
fun Context.checkNotificationPermission(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
NotificationManagerCompat.from(this).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
suspend fun Bitmap.compressToPNG(output: File) = runInterruptible(Dispatchers.IO) {
|
||||
output.outputStream().use { os ->
|
||||
if (!compress(Bitmap.CompressFormat.PNG, 100, os)) {
|
||||
throw IOException("Failed to encode bitmap into PNG format")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val Context.ramAvailable: Long
|
||||
get() {
|
||||
val result = ActivityManager.MemoryInfo()
|
||||
activityManager?.getMemoryInfo(result)
|
||||
return result.availMem
|
||||
}
|
||||
|
||||
fun Context.ensureRamAtLeast(requiredSize: Long) {
|
||||
if (ramAvailable < requiredSize) {
|
||||
throw IllegalStateException("Not enough free memory")
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.isPowerSaveMode(): Boolean {
|
||||
return powerManager?.isPowerSaveMode == true
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package org.xtimms.tokusho.work
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xtimms.tokusho.utils.lang.processLifecycleScope
|
||||
import org.xtimms.tokusho.work.suggestions.SuggestionsWorker
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class WorkScheduleManager @Inject constructor(
|
||||
private val suggestionScheduler: SuggestionsWorker.Scheduler,
|
||||
) {
|
||||
|
||||
fun init() {
|
||||
processLifecycleScope.launch(Dispatchers.Default) {
|
||||
updateWorkerImpl(suggestionScheduler, isEnabled = true, force = false)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateWorkerImpl(scheduler: PeriodicWorkScheduler, isEnabled: Boolean, force: Boolean) {
|
||||
if (force || scheduler.isScheduled() != isEnabled) {
|
||||
if (isEnabled) {
|
||||
scheduler.schedule()
|
||||
} else {
|
||||
scheduler.unschedule()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,373 @@
|
||||
package org.xtimms.tokusho.work.suggestions
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.core.text.bold
|
||||
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.await
|
||||
import androidx.work.workDataOf
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import dagger.Reusable
|
||||
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 kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
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.almostEquals
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.xtimms.tokusho.R
|
||||
import org.xtimms.tokusho.core.model.MangaSuggestion
|
||||
import org.xtimms.tokusho.core.model.TagsBlacklist
|
||||
import org.xtimms.tokusho.core.model.distinctById
|
||||
import org.xtimms.tokusho.core.parser.MangaRepository
|
||||
import org.xtimms.tokusho.data.repository.FavouritesRepository
|
||||
import org.xtimms.tokusho.data.repository.HistoryRepository
|
||||
import org.xtimms.tokusho.data.repository.MangaSourcesRepository
|
||||
import org.xtimms.tokusho.data.repository.SuggestionRepository
|
||||
import org.xtimms.tokusho.utils.lang.asArrayList
|
||||
import org.xtimms.tokusho.utils.lang.awaitUniqueWorkInfoByName
|
||||
import org.xtimms.tokusho.utils.lang.flatten
|
||||
import org.xtimms.tokusho.utils.lang.sanitize
|
||||
import org.xtimms.tokusho.utils.lang.takeMostFrequent
|
||||
import org.xtimms.tokusho.utils.lang.toBitmapOrNull
|
||||
import org.xtimms.tokusho.utils.system.checkNotificationPermission
|
||||
import org.xtimms.tokusho.utils.system.trySetForeground
|
||||
import org.xtimms.tokusho.work.PeriodicWorkScheduler
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
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 workManager: WorkManager,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val sourcesRepository: MangaSourcesRepository,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
private val notificationManager by lazy { NotificationManagerCompat.from(appContext) }
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
trySetForeground()
|
||||
val count = doWorkImpl()
|
||||
val outputData = workDataOf(DATA_COUNT to count)
|
||||
return Result.success(outputData)
|
||||
}
|
||||
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||
val title = applicationContext.getString(R.string.suggestions_updating)
|
||||
val channel = NotificationChannelCompat.Builder(WORKER_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(title)
|
||||
.setShowBadge(true)
|
||||
.setVibrationEnabled(false)
|
||||
.setSound(null, null)
|
||||
.setLightsEnabled(true)
|
||||
.build()
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
|
||||
val notification = NotificationCompat.Builder(applicationContext, WORKER_CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.addAction(
|
||||
com.google.android.material.R.drawable.material_ic_clear_black_24dp,
|
||||
applicationContext.getString(android.R.string.cancel),
|
||||
workManager.createCancelPendingIntent(id),
|
||||
)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setDefaults(0)
|
||||
.setOngoing(false)
|
||||
.setSilent(true)
|
||||
.setProgress(0, 0, true)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setForegroundServiceBehavior(
|
||||
if (TAG_ONESHOT in tags) {
|
||||
NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
|
||||
} else {
|
||||
NotificationCompat.FOREGROUND_SERVICE_DEFERRED
|
||||
},
|
||||
)
|
||||
.build()
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ForegroundInfo(WORKER_NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
ForegroundInfo(WORKER_NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doWorkImpl(): Int {
|
||||
val seed = (
|
||||
historyRepository.getList(0, 20) +
|
||||
favouritesRepository.getLastManga(20)
|
||||
).distinctById()
|
||||
val sources = sourcesRepository.getEnabledSources()
|
||||
if (seed.isEmpty() || sources.isEmpty()) {
|
||||
return 0
|
||||
}
|
||||
val tagsBlacklist = TagsBlacklist(setOf(""), TAG_EQ_THRESHOLD)
|
||||
val tags = seed.flatMap { it.tags.map { x -> x.title } }.takeMostFrequent(10)
|
||||
|
||||
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||
val producer = channelFlow {
|
||||
for (it in sources.shuffled()) {
|
||||
launch {
|
||||
semaphore.withPermit {
|
||||
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 (applicationContext.checkNotificationPermission()) {
|
||||
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.chapters.isNullOrEmpty()) {
|
||||
continue
|
||||
}
|
||||
if (details.rating > 0 && details.rating < RATING_MIN) {
|
||||
continue
|
||||
}
|
||||
if (details.isNsfw) {
|
||||
continue
|
||||
}
|
||||
if (details in tagsBlacklist) {
|
||||
continue
|
||||
}
|
||||
showNotification(details)
|
||||
break
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
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 !in blacklist && x.title.almostEquals(title, TAG_EQ_THRESHOLD) }
|
||||
}
|
||||
val list = repository.getList(
|
||||
offset = 0,
|
||||
filter = MangaListFilter.Advanced.Builder(order)
|
||||
.tags(setOfNotNull(tag))
|
||||
.build(),
|
||||
).asArrayList()
|
||||
if (blacklist.isNotEmpty()) {
|
||||
list.removeAll { manga -> manga in blacklist }
|
||||
}
|
||||
list.shuffle()
|
||||
list.take(MAX_SOURCE_RESULTS)
|
||||
}.onFailure { e ->
|
||||
e.printStackTrace()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private suspend fun showNotification(manga: Manga) {
|
||||
val channel = NotificationChannelCompat.Builder(MANGA_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
||||
.setName(applicationContext.getString(R.string.suggestions))
|
||||
.setDescription(applicationContext.getString(R.string.suggestions_summary))
|
||||
.setLightsEnabled(true)
|
||||
.setShowBadge(true)
|
||||
.build()
|
||||
notificationManager.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)?.sanitize()
|
||||
if (!description.isNullOrBlank()) {
|
||||
val style = NotificationCompat.BigTextStyle()
|
||||
style.bigText(
|
||||
buildSpannedString {
|
||||
append(tagsText)
|
||||
val chaptersCount = manga.chapters?.size ?: 0
|
||||
appendLine()
|
||||
bold {
|
||||
append(
|
||||
applicationContext.resources.getQuantityString(
|
||||
R.plurals.chapters,
|
||||
chaptersCount,
|
||||
chaptersCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
appendLine()
|
||||
append(description)
|
||||
},
|
||||
)
|
||||
style.setBigContentTitle(title)
|
||||
setStyle(style)
|
||||
}
|
||||
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
|
||||
}
|
||||
notificationManager.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
|
||||
}
|
||||
|
||||
@Reusable
|
||||
class Scheduler @Inject constructor(
|
||||
private val workManager: WorkManager,
|
||||
) : PeriodicWorkScheduler {
|
||||
|
||||
override suspend fun schedule() {
|
||||
val request = PeriodicWorkRequestBuilder<SuggestionsWorker>(6, TimeUnit.HOURS)
|
||||
.setConstraints(createConstraints())
|
||||
.addTag(TAG)
|
||||
.setBackoffCriteria(BackoffPolicy.LINEAR, 1, TimeUnit.HOURS)
|
||||
.build()
|
||||
workManager
|
||||
.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||
.await()
|
||||
}
|
||||
|
||||
override suspend fun unschedule() {
|
||||
workManager
|
||||
.cancelUniqueWork(TAG)
|
||||
.await()
|
||||
}
|
||||
|
||||
override suspend fun isScheduled(): Boolean {
|
||||
return workManager
|
||||
.awaitUniqueWorkInfoByName(TAG)
|
||||
.any { !it.state.isFinished }
|
||||
}
|
||||
|
||||
fun startNow() {
|
||||
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.enqueue(request)
|
||||
}
|
||||
|
||||
private fun createConstraints() = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val TAG = "suggestions"
|
||||
const val TAG_ONESHOT = "suggestions_oneshot"
|
||||
const val DATA_COUNT = "count"
|
||||
const val WORKER_CHANNEL_ID = "suggestion_worker"
|
||||
const val MANGA_CHANNEL_ID = "suggestions"
|
||||
const val WORKER_NOTIFICATION_ID = 36
|
||||
const val MAX_RESULTS = 80
|
||||
const val MAX_PARALLELISM = 3
|
||||
const val MAX_SOURCE_RESULTS = 14
|
||||
const val MAX_RAW_RESULTS = 200
|
||||
const val TAG_EQ_THRESHOLD = 0.4f
|
||||
const val RATING_MIN = 0.5f
|
||||
|
||||
val preferredSortOrders = listOf(
|
||||
SortOrder.UPDATED,
|
||||
SortOrder.NEWEST,
|
||||
SortOrder.POPULARITY,
|
||||
SortOrder.RATING,
|
||||
)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1007 B |
Binary file not shown.
|
After Width: | Height: | Size: 667 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
Loading…
Reference in New Issue