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