diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt index b7e4a07f5..a24d22ca3 100644 --- a/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt +++ b/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt @@ -17,7 +17,7 @@ class MangaDatabaseTest { MangaDatabase::class.java, ) - private val migrations = databaseMigrations + private val migrations = getDatabaseMigrations() @Test fun versions() { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt index d70dcb983..226879511 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt @@ -12,11 +12,13 @@ import kotlinx.coroutines.launch import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.bookmarks.data.BookmarksDao 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.TagsDao import org.koitharu.kotatsu.core.db.dao.TrackLogsDao import org.koitharu.kotatsu.core.db.entity.MangaEntity 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.TagEntity 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.Migration14To15 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.Migration2To3 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.TracksDao -const val DATABASE_VERSION = 16 +const val DATABASE_VERSION = 17 @Database( entities = [ MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, - ScrobblingEntity::class, + ScrobblingEntity::class, MangaSourceEntity::class, ], version = DATABASE_VERSION, ) @@ -83,30 +86,32 @@ abstract class MangaDatabase : RoomDatabase() { abstract val bookmarksDao: BookmarksDao abstract val scrobblingDao: ScrobblingDao + + abstract val sourcesDao: MangaSourcesDao } -val databaseMigrations: Array - get() = arrayOf( - Migration1To2(), - Migration2To3(), - Migration3To4(), - Migration4To5(), - Migration5To6(), - Migration6To7(), - Migration7To8(), - Migration8To9(), - Migration9To10(), - Migration10To11(), - Migration11To12(), - Migration12To13(), - Migration13To14(), - Migration14To15(), - Migration15To16(), - ) +fun getDatabaseMigrations(context: Context): Array = arrayOf( + Migration1To2(), + Migration2To3(), + Migration3To4(), + Migration4To5(), + Migration5To6(), + Migration6To7(), + Migration7To8(), + Migration8To9(), + Migration9To10(), + Migration10To11(), + Migration11To12(), + Migration12To13(), + Migration13To14(), + Migration14To15(), + Migration15To16(), + Migration16To17(context), +) fun MangaDatabase(context: Context): MangaDatabase = Room .databaseBuilder(context, MangaDatabase::class.java, "kotatsu-db") - .addMigrations(*databaseMigrations) + .addMigrations(*getDatabaseMigrations(context)) .addCallback(DatabasePrePopulateCallback(context.resources)) .build() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt new file mode 100644 index 000000000..ba0939de8 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt @@ -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 + + @Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key") + abstract suspend fun findAllEnabled(): List + + @Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key") + abstract fun observeEnabled(): Flow> + + @Query("SELECT * FROM sources ORDER BY sort_key") + abstract fun observeAll(): Flow> + + @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) + + @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 +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaSourceEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaSourceEntity.kt new file mode 100644 index 000000000..bd570c0c6 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaSourceEntity.kt @@ -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, +) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration16To17.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration16To17.kt new file mode 100644 index 000000000..776fdc0c1 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration16To17.kt @@ -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 +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 59297d4e9..2b04b7d27 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -15,24 +15,19 @@ import androidx.core.os.LocaleListCompat import androidx.preference.PreferenceManager import dagger.hilt.android.qualifiers.ApplicationContext import org.json.JSONArray -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.network.DoHProvider 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.observe import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.core.util.ext.takeIfReadable 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.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.mapToSet import java.io.File import java.net.Proxy -import java.util.Collections -import java.util.EnumSet import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -43,16 +38,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { private val prefs = PreferenceManager.getDefaultSharedPreferences(context) 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 - get() = Collections.unmodifiableSet(remoteSources) - var listMode: ListMode get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID) set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) } @@ -183,37 +168,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { return policy.isNetworkAllowed(connectivityManager) } - var sourcesOrder: List - get() = prefs.getString(KEY_SOURCES_ORDER, null) - ?.split('|') - .orEmpty() - set(value) = prefs.edit { - putString(KEY_SOURCES_ORDER, value.joinToString("|")) - } - - var hiddenSources: Set - 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 - 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) { - sourcesOrder = (sourcesOrder + sources.map { it.name }).distinct() - } - var isSourcesGridMode: Boolean get() = prefs.getBoolean(KEY_SOURCES_GRID, false) set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) } @@ -335,20 +289,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { return policy.isNetworkAllowed(connectivityManager) } - fun getMangaSources(includeHidden: Boolean): List { - 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 { 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_COLOR_THEME = "color_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_PAGES_CACHE_CLEAR = "pages_cache_clear" const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt new file mode 100644 index 000000000..1f03adf0a --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt @@ -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 + get() = Collections.unmodifiableSet(remoteSources) + + suspend fun getEnabledSources(): List { + return dao.findAllEnabled().toSources() + } + + fun observeEnabledSources(): Flow> = dao.observeEnabled().map { + it.toSources() + } + + fun observeAll(): Flow>> = dao.observeAll().map { entities -> + val result = ArrayList>(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, 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> = dao.observeAll().map { entities -> + val result = EnumSet.copyOf(remoteSources) + for (e in entities) { + result.remove(MangaSource(e.source)) + } + result + }.distinctUntilChanged() + + suspend fun getNewSources(): Set { + val entities = dao.findAll() + val result = EnumSet.copyOf(remoteSources) + for (e in entities) { + result.remove(MangaSource(e.source)) + } + return result + } + + suspend fun assimilateNewSources(): Set { + 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.toSources(): List { + val result = ArrayList(size) + for (entity in this) { + val source = MangaSource(entity.source) + if (source in remoteSources) { + result.add(source) + } + } + return result + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt index c91336c1c..e81600499 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt @@ -4,16 +4,18 @@ import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.almostEquals 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.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import javax.inject.Inject class ExploreRepository @Inject constructor( private val settings: AppSettings, + private val sourcesRepository: MangaSourcesRepository, private val historyRepository: HistoryRepository, private val mangaRepositoryFactory: MangaRepository.Factory, ) { @@ -23,7 +25,7 @@ class ExploreRepository @Inject constructor( val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull { if (it in blacklistTagRegex) null else it.title } - val sources = settings.getMangaSources(includeHidden = false) + val sources = sourcesRepository.getEnabledSources() check(sources.isNotEmpty()) { "No sources available" } for (i in 0..4) { val list = getList(sources.random(), tags, blacklistTagRegex) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt index 15946493d..9a80ae491 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt @@ -7,13 +7,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus 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.ui.BaseViewModel 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.call +import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.ui.model.ExploreButtons import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem @@ -45,6 +41,7 @@ class ExploreViewModel @Inject constructor( private val settings: AppSettings, private val suggestionRepository: SuggestionRepository, private val exploreRepository: ExploreRepository, + private val sourcesRepository: MangaSourcesRepository, ) : BaseViewModel() { val isGrid = settings.observeAsStateFlow( @@ -96,10 +93,7 @@ class ExploreViewModel @Inject constructor( fun hideSource(source: MangaSource) { launchJob(Dispatchers.Default) { - settings.hiddenSources += source.name - val rollback = ReversibleHandle { - settings.hiddenSources -= source.name - } + val rollback = sourcesRepository.setSourceEnabled(source, isEnabled = false) onActionDone.call(ReversibleAction(R.string.source_disabled, rollback)) } } @@ -114,11 +108,11 @@ class ExploreViewModel @Inject constructor( } private fun createContentFlow() = combine( - observeSources(), + sourcesRepository.observeEnabledSources(), getSuggestionFlow(), isGrid, isRandomLoading, - observeNewSources(), + sourcesRepository.observeNewSources(), ) { content, suggestions, grid, randomLoading, newSources -> buildList(content, suggestions, grid, randomLoading, newSources) } @@ -160,15 +154,6 @@ class ExploreViewModel @Inject constructor( 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( ExploreButtons(isRandomLoading.value), 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 { private const val TIP_SUGGESTIONS = "suggestions" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt index 4a887bce9..0a115283f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -63,7 +63,6 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.about.AppUpdateDialog -import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment import javax.inject.Inject import com.google.android.material.R as materialR @@ -136,6 +135,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged) viewModel.counters.observe(this, ::onCountersChanged) viewModel.appUpdate.observe(this) { invalidateMenu() } + viewModel.onFirstStart.observeEvent(this) { OnboardDialogFragment.showWelcome(supportFragmentManager) } viewModel.isFeedAvailable.observe(this, ::onFeedAvailabilityChanged) searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged) } @@ -324,15 +324,6 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav private fun onFirstStart() { 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) { LocalStorageCleanupWorker.enqueue(applicationContext) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainViewModel.kt index 4ae301c11..fc90f1382 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainViewModel.kt @@ -6,9 +6,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus 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.util.ext.MutableEventFlow 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.main.domain.ReadingResumeEnabledUseCase import org.koitharu.kotatsu.parsers.model.Manga @@ -31,9 +30,11 @@ class MainViewModel @Inject constructor( trackingRepository: TrackingRepository, private val settings: AppSettings, readingResumeEnabledUseCase: ReadingResumeEnabledUseCase, + private val sourcesRepository: MangaSourcesRepository, ) : BaseViewModel() { val onOpenReader = MutableEventFlow() + val onFirstStart = MutableEventFlow() val isResumeEnabled = readingResumeEnabledUseCase().stateIn( scope = viewModelScope + Dispatchers.Default, @@ -64,6 +65,11 @@ class MainViewModel @Inject constructor( launchJob { appUpdateRepository.fetchUpdate() } + launchJob(Dispatchers.Default) { + if (sourcesRepository.isSetupRequired()) { + onFirstStart.call(Unit) + } + } } fun openLastReader() { @@ -77,10 +83,7 @@ class MainViewModel @Inject constructor( settings.isIncognitoModeEnabled = isEnabled } - private fun observeNewSourcesCount() = settings.observe() - .filter { it == AppSettings.KEY_SOURCES_ORDER || it == AppSettings.KEY_SOURCES_HIDDEN } - .onStart { emit("") } - .map { settings.newSources.size } + private fun observeNewSourcesCount() = sourcesRepository.observeNewSources() + .map { it.size } .distinctUntilChanged() - } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt index 79c106a80..3ee371282 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt @@ -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.toMangaTag 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.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag @@ -26,27 +26,28 @@ import javax.inject.Inject @Reusable class MangaSearchRepository @Inject constructor( - private val settings: AppSettings, private val db: MangaDatabase, + private val sourcesRepository: MangaSourcesRepository, @ApplicationContext private val context: Context, private val recentSuggestions: SearchRecentSuggestions, private val mangaRepositoryFactory: MangaRepository.Factory, ) { fun globalSearch(query: String, concurrency: Int = DEFAULT_CONCURRENCY): Flow = - settings.getMangaSources(includeHidden = false).asFlow() - .flatMapMerge(concurrency) { source -> - runCatchingCancellable { - mangaRepositoryFactory.create(source).getList( - offset = 0, - query = query, - ) - }.getOrElse { - emptyList() - }.asFlow() - }.filter { - match(it, query) - } + flow { + emitAll(sourcesRepository.getEnabledSources().asFlow()) + }.flatMapMerge(concurrency) { source -> + runCatchingCancellable { + mangaRepositoryFactory.create(source).getList( + offset = 0, + query = query, + ) + }.getOrElse { + emptyList() + }.asFlow() + }.filter { + match(it, query) + } suspend fun getMangaSuggestion(query: String, limit: Int, source: MangaSource?): List { if (query.isEmpty()) { @@ -101,7 +102,7 @@ class MangaSearchRepository @Inject constructor( if (query.length < 3) { return emptyList() } - val sources = settings.remoteMangaSources + val sources = sourcesRepository.allMangaSources .filter { x -> x.title.contains(query, ignoreCase = true) } return if (limit == 0) { sources diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt index 06d28f85a..bb0c8b43b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt @@ -19,12 +19,13 @@ import kotlinx.coroutines.plus import kotlinx.coroutines.withTimeout import org.koitharu.kotatsu.R 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.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.MutableEventFlow 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.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.ui.model.EmptyState 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.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import javax.inject.Inject private const val MAX_PARALLELISM = 4 @@ -43,9 +43,9 @@ private const val MIN_HAS_MORE_ITEMS = 8 class MultiSearchViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val extraProvider: ListExtraProvider, - private val settings: AppSettings, private val mangaRepositoryFactory: MangaRepository.Factory, private val downloadScheduler: DownloadWorker.Scheduler, + private val sourcesRepository: MangaSourcesRepository, ) : BaseViewModel() { private var searchJob: Job? = null @@ -117,7 +117,7 @@ class MultiSearchViewModel @Inject constructor( } 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 deferredList = sources.map { source -> async(dispatcher) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt index d44357b08..2be9f36c4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt @@ -11,18 +11,20 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.plus 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.ui.BaseViewModel 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.MangaTag import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem +import java.util.EnumSet import javax.inject.Inject private const val DEBOUNCE_TIMEOUT = 500L @@ -35,6 +37,7 @@ private const val MAX_SOURCES_ITEMS = 6 class SearchSuggestionViewModel @Inject constructor( private val repository: MangaSearchRepository, private val settings: AppSettings, + private val sourcesRepository: MangaSourcesRepository, ) : BaseViewModel() { private val query = MutableStateFlow("") @@ -72,10 +75,8 @@ class SearchSuggestionViewModel @Inject constructor( } fun onSourceToggle(source: MangaSource, isEnabled: Boolean) { - settings.hiddenSources = if (isEnabled) { - settings.hiddenSources - source.name - } else { - settings.hiddenSources + source.name + launchJob(Dispatchers.Default) { + sourcesRepository.setSourceEnabled(source, isEnabled) } } @@ -90,10 +91,10 @@ class SearchSuggestionViewModel @Inject constructor( suggestionJob?.cancel() suggestionJob = combine( query.debounce(DEBOUNCE_TIMEOUT), - settings.observeAsFlow(AppSettings.KEY_SOURCES_HIDDEN) { hiddenSources }, + sourcesRepository.observeEnabledSources().map { EnumSet.copyOf(it) }, ::Pair, - ).mapLatest { (searchQuery, hiddenSources) -> - buildSearchSuggestion(searchQuery, hiddenSources) + ).mapLatest { (searchQuery, enabledSources) -> + buildSearchSuggestion(searchQuery, enabledSources) }.distinctUntilChanged() .onEach { suggestion.value = it @@ -102,7 +103,7 @@ class SearchSuggestionViewModel @Inject constructor( private suspend fun buildSearchSuggestion( searchQuery: String, - hiddenSources: Set, + enabledSources: Set, ): List = coroutineScope { val queriesDeferred = async { repository.getQuerySuggestion(searchQuery, MAX_QUERY_ITEMS) @@ -127,7 +128,7 @@ class SearchSuggestionViewModel @Inject constructor( add(SearchSuggestionItem.MangaList(mangaList)) } 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) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsFragment.kt index 63c2a1d75..f095ec6c7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsFragment.kt @@ -1,16 +1,21 @@ package org.koitharu.kotatsu.settings -import android.content.SharedPreferences import android.os.Bundle import android.view.View import androidx.annotation.StringRes +import androidx.fragment.app.viewModels import androidx.preference.Preference +import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings 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?) { 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("services", R.string.suggestions, R.string.sync, R.string.tracking) findPreference("about")?.summary = getString(R.string.app_version, BuildConfig.VERSION_NAME) - bindRemoteSourcesSummary() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - settings.subscribe(this) - } - - override fun onDestroyView() { - settings.unsubscribe(this) - super.onDestroyView() - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - when (key) { - AppSettings.KEY_SOURCES_HIDDEN -> { - bindRemoteSourcesSummary() + findPreference(AppSettings.KEY_REMOTE_SOURCES)?.let { pref -> + val total = viewModel.totalSourcesCount + viewModel.enabledSourcesCount.observe(viewLifecycleOwner) { + pref.summary = if (it >= 0) { + getString(R.string.enabled_d_of_d, it, total) + } else { + resources.getQuantityString(R.plurals.items, total, total) + } } } } @@ -46,11 +46,4 @@ class RootSettingsFragment : BasePreferenceFragment(0), SharedPreferences.OnShar private fun bindPreferenceSummary(key: String, @StringRes vararg items: Int) { findPreference(key)?.summary = items.joinToString { getString(it) } } - - private fun bindRemoteSourcesSummary() { - findPreference(AppSettings.KEY_REMOTE_SOURCES)?.run { - val total = settings.remoteMangaSources.size - summary = getString(R.string.enabled_d_of_d, total - settings.hiddenSources.size, total) - } - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsViewModel.kt new file mode 100644 index 000000000..9ede5f2f8 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsViewModel.kt @@ -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) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt index b35024a20..c364ff800 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt @@ -9,7 +9,6 @@ import androidx.fragment.app.viewModels import coil.ImageLoader import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.filterNotNull import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.util.ext.observe @@ -39,8 +38,7 @@ class NewSourcesDialogFragment : binding.recyclerView.adapter = adapter binding.textViewTitle.setText(R.string.new_sources_text) - viewModel.sources.filterNotNull() - .observe(viewLifecycleOwner) { adapter.items = it } + viewModel.content.observe(viewLifecycleOwner) { adapter.items = it } } override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { @@ -51,7 +49,6 @@ class NewSourcesDialogFragment : } override fun onClick(dialog: DialogInterface, which: Int) { - viewModel.apply() dialog.dismiss() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt index bec1e8f3d..1db085503 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt @@ -1,74 +1,43 @@ package org.koitharu.kotatsu.settings.newsources -import androidx.annotation.WorkerThread -import androidx.core.os.LocaleListCompat +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus 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.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 javax.inject.Inject @HiltViewModel class NewSourcesViewModel @Inject constructor( - private val settings: AppSettings, + private val repository: MangaSourcesRepository, ) : BaseViewModel() { - private val initialList = settings.newSources - val sources = MutableStateFlow?>(null) - private var listUpdateJob: Job? = null - - init { - listUpdateJob = launchJob(Dispatchers.Default) { - sources.value = buildList() - } + private val newSources = SuspendLazy { + repository.assimilateNewSources() } - - fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { - val prevJob = listUpdateJob - listUpdateJob = launchJob(Dispatchers.Default) { - if (isEnabled) { - settings.hiddenSources -= item.source.name - } else { - settings.hiddenSources += item.source.name + val content: StateFlow> = repository.observeAll() + .map { sources -> + val new = newSources.get() + sources.mapNotNull { (source, enabled) -> + if (source in new) { + SourceConfigItem.SourceItem(source, enabled, source.getLocaleTitle(), false) + } else { + null + } } - prevJob?.cancelAndJoin() - val list = buildList() - ensureActive() - sources.value = list - } - } + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList()) - fun apply() { - settings.markKnownSources(initialList) - } - - @WorkerThread - private fun buildList(): List { - val locales = LocaleListCompat.getDefault().mapToSet { it.language } - val pendingHidden = HashSet() - 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 - } + fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { + launchJob(Dispatchers.Default) { + repository.setSourceEnabled(item.source, isEnabled) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt index 14ac39e54..c88dcd3e5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt @@ -66,9 +66,9 @@ class OnboardDialogFragment : viewModel.setItemChecked(item.key, isChecked) } - override fun onClick(dialog: DialogInterface?, which: Int) { + override fun onClick(dialog: DialogInterface, which: Int) { when (which) { - DialogInterface.BUTTON_POSITIVE -> viewModel.apply() + DialogInterface.BUTTON_POSITIVE -> dialog.dismiss() } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt index 4707da3cc..e8f413216 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt @@ -1,15 +1,16 @@ package org.koitharu.kotatsu.settings.onboard +import androidx.annotation.WorkerThread import androidx.core.os.LocaleListCompat import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job 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.util.ext.map 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.mapToSet import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.settings.onboard.model.SourceLocale import java.util.Locale @@ -17,31 +18,34 @@ import javax.inject.Inject @HiltViewModel class OnboardViewModel @Inject constructor( - private val settings: AppSettings, + private val repository: MangaSourcesRepository, ) : BaseViewModel() { - private val allSources = settings.remoteMangaSources - + private val allSources = repository.allMangaSources private val locales = allSources.groupBy { it.locale } - - private val selectedLocales = locales.keys.toMutableSet() - + private val selectedLocales = HashSet() val list = MutableStateFlow?>(null) + private var updateJob: Job init { - if (settings.isSourcesSelected) { - selectedLocales.removeAll(settings.hiddenSources.mapNotNullToSet { x -> MangaSource(x).locale }) - } else { - val deviceLocales = LocaleListCompat.getDefault().mapToSet { x -> - x.language - } - selectedLocales.retainAll(deviceLocales) - if (selectedLocales.isEmpty()) { - selectedLocales += "en" + updateJob = launchJob(Dispatchers.Default) { + if (repository.isSetupRequired()) { + val deviceLocales = LocaleListCompat.getDefault().mapToSet { x -> + x.language + } + selectedLocales.addAll(deviceLocales) + if (selectedLocales.isEmpty()) { + selectedLocales += "en" + } + selectedLocales += null + } else { + selectedLocales.addAll( + repository.getEnabledSources().mapNotNullToSet { x -> x.locale }, + ) } - selectedLocales += null + rebuildList() + repository.assimilateNewSources() } - rebuildList() } fun setItemChecked(key: String?, isChecked: Boolean) { @@ -51,17 +55,17 @@ class OnboardViewModel @Inject constructor( selectedLocales.remove(key) } if (isModified) { - rebuildList() + val prevJob = updateJob + updateJob = launchJob(Dispatchers.Default) { + prevJob.join() + val sources = allSources.filter { x -> x.locale == key } + repository.setSourcesEnabled(sources, isChecked) + rebuildList() + } } } - fun apply() { - settings.hiddenSources = allSources.filterNot { x -> - x.locale in selectedLocales - }.mapToSet { x -> x.name } - settings.markKnownSources(settings.newSources) - } - + @WorkerThread private fun rebuildList() { list.value = locales.map { (key, srcs) -> val locale = if (key != null) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesListViewModel.kt index 5ba8cd767..02ee26737 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesListViewModel.kt @@ -3,25 +3,26 @@ package org.koitharu.kotatsu.settings.sources import androidx.core.os.LocaleListCompat import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R 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.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.call 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.util.mapToSet -import org.koitharu.kotatsu.parsers.util.move import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem +import java.util.EnumSet import java.util.Locale import java.util.TreeMap import javax.inject.Inject @@ -34,6 +35,7 @@ private const val TIP_REORDER = "src_reorder" @HiltViewModel class SourcesListViewModel @Inject constructor( private val settings: AppSettings, + private val repository: MangaSourcesRepository, ) : BaseViewModel() { val items = MutableStateFlow>(emptyList()) @@ -51,13 +53,19 @@ class SourcesListViewModel @Inject constructor( fun reorderSources(oldPos: Int, newPos: Int): Boolean { val snapshot = items.value.toMutableList() - if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false - if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false + val item = (snapshot[oldPos] as? SourceConfigItem.SourceItem) ?: return false + if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) return false launchAtomicJob(Dispatchers.Default) { - snapshot.move(oldPos, newPos) - settings.sourcesOrder = snapshot.mapNotNull { - (it as? SourceConfigItem.SourceItem)?.source?.name + var targetPosition = 0 + for ((i, x) in snapshot.withIndex()) { + if (i == newPos) { + break + } + if (x is SourceConfigItem.SourceItem) { + targetPosition++ + } } + repository.setPosition(item.source, targetPosition) buildList() } return true @@ -71,17 +79,8 @@ class SourcesListViewModel @Inject constructor( fun setEnabled(source: MangaSource, isEnabled: Boolean) { launchAtomicJob(Dispatchers.Default) { - settings.hiddenSources = if (isEnabled) { - settings.hiddenSources - source.name - } else { - settings.hiddenSources + source.name - } - if (isEnabled) { - settings.markKnownSources(setOf(source)) - } else { - val rollback = ReversibleHandle { - setEnabled(source, true) - } + val rollback = repository.setSourceEnabled(source, isEnabled) + if (!isEnabled) { onActionDone.call(ReversibleAction(R.string.source_disabled, rollback)) } buildList() @@ -90,9 +89,7 @@ class SourcesListViewModel @Inject constructor( fun disableAll() { launchAtomicJob(Dispatchers.Default) { - settings.hiddenSources = settings.getMangaSources(includeHidden = true).mapToSet { - it.name - } + repository.disableAllSources() buildList() } } @@ -122,36 +119,37 @@ class SourcesListViewModel @Inject constructor( } } - private suspend fun buildList() = runInterruptible(Dispatchers.Default) { - val sources = settings.getMangaSources(includeHidden = true) - val hiddenSources = settings.hiddenSources + private suspend fun buildList() = withContext(Dispatchers.Default) { + val allSources = repository.allMangaSources + val enabledSources = repository.getEnabledSources() + val enabledSet = EnumSet.copyOf(enabledSources) val query = searchQuery if (!query.isNullOrEmpty()) { - items.value = sources.mapNotNull { + items.value = allSources.mapNotNull { if (!it.title.contains(query, ignoreCase = true)) { return@mapNotNull null } SourceConfigItem.SourceItem( source = it, summary = it.getLocaleTitle(), - isEnabled = it.name !in hiddenSources, + isEnabled = it in enabledSet, isDraggable = false, ) }.ifEmpty { listOf(SourceConfigItem.EmptySearchResult) } - return@runInterruptible + return@withContext } - val map = sources.groupByTo(TreeMap(LocaleKeyComparator())) { - if (it.name !in hiddenSources) { + val map = allSources.groupByTo(TreeMap(LocaleKeyComparator())) { + if (it in enabledSet) { KEY_ENABLED } else { it.locale } } - val result = ArrayList(sources.size + map.size + 2) - val enabledSources = map.remove(KEY_ENABLED) - if (!enabledSources.isNullOrEmpty()) { + map.remove(KEY_ENABLED) + val result = ArrayList(allSources.size + map.size + 2) + if (enabledSources.isNotEmpty()) { result += SourceConfigItem.Header(R.string.enabled_sources) if (settings.isTipEnabled(TIP_REORDER)) { 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) + val comparator = compareBy(AlphanumComparator()) { it.name } for ((key, list) in map) { - list.sortBy { it.ordinal } + list.sortWith(comparator) val isExpanded = key in expandedGroups result += SourceConfigItem.LocaleGroup( localeId = key, @@ -195,12 +194,12 @@ class SourcesListViewModel @Inject constructor( return locale.getDisplayLanguage(locale).toTitleCase(locale) } - private inline fun launchAtomicJob( + private fun launchAtomicJob( context: CoroutineContext = EmptyCoroutineContext, - crossinline block: suspend CoroutineScope.() -> Unit - ) = launchJob(context) { + block: suspend CoroutineScope.() -> Unit + ) = launchJob(start = CoroutineStart.ATOMIC) { mutex.withLock { - block() + withContext(context, block) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt index bd469f0b2..e005ba431 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt @@ -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.trySetForeground 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.history.data.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga @@ -79,6 +80,7 @@ class SuggestionsWorker @AssistedInject constructor( private val favouritesRepository: FavouritesRepository, private val appSettings: AppSettings, private val mangaRepositoryFactory: MangaRepository.Factory, + private val sourcesRepository: MangaSourcesRepository, ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { @@ -128,7 +130,7 @@ class SuggestionsWorker @AssistedInject constructor( historyRepository.getList(0, 20) + favouritesRepository.getLastManga(20) ).distinctById() - val sources = appSettings.getMangaSources(includeHidden = false) + val sources = sourcesRepository.getEnabledSources() if (seed.isEmpty() || sources.isEmpty()) { return 0 }