Suggestions

This commit is contained in:
Koitharu
2021-05-12 20:10:47 +03:00
parent 0e74d6e017
commit f0e56c4b6a
20 changed files with 317 additions and 3 deletions

3
.idea/misc.xml generated
View File

@@ -9,6 +9,8 @@
<entry key="../../../../.gradle/caches/transforms-3/0998d1b3fbd6b77213a827054a7dfcfd/transformed/appcompat-1.2.0/res/layout/abc_alert_dialog_material.xml" value="0.25885416666666666" />
<entry key="../../../../.gradle/caches/transforms-3/0998d1b3fbd6b77213a827054a7dfcfd/transformed/appcompat-1.2.0/res/layout/abc_select_dialog_material.xml" value="0.25885416666666666" />
<entry key="../../../../.gradle/caches/transforms-3/688e95ad986d2d0286c79f787589b7cb/transformed/material-1.3.0/res/layout/mtrl_alert_dialog.xml" value="0.25885416666666666" />
<entry key="app/src/main/res/drawable/ic_storage.xml" value="0.275" />
<entry key="app/src/main/res/drawable/ic_suggestion.xml" value="0.275" />
<entry key="app/src/main/res/drawable/tab_indicator.xml" value="0.28512820512820514" />
<entry key="app/src/main/res/drawable/tabs_background.xml" value="0.28512820512820514" />
<entry key="app/src/main/res/layout-w600dp/activity_details.xml" value="0.18072916666666666" />
@@ -33,6 +35,7 @@
<entry key="app/src/main/res/layout/item_recent.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/item_source_config.xml" value="0.25885416666666666" />
<entry key="app/src/main/res/layout/sheet_pages.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/menu/nav_drawer.xml" value="0.25885416666666666" />
<entry key="app/src/main/res/menu/opt_protect.xml" value="0.26927083333333335" />
<entry key="app/src/main/res/menu/popup_category.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/xml/pref_main.xml" value="0.26927083333333335" />

View File

@@ -26,6 +26,7 @@ import org.koitharu.kotatsu.reader.readerModule
import org.koitharu.kotatsu.remotelist.remoteListModule
import org.koitharu.kotatsu.search.searchModule
import org.koitharu.kotatsu.settings.settingsModule
import org.koitharu.kotatsu.suggestions.suggestionsModule
import org.koitharu.kotatsu.tracker.trackerModule
import org.koitharu.kotatsu.widget.WidgetUpdater
import org.koitharu.kotatsu.widget.appWidgetModule
@@ -65,7 +66,8 @@ class KotatsuApp : Application() {
trackerModule,
settingsModule,
readerModule,
appWidgetModule
appWidgetModule,
suggestionsModule,
)
}
}

View File

@@ -10,6 +10,8 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.FavouritesDao
import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.suggestions.data.SuggestionDao
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
@Database(
entities = [
@@ -35,4 +37,6 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val tracksDao: TracksDao
abstract val trackLogsDao: TrackLogsDao
abstract val suggestionDao: SuggestionDao
}

View File

@@ -2,5 +2,5 @@ package org.koitharu.kotatsu.core.prefs
enum class AppSection {
LOCAL, FAVOURITES, HISTORY, FEED
LOCAL, FAVOURITES, HISTORY, FEED, SUGGESTIONS
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.history.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
@Dao
@@ -22,6 +23,9 @@ abstract class HistoryDao {
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history)")
abstract suspend fun findAllManga(): List<MangaEntity>
@Query("SELECT * FROM tags WHERE tag_id IN (SELECT tag_id FROM manga_tags WHERE manga_id IN (SELECT manga_id FROM history))")
abstract suspend fun findAllTags(): List<TagEntity>
@Query("SELECT * FROM history WHERE manga_id = :id")
abstract suspend fun find(id: Long): HistoryEntity?

View File

@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.mapItems
@@ -84,4 +85,8 @@ class HistoryRepository(
db.historyDao.delete(manga.id)
}
}
suspend fun getAllTags(): List<MangaTag> {
return db.historyDao.findAllTags().map { x -> x.toMangaTag() }
}
}

