Suggestions settings

This commit is contained in:
Koitharu
2022-02-27 18:25:18 +02:00
parent 97c0fcf022
commit 632715e6c9
15 changed files with 204 additions and 29 deletions

View File

@@ -135,6 +135,12 @@ class AppSettings(context: Context) {
} }
} }
val isSuggestionsEnabled: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
val isSuggestionsExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false)
fun getDateFormat(format: String = prefs.getString(KEY_DATE_FORMAT, "").orEmpty()): DateFormat = fun getDateFormat(format: String = prefs.getString(KEY_DATE_FORMAT, "").orEmpty()): DateFormat =
when (format) { when (format) {
"" -> DateFormat.getDateInstance(DateFormat.SHORT) "" -> DateFormat.getDateInstance(DateFormat.SHORT)
@@ -224,6 +230,8 @@ class AppSettings(context: Context) {
const val KEY_REVERSE_CHAPTERS = "reverse_chapters" const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw" const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
const val KEY_PAGES_NUMBERS = "pages_numbers" const val KEY_PAGES_NUMBERS = "pages_numbers"
const val KEY_SUGGESTIONS = "suggestions"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

View File

@@ -91,7 +91,7 @@ class HistoryRepository(
} }
} }
suspend fun getAllTags(): List<MangaTag> { suspend fun getAllTags(): Set<MangaTag> {
return db.historyDao.findAllTags().map { x -> x.toMangaTag() } return db.historyDao.findAllTags().mapToSet { x -> x.toMangaTag() }
} }
} }

View File

