mirror of
https://github.com/Suwayomi/TachideskJUI.git
synced 2025-12-10 14:52:03 +01:00
WIP Android DownloadService
This commit is contained in:
@@ -5,6 +5,9 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- Storage -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
|
||||
@@ -29,5 +32,9 @@
|
||||
android:launchMode="singleTask"
|
||||
android:exported="false">
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".data.AndroidDownloadService"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -11,8 +11,10 @@ import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import ca.gosyer.core.logging.CKLogger
|
||||
import ca.gosyer.core.prefs.getAsFlow
|
||||
import ca.gosyer.data.ui.model.ThemeMode
|
||||
import ca.gosyer.jui.android.data.Notifications
|
||||
import ca.gosyer.ui.AppComponent
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
|
||||
@@ -28,6 +30,9 @@ class App : Application(), DefaultLifecycleObserver {
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
|
||||
val appComponent = AppComponent.getInstance(this)
|
||||
|
||||
setupNotificationChannels()
|
||||
|
||||
appComponent.dataComponent.uiPreferences.themeMode()
|
||||
.getAsFlow {
|
||||
AppCompatDelegate.setDefaultNightMode(
|
||||
@@ -39,6 +44,15 @@ class App : Application(), DefaultLifecycleObserver {
|
||||
)
|
||||
}
|
||||
.launchIn(ProcessLifecycleOwner.get().lifecycleScope)
|
||||
}
|
||||
|
||||
private fun setupNotificationChannels() {
|
||||
try {
|
||||
Notifications.createChannels(this)
|
||||
} catch (e: Exception) {
|
||||
error(e) { "Failed to modify notification channels" }
|
||||
}
|
||||
}
|
||||
|
||||
protected companion object : CKLogger({})
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import ca.gosyer.jui.android.data.AndroidDownloadService
|
||||
import ca.gosyer.ui.AppComponent
|
||||
import ca.gosyer.ui.base.theme.AppTheme
|
||||
import ca.gosyer.ui.main.MainMenu
|
||||
@@ -16,6 +17,9 @@ class MainActivity : AppCompatActivity() {
|
||||
if (savedInstanceState == null) {
|
||||
appComponent.dataComponent.migrations.runMigrations()
|
||||
}
|
||||
|
||||
AndroidDownloadService.start(this, AndroidDownloadService.Actions.START)
|
||||
|
||||
val uiHooks = appComponent.uiComponent.getHooks()
|
||||
setContent {
|
||||
CompositionLocalProvider(*uiHooks) {
|
||||
@@ -25,4 +29,9 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
AndroidDownloadService.stop(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import ca.gosyer.core.lang.throwIfCancellation
|
||||
import ca.gosyer.core.logging.CKLogger
|
||||
import ca.gosyer.core.prefs.getAsFlow
|
||||
import ca.gosyer.data.base.WebsocketService
|
||||
import ca.gosyer.data.download.DownloadService
|
||||
import ca.gosyer.data.download.model.DownloadState
|
||||
import ca.gosyer.data.download.model.DownloadStatus
|
||||
import ca.gosyer.data.server.requests.downloadsQuery
|
||||
import ca.gosyer.i18n.MR
|
||||
import ca.gosyer.jui.android.R
|
||||
import ca.gosyer.jui.android.util.acquireWakeLock
|
||||
import ca.gosyer.jui.android.util.notification
|
||||
import ca.gosyer.jui.android.util.notificationBuilder
|
||||
import ca.gosyer.jui.android.util.notificationManager
|
||||
import ca.gosyer.ui.AppComponent
|
||||
import dev.icerock.moko.resources.desc.desc
|
||||
import dev.icerock.moko.resources.format
|
||||
import io.ktor.client.features.websocket.ws
|
||||
import io.ktor.http.cio.websocket.Frame
|
||||
import io.ktor.http.cio.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.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class AndroidDownloadService : Service() {
|
||||
|
||||
enum class Actions {
|
||||
STOP,
|
||||
START,
|
||||
RESTART
|
||||
}
|
||||
|
||||
companion object : CKLogger({}) {
|
||||
val running = MutableStateFlow(true)
|
||||
|
||||
private var instance: AndroidDownloadService? = null
|
||||
|
||||
|
||||
fun start(context: Context, actions: Actions) {
|
||||
if (!isRunning() && actions != Actions.STOP) {
|
||||
val intent = Intent(context, AndroidDownloadService::class.java).apply {
|
||||
action = actions.name
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, AndroidDownloadService::class.java))
|
||||
}
|
||||
|
||||
fun isRunning(): Boolean {
|
||||
return instance != null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wake lock to prevent the device to enter sleep mode.
|
||||
*/
|
||||
private lateinit var wakeLock: PowerManager.WakeLock
|
||||
|
||||
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_DOWNLOAD_CHAPTER_RUNNING, getPlaceholderNotification())
|
||||
wakeLock = acquireWakeLock(javaClass.name)
|
||||
running.value = true
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
ioScope.cancel()
|
||||
running.value = false
|
||||
wakeLock.releaseIfNeeded()
|
||||
notificationManager.cancel(Notifications.ID_DOWNLOAD_CHAPTER_PROGRESS)
|
||||
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
|
||||
info("using an intent with action $action")
|
||||
when (action) {
|
||||
Actions.START.name -> startWebsocket()
|
||||
Actions.STOP.name -> stopSelf()
|
||||
else -> info("This should never happen. No action in the received intent")
|
||||
}
|
||||
} else {
|
||||
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 dataComponent = AppComponent.getInstance(applicationContext)
|
||||
.dataComponent
|
||||
val client = dataComponent.http
|
||||
|
||||
var errorConnectionCount = 0
|
||||
|
||||
dataComponent
|
||||
.serverPreferences
|
||||
.serverUrl()
|
||||
.getAsFlow()
|
||||
.mapLatest { serverUrl ->
|
||||
DownloadService.status.value = WebsocketService.Status.STARTING
|
||||
while (true) {
|
||||
if (errorConnectionCount > 3) {
|
||||
DownloadService.status.value = WebsocketService.Status.STOPPED
|
||||
throw CancellationException()
|
||||
}
|
||||
runCatching {
|
||||
client.ws(
|
||||
host = serverUrl.substringAfter("://"),
|
||||
path = downloadsQuery()
|
||||
) {
|
||||
errorConnectionCount = 0
|
||||
DownloadService.status.value = WebsocketService.Status.RUNNING
|
||||
send(Frame.Text("STATUS"))
|
||||
|
||||
incoming.receiveAsFlow()
|
||||
.filterIsInstance<Frame.Text>()
|
||||
.mapLatest(::onReceived)
|
||||
.catch {
|
||||
info(it) { "Error running downloader" }
|
||||
}
|
||||
.collect()
|
||||
}
|
||||
}.throwIfCancellation().isFailure.let {
|
||||
DownloadService.status.value = WebsocketService.Status.STARTING
|
||||
if (it) errorConnectionCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
.catch {
|
||||
DownloadService.status.value = WebsocketService.Status.STOPPED
|
||||
error(it) { "Error while running websocket service" }
|
||||
stopSelf()
|
||||
}
|
||||
.launchIn(ioScope)
|
||||
}
|
||||
|
||||
private fun onReceived(frame: Frame.Text) {
|
||||
val status = Json.decodeFromString<DownloadStatus>(frame.readText())
|
||||
DownloadService.downloaderStatus.value = status.status
|
||||
DownloadService.downloadQueue.value = status.queue
|
||||
val downloadingChapter = status.queue.lastOrNull { it.state == DownloadState.Downloading }
|
||||
if (downloadingChapter != null) {
|
||||
val notification = with(progressNotificationBuilder) {
|
||||
val max = downloadingChapter.chapter.pageCount ?: 0
|
||||
val current = downloadingChapter.progress.toInt().coerceAtMost(max)
|
||||
setProgress(max, current, false)
|
||||
setContentText(
|
||||
MR.strings.chapter_downloading_progress
|
||||
.format(
|
||||
current,
|
||||
max
|
||||
)
|
||||
.toString(this@AndroidDownloadService)
|
||||
)
|
||||
}.build()
|
||||
notificationManager.notify(
|
||||
Notifications.ID_DOWNLOAD_CHAPTER_PROGRESS,
|
||||
notification
|
||||
)
|
||||
} else {
|
||||
notificationManager.cancel(Notifications.ID_DOWNLOAD_CHAPTER_PROGRESS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun PowerManager.WakeLock.releaseIfNeeded() {
|
||||
if (isHeld) release()
|
||||
}
|
||||
|
||||
private fun PowerManager.WakeLock.acquireIfNeeded() {
|
||||
if (!isHeld) acquire()
|
||||
}
|
||||
|
||||
private val icon by lazy {
|
||||
BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)
|
||||
}
|
||||
|
||||
private fun getPlaceholderNotification(): Notification {
|
||||
return 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_PROGRESS) {
|
||||
setLargeIcon(icon)
|
||||
setSmallIcon(R.drawable.ic_round_get_app_24)
|
||||
setAutoCancel(false)
|
||||
setOnlyAlertOnce(true)
|
||||
setOngoing(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import ca.gosyer.i18n.MR
|
||||
import ca.gosyer.jui.android.util.buildNotificationChannel
|
||||
import ca.gosyer.jui.android.util.buildNotificationChannelGroup
|
||||
import dev.icerock.moko.resources.desc.desc
|
||||
|
||||
object Notifications {
|
||||
|
||||
/**
|
||||
* Notification channel and ids used by the downloader.
|
||||
*/
|
||||
private const val GROUP_DOWNLOADER = "group_downloader"
|
||||
const val CHANNEL_DOWNLOADER_RUNNING = "downloader_running_channel"
|
||||
const val ID_DOWNLOAD_CHAPTER_RUNNING = -101
|
||||
const val CHANNEL_DOWNLOADER_PROGRESS = "downloader_progress_channel"
|
||||
const val ID_DOWNLOAD_CHAPTER_PROGRESS = -102
|
||||
|
||||
/**
|
||||
* Notification channel and ids used for app updates.
|
||||
*/
|
||||
private const val GROUP_APK_UPDATES = "group_apk_updates"
|
||||
const val CHANNEL_APP_UPDATE = "app_apk_update_channel"
|
||||
const val ID_UPDATES_TO_APP = -201
|
||||
const val CHANNEL_EXTENSIONS_UPDATE = "ext_apk_update_channel"
|
||||
const val ID_UPDATES_TO_EXTS = -202
|
||||
|
||||
fun createChannels(context: Context) {
|
||||
val notificationService = NotificationManagerCompat.from(context)
|
||||
|
||||
notificationService.createNotificationChannelGroupsCompat(
|
||||
listOf(
|
||||
buildNotificationChannelGroup(GROUP_DOWNLOADER) {
|
||||
setName(MR.strings.group_downloader.desc().toString(context))
|
||||
},
|
||||
buildNotificationChannelGroup(GROUP_APK_UPDATES) {
|
||||
setName(MR.strings.group_updates.desc().toString(context))
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
notificationService.createNotificationChannelsCompat(
|
||||
listOf(
|
||||
buildNotificationChannel(
|
||||
CHANNEL_DOWNLOADER_RUNNING,
|
||||
NotificationManagerCompat.IMPORTANCE_MIN
|
||||
) {
|
||||
setName(MR.strings.group_downloader_channel_running.desc().toString(context))
|
||||
setGroup(GROUP_DOWNLOADER)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(
|
||||
CHANNEL_DOWNLOADER_PROGRESS,
|
||||
NotificationManagerCompat.IMPORTANCE_LOW
|
||||
) {
|
||||
setName(MR.strings.group_downloader_channel_progress.desc().toString(context))
|
||||
setGroup(GROUP_DOWNLOADER)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(
|
||||
CHANNEL_APP_UPDATE,
|
||||
NotificationManagerCompat.IMPORTANCE_DEFAULT
|
||||
) {
|
||||
setGroup(GROUP_APK_UPDATES)
|
||||
setName(MR.strings.group_updates_channel_app.desc().toString(context))
|
||||
},
|
||||
buildNotificationChannel(
|
||||
CHANNEL_EXTENSIONS_UPDATE,
|
||||
NotificationManagerCompat.IMPORTANCE_DEFAULT
|
||||
) {
|
||||
setGroup(GROUP_APK_UPDATES)
|
||||
setName(MR.strings.group_updates_channel_ext.desc().toString(context))
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
60
android/src/main/java/ca/gosyer/jui/android/util/Context.kt
Normal file
60
android/src/main/java/ca/gosyer/jui/android/util/Context.kt
Normal file
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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.util
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.getSystemService
|
||||
|
||||
// The following file is mostly taken from Tachiyomi
|
||||
|
||||
val Context.powerManager: PowerManager
|
||||
get() = getSystemService()!!
|
||||
|
||||
val Context.notificationManager: NotificationManager
|
||||
get() = getSystemService()!!
|
||||
|
||||
/**
|
||||
* Convenience method to acquire a partial wake lock.
|
||||
*/
|
||||
fun Context.acquireWakeLock(tag: String): PowerManager.WakeLock {
|
||||
val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag:WakeLock")
|
||||
wakeLock.acquire()
|
||||
return wakeLock
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create a notification builder.
|
||||
*
|
||||
* @param id the channel id.
|
||||
* @param block the function that will execute inside the builder.
|
||||
* @return a notification to be displayed or updated.
|
||||
*/
|
||||
fun Context.notificationBuilder(channelId: String, block: (NotificationCompat.Builder.() -> Unit)? = null): NotificationCompat.Builder {
|
||||
val builder = NotificationCompat.Builder(this, channelId)
|
||||
//.setColor(getColor(R.color.accent_blue))
|
||||
if (block != null) {
|
||||
builder.block()
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Helper method to create a notification.
|
||||
*
|
||||
* @param id the channel id.
|
||||
* @param block the function that will execute inside the builder.
|
||||
* @return a notification to be displayed or updated.
|
||||
*/
|
||||
fun Context.notification(channelId: String, block: (NotificationCompat.Builder.() -> Unit)?): Notification {
|
||||
val builder = notificationBuilder(channelId, block)
|
||||
return builder.build()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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.util
|
||||
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationChannelGroupCompat
|
||||
|
||||
// This file is mostly from Tachiyomi
|
||||
|
||||
/**
|
||||
* Helper method to build a notification channel group.
|
||||
*
|
||||
* @param channelId the channel id.
|
||||
* @param block the function that will execute inside the builder.
|
||||
* @return a notification channel group to be displayed or updated.
|
||||
*/
|
||||
fun buildNotificationChannelGroup(
|
||||
channelId: String,
|
||||
block: (NotificationChannelGroupCompat.Builder.() -> Unit)
|
||||
): NotificationChannelGroupCompat {
|
||||
val builder = NotificationChannelGroupCompat.Builder(channelId)
|
||||
builder.block()
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to build a notification channel.
|
||||
*
|
||||
* @param channelId the channel id.
|
||||
* @param channelImportance the channel importance.
|
||||
* @param block the function that will execute inside the builder.
|
||||
* @return a notification channel to be displayed or updated.
|
||||
*/
|
||||
fun buildNotificationChannel(
|
||||
channelId: String,
|
||||
channelImportance: Int,
|
||||
block: (NotificationChannelCompat.Builder.() -> Unit)
|
||||
): NotificationChannelCompat {
|
||||
val builder = NotificationChannelCompat.Builder(channelId, channelImportance)
|
||||
builder.block()
|
||||
return builder.build()
|
||||
}
|
||||
10
android/src/main/res/drawable/ic_round_get_app_24.xml
Normal file
10
android/src/main/res/drawable/ic_round_get_app_24.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M16.59,9H15V4c0,-0.55 -0.45,-1 -1,-1h-4c-0.55,0 -1,0.45 -1,1v5H7.41c-0.89,0 -1.34,1.08 -0.71,1.71l4.59,4.59c0.39,0.39 1.02,0.39 1.41,0l4.59,-4.59c0.63,-0.63 0.19,-1.71 -0.7,-1.71zM5,19c0,0.55 0.45,1 1,1h12c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1H6c-0.55,0 -1,0.45 -1,1z"/>
|
||||
</vector>
|
||||
@@ -10,7 +10,6 @@ import android.content.Context
|
||||
import ca.gosyer.core.di.AppScope
|
||||
import ca.gosyer.core.prefs.PreferenceStoreFactory
|
||||
import ca.gosyer.data.catalog.CatalogPreferences
|
||||
import ca.gosyer.data.download.DownloadService
|
||||
import ca.gosyer.data.extension.ExtensionPreferences
|
||||
import ca.gosyer.data.library.LibraryPreferences
|
||||
import ca.gosyer.data.library.LibraryUpdateService
|
||||
@@ -37,8 +36,6 @@ actual abstract class DataComponent(
|
||||
|
||||
protected abstract val httpProvider: HttpProvider
|
||||
|
||||
abstract val downloadService: DownloadService
|
||||
|
||||
abstract val libraryUpdateService: LibraryUpdateService
|
||||
|
||||
abstract val migrations: Migrations
|
||||
@@ -115,11 +112,6 @@ actual abstract class DataComponent(
|
||||
protected val libraryUpdateServiceFactory: LibraryUpdateService
|
||||
get() = LibraryUpdateService(serverPreferences, http)
|
||||
|
||||
@get:AppScope
|
||||
@get:Provides
|
||||
protected val downloadServiceFactory: DownloadService
|
||||
get() = DownloadService(serverPreferences, http)
|
||||
|
||||
@get:AppScope
|
||||
@get:Provides
|
||||
protected val migrationsFactory: Migrations
|
||||
|
||||
@@ -18,7 +18,6 @@ import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
@@ -35,8 +34,7 @@ abstract class WebsocketService(
|
||||
protected val json = Json {
|
||||
ignoreUnknownKeys = !BuildKonfig.DEBUG
|
||||
}
|
||||
private val _status = MutableStateFlow(Status.STARTING)
|
||||
val status = _status.asStateFlow()
|
||||
protected abstract val _status: MutableStateFlow<Status>
|
||||
|
||||
protected val serverUrl = serverPreferences.serverUrl().stateIn(GlobalScope)
|
||||
|
||||
@@ -44,14 +42,11 @@ abstract class WebsocketService(
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
init {
|
||||
init()
|
||||
}
|
||||
|
||||
fun init() {
|
||||
errorConnectionCount = 0
|
||||
job?.cancel()
|
||||
job = serverUrl.mapLatest { serverUrl ->
|
||||
job = serverUrl
|
||||
.mapLatest { serverUrl ->
|
||||
_status.value = Status.STARTING
|
||||
while (true) {
|
||||
if (errorConnectionCount > 3) {
|
||||
@@ -70,7 +65,9 @@ abstract class WebsocketService(
|
||||
incoming.receiveAsFlow()
|
||||
.filterIsInstance<Frame.Text>()
|
||||
.mapLatest(::onReceived)
|
||||
.catch { it.throwIfCancellation() }
|
||||
.catch {
|
||||
info(it) { "Error running websocket" }
|
||||
}
|
||||
.collect()
|
||||
}
|
||||
}.throwIfCancellation().isFailure.let {
|
||||
@@ -78,11 +75,12 @@ abstract class WebsocketService(
|
||||
if (it) errorConnectionCount++
|
||||
}
|
||||
}
|
||||
}.catch {
|
||||
}
|
||||
.catch {
|
||||
_status.value = Status.STOPPED
|
||||
error(it) { "Error while running websocket service" }
|
||||
throw it
|
||||
}.launchIn(GlobalScope)
|
||||
}
|
||||
.launchIn(GlobalScope)
|
||||
}
|
||||
|
||||
abstract val query: String
|
||||
|
||||
@@ -17,10 +17,8 @@ import ca.gosyer.data.server.requests.downloadsQuery
|
||||
import io.ktor.http.cio.websocket.Frame
|
||||
import io.ktor.http.cio.websocket.readText
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import me.tatarka.inject.annotations.Inject
|
||||
|
||||
@@ -29,39 +27,32 @@ class DownloadService @Inject constructor(
|
||||
serverPreferences: ServerPreferences,
|
||||
client: Http
|
||||
) : WebsocketService(serverPreferences, client) {
|
||||
|
||||
private val _downloaderStatus = MutableStateFlow(DownloaderStatus.Stopped)
|
||||
val downloaderStatus = _downloaderStatus.asStateFlow()
|
||||
|
||||
private val _downloadQueue = MutableStateFlow(emptyList<DownloadChapter>())
|
||||
val downloadQueue = _downloadQueue.asStateFlow()
|
||||
|
||||
private val watching = mutableMapOf<Long, MutableSharedFlow<Pair<Long, List<DownloadChapter>>>>()
|
||||
override val _status: MutableStateFlow<Status>
|
||||
get() = status
|
||||
|
||||
override val query: String
|
||||
get() = downloadsQuery()
|
||||
|
||||
override suspend fun onReceived(frame: Frame.Text) {
|
||||
val status = json.decodeFromString<DownloadStatus>(frame.readText())
|
||||
_downloaderStatus.value = status.status
|
||||
_downloadQueue.value = status.queue
|
||||
val queue = status.queue.groupBy { it.mangaId }
|
||||
watching.forEach { (mangaId, flow) ->
|
||||
flow.emit(mangaId to queue[mangaId].orEmpty())
|
||||
}
|
||||
downloaderStatus.value = status.status
|
||||
downloadQueue.value = status.queue
|
||||
}
|
||||
|
||||
companion object : CKLogger({}) {
|
||||
val status = MutableStateFlow(Status.STARTING)
|
||||
val downloadQueue = MutableStateFlow(emptyList<DownloadChapter>())
|
||||
val downloaderStatus = MutableStateFlow(DownloaderStatus.Stopped)
|
||||
|
||||
fun registerWatch(mangaId: Long) =
|
||||
MutableSharedFlow<Pair<Long, List<DownloadChapter>>>().also { watching[mangaId] = it }.asSharedFlow()
|
||||
downloadQueue
|
||||
.map {
|
||||
it.filter { it.mangaId == mangaId }
|
||||
}
|
||||
fun registerWatches(mangaIds: Set<Long>) =
|
||||
mangaIds.map { registerWatch(it) }
|
||||
|
||||
fun removeWatch(mangaId: Long) {
|
||||
watching -= mangaId
|
||||
downloadQueue
|
||||
.map {
|
||||
it.filter { it.mangaId in mangaIds }
|
||||
}
|
||||
fun removeWatches(mangaIds: Set<Long>) {
|
||||
watching -= mangaIds
|
||||
}
|
||||
|
||||
private companion object : CKLogger({})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import ca.gosyer.data.server.requests.updatesQuery
|
||||
import io.ktor.http.cio.websocket.Frame
|
||||
import io.ktor.http.cio.websocket.readText
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import me.tatarka.inject.annotations.Inject
|
||||
|
||||
@@ -24,6 +25,8 @@ class LibraryUpdateService @Inject constructor(
|
||||
client: Http
|
||||
) : WebsocketService(serverPreferences, client) {
|
||||
|
||||
override val _status: MutableStateFlow<Status> = MutableStateFlow(Status.STARTING)
|
||||
|
||||
override val query: String
|
||||
get() = updatesQuery()
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ suspend fun main() {
|
||||
val dataComponent = appComponent.dataComponent
|
||||
val uiComponent = appComponent.uiComponent
|
||||
dataComponent.migrations.runMigrations()
|
||||
dataComponent.downloadService.init()
|
||||
// dataComponent.libraryUpdateService.init()
|
||||
val serverService = dataComponent.serverService
|
||||
val uiPreferences = dataComponent.uiPreferences
|
||||
val uiHooks = uiComponent.getHooks()
|
||||
|
||||
@@ -235,4 +235,14 @@
|
||||
|
||||
<!-- Advanced Settings -->
|
||||
<string name="update_checker">Check for updates</string>
|
||||
|
||||
<!-- Android notifications -->
|
||||
<string name="group_downloader">Downloader</string>
|
||||
<string name="group_downloader_channel_running">Running</string>
|
||||
<string name="group_downloader_channel_progress">Progress</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>
|
||||
</resources>
|
||||
|
||||
@@ -63,7 +63,7 @@ data class ChapterDownloadItem(
|
||||
|
||||
fun updateFrom(downloadingChapters: List<DownloadChapter>) {
|
||||
val downloadingChapter = downloadingChapters.find {
|
||||
it.chapterIndex == chapter.index
|
||||
it.chapterIndex == chapter.index && it.mangaId == chapter.mangaId
|
||||
}
|
||||
if (downloadingChapter != null && downloadState.value != ChapterDownloadState.Downloading) {
|
||||
_downloadState.value = ChapterDownloadState.Downloading
|
||||
|
||||
@@ -11,7 +11,6 @@ import ca.gosyer.data.models.Manga
|
||||
import ca.gosyer.data.models.Source
|
||||
import ca.gosyer.data.server.Http
|
||||
import ca.gosyer.data.server.ServerPreferences
|
||||
import ca.gosyer.uicore.prefs.asStateIn
|
||||
import io.kamel.core.config.DefaultCacheSize
|
||||
import io.kamel.core.config.KamelConfig
|
||||
import io.kamel.core.config.KamelConfigBuilder
|
||||
@@ -26,7 +25,6 @@ import io.ktor.http.Url
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import me.tatarka.inject.annotations.Inject
|
||||
|
||||
class KamelConfigProvider @Inject constructor(
|
||||
@@ -34,7 +32,7 @@ class KamelConfigProvider @Inject constructor(
|
||||
serverPreferences: ServerPreferences
|
||||
) {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
val serverUrl = serverPreferences.serverUrl().asStateIn(GlobalScope)
|
||||
val serverUrl = serverPreferences.serverUrl().stateIn(GlobalScope)
|
||||
|
||||
fun get(resourcesFetcher: KamelConfigBuilder.() -> Unit): KamelConfig {
|
||||
return KamelConfig {
|
||||
@@ -52,7 +50,6 @@ class KamelConfigProvider @Inject constructor(
|
||||
install(http)
|
||||
}
|
||||
resourcesFetcher()
|
||||
val serverUrl = serverUrl.asStateFlow()
|
||||
mapper(MangaCoverMapper(serverUrl))
|
||||
mapper(ExtensionIconMapper(serverUrl))
|
||||
mapper(SourceIconMapper(serverUrl))
|
||||
|
||||
@@ -15,6 +15,7 @@ import ca.gosyer.uicore.vm.ContextWrapper
|
||||
import ca.gosyer.uicore.vm.ViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
@@ -35,9 +36,9 @@ class DownloadsScreenViewModel @Inject constructor(
|
||||
override val scope: CoroutineScope
|
||||
get() = uiScope ?: super.scope
|
||||
|
||||
val serviceStatus get() = downloadService.status
|
||||
val downloaderStatus get() = downloadService.downloaderStatus
|
||||
val downloadQueue get() = downloadService.downloadQueue
|
||||
val serviceStatus get() = DownloadService.status.asStateFlow()
|
||||
val downloaderStatus get() = DownloadService.downloaderStatus.asStateFlow()
|
||||
val downloadQueue get() = DownloadService.downloadQueue.asStateFlow()
|
||||
|
||||
fun start() {
|
||||
downloadsHandler.startDownloading()
|
||||
|
||||
@@ -45,13 +45,10 @@ class MangaScreenViewModel @Inject constructor(
|
||||
private val chapterHandler: ChapterInteractionHandler,
|
||||
private val categoryHandler: CategoryInteractionHandler,
|
||||
private val libraryHandler: LibraryInteractionHandler,
|
||||
private val downloadService: DownloadService,
|
||||
uiPreferences: UiPreferences,
|
||||
contextWrapper: ContextWrapper,
|
||||
private val params: Params,
|
||||
) : ViewModel(contextWrapper) {
|
||||
private val downloadingChapters = downloadService.registerWatch(params.mangaId)
|
||||
|
||||
private val _manga = MutableStateFlow<Manga?>(null)
|
||||
val manga = _manga.asStateFlow()
|
||||
|
||||
@@ -79,11 +76,13 @@ class MangaScreenViewModel @Inject constructor(
|
||||
.asStateFlow(getDateFormat(uiPreferences.dateFormat().get()))
|
||||
|
||||
init {
|
||||
downloadingChapters.mapLatest { (_, downloadingChapters) ->
|
||||
DownloadService.registerWatch(params.mangaId)
|
||||
.mapLatest { downloadingChapters->
|
||||
chapters.value.forEach { chapter ->
|
||||
chapter.updateFrom(downloadingChapters)
|
||||
}
|
||||
}.launchIn(scope)
|
||||
}
|
||||
.launchIn(scope)
|
||||
|
||||
scope.launch {
|
||||
refreshMangaAsync(params.mangaId).await() to refreshChaptersAsync(params.mangaId).await()
|
||||
@@ -317,10 +316,6 @@ class MangaScreenViewModel @Inject constructor(
|
||||
?.launchIn(scope)
|
||||
}
|
||||
|
||||
override fun onDispose() {
|
||||
downloadService.removeWatch(params.mangaId)
|
||||
}
|
||||
|
||||
private fun List<Chapter>.toDownloadChapters() = map {
|
||||
ChapterDownloadItem(null, it)
|
||||
}
|
||||
|
||||
@@ -14,12 +14,12 @@ import ca.gosyer.data.server.interactions.UpdatesInteractionHandler
|
||||
import ca.gosyer.ui.base.chapter.ChapterDownloadItem
|
||||
import ca.gosyer.uicore.vm.ContextWrapper
|
||||
import ca.gosyer.uicore.vm.ViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -28,7 +28,6 @@ import me.tatarka.inject.annotations.Inject
|
||||
class UpdatesScreenViewModel @Inject constructor(
|
||||
private val chapterHandler: ChapterInteractionHandler,
|
||||
private val updatesHandler: UpdatesInteractionHandler,
|
||||
private val downloadService: DownloadService,
|
||||
contextWrapper: ContextWrapper
|
||||
) : ViewModel(contextWrapper) {
|
||||
|
||||
@@ -44,6 +43,7 @@ class UpdatesScreenViewModel @Inject constructor(
|
||||
private val hasNextPage = MutableStateFlow(false)
|
||||
|
||||
private val updatesMutex = Mutex()
|
||||
private var downloadServiceJob: Job? = null
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
@@ -71,9 +71,10 @@ class UpdatesScreenViewModel @Inject constructor(
|
||||
it.chapter
|
||||
)
|
||||
}
|
||||
downloadService.registerWatches(mangaIds).merge()
|
||||
.onEach { (mangaId, chapters) ->
|
||||
_updates.value.filter { it.chapter.mangaId == mangaId }
|
||||
downloadServiceJob?.cancel()
|
||||
downloadServiceJob = DownloadService.registerWatches(mangaIds)
|
||||
.onEach { chapters ->
|
||||
_updates.value
|
||||
.forEach {
|
||||
it.updateFrom(chapters)
|
||||
}
|
||||
@@ -127,9 +128,5 @@ class UpdatesScreenViewModel @Inject constructor(
|
||||
?.launchIn(scope)
|
||||
}
|
||||
|
||||
override fun onDispose() {
|
||||
downloadService.removeWatches(mangaIds)
|
||||
}
|
||||
|
||||
private companion object : CKLogger({})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user