View File

@@ -32,6 +32,8 @@ import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.search.ui.SearchHelper
import org.koitharu.kotatsu.settings.AppUpdateChecker
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
import org.koitharu.kotatsu.tracker.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
@@ -75,6 +77,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
}
if (savedInstanceState == null) {
TrackWorker.setup(applicationContext)
SuggestionsWorker.setup(applicationContext)
AppUpdateChecker(this).launchIfNeeded()
}
@@ -144,6 +147,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
viewModel.defaultSection = AppSection.LOCAL
setPrimaryFragment(LocalListFragment.newInstance())
}
R.id.nav_suggestions -> {
viewModel.defaultSection = AppSection.SUGGESTIONS
setPrimaryFragment(SuggestionsFragment.newInstance())
}
R.id.nav_feed -> {
viewModel.defaultSection = AppSection.FEED
setPrimaryFragment(FeedFragment.newInstance())
@@ -229,6 +236,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
binding.navigationView.setCheckedItem(R.id.nav_feed)
setPrimaryFragment(FeedFragment.newInstance())
}
AppSection.SUGGESTIONS -> {
binding.navigationView.setCheckedItem(R.id.nav_suggestions)
setPrimaryFragment(SuggestionsFragment.newInstance())
}
}
}

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.suggestions
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.suggestions.ui.SuggestionsViewModel
val suggestionsModule
get() = module {
factory { SuggestionRepository(get()) }
viewModel { SuggestionsViewModel(get(), get()) }
}

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.suggestions.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
abstract class SuggestionDao {
@Transaction
@Query("SELECT * FROM suggestions ORDER BY relevance DESC")
abstract fun observeAll(): Flow<List<SuggestionWithManga>>
@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

@@ -1,9 +1,11 @@
package org.koitharu.kotatsu.core.db.entity
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",
@@ -19,6 +21,7 @@ import androidx.room.PrimaryKey
data 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.core.model.Manga
data class MangaSuggestion(
val manga: Manga,
@FloatRange(from = 0.0, to = 1.0)
val relevance: Float,
)

View File

@@ -0,0 +1,38 @@
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.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.utils.ext.mapItems
import org.koitharu.kotatsu.utils.ext.mapToSet
class SuggestionRepository(
private val db: MangaDatabase,
) {
fun observeAll(): Flow<List<Manga>> {
return db.suggestionDao.observeAll().mapItems {
it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag))
}
}
suspend fun replace(suggestions: Iterable<MangaSuggestion>) {
db.withTransaction {
db.suggestionDao.deleteAll()
suggestions.forEach { x ->
db.mangaDao.upsert(MangaEntity.from(x.manga))
db.suggestionDao.upsert(
SuggestionEntity(
mangaId = x.manga.id,
relevance = x.relevance,
createdAt = System.currentTimeMillis(),
)
)
}
}
}
}

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.suggestions.ui
import android.os.Bundle
import android.view.View
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment
class SuggestionsFragment : MangaListFragment() {
override val viewModel by viewModel<SuggestionsViewModel>(mode = LazyThreadSafetyMode.NONE)
override val isSwipeRefreshEnabled = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
}
override fun onScrolledToEnd() = Unit
override fun getTitle(): CharSequence? {
return context?.getString(R.string.suggestions)
}
companion object {
fun newInstance() = SuggestionsFragment()
}
}

View File

