Merge branch 'feature/suggestions' into devel

This commit is contained in:
Koitharu
2022-03-04 18:34:15 +02:00
43 changed files with 578 additions and 48 deletions

View File

@@ -27,6 +27,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
@@ -67,6 +68,7 @@ class KotatsuApp : Application() {
settingsModule,
readerModule,
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

@@ -237,7 +237,6 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
when {
c == '-' -> {
builder.setCharAt(i, ' ')
capitalize = true
}
capitalize -> {
builder.setCharAt(i, c.uppercaseChar())

View File

@@ -61,7 +61,7 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
tags = runCatching {
row.selectFirst("div.genre")?.select("a")?.mapToSet {
MangaTag(
title = it.text(),
title = it.text().toTitleCase(),
key = it.attr("href").substringAfterLast('/').urlEncoded(),
source = source
)
@@ -136,7 +136,7 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
return root.select("li.sidetag").mapToSet { li ->
val a = li.children().last() ?: throw ParseException("a is null")
MangaTag(
title = a.text().toCamelCase(),
title = a.text().toTitleCase(),
key = a.attr("href").substringAfterLast('/'),
source = source
)

View File

@@ -85,7 +85,7 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
tags = json.getJSONArray("genres").mapToSet {
MangaTag(
key = it.getString("text"),
title = it.getString("russian"),
title = it.getString("russian").toTitleCase(),
source = manga.source
)
},
@@ -133,7 +133,7 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
MangaTag(
source = source,
key = it.selectFirst("input")?.attr("data-genre") ?: parseFailed(),
title = it.selectFirst("label")?.text() ?: parseFailed()
title = it.selectFirst("label")?.text()?.toTitleCase() ?: parseFailed()
)
}
}

View File

@@ -85,7 +85,7 @@ class ExHentaiRepository(
val tagsDiv = glink.nextElementSibling() ?: parseFailed("tags div not found")
val mainTag = td2.selectFirst("div.cn")?.let { div ->
MangaTag(
title = div.text(),
title = div.text().toTitleCase(),
key = tagIdByClass(div.classNames()) ?: return@let null,
source = source,
)
@@ -181,7 +181,7 @@ class ExHentaiRepository(
val id = div.id().substringAfterLast('_').toIntOrNull()
?: return@mapNotNullToSet null
MangaTag(
title = div.text(),
title = div.text().toTitleCase(),
key = id.toString(),
source = source
)

View File

@@ -89,7 +89,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
tileInfo?.select("a.element-link")
?.mapToSet {
MangaTag(
title = it.text(),
title = it.text().toTitleCase(),
key = it.attr("href").substringAfterLast('/'),
source = source
)
@@ -119,7 +119,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
.mapNotNull {
val a = it.selectFirst("a.element-link") ?: return@mapNotNull null
MangaTag(
title = a.text(),
title = a.text().toTitleCase(),
key = a.attr("href").substringAfterLast('/'),
source = source
)
@@ -183,7 +183,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
?.selectFirst("table.table") ?: parseFailed("Cannot find root")
return root.select("a.element-link").mapToSet { a ->
MangaTag(
title = a.text().toCamelCase(),
title = a.text().toTitleCase(),
key = a.attr("href").substringAfterLast('/'),
source = source
)

View File

@@ -5,6 +5,7 @@ import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.utils.ext.mapToSet
import org.koitharu.kotatsu.utils.ext.parseHtml
import org.koitharu.kotatsu.utils.ext.toTitleCase
class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) {
@@ -36,7 +37,7 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load
tags = root.selectFirst("div.sidetags")?.select("li.sidetag")?.mapToSet {
val a = it.children().last() ?: parseFailed("Invalid tag")
MangaTag(
title = a.text(),
title = a.text().toTitleCase(),
key = a.attr("href").substringAfterLast('/'),
source = source
)

View File

@@ -94,7 +94,8 @@ class MangaDexRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
MangaTag(
title = tag.getJSONObject("attributes")
.getJSONObject("name")
.firstStringValue(),
.firstStringValue()
.toTitleCase(),
key = tag.getString("id"),
source = source,
)
@@ -194,7 +195,7 @@ class MangaDexRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
.getJSONArray("data")
return tags.mapToSet { jo ->
MangaTag(
title = jo.getJSONObject("attributes").getJSONObject("name").firstStringValue(),
title = jo.getJSONObject("attributes").getJSONObject("name").firstStringValue().toTitleCase(),
key = jo.getString("id"),
source = source,
)

View File

@@ -139,7 +139,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
tags = info?.selectFirst("div.media-tags")
?.select("a.media-tag-item")?.mapToSet { a ->
MangaTag(
title = a.text().toCamelCase(),
title = a.text().toTitleCase(),
key = a.attr("href").substringAfterLast('='),
source = source
)
@@ -203,7 +203,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
result += MangaTag(
source = source,
key = x.getInt("id").toString(),
title = x.getString("name").toCamelCase()
title = x.getString("name").toTitleCase(),
)
}
return result

View File

@@ -91,7 +91,7 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
.mapNotNull {
val a = it.selectFirst("a") ?: return@mapNotNull null
MangaTag(
title = a.text(),
title = a.text().toTitleCase(),
key = a.attr("href"),
source = source
)
@@ -144,7 +144,7 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
return root.mapToSet { p ->
val a = p.selectFirst("a") ?: parseFailed("a is null")
MangaTag(
title = a.text().toCamelCase(),
title = a.text().toTitleCase(),
key = a.attr("href"),
source = source
)

View File

@@ -80,7 +80,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
},
tags = li.selectFirst("p.keyWord")?.select("a")?.mapNotNullToSet tags@{ x ->
MangaTag(
title = x.attr("title"),
title = x.attr("title").toTitleCase(),
key = x.attr("href").parseTagKey() ?: return@tags null,
source = MangaSource.MANGATOWN
)
@@ -104,7 +104,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
x.selectFirst("b")?.ownText() == "Genre(s):"
}?.select("a")?.mapNotNull { a ->
MangaTag(
title = a.attr("title"),
title = a.attr("title").toTitleCase(),
key = a.attr("href").parseTagKey() ?: return@mapNotNull null,
source = MangaSource.MANGATOWN
)
@@ -172,7 +172,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
MangaTag(
source = MangaSource.MANGATOWN,
key = key,
title = a.text()
title = a.text().toTitleCase()
)
}
}

View File

@@ -62,7 +62,7 @@ class MangareadRepository(
tags = summary?.selectFirst(".mg_genres")?.select("a")?.mapToSet { a ->
MangaTag(
key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
title = a.text(),
title = a.text().toTitleCase(),
source = MangaSource.MANGAREAD
)
}.orEmpty(),
@@ -91,7 +91,7 @@ class MangareadRepository(
}
MangaTag(
key = href,
title = a.text(),
title = a.text().toTitleCase(),
source = MangaSource.MANGAREAD
)
}
@@ -113,7 +113,7 @@ class MangareadRepository(
?.mapNotNullToSet { a ->
MangaTag(
key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
title = a.text(),
title = a.text().toTitleCase(),
source = MangaSource.MANGAREAD
)
} ?: manga.tags,

View File

@@ -94,7 +94,7 @@ abstract class NineMangaRepository(
tags = infoRoot.getElementsByAttributeValue("itemprop", "genre").first()
?.select("a")?.mapToSet { a ->
MangaTag(
title = a.text(),
title = a.text().toTitleCase(),
key = a.attr("href").substringBetween("/", "."),
source = source,
)

View File

@@ -73,7 +73,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
author = null,
tags = jo.optJSONArray("genres")?.mapToSet { g ->
MangaTag(
title = g.getString("name"),
title = g.getString("name").toTitleCase(),
key = g.getInt("id").toString(),
source = MangaSource.REMANGA
)
@@ -109,7 +109,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
},
tags = content.getJSONArray("genres").mapToSet { g ->
MangaTag(
title = g.getString("name"),
title = g.getString("name").toTitleCase(),
key = g.getInt("id").toString(),
source = MangaSource.REMANGA
)
@@ -175,7 +175,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
.parseJson().getJSONObject("content").getJSONArray("genres")
return content.mapToSet { jo ->
MangaTag(
title = jo.getString("name"),
title = jo.getString("name").toTitleCase(),
key = jo.getInt("id").toString(),
source = source
)

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

@@ -141,6 +141,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)
@@ -231,6 +237,8 @@ class AppSettings(context: Context) {
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
const val KEY_PAGES_NUMBERS = "pages_numbers"
const val KEY_SCREENSHOTS_POLICY = "screenshots_policy"
const val KEY_SUGGESTIONS = "suggestions"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
// About
const val KEY_APP_UPDATE = "app_update"

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.core.prefs.AppSettings
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
@@ -89,4 +90,8 @@ class HistoryRepository(
db.historyDao.delete(manga.id)
}
}
suspend fun getAllTags(): Set<MangaTag> {
return db.historyDao.findAllTags().mapToSet { x -> x.toMangaTag() }
}
}

View File

@@ -7,10 +7,7 @@ import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.utils.ext.getBooleanOrDefault
import org.koitharu.kotatsu.utils.ext.getLongOrDefault
import org.koitharu.kotatsu.utils.ext.getStringOrNull
import org.koitharu.kotatsu.utils.ext.mapToSet
import org.koitharu.kotatsu.utils.ext.*
class MangaIndex(source: String?) {
@@ -61,7 +58,7 @@ class MangaIndex(source: String?) {
description = json.getStringOrNull("description"),
tags = json.getJSONArray("tags").mapToSet { x ->
MangaTag(
title = x.getString("title"),
title = x.getString("title").toTitleCase(),
key = x.getString("key"),
source = source
)

View File

@@ -49,6 +49,8 @@ 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.tracker.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
@@ -122,6 +124,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
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) {
@@ -187,6 +190,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())
@@ -303,6 +310,14 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
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 -> {
@@ -321,6 +336,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())
}
}
}
@@ -344,6 +363,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
private fun onFirstStart() {
lifecycleScope.launch(Dispatchers.Default) {
TrackWorker.setup(applicationContext)
SuggestionsWorker.setup(applicationContext)
AppUpdateChecker(this@MainActivity).checkIfNeeded()
if (!get<AppSettings>().isSourcesSelected) {
withContext(Dispatchers.Main) {

View File

@@ -21,6 +21,12 @@ class MainViewModel(
val onOpenReader = SingleLiveEvent<Manga>()
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("") }

View File

@@ -14,10 +14,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.databinding.ActivitySettingsSimpleBinding
import org.koitharu.kotatsu.settings.MainSettingsFragment
import org.koitharu.kotatsu.settings.NetworkSettingsFragment
import org.koitharu.kotatsu.settings.ReaderSettingsFragment
import org.koitharu.kotatsu.settings.SourceSettingsFragment
import org.koitharu.kotatsu.settings.*
class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>() {
@@ -27,9 +24,11 @@ class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>() {
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportFragmentManager.commit {
replace(
R.id.container, when (intent?.action) {
R.id.container,
when (intent?.action) {
Intent.ACTION_MANAGE_NETWORK_USAGE -> NetworkSettingsFragment()
ACTION_READER -> ReaderSettingsFragment()
ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
ACTION_SOURCE -> SourceSettingsFragment.newInstance(
intent.getParcelableExtra(EXTRA_SOURCE) ?: MangaSource.LOCAL
)
@@ -55,6 +54,8 @@ class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>() {
private const val ACTION_READER =
"${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
private const val ACTION_SUGGESTIONS =
"${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
private const val ACTION_SOURCE =
"${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
private const val EXTRA_SOURCE = "source"
@@ -63,6 +64,10 @@ class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>() {
Intent(context, SimpleSettingsActivity::class.java)
.setAction(ACTION_READER)
fun newSuggestionsSettingsIntent(context: Context) =
Intent(context, SimpleSettingsActivity::class.java)
.setAction(ACTION_SUGGESTIONS)
fun newSourceSettingsIntent(context: Context, source: MangaSource) =
Intent(context, SimpleSettingsActivity::class.java)
.setAction(ACTION_SOURCE)

View File

@@ -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<SwitchPreference>(AppSettings.KEY_DYNAMIC_THEME)?.isVisible =
findPreference<Preference>(AppSettings.KEY_DYNAMIC_THEME)?.isVisible =
AppSettings.isDynamicColorAvailable
findPreference<ListPreference>(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<Preference>(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<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
findPreference<SwitchPreference>(AppSettings.KEY_PROTECT_APP)?.isChecked =
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)?.isChecked =
!settings.appPassword.isNullOrEmpty()
settings.subscribe(this)
}
@@ -114,15 +117,20 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
findPreference<Preference>(key)?.setSummary(R.string.restart_required)
}
AppSettings.KEY_HIDE_TOOLBAR -> {
findPreference<SwitchPreference>(key)?.setSummary(R.string.restart_required)
findPreference<Preference>(key)?.setSummary(R.string.restart_required)
}
AppSettings.KEY_LOCAL_STORAGE -> {
findPreference<Preference>(key)?.bindStorageName()
}
AppSettings.KEY_APP_PASSWORD -> {
findPreference<SwitchPreference>(AppSettings.KEY_PROTECT_APP)
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.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
}
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))

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

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

@@ -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
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,48 @@
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 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 { x ->
val tags = x.manga.tags.map(TagEntity.Companion::fromMangaTag)
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(x.manga), tags)
db.suggestionDao.upsert(
SuggestionEntity(
mangaId = x.manga.id,
relevance = x.relevance,
createdAt = System.currentTimeMillis(),
)
)
}
}
}
}

View File

@@ -0,0 +1,57 @@
package org.koitharu.kotatsu.suggestions.ui
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity
class SuggestionsFragment : MangaListFragment() {
override val viewModel by viewModel<SuggestionsViewModel>()
override val isSwipeRefreshEnabled = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.opt_suggestions, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_update -> {
SuggestionsWorker.startNow(requireContext())
Snackbar.make(
binding.recyclerView,
R.string.feed_will_update_soon,
Snackbar.LENGTH_LONG,
).show()
true
}
R.id.action_settings -> {
startActivity(SimpleSettingsActivity.newSuggestionsSettingsIntent(requireContext()))
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onScrolledToEnd() = Unit
override fun getTitle(): CharSequence? {
return context?.getString(R.string.suggestions)
}
companion object {
fun newInstance() = SuggestionsFragment()
}
}

View File

@@ -0,0 +1,49 @@
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.prefs.AppSettings
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) {
private val headerModel = ListHeader(null, R.string.suggestions)
override val content = combine(
repository.observeAll(),
createListModeFlow()
) { list, mode ->
when {
list.isEmpty() -> listOf(EmptyState(
icon = R.drawable.ic_book_cross,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_suggestion_holder,
))
else -> buildList<ListModel>(list.size + 1) {
add(headerModel)
list.toUi(this, 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
}

View File

@@ -0,0 +1,104 @@
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.core.prefs.AppSettings
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>()
private val appSettings by inject<AppSettings>()
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 allTags = historyRepository.getAllTags()
if (allTags.isEmpty()) {
return 0
}
val tagsBySources = allTags.groupBy { x -> x.source }
for ((source, tags) in tagsBySources) {
val repo = mangaRepositoryOf(source)
tags.flatMapTo(rawResults) { tag ->
repo.getList2(
offset = 0,
sortOrder = SortOrder.UPDATED,
tags = setOf(tag),
)
}
}
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()
.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)
.build()
WorkManager.getInstance(context)
.enqueue(request)
}
}
}

View File

@@ -80,7 +80,7 @@ class FeedFragment : BaseFragment<FragmentFeedBinding>(), PaginationScrollListen
Snackbar.make(
binding.recyclerView,
R.string.feed_will_update_soon,
Snackbar.LENGTH_SHORT
Snackbar.LENGTH_LONG,
).show()
true
}

View File

@@ -236,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) {
@@ -276,7 +277,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
.build()
val request = OneTimeWorkRequestBuilder<TrackWorker>()
.setConstraints(constraints)
.addTag(TAG)
.addTag(TAG_ONESHOT)
.build()
WorkManager.getInstance(context)
.enqueue(request)