Store manga sources in database #426

pull/440/head
Koitharu 3 years ago
parent 03b92c4898
commit 376cee1859
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -17,7 +17,7 @@ class MangaDatabaseTest {
MangaDatabase::class.java, MangaDatabase::class.java,
) )
private val migrations = databaseMigrations private val migrations = getDatabaseMigrations()
@Test @Test
fun versions() { fun versions() {

@ -12,11 +12,13 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
import org.koitharu.kotatsu.core.db.dao.MangaDao import org.koitharu.kotatsu.core.db.dao.MangaDao
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
import org.koitharu.kotatsu.core.db.dao.PreferencesDao import org.koitharu.kotatsu.core.db.dao.PreferencesDao
import org.koitharu.kotatsu.core.db.dao.TagsDao import org.koitharu.kotatsu.core.db.dao.TagsDao
import org.koitharu.kotatsu.core.db.dao.TrackLogsDao import org.koitharu.kotatsu.core.db.dao.TrackLogsDao
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.db.migrations.Migration10To11 import org.koitharu.kotatsu.core.db.migrations.Migration10To11
@ -25,6 +27,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration12To13
import org.koitharu.kotatsu.core.db.migrations.Migration13To14 import org.koitharu.kotatsu.core.db.migrations.Migration13To14
import org.koitharu.kotatsu.core.db.migrations.Migration14To15 import org.koitharu.kotatsu.core.db.migrations.Migration14To15
import org.koitharu.kotatsu.core.db.migrations.Migration15To16 import org.koitharu.kotatsu.core.db.migrations.Migration15To16
import org.koitharu.kotatsu.core.db.migrations.Migration16To17
import org.koitharu.kotatsu.core.db.migrations.Migration1To2 import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3 import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4 import org.koitharu.kotatsu.core.db.migrations.Migration3To4
@ -49,14 +52,14 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 16 const val DATABASE_VERSION = 17
@Database( @Database(
entities = [ entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
ScrobblingEntity::class, ScrobblingEntity::class, MangaSourceEntity::class,
], ],
version = DATABASE_VERSION, version = DATABASE_VERSION,
) )
@ -83,10 +86,11 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val bookmarksDao: BookmarksDao abstract val bookmarksDao: BookmarksDao
abstract val scrobblingDao: ScrobblingDao abstract val scrobblingDao: ScrobblingDao
abstract val sourcesDao: MangaSourcesDao
} }
val databaseMigrations: Array<Migration> fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
get() = arrayOf(
Migration1To2(), Migration1To2(),
Migration2To3(), Migration2To3(),
Migration3To4(), Migration3To4(),
@ -102,11 +106,12 @@ val databaseMigrations: Array<Migration>
Migration13To14(), Migration13To14(),
Migration14To15(), Migration14To15(),
Migration15To16(), Migration15To16(),
) Migration16To17(context),
)
fun MangaDatabase(context: Context): MangaDatabase = Room fun MangaDatabase(context: Context): MangaDatabase = Room
.databaseBuilder(context, MangaDatabase::class.java, "kotatsu-db") .databaseBuilder(context, MangaDatabase::class.java, "kotatsu-db")
.addMigrations(*databaseMigrations) .addMigrations(*getDatabaseMigrations(context))
.addCallback(DatabasePrePopulateCallback(context.resources)) .addCallback(DatabasePrePopulateCallback(context.resources))
.build() .build()

@ -0,0 +1,57 @@
package org.koitharu.kotatsu.core.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
@Dao
abstract class MangaSourcesDao {
@Query("SELECT * FROM sources ORDER BY sort_key")
abstract suspend fun findAll(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key")
abstract suspend fun findAllEnabled(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key")
abstract fun observeEnabled(): Flow<List<MangaSourceEntity>>
@Query("SELECT * FROM sources ORDER BY sort_key")
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
@Query("SELECT MAX(sort_key) FROM sources")
abstract suspend fun getMaxSortKey(): Int
@Query("UPDATE sources SET enabled = 0")
abstract suspend fun disableAllSources()
@Query("UPDATE sources SET sort_key = :sortKey WHERE source = :source")
abstract suspend fun setSortKey(source: String, sortKey: Int)
@Insert(onConflict = OnConflictStrategy.IGNORE)
@Transaction
abstract suspend fun insertIfAbsent(entries: Iterable<MangaSourceEntity>)
@Upsert
abstract suspend fun upsert(entry: MangaSourceEntity)
@Transaction
open suspend fun setEnabled(source: String, isEnabled: Boolean) {
if (updateIsEnabled(source, isEnabled) == 0) {
val entity = MangaSourceEntity(
source = source,
isEnabled = isEnabled,
sortKey = getMaxSortKey() + 1,
)
upsert(entity)
}
}
@Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int
}

@ -0,0 +1,16 @@
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(
tableName = "sources",
)
data class MangaSourceEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "source")
val source: String,
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
)

@ -0,0 +1,36 @@
package org.koitharu.kotatsu.core.db.migrations
import android.content.Context
import androidx.preference.PreferenceManager
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import org.koitharu.kotatsu.parsers.model.MangaSource
class Migration16To17(context: Context) : Migration(16, 17) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE `sources` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))")
database.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
val sources = MangaSource.values()
for (source in sources) {
if (source == MangaSource.LOCAL) {
continue
}
val name = source.name
var sortKey = order.indexOf(name)
if (sortKey == -1) {
sortKey = order.size + source.ordinal
}
database.execSQL(
"INSERT INTO `sources` (`source`, `enabled`, `sort_key`) VALUES (?, ?, ?)",
arrayOf(name, (name !in hiddenSources).toInt(), sortKey),
)
}
}
private fun Boolean.toInt() = if (this) 1 else 0
}