@@ -0,0 +1,53 @@
package org.koitharu.kotatsu.suggestions.ui
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.onFirst
class SuggestionsViewModel(
repository: SuggestionRepository,
settings: AppSettings,
) : MangaListViewModel(settings) {
override val content = combine(
repository.observeAll(),
createListModeFlow()
) { list, mode ->
when {
list.isEmpty() -> listOf(EmptyState(R.string.text_suggestion_holder))
else -> mapList(list, mode)
}
}.onFirst {
isLoading.postValue(false)
}.catch {
it.toErrorState(canRetry = false)
}.asLiveDataDistinct(
viewModelScope.coroutineContext + Dispatchers.Default,
listOf(LoadingState)
)
override fun onRefresh() = Unit
override fun onRetry() = Unit
private fun mapList(
list: List<Manga>,
mode: ListMode,
): List<ListModel> = list.map { manga ->
when (mode) {
ListMode.LIST -> manga.toListModel()
ListMode.DETAILED_LIST -> manga.toListDetailedModel()
ListMode.GRID -> manga.toGridModel()
}
}
}

View File

@@ -0,0 +1,68 @@
package org.koitharu.kotatsu.suggestions.ui
import android.content.Context
import androidx.work.*
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.utils.ext.mangaRepositoryOf
import java.util.concurrent.TimeUnit
import kotlin.math.pow
class SuggestionsWorker(appContext: Context, params: WorkerParameters) :
CoroutineWorker(appContext, params), KoinComponent {
private val suggestionRepository by inject<SuggestionRepository>()
private val historyRepository by inject<HistoryRepository>()
override suspend fun doWork(): Result {
val rawResults = ArrayList<Manga>()
val allTags = historyRepository.getAllTags()
val tagsBySources = allTags.groupBy { x -> x.source }
for ((source, tags) in tagsBySources) {
val repo = mangaRepositoryOf(source)
tags.flatMapTo(rawResults) { tag ->
repo.getList(
offset = 0,
sortOrder = SortOrder.UPDATED,
tag = tag,
)
}
}
suggestionRepository.replace(
rawResults.distinctBy { manga ->
manga.id
}.map { manga ->
val jointTags = manga.tags intersect allTags
MangaSuggestion(
manga = manga,
relevance = (jointTags.size / manga.tags.size.toDouble()).pow(2.0).toFloat(),
)
}
)
return Result.success()
}
companion object {
private const val TAG = "suggestions"
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)
}
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M12,2A7,7 0 0,1 19,9C19,11.38 17.81,13.47 16,14.74V17A1,1 0 0,1 15,18H9A1,1 0 0,1 8,17V14.74C6.19,13.47 5,11.38 5,9A7,7 0 0,1 12,2M9,21V20H15V21A1,1 0 0,1 14,22H10A1,1 0 0,1 9,21M12,4A5,5 0 0,0 7,9C7,11.05 8.23,12.81 10,13.58V16H14V13.58C15.77,12.81 17,11.05 17,9A5,5 0 0,0 12,4Z" />
</vector>

View File

@@ -14,6 +14,10 @@
android:id="@+id/nav_history"
android:icon="@drawable/ic_history"
android:title="@string/history" />
<item
android:id="@+id/nav_suggestions"
android:icon="@drawable/ic_suggestion"
android:title="@string/suggestions" />
<item
android:id="@+id/nav_feed"
android:icon="@drawable/ic_feed"

View File

@@ -205,4 +205,6 @@
<string name="protect_application_subtitle">Enter password that will be required when the application starts</string>
<string name="confirm">Confirm</string>
<string name="password_length_hint">Password must be at least 4 characters</string>
<string name="suggestions">Suggestions</string>
<string name="text_suggestion_holder">Start reading manga and you will get personalized suggestions</string>
</resources>

View File

@@ -207,4 +207,6 @@
<string name="protect_application_subtitle">Enter password that will be required when the application starts</string>
<string name="confirm">Confirm</string>
<string name="password_length_hint">Password must be at least 4 characters</string>
<string name="suggestions">Suggestions</string>
<string name="text_suggestion_holder">Start reading manga and you will get personalized suggestions</string>
</resources>