@@ -43,12 +43,15 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.settings.AppUpdateChecker import org.koitharu.kotatsu.settings.AppUpdateChecker
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.tracker.ui.FeedFragment import org.koitharu.kotatsu.tracker.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.hideKeyboard
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.resolveDp
class MainActivity : BaseActivity<ActivityMainBinding>(), class MainActivity : BaseActivity<ActivityMainBinding>(),
NavigationView.OnNavigationItemSelectedListener, AppBarOwner, NavigationView.OnNavigationItemSelectedListener, AppBarOwner,
@@ -116,6 +119,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
viewModel.onError.observe(this, this::onError) viewModel.onError.observe(this, this::onError)
viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.remoteSources.observe(this, this::updateSideMenu) viewModel.remoteSources.observe(this, this::updateSideMenu)
viewModel.isSuggestionsEnabled.observe(this, this::setSuggestionsEnabled)
} }
override fun onRestoreInstanceState(savedInstanceState: Bundle) { override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@@ -301,6 +305,14 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
submenu.setGroupCheckable(R.id.group_remote_sources, true, true) submenu.setGroupCheckable(R.id.group_remote_sources, true, true)
} }
private fun setSuggestionsEnabled(isEnabled: Boolean) {
val item = binding.navigationView.menu.findItem(R.id.nav_suggestions) ?: return
if (!isEnabled && item.isChecked) {
binding.navigationView.setCheckedItem(R.id.nav_history)
}
item.isVisible = isEnabled
}
private fun openDefaultSection() { private fun openDefaultSection() {
when (viewModel.defaultSection) { when (viewModel.defaultSection) {
AppSection.LOCAL -> { AppSection.LOCAL -> {

View File

@@ -21,6 +21,12 @@ class MainViewModel(
val onOpenReader = SingleLiveEvent<Manga>() val onOpenReader = SingleLiveEvent<Manga>()
var defaultSection by settings::defaultSection var defaultSection by settings::defaultSection
val isSuggestionsEnabled = settings.observe()
.filter { it == AppSettings.KEY_SUGGESTIONS }
.onStart { emit("") }
.map { settings.isSuggestionsEnabled }
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val remoteSources = settings.observe() val remoteSources = settings.observe()
.filter { it == AppSettings.KEY_SOURCES_ORDER || it == AppSettings.KEY_SOURCES_HIDDEN } .filter { it == AppSettings.KEY_SOURCES_ORDER || it == AppSettings.KEY_SOURCES_HIDDEN }
.onStart { emit("") } .onStart { emit("") }

View File

@@ -11,7 +11,7 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreference import androidx.preference.TwoStatePreference
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import leakcanary.LeakCanary import leakcanary.LeakCanary
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@@ -56,7 +56,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
entryValues = ListMode.values().names() entryValues = ListMode.values().names()
setDefaultValueCompat(ListMode.GRID.name) setDefaultValueCompat(ListMode.GRID.name)
} }
findPreference<SwitchPreference>(AppSettings.KEY_DYNAMIC_THEME)?.isVisible = findPreference<Preference>(AppSettings.KEY_DYNAMIC_THEME)?.isVisible =
AppSettings.isDynamicColorAvailable AppSettings.isDynamicColorAvailable
findPreference<ListPreference>(AppSettings.KEY_DATE_FORMAT)?.run { findPreference<ListPreference>(AppSettings.KEY_DATE_FORMAT)?.run {
entryValues = arrayOf("", "MM/dd/yy", "dd/MM/yy", "yyyy-MM-dd", "dd MMM yyyy", "MMM dd, yyyy") entryValues = arrayOf("", "MM/dd/yy", "dd/MM/yy", "yyyy-MM-dd", "dd MMM yyyy", "MMM dd, yyyy")
@@ -72,12 +72,15 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
setDefaultValueCompat("") setDefaultValueCompat("")
summary = "%s" summary = "%s"
} }
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
)
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName() findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
findPreference<SwitchPreference>(AppSettings.KEY_PROTECT_APP)?.isChecked = findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)?.isChecked =
!settings.appPassword.isNullOrEmpty() !settings.appPassword.isNullOrEmpty()
settings.subscribe(this) settings.subscribe(this)
} }
@@ -114,15 +117,20 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
findPreference<Preference>(key)?.setSummary(R.string.restart_required) findPreference<Preference>(key)?.setSummary(R.string.restart_required)
} }
AppSettings.KEY_HIDE_TOOLBAR -> { AppSettings.KEY_HIDE_TOOLBAR -> {
findPreference<SwitchPreference>(key)?.setSummary(R.string.restart_required) findPreference<Preference>(key)?.setSummary(R.string.restart_required)
} }
AppSettings.KEY_LOCAL_STORAGE -> { AppSettings.KEY_LOCAL_STORAGE -> {
findPreference<Preference>(key)?.bindStorageName() findPreference<Preference>(key)?.bindStorageName()
} }
AppSettings.KEY_APP_PASSWORD -> { AppSettings.KEY_APP_PASSWORD -> {
findPreference<SwitchPreference>(AppSettings.KEY_PROTECT_APP) findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.isChecked = !settings.appPassword.isNullOrEmpty() ?.isChecked = !settings.appPassword.isNullOrEmpty()
} }
AppSettings.KEY_SUGGESTIONS -> {
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
)
}
} }
} }
@@ -148,7 +156,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
true true
} }
AppSettings.KEY_PROTECT_APP -> { AppSettings.KEY_PROTECT_APP -> {
val pref = (preference as? SwitchPreference ?: return false) val pref = (preference as? TwoStatePreference ?: return false)
if (pref.isChecked) { if (pref.isChecked) {
pref.isChecked = false pref.isChecked = false
startActivity(Intent(preference.context, ProtectSetupActivity::class.java)) startActivity(Intent(preference.context, ProtectSetupActivity::class.java))

View File

@@ -0,0 +1,46 @@
package org.koitharu.kotatsu.settings
import android.content.SharedPreferences
import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
class SuggestionsSettingsFragment : BasePreferenceFragment(R.string.suggestions),
SharedPreferences.OnSharedPreferenceChangeListener {
private val repository by inject<SuggestionRepository>(mode = LazyThreadSafetyMode.NONE)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
settings.subscribe(this)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_suggestions)
}
override fun onDestroy() {
super.onDestroy()
settings.unsubscribe(this)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (key == AppSettings.KEY_SUGGESTIONS && settings.isSuggestionsEnabled) {
onSuggestionsEnabled()
}
}
private fun onSuggestionsEnabled() {
lifecycleScope.launch {
if (repository.isEmpty()) {
SuggestionsWorker.startNow(context ?: return@launch)
}
}
}
}

View File

@@ -10,6 +10,9 @@ abstract class SuggestionDao {
@Query("SELECT * FROM suggestions ORDER BY relevance DESC") @Query("SELECT * FROM suggestions ORDER BY relevance DESC")
abstract fun observeAll(): Flow<List<SuggestionWithManga>> abstract fun observeAll(): Flow<List<SuggestionWithManga>>
@Query("SELECT COUNT(*) FROM suggestions")
abstract suspend fun count(): Int
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(entity: SuggestionEntity): Long abstract suspend fun insert(entity: SuggestionEntity): Long

View File

@@ -20,6 +20,14 @@ class SuggestionRepository(
} }
} }
suspend fun clear() {
db.suggestionDao.deleteAll()
}
suspend fun isEmpty(): Boolean {
return db.suggestionDao.count() == 0
}
suspend fun replace(suggestions: Iterable<MangaSuggestion>) { suspend fun replace(suggestions: Iterable<MangaSuggestion>) {
db.withTransaction { db.withTransaction {
db.suggestionDao.deleteAll() db.suggestionDao.deleteAll()

View File

@@ -8,7 +8,7 @@ import org.koitharu.kotatsu.list.ui.MangaListFragment
class SuggestionsFragment : MangaListFragment() { class SuggestionsFragment : MangaListFragment() {
override val viewModel by viewModel<SuggestionsViewModel>(mode = LazyThreadSafetyMode.NONE) override val viewModel by viewModel<SuggestionsViewModel>()
override val isSwipeRefreshEnabled = false override val isSwipeRefreshEnabled = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View File

@@ -6,6 +6,7 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.SortOrder import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
@@ -18,10 +19,25 @@ class SuggestionsWorker(appContext: Context, params: WorkerParameters) :
private val suggestionRepository by inject<SuggestionRepository>() private val suggestionRepository by inject<SuggestionRepository>()
private val historyRepository by inject<HistoryRepository>() private val historyRepository by inject<HistoryRepository>()
private val appSettings by inject<AppSettings>()
override suspend fun doWork(): Result { override suspend fun doWork(): Result = try {
val count = doWorkImpl()
Result.success(workDataOf(DATA_COUNT to count))
} catch (t: Throwable) {
Result.failure()
}
private suspend fun doWorkImpl(): Int {
if (!appSettings.isSuggestionsEnabled) {
suggestionRepository.clear()
return 0
}
val rawResults = ArrayList<Manga>() val rawResults = ArrayList<Manga>()
val allTags = historyRepository.getAllTags() val allTags = historyRepository.getAllTags()
if (allTags.isEmpty()) {
return 0
}
val tagsBySources = allTags.groupBy { x -> x.source } val tagsBySources = allTags.groupBy { x -> x.source }
for ((source, tags) in tagsBySources) { for ((source, tags) in tagsBySources) {
val repo = mangaRepositoryOf(source) val repo = mangaRepositoryOf(source)
@@ -33,23 +49,31 @@ class SuggestionsWorker(appContext: Context, params: WorkerParameters) :
) )
} }
} }
suggestionRepository.replace( if (appSettings.isSuggestionsExcludeNsfw) {
rawResults.distinctBy { manga -> rawResults.removeAll { it.isNsfw }
manga.id }
}.map { manga -> if (rawResults.isEmpty()) {
val jointTags = manga.tags intersect allTags return 0
MangaSuggestion( }
manga = manga, val suggestions = rawResults.distinctBy { manga ->
relevance = (jointTags.size / manga.tags.size.toDouble()).pow(2.0).toFloat(), manga.id
) }.map { manga ->
} val jointTags = manga.tags intersect allTags
) MangaSuggestion(
return Result.success() manga = manga,
relevance = (jointTags.size / manga.tags.size.toDouble()).pow(2.0).toFloat(),
)
}.sortedBy { it.relevance }.take(LIMIT)
suggestionRepository.replace(suggestions)
return suggestions.size
} }
companion object { companion object {
private const val TAG = "suggestions" private const val TAG = "suggestions"
private const val TAG_ONESHOT = "suggestions_oneshot"
private const val LIMIT = 140
private const val DATA_COUNT = "count"
fun setup(context: Context) { fun setup(context: Context) {
val constraints = Constraints.Builder() val constraints = Constraints.Builder()
@@ -64,5 +88,17 @@ class SuggestionsWorker(appContext: Context, params: WorkerParameters) :
WorkManager.getInstance(context) WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request) .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)
.build()
WorkManager.getInstance(context)
.enqueue(request)
}
} }
} }