@ -15,24 +15,19 @@ import androidx.core.os.LocaleListCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.json.JSONArray import org.json.JSONArray
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.core.util.ext.connectivityManager import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.filterToSet
import org.koitharu.kotatsu.core.util.ext.getEnumValue import org.koitharu.kotatsu.core.util.ext.getEnumValue
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.core.util.ext.takeIfReadable import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import java.io.File import java.io.File
import java.net.Proxy import java.net.Proxy
import java.util.Collections
import java.util.EnumSet
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -43,16 +38,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context) private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private val connectivityManager = context.connectivityManager private val connectivityManager = context.connectivityManager
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
remove(MangaSource.LOCAL)
if (!BuildConfig.DEBUG) {
remove(MangaSource.DUMMY)
}
}
val remoteMangaSources: Set<MangaSource>
get() = Collections.unmodifiableSet(remoteSources)
var listMode: ListMode var listMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID) get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) } set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
@ -183,37 +168,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return policy.isNetworkAllowed(connectivityManager) return policy.isNetworkAllowed(connectivityManager)
} }
var sourcesOrder: List<String>
get() = prefs.getString(KEY_SOURCES_ORDER, null)
?.split('|')
.orEmpty()
set(value) = prefs.edit {
putString(KEY_SOURCES_ORDER, value.joinToString("|"))
}
var hiddenSources: Set<String>
get() = prefs.getStringSet(KEY_SOURCES_HIDDEN, null)?.filterToSet { name ->
remoteSources.any { it.name == name }
}.orEmpty()
set(value) = prefs.edit { putStringSet(KEY_SOURCES_HIDDEN, value) }
val isSourcesSelected: Boolean
get() = KEY_SOURCES_HIDDEN in prefs
val newSources: Set<MangaSource>
get() {
val known = sourcesOrder.toSet()
val hidden = hiddenSources
return remoteMangaSources
.filterNotTo(EnumSet.noneOf(MangaSource::class.java)) { x ->
x.name in known || x.name in hidden
}
}
fun markKnownSources(sources: Collection<MangaSource>) {
sourcesOrder = (sourcesOrder + sources.map { it.name }).distinct()
}
var isSourcesGridMode: Boolean var isSourcesGridMode: Boolean
get() = prefs.getBoolean(KEY_SOURCES_GRID, false) get() = prefs.getBoolean(KEY_SOURCES_GRID, false)
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) } set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
@ -335,20 +289,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return policy.isNetworkAllowed(connectivityManager) return policy.isNetworkAllowed(connectivityManager)
} }
fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
val list = remoteSources.toMutableList()
val order = sourcesOrder
list.sortBy { x ->
val e = order.indexOf(x.name)
if (e == -1) order.size + x.ordinal else e
}
if (!includeHidden) {
val hidden = hiddenSources
list.removeAll { x -> x.name in hidden }
}
return list
}
fun isTipEnabled(tip: String): Boolean { fun isTipEnabled(tip: String): Boolean {
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
} }
@ -417,8 +357,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_THEME = "theme" const val KEY_THEME = "theme"
const val KEY_COLOR_THEME = "color_theme" const val KEY_COLOR_THEME = "color_theme"
const val KEY_THEME_AMOLED = "amoled_theme" const val KEY_THEME_AMOLED = "amoled_theme"
const val KEY_SOURCES_ORDER = "sources_order_2"
const val KEY_SOURCES_HIDDEN = "sources_hidden"
const val KEY_TRAFFIC_WARNING = "traffic_warning" const val KEY_TRAFFIC_WARNING = "traffic_warning"
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear" const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear" const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear"

@ -0,0 +1,150 @@
package org.koitharu.kotatsu.explore.data
import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.move
import java.util.Collections
import java.util.EnumSet
import javax.inject.Inject
@Reusable
class MangaSourcesRepository @Inject constructor(
private val db: MangaDatabase,
) {
private val dao: MangaSourcesDao
get() = db.sourcesDao
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
remove(MangaSource.LOCAL)
if (!BuildConfig.DEBUG) {
remove(MangaSource.DUMMY)
}
}
val allMangaSources: Set<MangaSource>
get() = Collections.unmodifiableSet(remoteSources)
suspend fun getEnabledSources(): List<MangaSource> {
return dao.findAllEnabled().toSources()
}
fun observeEnabledSources(): Flow<List<MangaSource>> = dao.observeEnabled().map {
it.toSources()
}
fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities ->
val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size)
for (entity in entities) {
val source = MangaSource(entity.source)
if (source in remoteSources) {
result.add(source to entity.isEnabled)
}
}
result
}
suspend fun setSourceEnabled(source: MangaSource, isEnabled: Boolean): ReversibleHandle {
dao.setEnabled(source.name, isEnabled)
return ReversibleHandle {
dao.setEnabled(source.name, !isEnabled)
}
}
suspend fun setSourcesEnabled(sources: Iterable<MangaSource>, isEnabled: Boolean) {
db.withTransaction {
for (s in sources) {
dao.setEnabled(s.name, isEnabled)
}
}
}
suspend fun disableAllSources() {
db.withTransaction {
assimilateNewSources()
dao.disableAllSources()
}
}
suspend fun setPosition(source: MangaSource, index: Int) {
db.withTransaction {
val all = dao.findAll().toMutableList()
val sourceIndex = all.indexOfFirst { x -> x.source == source.name }
if (sourceIndex !in all.indices) {
val entity = MangaSourceEntity(
source = source.name,
isEnabled = false,
sortKey = index,
)
all.add(index, entity)
dao.upsert(entity)
} else {
all.move(sourceIndex, index)
}
for ((i, e) in all.withIndex()) {
if (e.sortKey != i) {
dao.setSortKey(e.source, i)
}
}
}
}
fun observeNewSources(): Flow<Set<MangaSource>> = dao.observeAll().map { entities ->
val result = EnumSet.copyOf(remoteSources)
for (e in entities) {
result.remove(MangaSource(e.source))
}
result
}.distinctUntilChanged()
suspend fun getNewSources(): Set<MangaSource> {
val entities = dao.findAll()
val result = EnumSet.copyOf(remoteSources)
for (e in entities) {
result.remove(MangaSource(e.source))
}
return result
}
suspend fun assimilateNewSources(): Set<MangaSource> {
val new = getNewSources()
if (new.isEmpty()) {
return emptySet()
}
var maxSortKey = dao.getMaxSortKey()
val entities = new.map { x ->
MangaSourceEntity(
source = x.name,
isEnabled = false,
sortKey = ++maxSortKey,
)
}
dao.insertIfAbsent(entities)
return new
}
suspend fun isSetupRequired(): Boolean {
return dao.findAll().isEmpty()
}
private fun List<MangaSourceEntity>.toSources(): List<MangaSource> {
val result = ArrayList<MangaSource>(size)
for (entity in this) {
val source = MangaSource(entity.source)
if (source in remoteSources) {
result.add(source)
}
}
return result
}
}

