Move sources from java to kotlin dir

This commit is contained in:
Koitharu
2023-05-22 18:16:50 +03:00
parent a8f5714b35
commit c3216871ed
711 changed files with 1 additions and 0 deletions

View File

@@ -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)
}
}
}

View File

@@ -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(),
)

View File

@@ -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>,
)

View File

@@ -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,
)

View File

@@ -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(),
),
)
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -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)
}
}
}