mirror of
https://github.com/Suwayomi/TachideskJUI.git
synced 2025-12-10 06:42:05 +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
|
||||
|
||||
Reference in New Issue
Block a user