diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 17b8adeeb..a0e9dbc00 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -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 = when (format) { "" -> DateFormat.getDateInstance(DateFormat.SHORT) @@ -224,6 +230,8 @@ class AppSettings(context: Context) { const val KEY_REVERSE_CHAPTERS = "reverse_chapters" const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw" const val KEY_PAGES_NUMBERS = "pages_numbers" + const val KEY_SUGGESTIONS = "suggestions" + const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" // About const val KEY_APP_UPDATE = "app_update" 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 e34a4fd92..c492e0e0b 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 @@ -91,7 +91,7 @@ class HistoryRepository( } } - suspend fun getAllTags(): List { - return db.historyDao.findAllTags().map { x -> x.toMangaTag() } + suspend fun getAllTags(): Set { + return db.historyDao.findAllTags().mapToSet { 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 6b59897ad..58078b4fe 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 @@ -43,12 +43,15 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel import org.koitharu.kotatsu.settings.AppUpdateChecker 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.SuggestionsWorker -import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment import org.koitharu.kotatsu.tracker.ui.FeedFragment 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(), NavigationView.OnNavigationItemSelectedListener, AppBarOwner, @@ -116,6 +119,7 @@ class MainActivity : BaseActivity(), viewModel.onError.observe(this, this::onError) viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.remoteSources.observe(this, this::updateSideMenu) + viewModel.isSuggestionsEnabled.observe(this, this::setSuggestionsEnabled) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { @@ -301,6 +305,14 @@ class MainActivity : BaseActivity(), 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() { when (viewModel.defaultSection) { AppSection.LOCAL -> { diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt index ec9566e68..f197e454c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt @@ -21,6 +21,12 @@ class MainViewModel( val onOpenReader = SingleLiveEvent() 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() .filter { it == AppSettings.KEY_SOURCES_ORDER || it == AppSettings.KEY_SOURCES_HIDDEN } .onStart { emit("") } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt index 17e8b6a93..7a2b992ae 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt @@ -11,7 +11,7 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceScreen -import androidx.preference.SwitchPreference +import androidx.preference.TwoStatePreference import kotlinx.coroutines.launch import leakcanary.LeakCanary import org.koin.android.ext.android.inject @@ -56,7 +56,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings), entryValues = ListMode.values().names() setDefaultValueCompat(ListMode.GRID.name) } - findPreference(AppSettings.KEY_DYNAMIC_THEME)?.isVisible = + findPreference(AppSettings.KEY_DYNAMIC_THEME)?.isVisible = AppSettings.isDynamicColorAvailable findPreference(AppSettings.KEY_DATE_FORMAT)?.run { 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("") summary = "%s" } + findPreference(AppSettings.KEY_SUGGESTIONS)?.setSummary( + if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled + ) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) findPreference(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName() - findPreference(AppSettings.KEY_PROTECT_APP)?.isChecked = + findPreference(AppSettings.KEY_PROTECT_APP)?.isChecked = !settings.appPassword.isNullOrEmpty() settings.subscribe(this) } @@ -114,15 +117,20 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings), findPreference(key)?.setSummary(R.string.restart_required) } AppSettings.KEY_HIDE_TOOLBAR -> { - findPreference(key)?.setSummary(R.string.restart_required) + findPreference(key)?.setSummary(R.string.restart_required) } AppSettings.KEY_LOCAL_STORAGE -> { findPreference(key)?.bindStorageName() } AppSettings.KEY_APP_PASSWORD -> { - findPreference(AppSettings.KEY_PROTECT_APP) + findPreference(AppSettings.KEY_PROTECT_APP) ?.isChecked = !settings.appPassword.isNullOrEmpty() } + AppSettings.KEY_SUGGESTIONS -> { + findPreference(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 } AppSettings.KEY_PROTECT_APP -> { - val pref = (preference as? SwitchPreference ?: return false) + val pref = (preference as? TwoStatePreference ?: return false) if (pref.isChecked) { pref.isChecked = false startActivity(Intent(preference.context, ProtectSetupActivity::class.java)) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SuggestionsSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SuggestionsSettingsFragment.kt new file mode 100644 index 000000000..02467f1d6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SuggestionsSettingsFragment.kt @@ -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(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) + } + } + } +} \ 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 index bf5f2f055..0f80321a0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt @@ -10,6 +10,9 @@ abstract class SuggestionDao { @Query("SELECT * FROM suggestions ORDER BY relevance DESC") abstract fun observeAll(): Flow> + @Query("SELECT COUNT(*) FROM suggestions") + abstract suspend fun count(): Int + @Insert(onConflict = OnConflictStrategy.IGNORE) abstract suspend fun insert(entity: SuggestionEntity): Long 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 index 4ea272ba5..8afa1dd3e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt @@ -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) { db.withTransaction { db.suggestionDao.deleteAll() 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 index c53b705e5..b4f61427b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt @@ -8,7 +8,7 @@ import org.koitharu.kotatsu.list.ui.MangaListFragment class SuggestionsFragment : MangaListFragment() { - override val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) + override val viewModel by viewModel() override val isSwipeRefreshEnabled = false override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 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 index 3eaeaba29..f602e534d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt @@ -6,6 +6,7 @@ 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.core.prefs.AppSettings import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository @@ -18,10 +19,25 @@ class SuggestionsWorker(appContext: Context, params: WorkerParameters) : private val suggestionRepository by inject() private val historyRepository by inject() + private val appSettings by inject() - 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() val allTags = historyRepository.getAllTags() + if (allTags.isEmpty()) { + return 0 + } val tagsBySources = allTags.groupBy { x -> x.source } for ((source, tags) in tagsBySources) { val repo = mangaRepositoryOf(source) @@ -33,23 +49,31 @@ class SuggestionsWorker(appContext: Context, params: WorkerParameters) : ) } } - 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() + if (appSettings.isSuggestionsExcludeNsfw) { + rawResults.removeAll { it.isNsfw } + } + if (rawResults.isEmpty()) { + return 0 + } + val suggestions = 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(), + ) + }.sortedBy { it.relevance }.take(LIMIT) + suggestionRepository.replace(suggestions) + return suggestions.size } companion object { 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) { val constraints = Constraints.Builder() @@ -64,5 +88,17 @@ class SuggestionsWorker(appContext: Context, params: WorkerParameters) : WorkManager.getInstance(context) .enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request) } + + fun startNow(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .addTag(TAG_ONESHOT) + .build() + WorkManager.getInstance(context) + .enqueue(request) + } } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt index d345ca5e4..ec47a74b3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt @@ -26,7 +26,6 @@ import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.PendingIntentCompat import org.koitharu.kotatsu.utils.ext.mangaRepositoryOf import org.koitharu.kotatsu.utils.ext.toBitmapOrNull -import org.koitharu.kotatsu.utils.ext.toUriOrNull import org.koitharu.kotatsu.utils.progress.Progress 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_TOTAL = "total" private const val TAG = "tracking" + private const val TAG_ONESHOT = "tracking_oneshot" @RequiresApi(Build.VERSION_CODES.O) private fun createNotificationChannel(context: Context) { @@ -277,7 +277,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : .build() val request = OneTimeWorkRequestBuilder() .setConstraints(constraints) - .addTag(TAG) + .addTag(TAG_ONESHOT) .build() WorkManager.getInstance(context) .enqueue(request) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c15cb6480..5846cd0f2 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -249,4 +249,12 @@ Доступные источники Динамическая тема Применяет тему приложения, основанную на цветовой палитре обоев на устройстве + Рекомендации + Включить рекомендации + Предлагать мангу на основе Ваших предпочтений + Все данные анализируются локально на устройстве. Ваши персональные данные не передаются в какие-либо сервисы + Начните читать мангу, чтобы получать персональные предложения + Не предлагать NSFW мангу + Включено + Выключено \ 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 38a302e0e..f58a73e48 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -251,6 +251,12 @@ Dynamic theme Applies a theme created on the color scheme of your wallpaper Importing manga: %1$d of %2$d - Suggestions - Start reading manga and you will get personalized suggestions + Suggestions + Enable suggestions + Suggest manga based on your preferences + All data is analyzed locally on this device. There is no transfer of your personal data to any services + Start reading manga and you will get personalized suggestions + Do not suggest NSFW manga + Enabled + Disabled \ No newline at end of file diff --git a/app/src/main/res/xml/pref_main.xml b/app/src/main/res/xml/pref_main.xml index 84bae3f2a..f6cbe59cf 100644 --- a/app/src/main/res/xml/pref_main.xml +++ b/app/src/main/res/xml/pref_main.xml @@ -61,6 +61,13 @@ app:allowDividerAbove="true" app:iconSpaceReserved="false" /> + + - + + + + + + + + + \ No newline at end of file