WIP Android DownloadService

This commit is contained in:
Syer10
2022-03-03 17:08:14 -05:00
parent 519b3d0ca8
commit fa9dea84be
19 changed files with 566 additions and 101 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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