diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksViewModel.kt index 7ba0b2a79..ced4e03cc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksViewModel.kt @@ -48,7 +48,7 @@ class BookmarksViewModel @Inject constructor( fun removeBookmarks(ids: Map>) { launchJob(Dispatchers.Default) { val handle = repository.removeBookmarks(ids) - onActionDone.postCall(ReversibleAction(R.string.bookmarks_removed, handle)) + onActionDone.emitCall(ReversibleAction(R.string.bookmarks_removed, handle)) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt index 43e5bba82..6797915b1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt @@ -20,6 +20,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import dagger.multibindings.ElementsIntoSet import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import okhttp3.CookieJar import okhttp3.OkHttpClient import org.koitharu.kotatsu.BuildConfig @@ -40,6 +43,8 @@ import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CbzFetcher +import org.koitharu.kotatsu.local.data.LocalManga +import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.parsers.MangaLoaderContext @@ -205,5 +210,16 @@ interface AppModule { MemoryContentCache(application) } } + + @Provides + @Singleton + @LocalStorageChanges + fun provideMutableLocalStorageChangesFlow(): MutableSharedFlow = MutableSharedFlow() + + @Provides + @LocalStorageChanges + fun provideLocalStorageChangesFlow( + @LocalStorageChanges flow: MutableSharedFlow, + ): SharedFlow = flow.asSharedFlow() } } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index 1250b1df0..4e3e5f37b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -1,9 +1,7 @@ package org.koitharu.kotatsu.details.ui -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.os.Bundle import android.transition.Slide import android.transition.TransitionManager @@ -66,13 +64,6 @@ class DetailsActivity : private val viewModel: DetailsViewModel by viewModels() private lateinit var chaptersMenuProvider: ChaptersMenuProvider - private val downloadReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - val downloadedManga = DownloadService.getDownloadedManga(intent) ?: return - viewModel.onDownloadComplete(downloadedManga) - } - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityDetailsBinding.inflate(layoutInflater)) @@ -130,7 +121,6 @@ class DetailsActivity : } viewModel.chapters.observe(this, PrefetchObserver(this)) - registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE)) addMenuProvider( DetailsMenuProvider( activity = this, @@ -142,11 +132,6 @@ class DetailsActivity : binding.headerChapters?.addOnExpansionChangeListener(this) ?: addMenuProvider(chaptersMenuProvider) } - override fun onDestroy() { - unregisterReceiver(downloadReceiver) - super.onDestroy() - } - override fun onClick(v: View) { val manga = viewModel.manga.value ?: return when (v.id) { diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt index e0eccd683..f8ed4aa69 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt @@ -9,7 +9,6 @@ import android.widget.Toast import androidx.appcompat.widget.PopupMenu import androidx.core.content.ContextCompat import androidx.core.graphics.Insets -import androidx.core.net.toUri import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updatePadding @@ -19,7 +18,6 @@ import coil.request.ImageRequest import coil.util.CoilUtils import com.google.android.material.chip.Chip import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener @@ -46,7 +44,6 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.utils.FileSize -import org.koitharu.kotatsu.utils.ext.computeSize import org.koitharu.kotatsu.utils.ext.crossfade import org.koitharu.kotatsu.utils.ext.drawableTop import org.koitharu.kotatsu.utils.ext.enqueueWith @@ -55,8 +52,6 @@ import org.koitharu.kotatsu.utils.ext.measureHeight import org.koitharu.kotatsu.utils.ext.resolveDp import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.utils.ext.textAndVisible -import org.koitharu.kotatsu.utils.ext.toFileOrNull -import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import org.koitharu.kotatsu.utils.image.CoverSizeResolver import javax.inject.Inject @@ -94,6 +89,7 @@ class DetailsFragment : viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged) viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged) viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged) + viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged) } override fun onItemClick(item: Bookmark, view: View) { @@ -150,20 +146,9 @@ class DetailsFragment : } if (manga.source == MangaSource.LOCAL) { infoLayout.textViewSource.isVisible = false - val file = manga.url.toUri().toFileOrNull() - if (file != null) { - viewLifecycleScope.launch { - val size = file.computeSize() - infoLayout.textViewSize.text = FileSize.BYTES.format(requireContext(), size) - infoLayout.textViewSize.isVisible = true - } - } else { - infoLayout.textViewSize.isVisible = false - } } else { infoLayout.textViewSource.text = manga.source.title infoLayout.textViewSource.isVisible = true - infoLayout.textViewSize.isVisible = false } infoLayout.textViewNsfw.isVisible = manga.isNsfw @@ -192,6 +177,16 @@ class DetailsFragment : } } + private fun onLocalSizeChanged(size: Long) { + val textView = binding.infoLayout.textViewSize + if (size == 0L) { + textView.isVisible = false + } else { + textView.text = FileSize.BYTES.format(textView.context, size) + textView.isVisible = true + } + } + private fun onHistoryChanged(history: HistoryInfo) { binding.progressView.setPercent(history.history?.percent ?: PROGRESS_NONE, animate = true) } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index 2a338a2fc..bffa668a0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -4,6 +4,7 @@ import android.text.Html import android.text.SpannableString import android.text.Spanned import android.text.style.ForegroundColorSpan +import androidx.core.net.toUri import androidx.core.text.getSpans import androidx.core.text.parseAsHtml import androidx.lifecycle.LiveData @@ -14,6 +15,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChangedBy @@ -36,6 +38,8 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.local.data.LocalManga +import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource @@ -46,8 +50,10 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.asFlowLiveData +import org.koitharu.kotatsu.utils.ext.computeSize import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.runCatchingCancellable +import org.koitharu.kotatsu.utils.ext.toFileOrNull import java.io.IOException import javax.inject.Inject @@ -62,6 +68,7 @@ class DetailsViewModel @Inject constructor( private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>, private val imageGetter: Html.ImageGetter, private val delegate: MangaDetailsDelegate, + @LocalStorageChanges private val localStorageChanges: SharedFlow, ) : BaseViewModel() { private var loadingJob: Job @@ -109,6 +116,23 @@ class DetailsViewModel @Inject constructor( if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList()) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) + val localSize = combine( + delegate.manga, + delegate.relatedManga, + ) { m1, m2 -> + val url = when { + m1?.source == MangaSource.LOCAL -> m1.url + m2?.source == MangaSource.LOCAL -> m2.url + else -> null + } + if (url != null) { + val file = url.toUri().toFileOrNull() + file?.computeSize() ?: 0L + } else { + 0L + } + }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, 0) + val description = delegate.manga .distinctUntilChangedBy { it?.description.orEmpty() } .transformLatest { @@ -174,6 +198,10 @@ class DetailsViewModel @Inject constructor( init { loadingJob = doLoad() + launchJob(Dispatchers.Default) { + localStorageChanges + .collect { onDownloadComplete(it) } + } } fun reload() { @@ -195,7 +223,7 @@ class DetailsViewModel @Inject constructor( runCatchingCancellable { historyRepository.deleteOrSwap(manga, original) } - onMangaRemoved.postCall(manga) + onMangaRemoved.emitCall(manga) } } @@ -222,26 +250,6 @@ class DetailsViewModel @Inject constructor( chaptersQuery.value = query?.trim().orEmpty() } - fun onDownloadComplete(downloadedManga: Manga) { - val currentManga = delegate.manga.value ?: return - if (currentManga.id != downloadedManga.id) { - return - } - if (currentManga.source == MangaSource.LOCAL) { - reload() - } else { - viewModelScope.launch(Dispatchers.Default) { - runCatchingCancellable { - localMangaRepository.getDetails(downloadedManga) - }.onSuccess { - delegate.relatedManga.value = it - }.onFailure { - it.printStackTraceDebug() - } - } - } - } - fun updateScrobbling(index: Int, rating: Float, status: ScrobblingStatus?) { val scrobbler = getScrobbler(index) ?: return launchJob(Dispatchers.Default) { @@ -287,6 +295,27 @@ class DetailsViewModel @Inject constructor( } } + private suspend fun onDownloadComplete(downloadedManga: LocalManga?) { + downloadedManga ?: return + val currentManga = delegate.manga.value ?: return + if (currentManga.id != downloadedManga.manga.id) { + return + } + if (currentManga.source == MangaSource.LOCAL) { + reload() + } else { + viewModelScope.launch(Dispatchers.Default) { + runCatchingCancellable { + localMangaRepository.getDetails(downloadedManga.manga) + }.onSuccess { + delegate.relatedManga.value = it + }.onFailure { + it.printStackTraceDebug() + } + } + } + } + private fun Spanned.filterSpans(): CharSequence { val spannable = SpannableString.valueOf(this) val spans = spannable.getSpans() diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt index a1070a56f..3d5b3c46d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore @@ -29,6 +30,8 @@ import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.download.ui.service.PausingHandle +import org.koitharu.kotatsu.local.data.LocalManga +import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput @@ -58,6 +61,7 @@ class DownloadManager @Inject constructor( private val localMangaRepository: LocalMangaRepository, private val settings: AppSettings, private val mangaRepositoryFactory: MangaRepository.Factory, + @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, ) { private val coverWidth = context.resources.getDimensionPixelSize( @@ -165,13 +169,18 @@ class DownloadManager @Inject constructor( delay(SLOWDOWN_DELAY) } } - output.flushChapter(chapter) + if (output.flushChapter(chapter)) { + runCatchingCancellable { + localStorageChanges.emit(LocalMangaInput.of(output.rootFile).getManga()) + }.onFailure(Throwable::printStackTraceDebug) + } } outState.value = DownloadState.PostProcessing(startId, data, cover) output.mergeWithExisting() output.finish() - val localManga = LocalMangaInput.of(output.rootFile).getManga().manga - outState.value = DownloadState.Done(startId, data, cover, localManga) + val localManga = LocalMangaInput.of(output.rootFile).getManga() + localStorageChanges.emit(localManga) + outState.value = DownloadState.Done(startId, data, cover, localManga.manga) } catch (e: CancellationException) { outState.value = DownloadState.Cancelled(startId, manga, cover) throw e diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt index 08f5577bd..8dd780d45 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.download.ui.service +import android.app.DownloadManager.ACTION_DOWNLOAD_COMPLETE import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -56,7 +57,6 @@ class DownloadService : BaseService() { override fun onCreate() { super.onCreate() - isRunning = true downloadNotification = DownloadNotification(this) wakeLock = (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager) .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") @@ -93,7 +93,6 @@ class DownloadService : BaseService() { if (wakeLock.isHeld) { wakeLock.release() } - isRunning = false super.onDestroy() } @@ -205,12 +204,6 @@ class DownloadService : BaseService() { companion object { - var isRunning: Boolean = false - private set - - @Deprecated("Use LocalMangaRepository.watchReadableDirs instead") - const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE" - private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL" private const val ACTION_DOWNLOAD_RESUME = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_RESUME" @@ -259,13 +252,6 @@ class DownloadService : BaseService() { fun getResumeIntent(startId: Int) = Intent(ACTION_DOWNLOAD_RESUME) .putExtra(EXTRA_CANCEL_ID, startId) - fun getDownloadedManga(intent: Intent?): Manga? { - if (intent?.action == ACTION_DOWNLOAD_COMPLETE) { - return intent.getParcelableExtraCompat(EXTRA_MANGA)?.manga - } - return null - } - private fun showStartedSnackbar(view: View) { Snackbar.make(view, R.string.download_started, Snackbar.LENGTH_LONG) .setAction(R.string.details) { diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt index 85f32745f..4b87979f7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt @@ -54,7 +54,7 @@ class ExploreViewModel @Inject constructor( fun openRandom() { launchLoadingJob(Dispatchers.Default) { val manga = exploreRepository.findRandomManga(tagsLimit = 8) - onOpenManga.postCall(manga) + onOpenManga.emitCall(manga) } } @@ -64,7 +64,7 @@ class ExploreViewModel @Inject constructor( val rollback = ReversibleHandle { settings.hiddenSources -= source.name } - onActionDone.postCall(ReversibleAction(R.string.source_disabled, rollback)) + onActionDone.emitCall(ReversibleAction(R.string.source_disabled, rollback)) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt index a322b9d49..8e84a52c1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt @@ -14,6 +14,7 @@ import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEdit import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.NO_ID import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.utils.SingleLiveEvent +import org.koitharu.kotatsu.utils.ext.emitValue import javax.inject.Inject @HiltViewModel @@ -34,7 +35,7 @@ class FavouritesCategoryEditViewModel @Inject constructor( init { launchLoadingJob(Dispatchers.Default) { - category.postValue( + category.emitValue( if (categoryId != NO_ID) { repository.getCategory(categoryId) } else { @@ -57,7 +58,7 @@ class FavouritesCategoryEditViewModel @Inject constructor( } else { repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled, isVisibleOnShelf) } - onSaved.postCall(Unit) + onSaved.emitCall(Unit) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt index 674701581..d540d1c17 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map -import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.core.parser.MangaTagHighlighter @@ -28,7 +27,6 @@ import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.asFlowLiveData -import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import javax.inject.Inject @HiltViewModel @@ -43,9 +41,6 @@ class FavouritesListViewModel @Inject constructor( val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID - var categoryName: String? = null - private set - val sortOrder: LiveData = if (categoryId == NO_ID) { MutableLiveData(null) } else { @@ -82,18 +77,6 @@ class FavouritesListViewModel @Inject constructor( emit(listOf(it.toErrorState(canRetry = false))) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) - init { - if (categoryId != NO_ID) { - launchJob { - categoryName = withContext(Dispatchers.Default) { - runCatchingCancellable { - repository.getCategory(categoryId).title - }.getOrNull() - } - } - } - } - override fun onRefresh() = Unit override fun onRetry() = Unit @@ -108,7 +91,7 @@ class FavouritesListViewModel @Inject constructor( } else { repository.removeFromCategory(categoryId, ids) } - onActionDone.postCall(ReversibleAction(R.string.removed_from_favourites, handle)) + onActionDone.emitCall(ReversibleAction(R.string.removed_from_favourites, handle)) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt index 8f38721f1..ef8f0f720 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt @@ -29,6 +29,7 @@ import org.koitharu.kotatsu.list.ui.model.toListModel import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.ext.daysDiff +import org.koitharu.kotatsu.utils.ext.emitValue import org.koitharu.kotatsu.utils.ext.onFirst import java.util.Date import java.util.concurrent.TimeUnit @@ -45,7 +46,7 @@ class HistoryListViewModel @Inject constructor( val isGroupingEnabled = MutableLiveData() private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { isHistoryGroupingEnabled } - .onEach { isGroupingEnabled.postValue(it) } + .onEach { isGroupingEnabled.emitValue(it) } override val content = combine( repository.observeAllWithHistory(), @@ -77,7 +78,7 @@ class HistoryListViewModel @Inject constructor( override fun onRetry() = Unit fun clearHistory() { - launchLoadingJob { + launchLoadingJob(Dispatchers.Default) { repository.clear() } } @@ -88,7 +89,7 @@ class HistoryListViewModel @Inject constructor( } launchJob(Dispatchers.Default) { val handle = repository.delete(ids) - onActionDone.postCall(ReversibleAction(R.string.removed_from_history, handle)) + onActionDone.emitCall(ReversibleAction(R.string.removed_from_history, handle)) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index cab87b11a..0cbb8cff7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -17,11 +17,9 @@ import androidx.core.view.updatePadding import androidx.recyclerview.widget.GridLayoutManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import coil.ImageLoader -import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.base.domain.reverseAsync import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.list.FitHeightGridLayoutManager import org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager @@ -30,7 +28,7 @@ import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller -import org.koitharu.kotatsu.base.ui.util.ReversibleAction +import org.koitharu.kotatsu.base.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.prefs.ListMode @@ -46,7 +44,6 @@ import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaItemModel import org.koitharu.kotatsu.main.ui.MainActivity import org.koitharu.kotatsu.main.ui.owners.AppBarOwner -import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.reader.ui.ReaderActivity @@ -127,7 +124,7 @@ abstract class MangaListFragment : viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.content.observe(viewLifecycleOwner, ::onListChanged) viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) - viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone) + viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) } override fun onDestroyView() { @@ -173,17 +170,6 @@ abstract class MangaListFragment : listAdapter?.setItems(list, listCommitCallback) } - private fun onActionDone(action: ReversibleAction) { - val handle = action.handle - val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG - val snackbar = Snackbar.make(binding.recyclerView, action.stringResId, length) - snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav - if (handle != null) { - snackbar.setAction(R.string.undo) { handle.reverseAsync() } - } - snackbar.show() - } - private fun resolveException(e: Throwable) { if (ExceptionResolver.canResolve(e)) { viewLifecycleScope.launch { diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/BadgeADUtil.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/BadgeADUtil.kt index fdeafeb00..2ec86a91d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/BadgeADUtil.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/BadgeADUtil.kt @@ -1,3 +1,5 @@ +@file:androidx.annotation.OptIn(ExperimentalBadgeUtils::class) + package org.koitharu.kotatsu.list.ui.adapter import android.view.View @@ -5,6 +7,7 @@ import androidx.annotation.CheckResult import androidx.core.view.doOnNextLayout import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeUtils +import com.google.android.material.badge.ExperimentalBadgeUtils import org.koitharu.kotatsu.R @CheckResult diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/EmptyHintAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/EmptyHintAD.kt index aced05368..d03a7cdff 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/EmptyHintAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/EmptyHintAD.kt @@ -1,12 +1,19 @@ package org.koitharu.kotatsu.list.ui.adapter +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding import org.koitharu.kotatsu.list.ui.model.EmptyHint import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.utils.ext.disposeImageRequest +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.setTextAndVisible fun emptyHintAD( + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, listener: ListStateHolderListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) }, @@ -15,9 +22,13 @@ fun emptyHintAD( binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() } bind { - binding.icon.setImageResource(item.icon) + binding.icon.newImageRequest(lifecycleOwner, item.icon)?.enqueueWith(coil) binding.textPrimary.setText(item.textPrimary) binding.textSecondary.setTextAndVisible(item.textSecondary) binding.buttonRetry.setTextAndVisible(item.actionStringRes) } + + onViewRecycled { + binding.icon.disposeImageRequest() + } } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt b/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt deleted file mode 100644 index e6d74c9fc..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt +++ /dev/null @@ -1 +0,0 @@ -package org.koitharu.kotatsu.local diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/LocalManga.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalManga.kt index a59f041a4..bebb4c12c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/LocalManga.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalManga.kt @@ -1,5 +1,7 @@ package org.koitharu.kotatsu.local.data +import androidx.core.net.toFile +import androidx.core.net.toUri import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag import java.io.File @@ -9,6 +11,8 @@ class LocalManga( val manga: Manga, ) { + constructor(manga: Manga) : this(manga.url.toUri().toFile(), manga) + var createdAt: Long = -1L private set get() { diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt index 8792b5bc8..7653dbf07 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt @@ -7,15 +7,10 @@ import androidx.annotation.WorkerThread import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import okhttp3.Cache import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.local.data.util.observe import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.utils.ext.computeSize import org.koitharu.kotatsu.utils.ext.getStorageName @@ -36,6 +31,7 @@ class LocalStorageManager @Inject constructor( val contentResolver: ContentResolver get() = context.contentResolver + @WorkerThread fun createHttpCache(): Cache { val directory = File(context.externalCacheDir ?: context.cacheDir, "http") directory.mkdirs() @@ -80,14 +76,6 @@ class LocalStorageManager @Inject constructor( fun getStorageDisplayName(file: File) = file.getStorageName(context) - fun observe(files: List): Flow { - if (files.isEmpty()) { - return emptyFlow() - } - return files.asFlow() - .flatMapMerge(files.size) { it.observe() } - } - @WorkerThread private fun getConfiguredStorageDirs(): MutableSet { val set = getAvailableStorageDirs() diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt index 246e5e931..1de0e9516 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt @@ -6,9 +6,12 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.ext.copyToSuspending import org.koitharu.kotatsu.utils.ext.longHashCode +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug +import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.subdir import org.koitharu.kotatsu.utils.ext.takeIfReadable import org.koitharu.kotatsu.utils.ext.takeIfWriteable @@ -20,47 +23,41 @@ import javax.inject.Singleton @Singleton class PagesCache @Inject constructor(@ApplicationContext context: Context) { - private val cacheDir = checkNotNull(findSuitableDir(context)) { - val dirs = (context.externalCacheDirs + context.cacheDir).joinToString(";") { - it?.absolutePath.toString() + private val cacheDir = SuspendLazy { + val dirs = context.externalCacheDirs + context.cacheDir + dirs.firstNotNullOf { + it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable() } - "Cannot find any suitable directory for PagesCache: [$dirs]" } - private val lruCache = createDiskLruCacheSafe( - dir = cacheDir, - size = FileSize.MEGABYTES.convert(200, FileSize.BYTES), - ) + private val lruCache = SuspendLazy { + val dir = cacheDir.get() + val size = FileSize.MEGABYTES.convert(200, FileSize.BYTES) + runCatchingCancellable { + DiskLruCache.create(dir, size) + }.recoverCatching { error -> + error.printStackTraceDebug() + dir.deleteRecursively() + dir.mkdir() + DiskLruCache.create(dir, size) + }.getOrThrow() + } - suspend fun get(url: String): File? = runInterruptible(Dispatchers.IO) { - lruCache.get(url)?.takeIfReadable() + suspend fun get(url: String): File? { + val cache = lruCache.get() + return runInterruptible(Dispatchers.IO) { + cache.get(url)?.takeIfReadable() + } } suspend fun put(url: String, inputStream: InputStream): File = withContext(Dispatchers.IO) { - val file = File(cacheDir.parentFile, url.longHashCode().toString()) + val file = File(cacheDir.get().parentFile, url.longHashCode().toString()) try { file.outputStream().use { out -> inputStream.copyToSuspending(out) } - lruCache.put(url, file) + lruCache.get().put(url, file) } finally { file.delete() } } } - -private fun createDiskLruCacheSafe(dir: File, size: Long): DiskLruCache { - return try { - DiskLruCache.create(dir, size) - } catch (e: Exception) { - dir.deleteRecursively() - dir.mkdir() - DiskLruCache.create(dir, size) - } -} - -private fun findSuitableDir(context: Context): File? { - val dirs = context.externalCacheDirs + context.cacheDir - return dirs.firstNotNullOfOrNull { - it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable() - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/TrackerLogger.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/TrackerLogger.kt new file mode 100644 index 000000000..abc8d7714 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/TrackerLogger.kt @@ -0,0 +1,7 @@ +package org.koitharu.kotatsu.local.data + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class LocalStorageChanges diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt index 82a05f6b1..7b170eb19 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt @@ -6,11 +6,13 @@ import androidx.documentfile.provider.DocumentFile import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.LocalManga +import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.utils.ext.copyToSuspending @@ -23,16 +25,19 @@ import javax.inject.Inject class SingleMangaImporter @Inject constructor( @ApplicationContext private val context: Context, private val storageManager: LocalStorageManager, + @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, ) { private val contentResolver = context.contentResolver suspend fun import(uri: Uri, progressState: MutableStateFlow?): LocalManga { - return if (isDirectory(uri)) { + val result = if (isDirectory(uri)) { importDirectory(uri, progressState) } else { importFile(uri, progressState) } + localStorageChanges.emit(result) + return result } private suspend fun importFile(uri: Uri, progressState: MutableStateFlow?): LocalManga { diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt index a96b9979f..0948d37af 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt @@ -57,10 +57,11 @@ class LocalMangaDirOutput( index.addChapter(chapter) } - override suspend fun flushChapter(chapter: MangaChapter) { - val output = chaptersOutput.remove(chapter) ?: return + override suspend fun flushChapter(chapter: MangaChapter): Boolean { + val output = chaptersOutput.remove(chapter) ?: return false output.flushAndFinish() flushIndex() + return true } override suspend fun finish() { diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt index 1b71c60f0..e3445f387 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt @@ -16,7 +16,7 @@ sealed class LocalMangaOutput( abstract suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) - abstract suspend fun flushChapter(chapter: MangaChapter) + abstract suspend fun flushChapter(chapter: MangaChapter): Boolean abstract suspend fun finish() diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt index 18bdcc023..412edc40f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt @@ -60,7 +60,7 @@ class LocalMangaZipOutput( index.addChapter(chapter) } - override suspend fun flushChapter(chapter: MangaChapter) = Unit + override suspend fun flushChapter(chapter: MangaChapter): Boolean = false override suspend fun finish() { runInterruptible(Dispatchers.IO) { diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/util/FlowFileObserver.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/util/FlowFileObserver.kt deleted file mode 100644 index c167b87c1..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/util/FlowFileObserver.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.koitharu.kotatsu.local.data.util - -import android.os.Build -import android.os.FileObserver -import androidx.annotation.RequiresApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.ProducerScope -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.channels.trySendBlocking -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.flowOn -import java.io.File - -fun File.observe() = callbackFlow { - val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - FlowFileObserverQ(this, this@observe) - } else { - FlowFileObserver(this, this@observe) - } - observer.startWatching() - awaitClose { observer.stopWatching() } -}.flowOn(Dispatchers.IO) - -@RequiresApi(Build.VERSION_CODES.Q) -private class FlowFileObserverQ( - private val producerScope: ProducerScope, - private val file: File, -) : FileObserver(file, CREATE or DELETE or CLOSE_WRITE) { - - override fun onEvent(event: Int, path: String?) { - producerScope.trySendBlocking( - if (path == null) file else file.resolve(path), - ) - } -} - -@Suppress("DEPRECATION") -private class FlowFileObserver( - private val producerScope: ProducerScope, - private val file: File, -) : FileObserver(file.absolutePath, CREATE or DELETE or CLOSE_WRITE) { - - override fun onEvent(event: Int, path: String?) { - producerScope.trySendBlocking( - if (path == null) file else file.resolve(path), - ) - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt index fb1f4ae81..f28d05262 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt @@ -6,8 +6,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.local.data.LocalManga @@ -106,21 +107,24 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local suspend fun findSavedManga(remoteManga: Manga): LocalManga? { val files = getAllFiles() - val input = files.firstNotNullOfOrNull { file -> - LocalMangaInput.of(file).takeIf { - runCatchingCancellable { - it.getMangaInfo() - }.getOrNull()?.id == remoteManga.id - } + if (files.isEmpty()) { + return null } - return input?.getManga() - } - - suspend fun watchReadableDirs(): Flow { - val filter = TempFileFilter() - val dirs = storageManager.getReadableDirs() - return storageManager.observe(dirs) - .filterNot { filter.accept(it, it.name) } + return channelFlow { + for (file in files) { + launch { + val mangaInput = LocalMangaInput.of(file) + runCatchingCancellable { + val mangaInfo = mangaInput.getMangaInfo() + if (mangaInfo != null && mangaInfo.id == remoteManga.id) { + send(mangaInput) + } + }.onFailure { + it.printStackTraceDebug() + } + } + } + }.firstOrNull()?.getManga() } override val sortOrders = setOf(SortOrder.ALPHABETICAL, SortOrder.RATING) @@ -149,7 +153,7 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local dirs.flatMap { dir -> dir.listFiles(TempFileFilter())?.toList().orEmpty() }.forEach { file -> - file.delete() + file.deleteRecursively() } } return true diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt index 9e73ffc69..ae742f144 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt @@ -9,10 +9,12 @@ import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.MutableSharedFlow import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.CoroutineIntentService import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.download.ui.service.DownloadService +import org.koitharu.kotatsu.local.data.LocalManga +import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.utils.ext.getDisplayMessage @@ -25,6 +27,10 @@ class LocalChaptersRemoveService : CoroutineIntentService() { @Inject lateinit var localMangaRepository: LocalMangaRepository + @Inject + @LocalStorageChanges + lateinit var localStorageChanges: MutableSharedFlow + override fun onCreate() { super.onCreate() isRunning = true @@ -41,10 +47,7 @@ class LocalChaptersRemoveService : CoroutineIntentService() { startForeground() val mangaWithChapters = localMangaRepository.getDetails(manga) localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds) - sendBroadcast( - Intent(DownloadService.ACTION_DOWNLOAD_COMPLETE) - .putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false)), - ) + localStorageChanges.emit(LocalManga(manga)) ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 9ba1345a1..575a566ec 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -9,11 +9,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.core.parser.MangaTagHighlighter @@ -27,6 +26,8 @@ import org.koitharu.kotatsu.list.ui.model.ListHeader2 import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toUi +import org.koitharu.kotatsu.local.data.LocalManga +import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag @@ -46,6 +47,7 @@ class LocalListViewModel @Inject constructor( private val trackingRepository: TrackingRepository, private val settings: AppSettings, private val tagHighlighter: MangaTagHighlighter, + @LocalStorageChanges private val localStorageChanges: SharedFlow, ) : MangaListViewModel(settings), ListExtraProvider { val onMangaRemoved = SingleLiveEvent() @@ -83,7 +85,14 @@ class LocalListViewModel @Inject constructor( init { onRefresh() - watchDirectories() + launchJob(Dispatchers.Default) { + localStorageChanges + .collectLatest { + if (refreshJob?.isActive != true) { + doRefresh() + } + } + } } override fun onUpdateFilter(tags: Set) { @@ -108,21 +117,19 @@ class LocalListViewModel @Inject constructor( } fun delete(ids: Set) { - launchLoadingJob { - withContext(Dispatchers.Default) { - val itemsToRemove = checkNotNull(mangaList.value).filter { it.id in ids } - for (manga in itemsToRemove) { - val original = repository.getRemoteManga(manga) - repository.delete(manga) || throw IOException("Unable to delete file") - runCatchingCancellable { - historyRepository.deleteOrSwap(manga, original) - } - mangaList.update { list -> - list?.filterNot { it.id == manga.id } - } + launchLoadingJob(Dispatchers.Default) { + val itemsToRemove = checkNotNull(mangaList.value).filter { it.id in ids } + for (manga in itemsToRemove) { + val original = repository.getRemoteManga(manga) + repository.delete(manga) || throw IOException("Unable to delete file") + runCatchingCancellable { + historyRepository.deleteOrSwap(manga, original) + } + mangaList.update { list -> + list?.filterNot { it.id == manga.id } } } - onMangaRemoved.call(Unit) + onMangaRemoved.emitCall(Unit) } } @@ -137,15 +144,6 @@ class LocalListViewModel @Inject constructor( } } - private fun watchDirectories() { - viewModelScope.launch(Dispatchers.Default) { - repository.watchReadableDirs() - .collectLatest { - doRefresh() - } - } - } - private fun createHeader(mangaList: List, selectedTags: Set, order: SortOrder): ListHeader2 { val tags = HashMap() for (item in mangaList) { diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt index 06d20579f..054b4cf5a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt @@ -60,9 +60,9 @@ class MainViewModel @Inject constructor( } fun openLastReader() { - launchLoadingJob { + launchLoadingJob(Dispatchers.Default) { val manga = historyRepository.getLastOrNull() ?: throw EmptyHistoryException() - onOpenReader.call(manga) + onOpenReader.emitCall(manga) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index 0ec298e2e..c877742b9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -53,6 +53,7 @@ import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.asFlowLiveData +import org.koitharu.kotatsu.utils.ext.emitValue import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.processLifecycleScope import org.koitharu.kotatsu.utils.ext.requireValue @@ -202,12 +203,12 @@ class ReaderViewModel @Inject constructor( prevJob?.cancelAndJoin() try { val dest = pageSaveHelper.savePage(pageLoader, page, saveLauncher) - onPageSaved.postCall(dest) + onPageSaved.emitCall(dest) } catch (e: CancellationException) { throw e } catch (e: Exception) { e.printStackTraceDebug() - onPageSaved.postCall(null) + onPageSaved.emitCall(null) } } } @@ -285,7 +286,7 @@ class ReaderViewModel @Inject constructor( percent = computePercent(state.chapterId, state.page), ) bookmarksRepository.addBookmark(bookmark) - onShowToast.postCall(R.string.bookmark_added) + onShowToast.emitCall(R.string.bookmark_added) } } @@ -322,7 +323,7 @@ class ReaderViewModel @Inject constructor( val branch = chapters[currentState.value?.chapterId ?: 0L]?.branch mangaData.value = manga.filterChapters(branch) - readerMode.postValue(mode) + readerMode.emitValue(mode) chaptersLoader.loadSingleChapter(manga, requireNotNull(currentState.value).chapterId) // save state @@ -333,7 +334,7 @@ class ReaderViewModel @Inject constructor( } } notifyStateChanged() - content.postValue(ReaderContent(chaptersLoader.snapshot(), currentState.value)) + content.emitValue(ReaderContent(chaptersLoader.snapshot(), currentState.value)) } } @@ -341,7 +342,7 @@ class ReaderViewModel @Inject constructor( private fun loadPrevNextChapter(currentId: Long, isNext: Boolean) { loadingJob = launchLoadingJob(Dispatchers.Default) { chaptersLoader.loadPrevNextChapter(mangaData.requireValue(), currentId, isNext) - content.postValue(ReaderContent(chaptersLoader.snapshot(), null)) + content.emitValue(ReaderContent(chaptersLoader.snapshot(), null)) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt index 3f1a4dde5..d5b3e517f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt @@ -13,6 +13,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.reader.domain.ReaderColorFilter import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity.Companion.EXTRA_MANGA import org.koitharu.kotatsu.utils.SingleLiveEvent +import org.koitharu.kotatsu.utils.ext.emitValue import javax.inject.Inject @HiltViewModel @@ -43,7 +44,7 @@ class ColorFilterConfigViewModel @Inject constructor( launchLoadingJob(Dispatchers.Default) { val repository = mangaRepositoryFactory.create(page.source) val url = repository.getPageUrl(page) - preview.postValue( + preview.emitValue( MangaPage( id = page.id, url = url, @@ -71,7 +72,7 @@ class ColorFilterConfigViewModel @Inject constructor( fun save() { launchLoadingJob(Dispatchers.Default) { mangaDataRepository.saveColorFilter(manga, colorFilter.value) - onDismiss.postCall(Unit) + onDismiss.emitCall(Unit) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index 1e23e2f02..3c4b6677d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -161,7 +161,7 @@ class RemoteListViewModel @Inject constructor( e.printStackTraceDebug() listError.value = e if (!mangaList.value.isNullOrEmpty()) { - errorEvent.postCall(e) + errorEvent.emitCall(e) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt index 770e38986..84826d62e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt @@ -24,6 +24,7 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.asFlowLiveData +import org.koitharu.kotatsu.utils.ext.emitValue import org.koitharu.kotatsu.utils.ext.onFirst import org.koitharu.kotatsu.utils.ext.require import javax.inject.Inject @@ -51,22 +52,22 @@ class ScrobblerConfigViewModel @Inject constructor( init { scrobbler.user - .onEach { user.postValue(it) } + .onEach { user.emitValue(it) } .launchIn(viewModelScope + Dispatchers.Default) } fun onAuthCodeReceived(authCode: String) { launchLoadingJob(Dispatchers.Default) { val newUser = scrobbler.authorize(authCode) - user.postValue(newUser) + user.emitValue(newUser) } } fun logout() { launchLoadingJob(Dispatchers.Default) { scrobbler.logout() - user.postValue(null) - onLoggedOut.postCall(Unit) + user.emitValue(null) + onLoggedOut.emitCall(Unit) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt index 7af348b48..de9c2898c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt @@ -23,6 +23,7 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.asFlowLiveData +import org.koitharu.kotatsu.utils.ext.emitValue import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.require import org.koitharu.kotatsu.utils.ext.requireValue @@ -135,7 +136,7 @@ class ScrobblingSelectorViewModel @Inject constructor( } doneJob = launchJob(Dispatchers.Default) { currentScrobbler.linkManga(manga.id, targetId) - onClose.postCall(Unit) + onClose.emitCall(Unit) } } @@ -154,7 +155,7 @@ class ScrobblingSelectorViewModel @Inject constructor( try { val info = currentScrobbler.getScrobblingInfoOrNull(manga.id) if (info != null) { - selectedItemId.postValue(info.targetId) + selectedItemId.emitValue(info.targetId) } } finally { loadList(append = false) diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt index fad26f82f..d2dc9c5c9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt @@ -28,6 +28,7 @@ import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.utils.asFlowLiveData +import org.koitharu.kotatsu.utils.ext.emitValue import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import javax.inject.Inject @@ -96,7 +97,7 @@ class MultiSearchViewModel @Inject constructor( listError.value = null listData.value = emptyList() loadingData.value = true - query.postValue(q) + query.emitValue(q) searchImpl(q) } catch (e: CancellationException) { throw e diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt index 361969911..6853f2d18 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt @@ -24,6 +24,7 @@ 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 org.koitharu.kotatsu.utils.ext.emitValue import javax.inject.Inject private const val DEBOUNCE_TIMEOUT = 500L @@ -97,7 +98,7 @@ class SearchSuggestionViewModel @Inject constructor( buildSearchSuggestion(searchQuery, hiddenSources) }.distinctUntilChanged() .onEach { - suggestion.postValue(it) + suggestion.emitValue(it) }.launchIn(viewModelScope + Dispatchers.Default) } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt index cbabc9b45..8acbc7a10 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt @@ -15,9 +15,9 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.databinding.DialogProgressBinding import org.koitharu.kotatsu.utils.ext.getDisplayMessage -import org.koitharu.kotatsu.utils.progress.Progress import java.io.File import java.io.FileOutputStream +import kotlin.math.roundToInt @AndroidEntryPoint class BackupDialogFragment : AlertDialogFragment() { @@ -66,13 +66,13 @@ class BackupDialogFragment : AlertDialogFragment() { dismiss() } - private fun onProgressChanged(progress: Progress?) { + private fun onProgressChanged(value: Float) { with(binding.progressBar) { - isIndeterminate = progress == null isVisible = true - if (progress != null) { - this.max = progress.total - this.progress = progress.value + val wasIndeterminate = isIndeterminate + isIndeterminate = value < 0 + if (value >= 0) { + setProgressCompat((value * max).roundToInt(), !wasIndeterminate) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt index 1f822aa82..ae1cae8a4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt @@ -4,13 +4,12 @@ import android.content.Context import androidx.lifecycle.MutableLiveData import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.File -import javax.inject.Inject import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.core.backup.BackupRepository import org.koitharu.kotatsu.core.backup.BackupZipOutput import org.koitharu.kotatsu.utils.SingleLiveEvent -import org.koitharu.kotatsu.utils.progress.Progress +import java.io.File +import javax.inject.Inject @HiltViewModel class BackupViewModel @Inject constructor( @@ -18,7 +17,7 @@ class BackupViewModel @Inject constructor( @ApplicationContext context: Context, ) : BaseViewModel() { - val progress = MutableLiveData(null) + val progress = MutableLiveData(-1f) val onBackupDone = SingleLiveEvent() init { @@ -26,18 +25,18 @@ class BackupViewModel @Inject constructor( val file = BackupZipOutput(context).use { backup -> backup.put(repository.createIndex()) - progress.value = Progress(0, 3) + progress.value = 0f backup.put(repository.dumpHistory()) - progress.value = Progress(1, 3) + progress.value = 0.3f backup.put(repository.dumpCategories()) - progress.value = Progress(2, 3) + progress.value = 0.6f backup.put(repository.dumpFavourites()) - progress.value = Progress(3, 3) + progress.value = 0.9f backup.finish() - progress.value = null + progress.value = 1f backup.close() backup.file } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt index 84341d278..e1e3dee63 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt @@ -15,7 +15,7 @@ import org.koitharu.kotatsu.core.backup.CompositeResult import org.koitharu.kotatsu.databinding.DialogProgressBinding import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.withArgs -import org.koitharu.kotatsu.utils.progress.Progress +import kotlin.math.roundToInt @AndroidEntryPoint class RestoreDialogFragment : AlertDialogFragment() { @@ -51,13 +51,13 @@ class RestoreDialogFragment : AlertDialogFragment() { dismiss() } - private fun onProgressChanged(progress: Progress?) { + private fun onProgressChanged(value: Float) { with(binding.progressBar) { isVisible = true - isIndeterminate = progress == null - if (progress != null) { - this.max = progress.total - this.progress = progress.value + val wasIndeterminate = isIndeterminate + isIndeterminate = value < 0 + if (value >= 0) { + setProgressCompat((value * max).roundToInt(), !wasIndeterminate) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt index ffb29d8f9..9efc859cf 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt @@ -14,7 +14,6 @@ import org.koitharu.kotatsu.core.backup.BackupZipInput import org.koitharu.kotatsu.core.backup.CompositeResult import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.toUriOrNull -import org.koitharu.kotatsu.utils.progress.Progress import java.io.File import java.io.FileNotFoundException import javax.inject.Inject @@ -26,7 +25,7 @@ class RestoreViewModel @Inject constructor( @ApplicationContext context: Context, ) : BaseViewModel() { - val progress = MutableLiveData(null) + val progress = MutableLiveData(-1f) val onRestoreDone = SingleLiveEvent() init { @@ -47,16 +46,16 @@ class RestoreViewModel @Inject constructor( try { val result = CompositeResult() - progress.value = Progress(0, 3) + progress.value = 0f result += repository.restoreHistory(backup.getEntry(BackupEntry.HISTORY)) - progress.value = Progress(1, 3) + progress.value = 0.3f result += repository.restoreCategories(backup.getEntry(BackupEntry.CATEGORIES)) - progress.value = Progress(2, 3) + progress.value = 0.6f result += repository.restoreFavourites(backup.getEntry(BackupEntry.FAVOURITES)) - progress.value = Progress(3, 3) + progress.value = 1f onRestoreDone.call(result) } finally { backup.close() diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesListViewModel.kt index 22b7a6c79..553988dce 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesListViewModel.kt @@ -82,7 +82,7 @@ class SourcesListViewModel @Inject constructor( val rollback = ReversibleHandle { setEnabled(source, true) } - onActionDone.postCall(ReversibleAction(R.string.source_disabled, rollback)) + onActionDone.emitCall(ReversibleAction(R.string.source_disabled, rollback)) } buildList() } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/tracker/TrackerSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/tracker/TrackerSettingsViewModel.kt index 3c6776347..21c75b086 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/tracker/TrackerSettingsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/tracker/TrackerSettingsViewModel.kt @@ -10,6 +10,7 @@ import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES import org.koitharu.kotatsu.core.db.removeObserverAsync import org.koitharu.kotatsu.tracker.domain.TrackingRepository +import org.koitharu.kotatsu.utils.ext.emitValue import javax.inject.Inject @HiltViewModel @@ -31,7 +32,7 @@ class TrackerSettingsViewModel @Inject constructor( private fun updateCategoriesCount() { launchJob(Dispatchers.Default) { - categoriesCount.postValue(repository.getCategoriesCount()) + categoriesCount.emitValue(repository.getCategoriesCount()) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/domain/ShelfRepository.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/domain/ShelfRepository.kt index 19cdc883c..7ed1c3161 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shelf/domain/ShelfRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shelf/domain/ShelfRepository.kt @@ -5,13 +5,14 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onStart import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toMangaTags @@ -19,6 +20,8 @@ import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.toFavouriteCategory import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.local.data.LocalManga +import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.SortOrder @@ -32,6 +35,7 @@ class ShelfRepository @Inject constructor( private val historyRepository: HistoryRepository, private val trackingRepository: TrackingRepository, private val db: MangaDatabase, + @LocalStorageChanges private val localStorageChanges: SharedFlow, ) { fun observeShelfContent(): Flow = combine( @@ -43,16 +47,15 @@ class ShelfRepository @Inject constructor( ShelfContent(history, favorites, updated, local) } - fun observeLocalManga(sortOrder: SortOrder): Flow> { - return flow { - emit(null) - emitAll(localMangaRepository.watchReadableDirs()) - }.mapLatest { - localMangaRepository.getList(0, null, sortOrder) - } + private fun observeLocalManga(sortOrder: SortOrder): Flow> { + return localStorageChanges + .onStart { emit(null) } + .mapLatest { + localMangaRepository.getList(0, null, sortOrder) + }.distinctUntilChanged() } - fun observeFavourites(): Flow>> { + private fun observeFavourites(): Flow>> { return db.favouriteCategoriesDao.observeAll() .flatMapLatest { categories -> val cats = categories.filter { it.isVisibleInLibrary } diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt index 590352a16..d0ad7dd6b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt @@ -7,7 +7,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.util.ReversibleAction @@ -60,10 +59,9 @@ class ShelfViewModel @Inject constructor( repository.observeShelfContent(), ) { sections, isTrackerEnabled, isConnected, content -> mapList(content, isTrackerEnabled, sections, isConnected) - }.debounce(500) - .catch { e -> - emit(listOf(e.toErrorState(canRetry = false))) - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + }.catch { e -> + emit(listOf(e.toErrorState(canRetry = false))) + }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) init { launchJob(Dispatchers.Default) { @@ -93,7 +91,7 @@ class ShelfViewModel @Inject constructor( } launchJob(Dispatchers.Default) { val handle = favouritesRepository.removeFromCategory(category.id, ids) - onActionDone.postCall(ReversibleAction(R.string.removed_from_favourites, handle)) + onActionDone.emitCall(ReversibleAction(R.string.removed_from_favourites, handle)) } } @@ -103,14 +101,14 @@ class ShelfViewModel @Inject constructor( } launchJob(Dispatchers.Default) { val handle = historyRepository.delete(ids) - onActionDone.postCall(ReversibleAction(R.string.removed_from_history, handle)) + onActionDone.emitCall(ReversibleAction(R.string.removed_from_history, handle)) } } fun deleteLocal(ids: Set) { launchLoadingJob(Dispatchers.Default) { repository.deleteLocalManga(ids) - onActionDone.postCall(ReversibleAction(R.string.removal_completed, null)) + onActionDone.emitCall(ReversibleAction(R.string.removal_completed, null)) } } @@ -123,7 +121,7 @@ class ShelfViewModel @Inject constructor( historyRepository.deleteAfter(minDate) R.string.removed_from_history } - onActionDone.postCall(ReversibleAction(stringRes, null)) + onActionDone.emitCall(ReversibleAction(stringRes, null)) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/adapter/ShelfAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/adapter/ShelfAdapter.kt index aab1daea3..e96a9735f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/adapter/ShelfAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/adapter/ShelfAdapter.kt @@ -44,7 +44,7 @@ class ShelfAdapter( ) .addDelegate(loadingStateAD()) .addDelegate(loadingFooterAD()) - .addDelegate(emptyHintAD(listener)) + .addDelegate(emptyHintAD(coil, lifecycleOwner, listener)) .addDelegate(emptyStateListAD(coil, lifecycleOwner, listener)) .addDelegate(errorStateListAD(listener)) } diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt index 160494545..a5343ade5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt @@ -1,12 +1,12 @@ package org.koitharu.kotatsu.sync.ui import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import kotlinx.coroutines.Dispatchers import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.sync.data.SyncAuthApi import org.koitharu.kotatsu.sync.domain.SyncAuthResult import org.koitharu.kotatsu.utils.SingleLiveEvent +import javax.inject.Inject @HiltViewModel class SyncAuthViewModel @Inject constructor( @@ -19,7 +19,7 @@ class SyncAuthViewModel @Inject constructor( launchLoadingJob(Dispatchers.Default) { val token = api.authenticate(email, password) val result = SyncAuthResult(email, password, token) - onTokenObtained.postCall(result) + onTokenObtained.emitCall(result) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/FeedViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/FeedViewModel.kt index 12dbdf497..2a5c6bcc5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/FeedViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/FeedViewModel.kt @@ -56,7 +56,7 @@ class FeedViewModel @Inject constructor( if (clearCounters) { repository.clearCounters() } - onFeedCleared.postCall(Unit) + onFeedCleared.emitCall(Unit) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt b/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt index 859e7e391..99f69e11a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt @@ -1,45 +1,43 @@ package org.koitharu.kotatsu.utils import androidx.collection.ArrayMap -import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import java.util.LinkedList import kotlin.coroutines.coroutineContext -import kotlin.coroutines.resume class CompositeMutex : Set { - private val data = ArrayMap>>() + private val state = ArrayMap>() private val mutex = Mutex() override val size: Int - get() = data.size + get() = state.size override fun contains(element: T): Boolean { - return data.containsKey(element) + return state.containsKey(element) } override fun containsAll(elements: Collection): Boolean { - return elements.all { x -> data.containsKey(x) } + return elements.all { x -> state.containsKey(x) } } override fun isEmpty(): Boolean { - return data.isEmpty + return state.isEmpty } override fun iterator(): Iterator { - return data.keys.iterator() + return state.keys.iterator() } suspend fun lock(element: T) { while (coroutineContext.isActive) { waitForRemoval(element) mutex.withLock { - if (data[element] == null) { - data[element] = LinkedList>() + if (state[element] == null) { + state[element] = MutableStateFlow(false) return } } @@ -47,23 +45,13 @@ class CompositeMutex : Set { } fun unlock(element: T) { - val continuations = checkNotNull(data.remove(element)) { + checkNotNull(state.remove(element)) { "CompositeMutex is not locked for $element" - } - continuations.forEach { c -> - if (c.isActive) { - c.resume(Unit) - } - } + }.value = true } private suspend fun waitForRemoval(element: T) { - val list = data[element] ?: return - suspendCancellableCoroutine { continuation -> - list.add(continuation) - continuation.invokeOnCancellation { - list.remove(continuation) - } - } + val flow = state[element] ?: return + flow.first { it } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/FlowLiveData.kt b/app/src/main/java/org/koitharu/kotatsu/utils/FlowLiveData.kt index c44ce3795..797893609 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/FlowLiveData.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/FlowLiveData.kt @@ -1,12 +1,18 @@ package org.koitharu.kotatsu.utils import androidx.lifecycle.LiveData -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext private const val DEFAULT_TIMEOUT = 5_000L @@ -51,11 +57,16 @@ class FlowLiveData( private inner class Collector : FlowCollector { private var previousValue: Any? = value + private val dispatcher = Dispatchers.Main.immediate override suspend fun emit(value: T) { if (previousValue != value) { previousValue = value - withContext(Dispatchers.Main.immediate) { + if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) { + withContext(dispatcher) { + setValue(value) + } + } else { setValue(value) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/SingleLiveEvent.kt b/app/src/main/java/org/koitharu/kotatsu/utils/SingleLiveEvent.kt index b8f982f33..cbc89d96b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/SingleLiveEvent.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/SingleLiveEvent.kt @@ -5,7 +5,10 @@ import androidx.annotation.MainThread import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.Observer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.EmptyCoroutineContext class SingleLiveEvent : LiveData() { @@ -33,4 +36,15 @@ class SingleLiveEvent : LiveData() { fun postCall(newValue: T) { postValue(newValue) } -} \ No newline at end of file + + suspend fun emitCall(newValue: T) { + val dispatcher = Dispatchers.Main.immediate + if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) { + withContext(dispatcher) { + setValue(newValue) + } + } else { + setValue(newValue) + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt index 310b3a91d..7f23b9487 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt @@ -2,7 +2,11 @@ package org.koitharu.kotatsu.utils.ext import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.koitharu.kotatsu.utils.BufferedObserver +import kotlin.coroutines.EmptyCoroutineContext fun LiveData.requireValue(): T = checkNotNull(value) { "LiveData value is null" @@ -15,3 +19,14 @@ fun LiveData.observeWithPrevious(owner: LifecycleOwner, observer: Buffere previous = it } } + +suspend fun MutableLiveData.emitValue(newValue: T) { + val dispatcher = Dispatchers.Main.immediate + if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) { + withContext(dispatcher) { + value = newValue + } + } else { + value = newValue + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/OtherExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/OtherExt.kt index 9671f9d60..61066cd5d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/OtherExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/OtherExt.kt @@ -1,11 +1,9 @@ package org.koitharu.kotatsu.utils.ext -import android.icu.lang.UCharacter.GraphemeClusterBreak.T - @Suppress("UNCHECKED_CAST") fun Class.castOrNull(obj: Any?): T? { if (obj == null || !isInstance(obj)) { return null } return obj as T -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/image/CoilImageGetter.kt b/app/src/main/java/org/koitharu/kotatsu/utils/image/CoilImageGetter.kt index b9fad7b91..381d71d9f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/image/CoilImageGetter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/image/CoilImageGetter.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.utils.image import android.content.Context import android.graphics.drawable.Drawable import android.text.Html +import androidx.annotation.WorkerThread import coil.ImageLoader import coil.executeBlocking import coil.request.ImageRequest @@ -14,6 +15,7 @@ class CoilImageGetter @Inject constructor( private val coil: ImageLoader, ) : Html.ImageGetter { + @WorkerThread override fun getDrawable(source: String?): Drawable? { return coil.executeBlocking( ImageRequest.Builder(context) diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt deleted file mode 100644 index 8956c4021..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.koitharu.kotatsu.utils.progress - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Deprecated("Should be replaced with Float") -@Parcelize -data class Progress( - val value: Int, - val total: Int, -) : Parcelable, Comparable { - - override fun compareTo(other: Progress): Int { - return if (this.total == other.total) { - this.value.compareTo(other.value) - } else { - this.part().compareTo(other.part()) - } - } - - val isIndeterminate: Boolean - get() = total <= 0 - - private fun part() = if (isIndeterminate) -1.0 else value / total.toDouble() -} diff --git a/app/src/main/res/layout/dialog_progress.xml b/app/src/main/res/layout/dialog_progress.xml index a3f627ad0..6c7a791ef 100644 --- a/app/src/main/res/layout/dialog_progress.xml +++ b/app/src/main/res/layout/dialog_progress.xml @@ -20,7 +20,9 @@ android:id="@+id/progressBar" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="6dp" /> + android:layout_marginTop="6dp" + android:indeterminate="true" + android:max="100" />