View File

@@ -26,7 +26,6 @@ import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.PendingIntentCompat import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.mangaRepositoryOf import org.koitharu.kotatsu.utils.ext.mangaRepositoryOf
import org.koitharu.kotatsu.utils.ext.toBitmapOrNull import org.koitharu.kotatsu.utils.ext.toBitmapOrNull
import org.koitharu.kotatsu.utils.ext.toUriOrNull
import org.koitharu.kotatsu.utils.progress.Progress import org.koitharu.kotatsu.utils.progress.Progress
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -237,6 +236,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
private const val DATA_PROGRESS = "progress" private const val DATA_PROGRESS = "progress"
private const val DATA_TOTAL = "total" private const val DATA_TOTAL = "total"
private const val TAG = "tracking" private const val TAG = "tracking"
private const val TAG_ONESHOT = "tracking_oneshot"
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel(context: Context) { private fun createNotificationChannel(context: Context) {
@@ -277,7 +277,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
.build() .build()
val request = OneTimeWorkRequestBuilder<TrackWorker>() val request = OneTimeWorkRequestBuilder<TrackWorker>()
.setConstraints(constraints) .setConstraints(constraints)
.addTag(TAG) .addTag(TAG_ONESHOT)
.build() .build()
WorkManager.getInstance(context) WorkManager.getInstance(context)
.enqueue(request) .enqueue(request)

View File

@@ -249,4 +249,12 @@
<string name="available_sources">Доступные источники</string> <string name="available_sources">Доступные источники</string>
<string name="dynamic_theme">Динамическая тема</string> <string name="dynamic_theme">Динамическая тема</string>
<string name="dynamic_theme_summary">Применяет тему приложения, основанную на цветовой палитре обоев на устройстве</string> <string name="dynamic_theme_summary">Применяет тему приложения, основанную на цветовой палитре обоев на устройстве</string>
<string name="suggestions">Рекомендации</string>
<string name="suggestions_enable">Включить рекомендации</string>
<string name="suggestions_summary">Предлагать мангу на основе Ваших предпочтений</string>
<string name="suggestions_info">Все данные анализируются локально на устройстве. Ваши персональные данные не передаются в какие-либо сервисы</string>
<string name="text_suggestion_holder">Начните читать мангу, чтобы получать персональные предложения</string>
<string name="exclude_nsfw_from_suggestions">Не предлагать NSFW мангу</string>
<string name="enabled">Включено</string>
<string name="disabled">Выключено</string>
</resources> </resources>

View File

@@ -251,6 +251,12 @@
<string name="dynamic_theme">Dynamic theme</string> <string name="dynamic_theme">Dynamic theme</string>
<string name="dynamic_theme_summary">Applies a theme created on the color scheme of your wallpaper</string> <string name="dynamic_theme_summary">Applies a theme created on the color scheme of your wallpaper</string>
<string name="importing_progress">Importing manga: %1$d of %2$d</string> <string name="importing_progress">Importing manga: %1$d of %2$d</string>
<string name="suggestions">Suggestions</string> <string name="suggestions">Suggestions</string>
<string name="text_suggestion_holder">Start reading manga and you will get personalized suggestions</string> <string name="suggestions_enable">Enable suggestions</string>
<string name="suggestions_summary">Suggest manga based on your preferences</string>
<string name="suggestions_info">All data is analyzed locally on this device. There is no transfer of your personal data to any services</string>
<string name="text_suggestion_holder">Start reading manga and you will get personalized suggestions</string>
<string name="exclude_nsfw_from_suggestions">Do not suggest NSFW manga</string>
<string name="enabled">Enabled</string>
<string name="disabled">Disabled</string>
</resources> </resources>

View File

@@ -61,6 +61,13 @@
app:allowDividerAbove="true" app:allowDividerAbove="true"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.SuggestionsSettingsFragment"
android:key="suggestions"
android:persistent="false"
android:title="@string/suggestions"
app:iconSpaceReserved="false" />
<Preference <Preference
android:key="local_storage" android:key="local_storage"
android:title="@string/manga_save_location" android:title="@string/manga_save_location"
@@ -71,7 +78,7 @@
android:title="@string/history_and_cache" android:title="@string/history_and_cache"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
<SwitchPreference <SwitchPreferenceCompat
android:key="protect_app" android:key="protect_app"
android:persistent="false" android:persistent="false"
android:summary="@string/protect_application_summary" android:summary="@string/protect_application_summary"

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="suggestions"
android:summary="@string/suggestions_summary"
android:title="@string/suggestions_enable"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat
android:dependency="suggestions"
android:key="suggestions_exclude_nsfw"
android:title="@string/exclude_nsfw_from_suggestions"
app:iconSpaceReserved="false" />
<Preference
android:icon="@drawable/ic_info_outline"
android:key="track_warning"
android:persistent="false"
android:selectable="false"
android:summary="@string/suggestions_info"
app:allowDividerAbove="true" />
</PreferenceScreen>