mirror of
https://github.com/Suwayomi/TachideskJUI.git
synced 2025-12-10 23:02:04 +01:00
Implement library updater
This commit is contained in:
@@ -37,5 +37,8 @@
|
||||
<service
|
||||
android:name=".data.download.AndroidDownloadService"
|
||||
android:exported="false" />
|
||||
<service
|
||||
android:name=".data.library.AndroidLibraryService"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Frame.Text>()
|
||||
.map { json.decodeFromString<DownloadStatus>(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<DownloadStatus>(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)
|
||||
|
||||
@@ -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<Frame.Text>()
|
||||
.map { json.decodeFromString<UpdateStatus>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -22,17 +22,20 @@ class LibraryUpdateService @Inject constructor(
|
||||
client: Http
|
||||
) : WebsocketService(serverPreferences, client) {
|
||||
|
||||
override val _status: MutableStateFlow<Status> = MutableStateFlow(Status.STARTING)
|
||||
override val _status: MutableStateFlow<Status>
|
||||
get() = status
|
||||
|
||||
override val query: String
|
||||
get() = "/api/v1/update"
|
||||
|
||||
override suspend fun onReceived(frame: Frame.Text) {
|
||||
val status = json.decodeFromString<UpdateStatus>(frame.readText())
|
||||
log.info { status }
|
||||
updateStatus.value = json.decodeFromString<UpdateStatus>(frame.readText())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
companion object {
|
||||
private val log = logging()
|
||||
|
||||
val status = MutableStateFlow(Status.STARTING)
|
||||
val updateStatus = MutableStateFlow(UpdateStatus(emptyMap(), false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@
|
||||
<string name="action_filter_bookmarked">Bookmarked</string>
|
||||
<string name="action_select_all">Select all</string>
|
||||
<string name="action_select_inverse">Select inverse</string>
|
||||
<string name="action_update_library">Update library</string>
|
||||
<string name="action_restart_library">Restart library service</string>
|
||||
|
||||
<!-- Locations -->
|
||||
<string name="location_library">Library</string>
|
||||
@@ -318,10 +320,15 @@
|
||||
|
||||
<!-- Android notifications -->
|
||||
<string name="group_downloader">Downloader</string>
|
||||
<string name="group_downloader_channel">Progress</string>
|
||||
<string name="group_library">Library</string>
|
||||
<string name="channel_progress">Progress</string>
|
||||
<string name="channel_downloading">Progress</string>
|
||||
<string name="channel_active">Active</string>
|
||||
<string name="group_updates">Updates</string>
|
||||
<string name="group_updates_channel_app">App updates</string>
|
||||
<string name="group_updates_channel_ext">Extension Updates</string>
|
||||
<string name="downloader_running">Downloader running</string>
|
||||
<string name="chapter_downloading_progress">Downloading (%1$d/%2$d)</string>
|
||||
<string name="chapter_downloading_progress">Downloading… (%1$d/%2$d)</string>
|
||||
<string name="library_updater_running">Library updater running</string>
|
||||
<string name="notification_updating">Updating library… (%1$d/%2$d)</string>
|
||||
</resources>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ActionItem> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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() }),
|
||||
|
||||
@@ -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<JobStatus, List<*>>.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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user