@ -4,16 +4,18 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.almostEquals import org.koitharu.kotatsu.core.util.ext.almostEquals
import org.koitharu.kotatsu.core.util.ext.asArrayList import org.koitharu.kotatsu.core.util.ext.asArrayList
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import javax.inject.Inject import javax.inject.Inject
class ExploreRepository @Inject constructor( class ExploreRepository @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
private val sourcesRepository: MangaSourcesRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) { ) {
@ -23,7 +25,7 @@ class ExploreRepository @Inject constructor(
val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull { val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull {
if (it in blacklistTagRegex) null else it.title if (it in blacklistTagRegex) null else it.title
} }
val sources = settings.getMangaSources(includeHidden = false) val sources = sourcesRepository.getEnabledSources()
check(sources.isNotEmpty()) { "No sources available" } check(sources.isNotEmpty()) { "No sources available" }
for (i in 0..4) { for (i in 0..4) {
val list = getList(sources.random(), tags, blacklistTagRegex) val list = getList(sources.random(), tags, blacklistTagRegex)

@ -7,13 +7,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@ -22,9 +18,9 @@ import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.explore.ui.model.ExploreButtons import org.koitharu.kotatsu.explore.ui.model.ExploreButtons
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
@ -45,6 +41,7 @@ class ExploreViewModel @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
private val suggestionRepository: SuggestionRepository, private val suggestionRepository: SuggestionRepository,
private val exploreRepository: ExploreRepository, private val exploreRepository: ExploreRepository,
private val sourcesRepository: MangaSourcesRepository,
) : BaseViewModel() { ) : BaseViewModel() {
val isGrid = settings.observeAsStateFlow( val isGrid = settings.observeAsStateFlow(
@ -96,10 +93,7 @@ class ExploreViewModel @Inject constructor(
fun hideSource(source: MangaSource) { fun hideSource(source: MangaSource) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
settings.hiddenSources += source.name val rollback = sourcesRepository.setSourceEnabled(source, isEnabled = false)
val rollback = ReversibleHandle {
settings.hiddenSources -= source.name
}
onActionDone.call(ReversibleAction(R.string.source_disabled, rollback)) onActionDone.call(ReversibleAction(R.string.source_disabled, rollback))
} }
} }
@ -114,11 +108,11 @@ class ExploreViewModel @Inject constructor(
} }
private fun createContentFlow() = combine( private fun createContentFlow() = combine(
observeSources(), sourcesRepository.observeEnabledSources(),
getSuggestionFlow(), getSuggestionFlow(),
isGrid, isGrid,
isRandomLoading, isRandomLoading,
observeNewSources(), sourcesRepository.observeNewSources(),
) { content, suggestions, grid, randomLoading, newSources -> ) { content, suggestions, grid, randomLoading, newSources ->
buildList(content, suggestions, grid, randomLoading, newSources) buildList(content, suggestions, grid, randomLoading, newSources)
} }
@ -160,15 +154,6 @@ class ExploreViewModel @Inject constructor(
return result return result
} }
private fun observeSources() = settings.observe()
.filter {
it == AppSettings.KEY_SOURCES_HIDDEN ||
it == AppSettings.KEY_SOURCES_ORDER ||
it == AppSettings.KEY_SUGGESTIONS
}
.onStart { emit("") }
.map { settings.getMangaSources(includeHidden = false) }
private fun getLoadingStateList() = listOf( private fun getLoadingStateList() = listOf(
ExploreButtons(isRandomLoading.value), ExploreButtons(isRandomLoading.value),
LoadingState, LoadingState,
@ -184,12 +169,6 @@ class ExploreViewModel @Inject constructor(
} }
} }
private fun observeNewSources() = settings.observe()
.filter { it == AppSettings.KEY_SOURCES_ORDER || it == AppSettings.KEY_SOURCES_HIDDEN }
.onStart { emit("") }
.map { settings.newSources }
.distinctUntilChanged()
companion object { companion object {
private const val TIP_SUGGESTIONS = "suggestions" private const val TIP_SUGGESTIONS = "suggestions"

@ -63,7 +63,6 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.about.AppUpdateDialog import org.koitharu.kotatsu.settings.about.AppUpdateDialog
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@ -136,6 +135,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged) viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged)
viewModel.counters.observe(this, ::onCountersChanged) viewModel.counters.observe(this, ::onCountersChanged)
viewModel.appUpdate.observe(this) { invalidateMenu() } viewModel.appUpdate.observe(this) { invalidateMenu() }
viewModel.onFirstStart.observeEvent(this) { OnboardDialogFragment.showWelcome(supportFragmentManager) }
viewModel.isFeedAvailable.observe(this, ::onFeedAvailabilityChanged) viewModel.isFeedAvailable.observe(this, ::onFeedAvailabilityChanged)
searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged) searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged)
} }
@ -324,15 +324,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
private fun onFirstStart() { private fun onFirstStart() {
lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher
when {
!settings.isSourcesSelected -> withResumed {
OnboardDialogFragment.showWelcome(supportFragmentManager)
}
settings.newSources.isNotEmpty() -> withResumed {
NewSourcesDialogFragment.show(supportFragmentManager)
}
}
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
LocalStorageCleanupWorker.enqueue(applicationContext) LocalStorageCleanupWorker.enqueue(applicationContext)
} }

