diff --git a/.idea/misc.xml b/.idea/misc.xml
index 7f598c7c9..44afccb0c 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -9,6 +9,8 @@
+
+
@@ -33,6 +35,7 @@
+
diff --git a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt
index 311a9bdb6..85e7adf5d 100644
--- a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt
@@ -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,
)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt
index f0844571e..9c40edaae 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt
@@ -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
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSection.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSection.kt
index 64ce67264..0efa45c92 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSection.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSection.kt
@@ -2,5 +2,5 @@ package org.koitharu.kotatsu.core.prefs
enum class AppSection {
- LOCAL, FAVOURITES, HISTORY, FEED
+ LOCAL, FAVOURITES, HISTORY, FEED, SUGGESTIONS
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt
index 9ee2642fb..0b973aa64 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt
@@ -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
+ @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
+
@Query("SELECT * FROM history WHERE manga_id = :id")
abstract suspend fun find(id: Long): HistoryEntity?
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt
index cece71003..ade87172b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt
@@ -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 {
+ return db.historyDao.findAllTags().map { x -> x.toMangaTag() }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt
index 443ab118f..6abc5fae4 100644
--- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt
@@ -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(),
}
if (savedInstanceState == null) {
TrackWorker.setup(applicationContext)
+ SuggestionsWorker.setup(applicationContext)
AppUpdateChecker(this).launchIfNeeded()
}
@@ -144,6 +147,10 @@ class MainActivity : BaseActivity(),
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(),
binding.navigationView.setCheckedItem(R.id.nav_feed)
setPrimaryFragment(FeedFragment.newInstance())
}
+ AppSection.SUGGESTIONS -> {
+ binding.navigationView.setCheckedItem(R.id.nav_suggestions)
+ setPrimaryFragment(SuggestionsFragment.newInstance())
+ }
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/SuggestionsModule.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/SuggestionsModule.kt
new file mode 100644
index 000000000..df0a2c870
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/SuggestionsModule.kt
@@ -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()) }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt
new file mode 100644
index 000000000..bf5f2f055
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt
@@ -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>
+
+ @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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/SuggestionEntity.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionEntity.kt
similarity index 77%
rename from app/src/main/java/org/koitharu/kotatsu/core/db/entity/SuggestionEntity.kt
rename to app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionEntity.kt
index 896732e15..174810bc2 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/SuggestionEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionEntity.kt
@@ -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(),
)
diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionWithManga.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionWithManga.kt
new file mode 100644
index 000000000..13aa11bec
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionWithManga.kt
@@ -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
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/MangaSuggestion.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/MangaSuggestion.kt
new file mode 100644
index 000000000..689d8276a
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/MangaSuggestion.kt
@@ -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,
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt
new file mode 100644
index 000000000..4ea272ba5
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt
@@ -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> {
+ return db.suggestionDao.observeAll().mapItems {
+ it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag))
+ }
+ }
+
+ suspend fun replace(suggestions: Iterable) {
+ 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(),
+ )
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt
new file mode 100644
index 000000000..c53b705e5
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt
@@ -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(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()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt
new file mode 100644
index 000000000..cc3e52d2f
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt
@@ -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,
+ mode: ListMode,
+ ): List = list.map { manga ->
+ when (mode) {
+ ListMode.LIST -> manga.toListModel()
+ ListMode.DETAILED_LIST -> manga.toListDetailedModel()
+ ListMode.GRID -> manga.toGridModel()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt
new file mode 100644
index 000000000..895508592
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt
@@ -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()
+ private val historyRepository by inject()
+
+ override suspend fun doWork(): Result {
+ val rawResults = ArrayList()
+ 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(6, TimeUnit.HOURS)
+ .setConstraints(constraints)
+ .addTag(TAG)
+ .setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES)
+ .build()
+ WorkManager.getInstance(context)
+ .enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_suggestion.xml b/app/src/main/res/drawable/ic_suggestion.xml
new file mode 100644
index 000000000..a93a75799
--- /dev/null
+++ b/app/src/main/res/drawable/ic_suggestion.xml
@@ -0,0 +1,12 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/nav_drawer.xml b/app/src/main/res/menu/nav_drawer.xml
index 75869c7a7..1e0db493c 100644
--- a/app/src/main/res/menu/nav_drawer.xml
+++ b/app/src/main/res/menu/nav_drawer.xml
@@ -14,6 +14,10 @@
android:id="@+id/nav_history"
android:icon="@drawable/ic_history"
android:title="@string/history" />
+
- Enter password that will be required when the application starts
Confirm
Password must be at least 4 characters
+ Suggestions
+ Start reading manga and you will get personalized suggestions
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 161e209c5..fce999bca 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -207,4 +207,6 @@
Enter password that will be required when the application starts
Confirm
Password must be at least 4 characters
+ Suggestions
+ Start reading manga and you will get personalized suggestions
\ No newline at end of file