From c60cfebd0724ac403d5beb7be44a0b7136a2e746 Mon Sep 17 00:00:00 2001 From: Syer10 Date: Tue, 8 Nov 2022 00:42:36 -0500 Subject: [PATCH] Implement library updater --- android/src/main/AndroidManifest.xml | 3 + .../ca/gosyer/jui/android/MainActivity.kt | 3 + .../data/download/AndroidDownloadService.kt | 25 +- .../data/library/AndroidLibraryService.kt | 230 ++++++++++++++++++ .../data/notification/Notifications.kt | 47 +++- .../main/kotlin/ca/gosyer/jui/desktop/main.kt | 2 +- .../library/service/LibraryUpdateService.kt | 11 +- .../resources/MR/values/base/strings.xml | 11 +- .../AndroidLibraryUpdatesService.kt | 27 ++ .../ca/gosyer/jui/ui/ViewModelComponent.kt | 2 + .../ca/gosyer/jui/ui/library/LibraryScreen.kt | 6 +- .../components/LibraryScreenContent.kt | 49 +++- .../ca/gosyer/jui/ui/main/TopLevelMenus.kt | 3 +- .../components/LibraryUpdatesExtraInfo.kt | 82 +++++++ .../main/components/LibraryUpdatesService.kt | 17 ++ .../components/LibraryUpdatesViewModel.kt | 47 ++++ .../DesktopLibraryUpdatesService.kt | 19 ++ 17 files changed, 556 insertions(+), 28 deletions(-) create mode 100644 android/src/main/kotlin/ca/gosyer/jui/android/data/library/AndroidLibraryService.kt create mode 100644 presentation/src/androidMain/kotlin/ca/gosyer/jui/ui/main/components/AndroidLibraryUpdatesService.kt create mode 100644 presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/main/components/LibraryUpdatesExtraInfo.kt create mode 100644 presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/main/components/LibraryUpdatesService.kt create mode 100644 presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/main/components/LibraryUpdatesViewModel.kt create mode 100644 presentation/src/desktopMain/kotlin/ca/gosyer/jui/ui/main/components/DesktopLibraryUpdatesService.kt diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 4cb7fe89..78cacbfa 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -37,5 +37,8 @@ + \ No newline at end of file diff --git a/android/src/main/kotlin/ca/gosyer/jui/android/MainActivity.kt b/android/src/main/kotlin/ca/gosyer/jui/android/MainActivity.kt index ef60b04c..1913f81c 100644 --- a/android/src/main/kotlin/ca/gosyer/jui/android/MainActivity.kt +++ b/android/src/main/kotlin/ca/gosyer/jui/android/MainActivity.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.graphics.Color import androidx.core.view.WindowCompat import ca.gosyer.jui.android.data.download.AndroidDownloadService +import ca.gosyer.jui.android.data.library.AndroidLibraryService import ca.gosyer.jui.domain.base.WebsocketService.Actions import ca.gosyer.jui.ui.base.theme.AppTheme import ca.gosyer.jui.ui.main.MainMenu @@ -33,6 +34,7 @@ class MainActivity : AppCompatActivity() { } AndroidDownloadService.start(this, Actions.START) + AndroidLibraryService.start(this, Actions.START) WindowCompat.setDecorFitsSystemWindows(window, false) @@ -71,5 +73,6 @@ class MainActivity : AppCompatActivity() { override fun onDestroy() { super.onDestroy() AndroidDownloadService.stop(this) + AndroidLibraryService.stop(this) } } diff --git a/android/src/main/kotlin/ca/gosyer/jui/android/data/download/AndroidDownloadService.kt b/android/src/main/kotlin/ca/gosyer/jui/android/data/download/AndroidDownloadService.kt index be0710a9..dbf2af58 100644 --- a/android/src/main/kotlin/ca/gosyer/jui/android/data/download/AndroidDownloadService.kt +++ b/android/src/main/kotlin/ca/gosyer/jui/android/data/download/AndroidDownloadService.kt @@ -40,8 +40,11 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.job @@ -72,6 +75,10 @@ class AndroidDownloadService : Service() { return instance != null } + private val json = Json { + ignoreUnknownKeys = true + } + private val log = logging() } @@ -84,14 +91,14 @@ class AndroidDownloadService : Service() { override fun onCreate() { super.onCreate() ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - startForeground(Notifications.ID_DOWNLOAD_CHAPTER, placeholderNotification) + startForeground(Notifications.ID_DOWNLOADER_RUNNING, placeholderNotification) status.value = Status.STARTING } override fun onDestroy() { ioScope.cancel() status.value = Status.STOPPED - notificationManager.cancel(Notifications.ID_DOWNLOAD_CHAPTER) + notificationManager.cancel(Notifications.ID_DOWNLOADER_RUNNING) if (instance == this) { instance = null } @@ -153,6 +160,9 @@ class AndroidDownloadService : Service() { incoming.receiveAsFlow() .filterIsInstance() + .map { json.decodeFromString(it.readText()) } + .distinctUntilChanged() + .drop(1) .mapLatest(::onReceived) .catch { log.warn(it) { "Error running downloader" } @@ -173,8 +183,7 @@ class AndroidDownloadService : Service() { .launchIn(ioScope) } - private fun onReceived(frame: Frame.Text) { - val status = Json.decodeFromString(frame.readText()) + private fun onReceived(status: DownloadStatus) { DownloadService.downloaderStatus.value = status.status DownloadService.downloadQueue.value = status.queue val downloadingChapter = status.queue.lastOrNull { it.state == DownloadState.Downloading } @@ -199,22 +208,22 @@ class AndroidDownloadService : Service() { ) }.build() notificationManager.notify( - Notifications.ID_DOWNLOAD_CHAPTER, + Notifications.ID_DOWNLOADER_DOWNLOADING, notification ) } else { - notificationManager.notify(Notifications.ID_DOWNLOAD_CHAPTER, placeholderNotification) + notificationManager.cancel(Notifications.ID_DOWNLOADER_DOWNLOADING) } } private val placeholderNotification by lazy { - notification(Notifications.CHANNEL_DOWNLOADER) { + notification(Notifications.CHANNEL_DOWNLOADER_RUNNING) { setContentTitle(MR.strings.downloader_running.desc().toString(this@AndroidDownloadService)) setSmallIcon(R.drawable.ic_round_get_app_24) } } private val progressNotificationBuilder by lazy { - notificationBuilder(Notifications.CHANNEL_DOWNLOADER) { + notificationBuilder(Notifications.CHANNEL_DOWNLOADER_DOWNLOADING) { setSmallIcon(R.drawable.ic_round_get_app_24) setAutoCancel(false) setOnlyAlertOnce(true) diff --git a/android/src/main/kotlin/ca/gosyer/jui/android/data/library/AndroidLibraryService.kt b/android/src/main/kotlin/ca/gosyer/jui/android/data/library/AndroidLibraryService.kt new file mode 100644 index 00000000..87fdf1de --- /dev/null +++ b/android/src/main/kotlin/ca/gosyer/jui/android/data/library/AndroidLibraryService.kt @@ -0,0 +1,230 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.jui.android.data.library + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import ca.gosyer.jui.android.AppComponent +import ca.gosyer.jui.android.R +import ca.gosyer.jui.android.data.notification.Notifications +import ca.gosyer.jui.android.util.notification +import ca.gosyer.jui.android.util.notificationBuilder +import ca.gosyer.jui.android.util.notificationManager +import ca.gosyer.jui.core.lang.chop +import ca.gosyer.jui.core.lang.throwIfCancellation +import ca.gosyer.jui.core.prefs.getAsFlow +import ca.gosyer.jui.domain.base.WebsocketService.Actions +import ca.gosyer.jui.domain.base.WebsocketService.Status +import ca.gosyer.jui.domain.library.model.JobStatus +import ca.gosyer.jui.domain.library.model.UpdateStatus +import ca.gosyer.jui.domain.library.service.LibraryUpdateService +import ca.gosyer.jui.domain.library.service.LibraryUpdateService.Companion.status +import ca.gosyer.jui.i18n.MR +import dev.icerock.moko.resources.desc.desc +import dev.icerock.moko.resources.format +import io.ktor.client.plugins.websocket.ws +import io.ktor.websocket.Frame +import io.ktor.websocket.readText +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.job +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import org.lighthousegames.logging.logging + +class AndroidLibraryService : Service() { + + companion object { + private var instance: AndroidLibraryService? = null + + fun start(context: Context, actions: Actions) { + if (!isRunning() && actions != Actions.STOP) { + val intent = Intent(context, AndroidLibraryService::class.java).apply { + action = actions.name + } + ContextCompat.startForegroundService(context, intent) + } + } + + fun stop(context: Context) { + context.stopService(Intent(context, AndroidLibraryService::class.java)) + } + + fun isRunning(): Boolean { + return instance != null + } + + private val json = Json { + ignoreUnknownKeys = true + } + + private val log = logging() + } + + private lateinit var ioScope: CoroutineScope + + override fun onBind(intent: Intent): IBinder? { + return null + } + + override fun onCreate() { + super.onCreate() + ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + startForeground(Notifications.ID_LIBRARY_UPDATES, placeholderNotification) + status.value = Status.STARTING + } + + override fun onDestroy() { + ioScope.cancel() + status.value = Status.STOPPED + notificationManager.cancel(Notifications.ID_LIBRARY_UPDATES) + if (instance == this) { + instance = null + } + super.onDestroy() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + instance = this + ioScope.coroutineContext.job.cancelChildren() + + if (intent != null) { + val action = intent.action + log.info { "using an intent with action $action" } + when (action) { + Actions.START.name, + Actions.RESTART.name -> startWebsocket() + Actions.STOP.name -> stopSelf() + else -> log.info { "This should never happen. No action in the received intent" } + } + } else { + log.info { "with a null intent. It has been probably restarted by the system." } + startWebsocket() + } + + return START_STICKY + } + + private fun startWebsocket() { + ioScope.coroutineContext.job.cancelChildren() + ioScope.coroutineContext.job.invokeOnCompletion { + stopSelf() + } + + val appComponent = AppComponent.getInstance(applicationContext) + val client = appComponent.http + + var errorConnectionCount = 0 + + appComponent + .serverPreferences + .serverUrl() + .getAsFlow() + .mapLatest { serverUrl -> + status.value = Status.STARTING + while (true) { + if (errorConnectionCount > 3) { + status.value = Status.STOPPED + throw CancellationException() + } + runCatching { + client.ws( + host = serverUrl.host, + port = serverUrl.port, + path = serverUrl.encodedPath + "/api/v1/update" + ) { + errorConnectionCount = 0 + status.value = Status.RUNNING + send(Frame.Text("STATUS")) + + incoming.receiveAsFlow() + .filterIsInstance() + .map { json.decodeFromString(it.readText()) } + .distinctUntilChanged() + .drop(1) + .mapLatest(::onReceived) + .catch { + log.warn(it) { "Error running library update" } + } + .collect() + } + }.throwIfCancellation().isFailure.let { + status.value = Status.STARTING + if (it) errorConnectionCount++ + } + } + } + .catch { + status.value = Status.STOPPED + log.warn(it) { "Error while running websocket service" } + stopSelf() + } + .launchIn(ioScope) + } + + private fun onReceived(status: UpdateStatus) { + LibraryUpdateService.updateStatus.value = status + + val complete = status.statusMap[JobStatus.COMPLETE]?.size ?: 0 + val failed = status.statusMap[JobStatus.FAILED]?.size ?: 0 + val running = status.statusMap[JobStatus.RUNNING]?.size ?: 0 + val pending = status.statusMap[JobStatus.PENDING]?.size ?: 0 + val total = complete + failed + running + pending + val current = complete + failed + if (current != total) { + val notification = with(progressNotificationBuilder) { + val updatingText = status.statusMap[JobStatus.RUNNING] + ?.joinToString("\n") { it.title.chop(40) } + setContentTitle( + MR.strings.notification_updating + .format(current, total) + .toString(this@AndroidLibraryService) + ) + setStyle(NotificationCompat.BigTextStyle().bigText(updatingText)) + setProgress(total, current, false) + }.build() + notificationManager.notify( + Notifications.ID_LIBRARY_PROGRESS, + notification + ) + } else { + notificationManager.cancel(Notifications.ID_LIBRARY_PROGRESS) + } + } + + private val placeholderNotification by lazy { + notification(Notifications.CHANNEL_LIBRARY_UPDATES) { + setContentTitle(MR.strings.library_updater_running.desc().toString(this@AndroidLibraryService)) + setSmallIcon(R.drawable.ic_round_get_app_24) + } + } + private val progressNotificationBuilder by lazy { + notificationBuilder(Notifications.CHANNEL_LIBRARY_PROGRESS) { + setSmallIcon(R.drawable.ic_round_get_app_24) + setAutoCancel(false) + setOnlyAlertOnce(true) + setOngoing(true) + } + } +} diff --git a/android/src/main/kotlin/ca/gosyer/jui/android/data/notification/Notifications.kt b/android/src/main/kotlin/ca/gosyer/jui/android/data/notification/Notifications.kt index 4ee750e6..c1624436 100644 --- a/android/src/main/kotlin/ca/gosyer/jui/android/data/notification/Notifications.kt +++ b/android/src/main/kotlin/ca/gosyer/jui/android/data/notification/Notifications.kt @@ -19,8 +19,20 @@ object Notifications { * Notification channel and ids used by the downloader. */ private const val GROUP_DOWNLOADER = "group_downloader" - const val CHANNEL_DOWNLOADER = "downloader_channel" - const val ID_DOWNLOAD_CHAPTER = -101 + const val CHANNEL_DOWNLOADER_RUNNING = "downloader_channel" + const val ID_DOWNLOADER_RUNNING = -101 + const val CHANNEL_DOWNLOADER_DOWNLOADING = "downloader_channel_downloading" + const val ID_DOWNLOADER_DOWNLOADING = -102 + + /** + * Notification channel and ids used by the library updates. + */ + private const val GROUP_LIBRARY = "group_library" + const val CHANNEL_LIBRARY_UPDATES = "library_updates_channel" + const val ID_LIBRARY_UPDATES = -301 + const val CHANNEL_LIBRARY_PROGRESS = "library_progress_channel" + const val ID_LIBRARY_PROGRESS = -302 + /** * Notification channel and ids used for app updates. @@ -39,6 +51,9 @@ object Notifications { buildNotificationChannelGroup(GROUP_DOWNLOADER) { setName(MR.strings.group_downloader.desc().toString(context)) }, + buildNotificationChannelGroup(GROUP_LIBRARY) { + setName(MR.strings.group_library.desc().toString(context)) + }, buildNotificationChannelGroup(GROUP_APK_UPDATES) { setName(MR.strings.group_updates.desc().toString(context)) } @@ -48,13 +63,37 @@ object Notifications { notificationService.createNotificationChannelsCompat( listOf( buildNotificationChannel( - CHANNEL_DOWNLOADER, + CHANNEL_DOWNLOADER_RUNNING, NotificationManagerCompat.IMPORTANCE_LOW ) { - setName(MR.strings.group_downloader_channel.desc().toString(context)) + setName(MR.strings.channel_active.desc().toString(context)) setGroup(GROUP_DOWNLOADER) setShowBadge(false) }, + buildNotificationChannel( + CHANNEL_DOWNLOADER_DOWNLOADING, + NotificationManagerCompat.IMPORTANCE_DEFAULT + ) { + setName(MR.strings.channel_progress.desc().toString(context)) + setGroup(GROUP_DOWNLOADER) + setShowBadge(false) + }, + buildNotificationChannel( + CHANNEL_LIBRARY_UPDATES, + NotificationManagerCompat.IMPORTANCE_DEFAULT + ) { + setName(MR.strings.channel_active.desc().toString(context)) + setGroup(GROUP_LIBRARY) + setShowBadge(false) + }, + buildNotificationChannel( + CHANNEL_LIBRARY_PROGRESS, + NotificationManagerCompat.IMPORTANCE_LOW + ) { + setName(MR.strings.channel_progress.desc().toString(context)) + setGroup(GROUP_LIBRARY) + setShowBadge(false) + }, buildNotificationChannel( CHANNEL_APP_UPDATE, NotificationManagerCompat.IMPORTANCE_DEFAULT diff --git a/desktop/src/main/kotlin/ca/gosyer/jui/desktop/main.kt b/desktop/src/main/kotlin/ca/gosyer/jui/desktop/main.kt index 5326a29f..99858991 100644 --- a/desktop/src/main/kotlin/ca/gosyer/jui/desktop/main.kt +++ b/desktop/src/main/kotlin/ca/gosyer/jui/desktop/main.kt @@ -100,7 +100,7 @@ suspend fun main() { .filter { it == ServerResult.STARTED || it == ServerResult.UNUSED } .onEach { appComponent.downloadService.init() - // dataComponent.libraryUpdateService.init() + appComponent.libraryUpdateService.init() } .launchIn(GlobalScope) diff --git a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/library/service/LibraryUpdateService.kt b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/library/service/LibraryUpdateService.kt index 9641d55b..c392a978 100644 --- a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/library/service/LibraryUpdateService.kt +++ b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/library/service/LibraryUpdateService.kt @@ -22,17 +22,20 @@ class LibraryUpdateService @Inject constructor( client: Http ) : WebsocketService(serverPreferences, client) { - override val _status: MutableStateFlow = MutableStateFlow(Status.STARTING) + override val _status: MutableStateFlow + get() = status override val query: String get() = "/api/v1/update" override suspend fun onReceived(frame: Frame.Text) { - val status = json.decodeFromString(frame.readText()) - log.info { status } + updateStatus.value = json.decodeFromString(frame.readText()) } - private companion object { + companion object { private val log = logging() + + val status = MutableStateFlow(Status.STARTING) + val updateStatus = MutableStateFlow(UpdateStatus(emptyMap(), false)) } } diff --git a/i18n/src/commonMain/resources/MR/values/base/strings.xml b/i18n/src/commonMain/resources/MR/values/base/strings.xml index e69332cc..723f9c53 100644 --- a/i18n/src/commonMain/resources/MR/values/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/values/base/strings.xml @@ -55,6 +55,8 @@ Bookmarked Select all Select inverse + Update library + Restart library service Library @@ -318,10 +320,15 @@ Downloader - Progress + Library + Progress + Progress + Active Updates App updates Extension Updates Downloader running - Downloading (%1$d/%2$d) + Downloading… (%1$d/%2$d) + Library updater running + Updating library… (%1$d/%2$d) diff --git a/presentation/src/androidMain/kotlin/ca/gosyer/jui/ui/main/components/AndroidLibraryUpdatesService.kt b/presentation/src/androidMain/kotlin/ca/gosyer/jui/ui/main/components/AndroidLibraryUpdatesService.kt new file mode 100644 index 00000000..e1d5501b --- /dev/null +++ b/presentation/src/androidMain/kotlin/ca/gosyer/jui/ui/main/components/AndroidLibraryUpdatesService.kt @@ -0,0 +1,27 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.jui.ui.main.components + +import android.content.Intent +import androidx.core.content.ContextCompat +import ca.gosyer.jui.domain.base.WebsocketService +import ca.gosyer.jui.domain.library.service.LibraryUpdateService +import ca.gosyer.jui.uicore.vm.ContextWrapper + +internal actual fun startLibraryUpdatesService( + contextWrapper: ContextWrapper, + libraryUpdatesService: LibraryUpdateService, + actions: WebsocketService.Actions +) { + val intent = Intent( + contextWrapper, + Class.forName("ca.gosyer.jui.android.data.library.AndroidLibraryService") + ).apply { + action = actions.name + } + ContextCompat.startForegroundService(contextWrapper, intent) +} diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/ViewModelComponent.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/ViewModelComponent.kt index 96a593e1..77023cd5 100644 --- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/ViewModelComponent.kt +++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/ViewModelComponent.kt @@ -19,6 +19,7 @@ import ca.gosyer.jui.ui.library.settings.LibrarySettingsViewModel import ca.gosyer.jui.ui.main.MainViewModel import ca.gosyer.jui.ui.main.about.AboutViewModel import ca.gosyer.jui.ui.main.components.DebugOverlayViewModel +import ca.gosyer.jui.ui.main.components.LibraryUpdatesViewModel import ca.gosyer.jui.ui.manga.MangaScreenViewModel import ca.gosyer.jui.ui.reader.ReaderMenuViewModel import ca.gosyer.jui.ui.settings.SettingsAdvancedViewModel @@ -47,6 +48,7 @@ interface SharedViewModelComponent { val extensionsViewModel: () -> ExtensionsScreenViewModel val libraryViewModel: (SavedStateHandle) -> LibraryScreenViewModel val librarySettingsViewModel: () -> LibrarySettingsViewModel + val libraryUpdatesViewModel: (Boolean) -> LibraryUpdatesViewModel val debugOverlayViewModel: () -> DebugOverlayViewModel val mainViewModel: () -> MainViewModel val mangaViewModel: (params: MangaScreenViewModel.Params) -> MangaScreenViewModel diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/library/LibraryScreen.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/library/LibraryScreen.kt index 5b08c702..d756a922 100644 --- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/library/LibraryScreen.kt +++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/library/LibraryScreen.kt @@ -25,6 +25,7 @@ class LibraryScreen : BaseScreen() { override fun Content() { val vm = stateViewModel { libraryViewModel(it) } val settingsVM = viewModel { librarySettingsViewModel() } + val updatesVM = viewModel { libraryUpdatesViewModel(false) } val navigator = LocalNavigator.currentOrThrow LibraryScreenContent( categories = vm.categories.collectAsState().value, @@ -40,6 +41,7 @@ class LibraryScreen : BaseScreen() { onPageChanged = vm::setSelectedPage, onClickManga = { navigator push MangaScreen(it) }, onRemoveMangaClicked = vm::removeManga, + onUpdateLibrary = vm::updateLibrary, showingMenu = vm.showingMenu.collectAsState().value, setShowingMenu = vm::setShowingMenu, libraryFilters = getLibraryFilters(settingsVM), @@ -48,7 +50,9 @@ class LibraryScreen : BaseScreen() { showUnread = vm.unreadBadges.collectAsState().value, showDownloaded = vm.downloadBadges.collectAsState().value, showLanguage = vm.languageBadges.collectAsState().value, - showLocal = vm.localBadges.collectAsState().value + showLocal = vm.localBadges.collectAsState().value, + updateWebsocketStatus = updatesVM.serviceStatus.collectAsState().value, + restartLibraryUpdates = updatesVM::restartLibraryUpdates ) } } diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/library/components/LibraryScreenContent.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/library/components/LibraryScreenContent.kt index 0c58a9e8..e4b98bb9 100644 --- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/library/components/LibraryScreenContent.kt +++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/library/components/LibraryScreenContent.kt @@ -28,6 +28,7 @@ import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.Scaffold import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.FilterList +import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -37,11 +38,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp +import ca.gosyer.jui.domain.base.WebsocketService import ca.gosyer.jui.domain.category.model.Category import ca.gosyer.jui.domain.library.model.DisplayMode import ca.gosyer.jui.i18n.MR import ca.gosyer.jui.ui.base.navigation.ActionItem import ca.gosyer.jui.ui.base.navigation.BackHandler +import ca.gosyer.jui.ui.base.navigation.OverflowMode import ca.gosyer.jui.ui.base.navigation.Toolbar import ca.gosyer.jui.ui.library.CategoryState import ca.gosyer.jui.ui.library.settings.LibrarySheet @@ -70,6 +73,7 @@ fun LibraryScreenContent( onPageChanged: (Int) -> Unit, onClickManga: (Long) -> Unit, onRemoveMangaClicked: (Long) -> Unit, + onUpdateLibrary: () -> Unit, showingMenu: Boolean, setShowingMenu: (Boolean) -> Unit, libraryFilters: @Composable () -> Unit, @@ -78,7 +82,9 @@ fun LibraryScreenContent( showUnread: Boolean, showDownloaded: Boolean, showLanguage: Boolean, - showLocal: Boolean + showLocal: Boolean, + updateWebsocketStatus: WebsocketService.Status, + restartLibraryUpdates: () -> Unit ) { BackHandler(showingMenu) { setShowingMenu(false) @@ -112,6 +118,7 @@ fun LibraryScreenContent( onPageChanged = onPageChanged, onClickManga = onClickManga, onRemoveMangaClicked = onRemoveMangaClicked, + onUpdateLibrary = onUpdateLibrary, showingMenu = showingMenu, setShowingMenu = setShowingMenu, libraryFilters = libraryFilters, @@ -138,6 +145,7 @@ fun LibraryScreenContent( onPageChanged = onPageChanged, onClickManga = onClickManga, onRemoveMangaClicked = onRemoveMangaClicked, + onUpdateLibrary = onUpdateLibrary, showingSheet = showingMenu, setShowingSheet = setShowingMenu, libraryFilters = libraryFilters, @@ -146,7 +154,9 @@ fun LibraryScreenContent( showUnread = showUnread, showDownloaded = showDownloaded, showLanguage = showLanguage, - showLocal = showLocal + showLocal = showLocal, + updateWebsocketStatus = updateWebsocketStatus, + restartLibraryUpdates = restartLibraryUpdates ) } } @@ -168,6 +178,7 @@ fun WideLibraryScreenContent( onPageChanged: (Int) -> Unit, onClickManga: (Long) -> Unit, onRemoveMangaClicked: (Long) -> Unit, + onUpdateLibrary: () -> Unit, showingMenu: Boolean, setShowingMenu: (Boolean) -> Unit, libraryFilters: @Composable () -> Unit, @@ -192,7 +203,8 @@ fun WideLibraryScreenContent( search = updateQuery, actions = { getActionItems( - onToggleFiltersClick = { setShowingMenu(true) } + onToggleFiltersClick = { setShowingMenu(true) }, + onUpdateLibrary = onUpdateLibrary, ) } ) @@ -269,6 +281,7 @@ fun ThinLibraryScreenContent( onPageChanged: (Int) -> Unit, onClickManga: (Long) -> Unit, onRemoveMangaClicked: (Long) -> Unit, + onUpdateLibrary: () -> Unit, showingSheet: Boolean, setShowingSheet: (Boolean) -> Unit, libraryFilters: @Composable () -> Unit, @@ -277,7 +290,9 @@ fun ThinLibraryScreenContent( showUnread: Boolean, showDownloaded: Boolean, showLanguage: Boolean, - showLocal: Boolean + showLocal: Boolean, + updateWebsocketStatus: WebsocketService.Status, + restartLibraryUpdates: () -> Unit ) { val bottomSheetState = rememberModalBottomSheetState( ModalBottomSheetValue.Hidden, @@ -311,7 +326,10 @@ fun ThinLibraryScreenContent( search = updateQuery, actions = { getActionItems( - onToggleFiltersClick = { setShowingSheet(true) } + onToggleFiltersClick = { setShowingSheet(true) }, + onUpdateLibrary = onUpdateLibrary, + updateWebsocketStatus = updateWebsocketStatus, + restartLibraryUpdates = restartLibraryUpdates ) } ) @@ -361,13 +379,30 @@ fun ThinLibraryScreenContent( @Composable @Stable private fun getActionItems( - onToggleFiltersClick: () -> Unit + onToggleFiltersClick: () -> Unit, + onUpdateLibrary: () -> Unit, + updateWebsocketStatus: WebsocketService.Status? = null, + restartLibraryUpdates: (() -> Unit)? = null ): ImmutableList { return listOfNotNull( ActionItem( name = stringResource(MR.strings.action_filter), icon = Icons.Rounded.FilterList, doAction = onToggleFiltersClick - ) + ), + ActionItem( + name = stringResource(MR.strings.action_update_library), + icon = Icons.Rounded.Refresh, + doAction = onUpdateLibrary + ), + if (updateWebsocketStatus == WebsocketService.Status.STOPPED && restartLibraryUpdates != null) { + ActionItem( + name = stringResource(MR.strings.action_restart_library), + overflowMode = OverflowMode.ALWAYS_OVERFLOW, + doAction = restartLibraryUpdates + ) + } else { + null + } ).toImmutableList() } diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/main/TopLevelMenus.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/main/TopLevelMenus.kt index 62612069..66eb67e6 100644 --- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/main/TopLevelMenus.kt +++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/main/TopLevelMenus.kt @@ -31,6 +31,7 @@ import ca.gosyer.jui.ui.extensions.ExtensionsScreen import ca.gosyer.jui.ui.library.LibraryScreen import ca.gosyer.jui.ui.main.about.AboutScreen import ca.gosyer.jui.ui.main.components.DownloadsExtraInfo +import ca.gosyer.jui.ui.main.components.LibraryUpdatesExtraInfo import ca.gosyer.jui.ui.main.more.MoreScreen import ca.gosyer.jui.ui.settings.SettingsScreen import ca.gosyer.jui.ui.sources.SourcesScreen @@ -59,7 +60,7 @@ enum class TopLevelMenus( override val createScreen: () -> Screen, override val extraInfo: (@Composable () -> Unit)? = null ) : Menu { - Library(MR.strings.location_library, Icons.Outlined.Book, Icons.Rounded.Book, LibraryScreen::class, { LibraryScreen() }), + Library(MR.strings.location_library, Icons.Outlined.Book, Icons.Rounded.Book, LibraryScreen::class, { LibraryScreen() }, extraInfo = { LibraryUpdatesExtraInfo() }), Updates(MR.strings.location_updates, Icons.Outlined.NewReleases, Icons.Rounded.NewReleases, UpdatesScreen::class, { UpdatesScreen() }), Sources(MR.strings.location_sources, Icons.Outlined.Explore, Icons.Rounded.Explore, SourcesScreen::class, { SourcesScreen() }), Extensions(MR.strings.location_extensions, Icons.Outlined.Store, Icons.Rounded.Store, ExtensionsScreen::class, { ExtensionsScreen() }), diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/main/components/LibraryUpdatesExtraInfo.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/main/components/LibraryUpdatesExtraInfo.kt new file mode 100644 index 00000000..f2b609b0 --- /dev/null +++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/main/components/LibraryUpdatesExtraInfo.kt @@ -0,0 +1,82 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.jui.ui.main.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.ContentAlpha +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import ca.gosyer.jui.domain.base.WebsocketService +import ca.gosyer.jui.domain.library.model.JobStatus +import ca.gosyer.jui.i18n.MR +import ca.gosyer.jui.ui.base.LocalViewModels +import ca.gosyer.jui.uicore.resources.stringResource + +@Composable +fun LibraryUpdatesExtraInfo() { + val viewModels = LocalViewModels.current + val vm = remember { viewModels.libraryUpdatesViewModel(true) } + DisposableEffect(vm) { + onDispose(vm::onDispose) + } + val serviceStatus by vm.serviceStatus.collectAsState() + val updateStatus by vm.updateStatus.collectAsState() + + fun Map>.getSize(jobStatus: JobStatus): Int = get(jobStatus)?.size ?: 0 + val current = remember(updateStatus) { + updateStatus.statusMap.run { + getSize(JobStatus.COMPLETE) + getSize(JobStatus.FAILED) + } + } + val total = remember(updateStatus) { + updateStatus.statusMap.run { + getSize(JobStatus.COMPLETE) + getSize(JobStatus.FAILED) + getSize(JobStatus.PENDING) + getSize(JobStatus.RUNNING) + } + } + + val text = when (serviceStatus) { + WebsocketService.Status.STARTING -> stringResource(MR.strings.downloads_loading) + WebsocketService.Status.RUNNING -> { + if (updateStatus.running) { + stringResource(MR.strings.notification_updating, current, total) + } else { + null + } + } + WebsocketService.Status.STOPPED -> null + } + if (!text.isNullOrBlank()) { + Text( + text, + style = MaterialTheme.typography.body2, + color = LocalContentColor.current.copy(alpha = ContentAlpha.disabled) + ) + } else if (serviceStatus == WebsocketService.Status.STOPPED) { + Box( + Modifier.fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = vm::restartLibraryUpdates) + ) { + Text( + stringResource(MR.strings.downloads_stopped), + style = MaterialTheme.typography.body2, + color = Color.Red.copy(alpha = ContentAlpha.disabled) + ) + } + } +} diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/main/components/LibraryUpdatesService.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/main/components/LibraryUpdatesService.kt new file mode 100644 index 00000000..03455e31 --- /dev/null +++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/main/components/LibraryUpdatesService.kt @@ -0,0 +1,17 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.jui.ui.main.components + +import ca.gosyer.jui.domain.base.WebsocketService +import ca.gosyer.jui.domain.library.service.LibraryUpdateService +import ca.gosyer.jui.uicore.vm.ContextWrapper + +internal expect fun startLibraryUpdatesService( + contextWrapper: ContextWrapper, + libraryUpdatesService: LibraryUpdateService, + actions: WebsocketService.Actions +) diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/main/components/LibraryUpdatesViewModel.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/main/components/LibraryUpdatesViewModel.kt new file mode 100644 index 00000000..2f45e2ed --- /dev/null +++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/main/components/LibraryUpdatesViewModel.kt @@ -0,0 +1,47 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.jui.ui.main.components + +import ca.gosyer.jui.domain.base.WebsocketService.Actions +import ca.gosyer.jui.domain.library.service.LibraryUpdateService +import ca.gosyer.jui.uicore.vm.ContextWrapper +import ca.gosyer.jui.uicore.vm.ViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.asStateFlow +import me.tatarka.inject.annotations.Inject +import org.lighthousegames.logging.logging + +class LibraryUpdatesViewModel @Inject constructor( + private val libraryUpdateService: LibraryUpdateService, + private val contextWrapper: ContextWrapper, + standalone: Boolean +) : ViewModel(contextWrapper) { + private val uiScope = if (standalone) { + MainScope() + } else { + null + } + + override val scope: CoroutineScope + get() = uiScope ?: super.scope + + val serviceStatus = LibraryUpdateService.status.asStateFlow() + val updateStatus = LibraryUpdateService.updateStatus.asStateFlow() + + fun restartLibraryUpdates() = startLibraryUpdatesService(contextWrapper, libraryUpdateService, Actions.RESTART) + + override fun onDispose() { + super.onDispose() + uiScope?.cancel() + } + + private companion object { + private val log = logging() + } +} diff --git a/presentation/src/desktopMain/kotlin/ca/gosyer/jui/ui/main/components/DesktopLibraryUpdatesService.kt b/presentation/src/desktopMain/kotlin/ca/gosyer/jui/ui/main/components/DesktopLibraryUpdatesService.kt new file mode 100644 index 00000000..bdf7b6bb --- /dev/null +++ b/presentation/src/desktopMain/kotlin/ca/gosyer/jui/ui/main/components/DesktopLibraryUpdatesService.kt @@ -0,0 +1,19 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.jui.ui.main.components + +import ca.gosyer.jui.domain.base.WebsocketService +import ca.gosyer.jui.domain.library.service.LibraryUpdateService +import ca.gosyer.jui.uicore.vm.ContextWrapper + +internal actual fun startLibraryUpdatesService( + contextWrapper: ContextWrapper, + libraryUpdatesService: LibraryUpdateService, + actions: WebsocketService.Actions +) { + libraryUpdatesService.init() +}