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.remotelist.remoteListModule
import org.koitharu.kotatsu.search.searchModule import org.koitharu.kotatsu.search.searchModule
import org.koitharu.kotatsu.settings.settingsModule import org.koitharu.kotatsu.settings.settingsModule
import org.koitharu.kotatsu.suggestions.suggestionsModule
import org.koitharu.kotatsu.tracker.trackerModule import org.koitharu.kotatsu.tracker.trackerModule
import org.koitharu.kotatsu.widget.WidgetUpdater import org.koitharu.kotatsu.widget.WidgetUpdater
import org.koitharu.kotatsu.widget.appWidgetModule import org.koitharu.kotatsu.widget.appWidgetModule
@@ -67,6 +68,7 @@ class KotatsuApp : Application() {
settingsModule, settingsModule,
readerModule, 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.favourites.data.FavouritesDao
import org.koitharu.kotatsu.history.data.HistoryDao import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.suggestions.data.SuggestionDao
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
@Database( @Database(
entities = [ entities = [
@@ -35,4 +37,6 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val tracksDao: TracksDao abstract val tracksDao: TracksDao
abstract val trackLogsDao: TrackLogsDao abstract val trackLogsDao: TrackLogsDao
abstract val suggestionDao: SuggestionDao
} }

View File

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

View File

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

View File

@@ -85,7 +85,7 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
tags = json.getJSONArray("genres").mapToSet { tags = json.getJSONArray("genres").mapToSet {
MangaTag( MangaTag(
key = it.getString("text"), key = it.getString("text"),
title = it.getString("russian"), title = it.getString("russian").toTitleCase(),
source = manga.source source = manga.source
) )
}, },
@@ -133,7 +133,7 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
MangaTag( MangaTag(
source = source, source = source,
key = it.selectFirst("input")?.attr("data-genre") ?: parseFailed(), 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 tagsDiv = glink.nextElementSibling() ?: parseFailed("tags div not found")
val mainTag = td2.selectFirst("div.cn")?.let { div -> val mainTag = td2.selectFirst("div.cn")?.let { div ->
MangaTag( MangaTag(
title = div.text(), title = div.text().toTitleCase(),
key = tagIdByClass(div.classNames()) ?: return@let null, key = tagIdByClass(div.classNames()) ?: return@let null,
source = source, source = source,
) )
@@ -181,7 +181,7 @@ class ExHentaiRepository(
val id = div.id().substringAfterLast('_').toIntOrNull() val id = div.id().substringAfterLast('_').toIntOrNull()
?: return@mapNotNullToSet null ?: return@mapNotNullToSet null
MangaTag( MangaTag(
title = div.text(), title = div.text().toTitleCase(),
key = id.toString(), key = id.toString(),
source = source source = source
) )

View File

@@ -89,7 +89,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
tileInfo?.select("a.element-link") tileInfo?.select("a.element-link")
?.mapToSet { ?.mapToSet {
MangaTag( MangaTag(
title = it.text(), title = it.text().toTitleCase(),
key = it.attr("href").substringAfterLast('/'), key = it.attr("href").substringAfterLast('/'),
source = source source = source
) )
@@ -119,7 +119,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
.mapNotNull { .mapNotNull {
val a = it.selectFirst("a.element-link") ?: return@mapNotNull null val a = it.selectFirst("a.element-link") ?: return@mapNotNull null
MangaTag( MangaTag(
title = a.text(), title = a.text().toTitleCase(),
key = a.attr("href").substringAfterLast('/'), key = a.attr("href").substringAfterLast('/'),
source = source source = source
) )
@@ -183,7 +183,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
?.selectFirst("table.table") ?: parseFailed("Cannot find root") ?.selectFirst("table.table") ?: parseFailed("Cannot find root")
return root.select("a.element-link").mapToSet { a -> return root.select("a.element-link").mapToSet { a ->
MangaTag( MangaTag(
title = a.text().toCamelCase(), title = a.text().toTitleCase(),
key = a.attr("href").substringAfterLast('/'), key = a.attr("href").substringAfterLast('/'),
source = source 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.core.model.*
import org.koitharu.kotatsu.utils.ext.mapToSet import org.koitharu.kotatsu.utils.ext.mapToSet
import org.koitharu.kotatsu.utils.ext.parseHtml import org.koitharu.kotatsu.utils.ext.parseHtml
import org.koitharu.kotatsu.utils.ext.toTitleCase
class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) { 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 { tags = root.selectFirst("div.sidetags")?.select("li.sidetag")?.mapToSet {
val a = it.children().last() ?: parseFailed("Invalid tag") val a = it.children().last() ?: parseFailed("Invalid tag")
MangaTag( MangaTag(
title = a.text(), title = a.text().toTitleCase(),
key = a.attr("href").substringAfterLast('/'), key = a.attr("href").substringAfterLast('/'),
source = source source = source
) )

View File

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

View File

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

View File

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

View File

@@ -80,7 +80,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
}, },
tags = li.selectFirst("p.keyWord")?.select("a")?.mapNotNullToSet tags@{ x -> tags = li.selectFirst("p.keyWord")?.select("a")?.mapNotNullToSet tags@{ x ->
MangaTag( MangaTag(
title = x.attr("title"), title = x.attr("title").toTitleCase(),
key = x.attr("href").parseTagKey() ?: return@tags null, key = x.attr("href").parseTagKey() ?: return@tags null,
source = MangaSource.MANGATOWN source = MangaSource.MANGATOWN
) )
@@ -104,7 +104,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
x.selectFirst("b")?.ownText() == "Genre(s):" x.selectFirst("b")?.ownText() == "Genre(s):"
}?.select("a")?.mapNotNull { a -> }?.select("a")?.mapNotNull { a ->
MangaTag( MangaTag(
title = a.attr("title"), title = a.attr("title").toTitleCase(),
key = a.attr("href").parseTagKey() ?: return@mapNotNull null, key = a.attr("href").parseTagKey() ?: return@mapNotNull null,
source = MangaSource.MANGATOWN source = MangaSource.MANGATOWN
) )
@@ -172,7 +172,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
MangaTag( MangaTag(
source = MangaSource.MANGATOWN, source = MangaSource.MANGATOWN,
key = key, 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 -> tags = summary?.selectFirst(".mg_genres")?.select("a")?.mapToSet { a ->
MangaTag( MangaTag(
key = a.attr("href").removeSuffix("/").substringAfterLast('/'), key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
title = a.text(), title = a.text().toTitleCase(),
source = MangaSource.MANGAREAD source = MangaSource.MANGAREAD
) )
}.orEmpty(), }.orEmpty(),
@@ -91,7 +91,7 @@ class MangareadRepository(
} }
MangaTag( MangaTag(
key = href, key = href,
title = a.text(), title = a.text().toTitleCase(),
source = MangaSource.MANGAREAD source = MangaSource.MANGAREAD
) )
} }
@@ -113,7 +113,7 @@ class MangareadRepository(
?.mapNotNullToSet { a -> ?.mapNotNullToSet { a ->
MangaTag( MangaTag(
key = a.attr("href").removeSuffix("/").substringAfterLast('/'), key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
title = a.text(), title = a.text().toTitleCase(),
source = MangaSource.MANGAREAD source = MangaSource.MANGAREAD
) )
} ?: manga.tags, } ?: manga.tags,

View File

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

View File

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

View File

@@ -2,5 +2,5 @@ package org.koitharu.kotatsu.core.prefs
enum class AppSection { 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 = fun getDateFormat(format: String = prefs.getString(KEY_DATE_FORMAT, "").orEmpty()): DateFormat =
when (format) { when (format) {
"" -> DateFormat.getDateInstance(DateFormat.SHORT) "" -> DateFormat.getDateInstance(DateFormat.SHORT)
@@ -231,6 +237,8 @@ class AppSettings(context: Context) {
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_SCREENSHOTS_POLICY = "screenshots_policy" const val KEY_SCREENSHOTS_POLICY = "screenshots_policy"
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

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.history.data
import androidx.room.* import androidx.room.*
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
@Dao @Dao
@@ -22,6 +23,9 @@ abstract class HistoryDao {
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history)") @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history)")
abstract suspend fun findAllManga(): List<MangaEntity> 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") @Query("SELECT * FROM history WHERE manga_id = :id")
abstract suspend fun find(id: Long): HistoryEntity? 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.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory 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.core.prefs.AppSettings
import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
@@ -89,4 +90,8 @@ class HistoryRepository(
db.historyDao.delete(manga.id) 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.MangaChapter
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.utils.ext.getBooleanOrDefault import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.ext.getLongOrDefault
import org.koitharu.kotatsu.utils.ext.getStringOrNull
import org.koitharu.kotatsu.utils.ext.mapToSet
class MangaIndex(source: String?) { class MangaIndex(source: String?) {
@@ -61,7 +58,7 @@ class MangaIndex(source: String?) {
description = json.getStringOrNull("description"), description = json.getStringOrNull("description"),
tags = json.getJSONArray("tags").mapToSet { x -> tags = json.getJSONArray("tags").mapToSet { x ->
MangaTag( MangaTag(
title = x.getString("title"), title = x.getString("title").toTitleCase(),
key = x.getString("key"), key = x.getString("key"),
source = source 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.AppUpdateChecker
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment 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.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
@@ -122,6 +124,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) {
@@ -187,6 +190,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
viewModel.defaultSection = AppSection.LOCAL viewModel.defaultSection = AppSection.LOCAL
setPrimaryFragment(LocalListFragment.newInstance()) setPrimaryFragment(LocalListFragment.newInstance())
} }
R.id.nav_suggestions -> {
viewModel.defaultSection = AppSection.SUGGESTIONS
setPrimaryFragment(SuggestionsFragment.newInstance())
}
R.id.nav_feed -> { R.id.nav_feed -> {
viewModel.defaultSection = AppSection.FEED viewModel.defaultSection = AppSection.FEED
setPrimaryFragment(FeedFragment.newInstance()) setPrimaryFragment(FeedFragment.newInstance())
@@ -303,6 +310,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 -> {
@@ -321,6 +336,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
binding.navigationView.setCheckedItem(R.id.nav_feed) binding.navigationView.setCheckedItem(R.id.nav_feed)
setPrimaryFragment(FeedFragment.newInstance()) 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() { private fun onFirstStart() {
lifecycleScope.launch(Dispatchers.Default) { lifecycleScope.launch(Dispatchers.Default) {
TrackWorker.setup(applicationContext) TrackWorker.setup(applicationContext)
SuggestionsWorker.setup(applicationContext)
AppUpdateChecker(this@MainActivity).checkIfNeeded() AppUpdateChecker(this@MainActivity).checkIfNeeded()
if (!get<AppSettings>().isSourcesSelected) { if (!get<AppSettings>().isSourcesSelected) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {

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

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

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

@@ -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.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Entity( @Entity(
tableName = "suggestions", tableName = "suggestions",
@@ -19,6 +21,7 @@ import androidx.room.PrimaryKey
class SuggestionEntity( class SuggestionEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@FloatRange(from = 0.0, to = 1.0)
@ColumnInfo(name = "relevance") val relevance: Float, @ColumnInfo(name = "relevance") val relevance: Float,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(), @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( Snackbar.make(
binding.recyclerView, binding.recyclerView,
R.string.feed_will_update_soon, R.string.feed_will_update_soon,
Snackbar.LENGTH_SHORT Snackbar.LENGTH_LONG,
).show() ).show()
true true
} }

View File

@@ -236,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) {
@@ -276,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

@@ -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:id="@+id/nav_history"
android:icon="@drawable/ic_history" android:icon="@drawable/ic_history"
android:title="@string/history" /> android:title="@string/history" />
<item
android:id="@+id/nav_suggestions"
android:icon="@drawable/ic_suggestion"
android:title="@string/suggestions" />
<item <item
android:id="@+id/nav_feed" android:id="@+id/nav_feed"
android:icon="@drawable/ic_feed" android:icon="@drawable/ic_feed"

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_update"
android:orderInCategory="50"
android:title="@string/update"
app:showAsAction="never" />
<item
android:id="@+id/action_settings"
android:orderInCategory="90"
android:title="@string/settings"
app:showAsAction="never" />
</menu>

View File

@@ -253,4 +253,12 @@
<string name="screenshots_allow">Разрешить</string> <string name="screenshots_allow">Разрешить</string>
<string name="screenshots_block_nsfw">Запретить для NSFW</string> <string name="screenshots_block_nsfw">Запретить для NSFW</string>
<string name="screenshots_block_all">Запретить всегда</string> <string name="screenshots_block_all">Запретить всегда</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

@@ -255,4 +255,12 @@
<string name="screenshots_allow">Allow</string> <string name="screenshots_allow">Allow</string>
<string name="screenshots_block_nsfw">Block on NSFW</string> <string name="screenshots_block_nsfw">Block on NSFW</string>
<string name="screenshots_block_all">Block always</string> <string name="screenshots_block_all">Block always</string>
<string name="suggestions">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>