@ -6,9 +6,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
@ -18,6 +16,7 @@ import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.main.domain.ReadingResumeEnabledUseCase import org.koitharu.kotatsu.main.domain.ReadingResumeEnabledUseCase
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@ -31,9 +30,11 @@ class MainViewModel @Inject constructor(
trackingRepository: TrackingRepository, trackingRepository: TrackingRepository,
private val settings: AppSettings, private val settings: AppSettings,
readingResumeEnabledUseCase: ReadingResumeEnabledUseCase, readingResumeEnabledUseCase: ReadingResumeEnabledUseCase,
private val sourcesRepository: MangaSourcesRepository,
) : BaseViewModel() { ) : BaseViewModel() {
val onOpenReader = MutableEventFlow<Manga>() val onOpenReader = MutableEventFlow<Manga>()
val onFirstStart = MutableEventFlow<Unit>()
val isResumeEnabled = readingResumeEnabledUseCase().stateIn( val isResumeEnabled = readingResumeEnabledUseCase().stateIn(
scope = viewModelScope + Dispatchers.Default, scope = viewModelScope + Dispatchers.Default,
@ -64,6 +65,11 @@ class MainViewModel @Inject constructor(
launchJob { launchJob {
appUpdateRepository.fetchUpdate() appUpdateRepository.fetchUpdate()
} }
launchJob(Dispatchers.Default) {
if (sourcesRepository.isSetupRequired()) {
onFirstStart.call(Unit)
}
}
} }
fun openLastReader() { fun openLastReader() {
@ -77,10 +83,7 @@ class MainViewModel @Inject constructor(
settings.isIncognitoModeEnabled = isEnabled settings.isIncognitoModeEnabled = isEnabled
} }
private fun observeNewSourcesCount() = settings.observe() private fun observeNewSourcesCount() = sourcesRepository.observeNewSources()
.filter { it == AppSettings.KEY_SOURCES_ORDER || it == AppSettings.KEY_SOURCES_HIDDEN } .map { it.size }
.onStart { emit("") }
.map { settings.newSources.size }
.distinctUntilChanged() .distinctUntilChanged()
} }

@ -15,7 +15,7 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTag import org.koitharu.kotatsu.core.db.entity.toMangaTag
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
@ -26,16 +26,17 @@ import javax.inject.Inject
@Reusable @Reusable
class MangaSearchRepository @Inject constructor( class MangaSearchRepository @Inject constructor(
private val settings: AppSettings,
private val db: MangaDatabase, private val db: MangaDatabase,
private val sourcesRepository: MangaSourcesRepository,
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val recentSuggestions: SearchRecentSuggestions, private val recentSuggestions: SearchRecentSuggestions,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) { ) {
fun globalSearch(query: String, concurrency: Int = DEFAULT_CONCURRENCY): Flow<Manga> = fun globalSearch(query: String, concurrency: Int = DEFAULT_CONCURRENCY): Flow<Manga> =
settings.getMangaSources(includeHidden = false).asFlow() flow {
.flatMapMerge(concurrency) { source -> emitAll(sourcesRepository.getEnabledSources().asFlow())
}.flatMapMerge(concurrency) { source ->
runCatchingCancellable { runCatchingCancellable {
mangaRepositoryFactory.create(source).getList( mangaRepositoryFactory.create(source).getList(
offset = 0, offset = 0,
@ -101,7 +102,7 @@ class MangaSearchRepository @Inject constructor(
if (query.length < 3) { if (query.length < 3) {
return emptyList() return emptyList()
} }
val sources = settings.remoteMangaSources val sources = sourcesRepository.allMangaSources
.filter { x -> x.title.contains(query, ignoreCase = true) } .filter { x -> x.title.contains(query, ignoreCase = true) }
return if (limit == 0) { return if (limit == 0) {
sources sources

@ -19,12 +19,13 @@ import kotlinx.coroutines.plus
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
@ -33,7 +34,6 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import javax.inject.Inject import javax.inject.Inject
private const val MAX_PARALLELISM = 4 private const val MAX_PARALLELISM = 4
@ -43,9 +43,9 @@ private const val MIN_HAS_MORE_ITEMS = 8
class MultiSearchViewModel @Inject constructor( class MultiSearchViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val extraProvider: ListExtraProvider, private val extraProvider: ListExtraProvider,
private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val downloadScheduler: DownloadWorker.Scheduler, private val downloadScheduler: DownloadWorker.Scheduler,
private val sourcesRepository: MangaSourcesRepository,
) : BaseViewModel() { ) : BaseViewModel() {
private var searchJob: Job? = null private var searchJob: Job? = null
@ -117,7 +117,7 @@ class MultiSearchViewModel @Inject constructor(
} }
private suspend fun searchImpl(q: String) = coroutineScope { private suspend fun searchImpl(q: String) = coroutineScope {
val sources = settings.getMangaSources(includeHidden = false) val sources = sourcesRepository.getEnabledSources()
val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM) val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM)
val deferredList = sources.map { source -> val deferredList = sources.map { source ->
async(dispatcher) { async(dispatcher) {

@ -11,18 +11,20 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
import java.util.EnumSet
import javax.inject.Inject import javax.inject.Inject
private const val DEBOUNCE_TIMEOUT = 500L private const val DEBOUNCE_TIMEOUT = 500L
@ -35,6 +37,7 @@ private const val MAX_SOURCES_ITEMS = 6
class SearchSuggestionViewModel @Inject constructor( class SearchSuggestionViewModel @Inject constructor(
private val repository: MangaSearchRepository, private val repository: MangaSearchRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val sourcesRepository: MangaSourcesRepository,
) : BaseViewModel() { ) : BaseViewModel() {
private val query = MutableStateFlow("") private val query = MutableStateFlow("")
@ -72,10 +75,8 @@ class SearchSuggestionViewModel @Inject constructor(
} }
fun onSourceToggle(source: MangaSource, isEnabled: Boolean) { fun onSourceToggle(source: MangaSource, isEnabled: Boolean) {
settings.hiddenSources = if (isEnabled) { launchJob(Dispatchers.Default) {
settings.hiddenSources - source.name sourcesRepository.setSourceEnabled(source, isEnabled)
} else {
settings.hiddenSources + source.name
} }
} }
@ -90,10 +91,10 @@ class SearchSuggestionViewModel @Inject constructor(
suggestionJob?.cancel() suggestionJob?.cancel()
suggestionJob = combine( suggestionJob = combine(
query.debounce(DEBOUNCE_TIMEOUT), query.debounce(DEBOUNCE_TIMEOUT),
settings.observeAsFlow(AppSettings.KEY_SOURCES_HIDDEN) { hiddenSources }, sourcesRepository.observeEnabledSources().map { EnumSet.copyOf(it) },
::Pair, ::Pair,
).mapLatest { (searchQuery, hiddenSources) -> ).mapLatest { (searchQuery, enabledSources) ->
buildSearchSuggestion(searchQuery, hiddenSources) buildSearchSuggestion(searchQuery, enabledSources)
}.distinctUntilChanged() }.distinctUntilChanged()
.onEach { .onEach {
suggestion.value = it suggestion.value = it
@ -102,7 +103,7 @@ class SearchSuggestionViewModel @Inject constructor(
private suspend fun buildSearchSuggestion( private suspend fun buildSearchSuggestion(
searchQuery: String, searchQuery: String,
hiddenSources: Set<String>, enabledSources: Set<MangaSource>,
): List<SearchSuggestionItem> = coroutineScope { ): List<SearchSuggestionItem> = coroutineScope {
val queriesDeferred = async { val queriesDeferred = async {
repository.getQuerySuggestion(searchQuery, MAX_QUERY_ITEMS) repository.getQuerySuggestion(searchQuery, MAX_QUERY_ITEMS)
@ -127,7 +128,7 @@ class SearchSuggestionViewModel @Inject constructor(
add(SearchSuggestionItem.MangaList(mangaList)) add(SearchSuggestionItem.MangaList(mangaList))
} }
queries.mapTo(this) { SearchSuggestionItem.RecentQuery(it) } queries.mapTo(this) { SearchSuggestionItem.RecentQuery(it) }
sources.mapTo(this) { SearchSuggestionItem.Source(it, it.name !in hiddenSources) } sources.mapTo(this) { SearchSuggestionItem.Source(it, it in enabledSources) }
} }
} }

@ -1,16 +1,21 @@
package org.koitharu.kotatsu.settings package org.koitharu.kotatsu.settings
import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.fragment.app.viewModels
import androidx.preference.Preference import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.observe
class RootSettingsFragment : BasePreferenceFragment(0), SharedPreferences.OnSharedPreferenceChangeListener { @AndroidEntryPoint
class RootSettingsFragment : BasePreferenceFragment(0) {
private val viewModel: RootSettingsViewModel by viewModels()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_root) addPreferencesFromResource(R.xml.pref_root)
@ -22,23 +27,18 @@ class RootSettingsFragment : BasePreferenceFragment(0), SharedPreferences.OnShar
bindPreferenceSummary("tracker", R.string.track_sources, R.string.notifications_settings) bindPreferenceSummary("tracker", R.string.track_sources, R.string.notifications_settings)
bindPreferenceSummary("services", R.string.suggestions, R.string.sync, R.string.tracking) bindPreferenceSummary("services", R.string.suggestions, R.string.sync, R.string.tracking)
findPreference<Preference>("about")?.summary = getString(R.string.app_version, BuildConfig.VERSION_NAME) findPreference<Preference>("about")?.summary = getString(R.string.app_version, BuildConfig.VERSION_NAME)
bindRemoteSourcesSummary()
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
settings.subscribe(this) findPreference<Preference>(AppSettings.KEY_REMOTE_SOURCES)?.let { pref ->
} val total = viewModel.totalSourcesCount
viewModel.enabledSourcesCount.observe(viewLifecycleOwner) {
override fun onDestroyView() { pref.summary = if (it >= 0) {
settings.unsubscribe(this) getString(R.string.enabled_d_of_d, it, total)
super.onDestroyView() } else {
resources.getQuantityString(R.plurals.items, total, total)
} }
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
when (key) {
AppSettings.KEY_SOURCES_HIDDEN -> {
bindRemoteSourcesSummary()
} }
} }
} }
@ -46,11 +46,4 @@ class RootSettingsFragment : BasePreferenceFragment(0), SharedPreferences.OnShar
private fun bindPreferenceSummary(key: String, @StringRes vararg items: Int) { private fun bindPreferenceSummary(key: String, @StringRes vararg items: Int) {
findPreference<Preference>(key)?.summary = items.joinToString { getString(it) } findPreference<Preference>(key)?.summary = items.joinToString { getString(it) }
} }
private fun bindRemoteSourcesSummary() {
findPreference<Preference>(AppSettings.KEY_REMOTE_SOURCES)?.run {
val total = settings.remoteMangaSources.size
summary = getString(R.string.enabled_d_of_d, total - settings.hiddenSources.size, total)
}
}
} }

@ -0,0 +1,24 @@
package org.koitharu.kotatsu.settings
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import javax.inject.Inject
@HiltViewModel
class RootSettingsViewModel @Inject constructor(
sourcesRepository: MangaSourcesRepository,
) : BaseViewModel() {
val totalSourcesCount = sourcesRepository.allMangaSources.size
val enabledSourcesCount = sourcesRepository.observeEnabledSources()
.map { it.size }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, -1)
}

@ -9,7 +9,6 @@ import androidx.fragment.app.viewModels
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
@ -39,8 +38,7 @@ class NewSourcesDialogFragment :
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
binding.textViewTitle.setText(R.string.new_sources_text) binding.textViewTitle.setText(R.string.new_sources_text)
viewModel.sources.filterNotNull() viewModel.content.observe(viewLifecycleOwner) { adapter.items = it }
.observe(viewLifecycleOwner) { adapter.items = it }
} }
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
@ -51,7 +49,6 @@ class NewSourcesDialogFragment :
} }
override fun onClick(dialog: DialogInterface, which: Int) { override fun onClick(dialog: DialogInterface, which: Int) {
viewModel.apply()
dialog.dismiss() dialog.dismiss()
} }

@ -1,74 +1,43 @@
package org.koitharu.kotatsu.settings.newsources package org.koitharu.kotatsu.settings.newsources
import androidx.annotation.WorkerThread import androidx.lifecycle.viewModelScope
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.getLocaleTitle import org.koitharu.kotatsu.core.model.getLocaleTitle
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.mapToSet import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class NewSourcesViewModel @Inject constructor( class NewSourcesViewModel @Inject constructor(
private val settings: AppSettings, private val repository: MangaSourcesRepository,
) : BaseViewModel() { ) : BaseViewModel() {
private val initialList = settings.newSources private val newSources = SuspendLazy {
val sources = MutableStateFlow<List<SourceConfigItem>?>(null) repository.assimilateNewSources()
private var listUpdateJob: Job? = null
init {
listUpdateJob = launchJob(Dispatchers.Default) {
sources.value = buildList()
}
} }
val content: StateFlow<List<SourceConfigItem>> = repository.observeAll()
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { .map { sources ->
val prevJob = listUpdateJob val new = newSources.get()
listUpdateJob = launchJob(Dispatchers.Default) { sources.mapNotNull { (source, enabled) ->
if (isEnabled) { if (source in new) {
settings.hiddenSources -= item.source.name SourceConfigItem.SourceItem(source, enabled, source.getLocaleTitle(), false)
} else { } else {
settings.hiddenSources += item.source.name null
}
prevJob?.cancelAndJoin()
val list = buildList()
ensureActive()
sources.value = list
} }
} }
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
fun apply() { fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
settings.markKnownSources(initialList) launchJob(Dispatchers.Default) {
} repository.setSourceEnabled(item.source, isEnabled)
@WorkerThread
private fun buildList(): List<SourceConfigItem.SourceItem> {
val locales = LocaleListCompat.getDefault().mapToSet { it.language }
val pendingHidden = HashSet<String>()
return initialList.map {
val locale = it.locale
val isEnabledByLocale = locale == null || locale in locales
if (!isEnabledByLocale) {
pendingHidden += it.name
}
SourceConfigItem.SourceItem(
source = it,
summary = it.getLocaleTitle(),
isEnabled = isEnabledByLocale,
isDraggable = false,
)
}.also {
if (pendingHidden.isNotEmpty()) {
settings.hiddenSources += pendingHidden
}
} }
} }
} }

@ -66,9 +66,9 @@ class OnboardDialogFragment :
viewModel.setItemChecked(item.key, isChecked) viewModel.setItemChecked(item.key, isChecked)
} }
override fun onClick(dialog: DialogInterface?, which: Int) { override fun onClick(dialog: DialogInterface, which: Int) {
when (which) { when (which) {
DialogInterface.BUTTON_POSITIVE -> viewModel.apply() DialogInterface.BUTTON_POSITIVE -> dialog.dismiss()
} }
} }

@ -1,15 +1,16 @@
package org.koitharu.kotatsu.settings.onboard package org.koitharu.kotatsu.settings.onboard
import androidx.annotation.WorkerThread
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.map import org.koitharu.kotatsu.core.util.ext.map
import org.koitharu.kotatsu.core.util.ext.mapToSet import org.koitharu.kotatsu.core.util.ext.mapToSet
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
import java.util.Locale import java.util.Locale
@ -17,31 +18,34 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class OnboardViewModel @Inject constructor( class OnboardViewModel @Inject constructor(
private val settings: AppSettings, private val repository: MangaSourcesRepository,
) : BaseViewModel() { ) : BaseViewModel() {
private val allSources = settings.remoteMangaSources private val allSources = repository.allMangaSources
private val locales = allSources.groupBy { it.locale } private val locales = allSources.groupBy { it.locale }
private val selectedLocales = HashSet<String?>()
private val selectedLocales = locales.keys.toMutableSet()
val list = MutableStateFlow<List<SourceLocale>?>(null) val list = MutableStateFlow<List<SourceLocale>?>(null)
private var updateJob: Job
init { init {
if (settings.isSourcesSelected) { updateJob = launchJob(Dispatchers.Default) {
selectedLocales.removeAll(settings.hiddenSources.mapNotNullToSet { x -> MangaSource(x).locale }) if (repository.isSetupRequired()) {
} else {
val deviceLocales = LocaleListCompat.getDefault().mapToSet { x -> val deviceLocales = LocaleListCompat.getDefault().mapToSet { x ->
x.language x.language
} }
selectedLocales.retainAll(deviceLocales) selectedLocales.addAll(deviceLocales)
if (selectedLocales.isEmpty()) { if (selectedLocales.isEmpty()) {
selectedLocales += "en" selectedLocales += "en"
} }
selectedLocales += null selectedLocales += null
} else {
selectedLocales.addAll(
repository.getEnabledSources().mapNotNullToSet { x -> x.locale },
)
} }
rebuildList() rebuildList()
repository.assimilateNewSources()
}
} }
fun setItemChecked(key: String?, isChecked: Boolean) { fun setItemChecked(key: String?, isChecked: Boolean) {
@ -51,17 +55,17 @@ class OnboardViewModel @Inject constructor(
selectedLocales.remove(key) selectedLocales.remove(key)
} }
if (isModified) { if (isModified) {
val prevJob = updateJob
updateJob = launchJob(Dispatchers.Default) {
prevJob.join()
val sources = allSources.filter { x -> x.locale == key }
repository.setSourcesEnabled(sources, isChecked)
rebuildList() rebuildList()
} }
} }
fun apply() {
settings.hiddenSources = allSources.filterNot { x ->
x.locale in selectedLocales
}.mapToSet { x -> x.name }
settings.markKnownSources(settings.newSources)
} }
@WorkerThread
private fun rebuildList() { private fun rebuildList() {
list.value = locales.map { (key, srcs) -> list.value = locales.map { (key, srcs) ->
val locale = if (key != null) { val locale = if (key != null) {

@ -3,25 +3,26 @@ package org.koitharu.kotatsu.settings.sources
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getLocaleTitle import org.koitharu.kotatsu.core.model.getLocaleTitle
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.map import org.koitharu.kotatsu.core.util.ext.map
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.move
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
import java.util.EnumSet
import java.util.Locale import java.util.Locale
import java.util.TreeMap import java.util.TreeMap
import javax.inject.Inject import javax.inject.Inject
@ -34,6 +35,7 @@ private const val TIP_REORDER = "src_reorder"
@HiltViewModel @HiltViewModel
class SourcesListViewModel @Inject constructor( class SourcesListViewModel @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
private val repository: MangaSourcesRepository,
) : BaseViewModel() { ) : BaseViewModel() {
val items = MutableStateFlow<List<SourceConfigItem>>(emptyList()) val items = MutableStateFlow<List<SourceConfigItem>>(emptyList())
@ -51,13 +53,19 @@ class SourcesListViewModel @Inject constructor(
fun reorderSources(oldPos: Int, newPos: Int): Boolean { fun reorderSources(oldPos: Int, newPos: Int): Boolean {
val snapshot = items.value.toMutableList() val snapshot = items.value.toMutableList()
if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false val item = (snapshot[oldPos] as? SourceConfigItem.SourceItem) ?: return false
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) return false
launchAtomicJob(Dispatchers.Default) { launchAtomicJob(Dispatchers.Default) {
snapshot.move(oldPos, newPos) var targetPosition = 0
settings.sourcesOrder = snapshot.mapNotNull { for ((i, x) in snapshot.withIndex()) {
(it as? SourceConfigItem.SourceItem)?.source?.name if (i == newPos) {
break
}
if (x is SourceConfigItem.SourceItem) {
targetPosition++
} }
}
repository.setPosition(item.source, targetPosition)
buildList() buildList()
} }
return true return true
@ -71,17 +79,8 @@ class SourcesListViewModel @Inject constructor(
fun setEnabled(source: MangaSource, isEnabled: Boolean) { fun setEnabled(source: MangaSource, isEnabled: Boolean) {
launchAtomicJob(Dispatchers.Default) { launchAtomicJob(Dispatchers.Default) {
settings.hiddenSources = if (isEnabled) { val rollback = repository.setSourceEnabled(source, isEnabled)
settings.hiddenSources - source.name if (!isEnabled) {
} else {
settings.hiddenSources + source.name
}
if (isEnabled) {
settings.markKnownSources(setOf(source))
} else {
val rollback = ReversibleHandle {
setEnabled(source, true)
}
onActionDone.call(ReversibleAction(R.string.source_disabled, rollback)) onActionDone.call(ReversibleAction(R.string.source_disabled, rollback))
} }
buildList() buildList()
@ -90,9 +89,7 @@ class SourcesListViewModel @Inject constructor(
fun disableAll() { fun disableAll() {
launchAtomicJob(Dispatchers.Default) { launchAtomicJob(Dispatchers.Default) {
settings.hiddenSources = settings.getMangaSources(includeHidden = true).mapToSet { repository.disableAllSources()
it.name
}
buildList() buildList()
} }
} }
@ -122,36 +119,37 @@ class SourcesListViewModel @Inject constructor(
} }
} }
private suspend fun buildList() = runInterruptible(Dispatchers.Default) { private suspend fun buildList() = withContext(Dispatchers.Default) {
val sources = settings.getMangaSources(includeHidden = true) val allSources = repository.allMangaSources
val hiddenSources = settings.hiddenSources val enabledSources = repository.getEnabledSources()
val enabledSet = EnumSet.copyOf(enabledSources)
val query = searchQuery val query = searchQuery
if (!query.isNullOrEmpty()) { if (!query.isNullOrEmpty()) {
items.value = sources.mapNotNull { items.value = allSources.mapNotNull {
if (!it.title.contains(query, ignoreCase = true)) { if (!it.title.contains(query, ignoreCase = true)) {
return@mapNotNull null return@mapNotNull null
} }
SourceConfigItem.SourceItem( SourceConfigItem.SourceItem(
source = it, source = it,
summary = it.getLocaleTitle(), summary = it.getLocaleTitle(),
isEnabled = it.name !in hiddenSources, isEnabled = it in enabledSet,
isDraggable = false, isDraggable = false,
) )
}.ifEmpty { }.ifEmpty {
listOf(SourceConfigItem.EmptySearchResult) listOf(SourceConfigItem.EmptySearchResult)
} }
return@runInterruptible return@withContext
} }
val map = sources.groupByTo(TreeMap(LocaleKeyComparator())) { val map = allSources.groupByTo(TreeMap(LocaleKeyComparator())) {
if (it.name !in hiddenSources) { if (it in enabledSet) {
KEY_ENABLED KEY_ENABLED
} else { } else {
it.locale it.locale
} }
} }
val result = ArrayList<SourceConfigItem>(sources.size + map.size + 2) map.remove(KEY_ENABLED)
val enabledSources = map.remove(KEY_ENABLED) val result = ArrayList<SourceConfigItem>(allSources.size + map.size + 2)
if (!enabledSources.isNullOrEmpty()) { if (enabledSources.isNotEmpty()) {
result += SourceConfigItem.Header(R.string.enabled_sources) result += SourceConfigItem.Header(R.string.enabled_sources)
if (settings.isTipEnabled(TIP_REORDER)) { if (settings.isTipEnabled(TIP_REORDER)) {
result += SourceConfigItem.Tip(TIP_REORDER, R.drawable.ic_tap_reorder, R.string.sources_reorder_tip) result += SourceConfigItem.Tip(TIP_REORDER, R.drawable.ic_tap_reorder, R.string.sources_reorder_tip)
@ -165,10 +163,11 @@ class SourcesListViewModel @Inject constructor(
) )
} }
} }
if (enabledSources?.size != sources.size) { if (enabledSources.size != allSources.size) {
result += SourceConfigItem.Header(R.string.available_sources) result += SourceConfigItem.Header(R.string.available_sources)
val comparator = compareBy<MangaSource, String>(AlphanumComparator()) { it.name }
for ((key, list) in map) { for ((key, list) in map) {
list.sortBy { it.ordinal } list.sortWith(comparator)
val isExpanded = key in expandedGroups val isExpanded = key in expandedGroups
result += SourceConfigItem.LocaleGroup( result += SourceConfigItem.LocaleGroup(
localeId = key, localeId = key,
@ -195,12 +194,12 @@ class SourcesListViewModel @Inject constructor(
return locale.getDisplayLanguage(locale).toTitleCase(locale) return locale.getDisplayLanguage(locale).toTitleCase(locale)
} }
private inline fun launchAtomicJob( private fun launchAtomicJob(
context: CoroutineContext = EmptyCoroutineContext, context: CoroutineContext = EmptyCoroutineContext,
crossinline block: suspend CoroutineScope.() -> Unit block: suspend CoroutineScope.() -> Unit
) = launchJob(context) { ) = launchJob(start = CoroutineStart.ATOMIC) {
mutex.withLock { mutex.withLock {
block() withContext(context, block)
} }
} }

@ -52,6 +52,7 @@ import org.koitharu.kotatsu.core.util.ext.takeMostFrequent
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
import org.koitharu.kotatsu.core.util.ext.trySetForeground import org.koitharu.kotatsu.core.util.ext.trySetForeground
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@ -79,6 +80,7 @@ class SuggestionsWorker @AssistedInject constructor(
private val favouritesRepository: FavouritesRepository, private val favouritesRepository: FavouritesRepository,
private val appSettings: AppSettings, private val appSettings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val sourcesRepository: MangaSourcesRepository,
) : CoroutineWorker(appContext, params) { ) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
@ -128,7 +130,7 @@ class SuggestionsWorker @AssistedInject constructor(
historyRepository.getList(0, 20) + historyRepository.getList(0, 20) +
favouritesRepository.getLastManga(20) favouritesRepository.getLastManga(20)
).distinctById() ).distinctById()
val sources = appSettings.getMangaSources(includeHidden = false) val sources = sourcesRepository.getEnabledSources()
if (seed.isEmpty() || sources.isEmpty()) { if (seed.isEmpty() || sources.isEmpty()) {
return 0 return 0
} }

Loading…
Cancel
Save