Implement library updater

This commit is contained in:
Syer10
2022-11-08 00:42:36 -05:00
parent a7062f2353
commit c60cfebd07
17 changed files with 556 additions and 28 deletions

View File

@@ -37,5 +37,8 @@
<service
android:name=".data.download.AndroidDownloadService"
android:exported="false" />
<service
android:name=".data.library.AndroidLibraryService"
android:exported="false" />
</application>
</manifest>

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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))
}
}

View File

@@ -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>

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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
)
}
}

View File

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

View File

@@ -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() }),

View File

@@ -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)
)
}
}
}

View File

@@ -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
)

View File

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

View File

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