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()
+}