mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
feat: introduce core data models and interfaces for playback and downloads
This commit is contained in:
@@ -3,13 +3,12 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "hu.bbara.purefin.feature.shared"
|
||||
namespace = "hu.bbara.purefin.feature.browse"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
@@ -31,12 +30,12 @@ kotlin {
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
implementation(project(":core:data"))
|
||||
implementation(project(":core:image"))
|
||||
implementation(project(":core:navigation"))
|
||||
implementation(project(":core:download"))
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.hilt)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.jellyfin.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
@@ -1,22 +1,21 @@
|
||||
package hu.bbara.purefin.feature.shared.home
|
||||
package hu.bbara.purefin.feature.browse.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.HomeRepository
|
||||
import hu.bbara.purefin.core.data.MediaCatalogReader
|
||||
import hu.bbara.purefin.core.data.domain.usecase.RefreshHomeDataUseCase
|
||||
import hu.bbara.purefin.core.data.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
import hu.bbara.purefin.core.model.LibraryKind
|
||||
import hu.bbara.purefin.core.model.Media
|
||||
import hu.bbara.purefin.core.model.MediaKind
|
||||
import hu.bbara.purefin.feature.shared.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.feature.shared.navigation.LibraryDto
|
||||
import hu.bbara.purefin.feature.shared.navigation.MovieDto
|
||||
import hu.bbara.purefin.feature.shared.navigation.NavigationManager
|
||||
import hu.bbara.purefin.feature.shared.navigation.Route
|
||||
import hu.bbara.purefin.feature.shared.navigation.SeriesDto
|
||||
import hu.bbara.purefin.core.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.core.navigation.LibraryDto
|
||||
import hu.bbara.purefin.core.navigation.MovieDto
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -34,7 +33,6 @@ class AppViewModel @Inject constructor(
|
||||
private val mediaCatalogReader: MediaCatalogReader,
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val refreshHomeDataUseCase: RefreshHomeDataUseCase,
|
||||
private val mediaDownloadManager: MediaDownloadController,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -207,7 +205,7 @@ class AppViewModel @Inject constructor(
|
||||
fun onResumed() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
refreshHomeDataUseCase()
|
||||
homeRepository.refreshHomeData()
|
||||
} catch (e: Exception) {
|
||||
// Refresh is best-effort; don't crash on failure
|
||||
}
|
||||
@@ -223,7 +221,7 @@ class AppViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
_isRefreshing.value = true
|
||||
try {
|
||||
refreshHomeDataUseCase()
|
||||
homeRepository.refreshHomeData()
|
||||
} catch (e: Exception) {
|
||||
// Refresh is best-effort; don't crash on failure
|
||||
} finally {
|
||||
@@ -1,13 +1,13 @@
|
||||
package hu.bbara.purefin.feature.shared.home
|
||||
package hu.bbara.purefin.feature.browse.home
|
||||
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import java.util.UUID
|
||||
import hu.bbara.purefin.core.model.MediaKind
|
||||
import hu.bbara.purefin.core.model.LibraryKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
|
||||
|
||||
sealed interface SuggestedItem {
|
||||
@@ -38,9 +38,9 @@ data class SuggestedEpisode (
|
||||
override val id: UUID = episode.id,
|
||||
override val title: String = episode.title,
|
||||
override val description: String = episode.synopsis,
|
||||
override val imageUrl: String = JellyfinImageHelper.finishImageUrl(
|
||||
override val imageUrl: String = ImageUrlBuilder.finishImageUrl(
|
||||
prefixImageUrl = episode.imageUrlPrefix,
|
||||
imageType = ImageType.PRIMARY
|
||||
artworkKind = ArtworkKind.PRIMARY
|
||||
)
|
||||
) : SuggestedItem
|
||||
|
||||
@@ -61,9 +61,9 @@ data class SuggestedSeries (
|
||||
override val id: UUID = series.id,
|
||||
override val title: String = series.name,
|
||||
override val description: String = series.synopsis,
|
||||
override val imageUrl: String = JellyfinImageHelper.finishImageUrl(
|
||||
override val imageUrl: String = ImageUrlBuilder.finishImageUrl(
|
||||
prefixImageUrl = series.imageUrlPrefix,
|
||||
imageType = ImageType.PRIMARY
|
||||
artworkKind = ArtworkKind.PRIMARY
|
||||
)
|
||||
) : SuggestedItem
|
||||
|
||||
@@ -82,9 +82,9 @@ data class SuggestedMovie (
|
||||
override val id: UUID = movie.id,
|
||||
override val title: String = movie.title,
|
||||
override val description: String = movie.synopsis,
|
||||
override val imageUrl: String = JellyfinImageHelper.finishImageUrl(
|
||||
override val imageUrl: String = ImageUrlBuilder.finishImageUrl(
|
||||
prefixImageUrl = movie.imageUrlPrefix,
|
||||
imageType = ImageType.PRIMARY
|
||||
artworkKind = ArtworkKind.PRIMARY
|
||||
)
|
||||
) : SuggestedItem
|
||||
|
||||
@@ -118,13 +118,13 @@ data class ContinueWatchingItem(
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: $type")
|
||||
}
|
||||
override val imageUrl: String = when (type) {
|
||||
MediaKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
|
||||
MediaKind.MOVIE -> ImageUrlBuilder.finishImageUrl(
|
||||
prefixImageUrl = movie!!.imageUrlPrefix,
|
||||
imageType = ImageType.PRIMARY
|
||||
artworkKind = ArtworkKind.PRIMARY
|
||||
)
|
||||
MediaKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
|
||||
MediaKind.EPISODE -> ImageUrlBuilder.finishImageUrl(
|
||||
prefixImageUrl = episode!!.imageUrlPrefix,
|
||||
imageType = ImageType.PRIMARY
|
||||
artworkKind = ArtworkKind.PRIMARY
|
||||
)
|
||||
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||
}
|
||||
@@ -147,9 +147,9 @@ data class NextUpItem(
|
||||
override val type: MediaKind
|
||||
get() = MediaKind.EPISODE
|
||||
override val imageUrl: String
|
||||
get() = JellyfinImageHelper.finishImageUrl(
|
||||
get() = ImageUrlBuilder.finishImageUrl(
|
||||
prefixImageUrl = episode.imageUrlPrefix,
|
||||
imageType = ImageType.PRIMARY
|
||||
artworkKind = ArtworkKind.PRIMARY
|
||||
)
|
||||
override val primaryText: String = episode.title
|
||||
override val secondaryText: String = episode.releaseDate
|
||||
@@ -184,17 +184,17 @@ data class PosterItem(
|
||||
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||
}
|
||||
override val imageUrl: String = when (type) {
|
||||
MediaKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
|
||||
MediaKind.MOVIE -> ImageUrlBuilder.finishImageUrl(
|
||||
prefixImageUrl = movie!!.imageUrlPrefix,
|
||||
imageType = ImageType.PRIMARY
|
||||
artworkKind = ArtworkKind.PRIMARY
|
||||
)
|
||||
MediaKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
|
||||
MediaKind.EPISODE -> ImageUrlBuilder.finishImageUrl(
|
||||
prefixImageUrl = episode!!.imageUrlPrefix,
|
||||
imageType = ImageType.PRIMARY
|
||||
artworkKind = ArtworkKind.PRIMARY
|
||||
)
|
||||
MediaKind.SERIES -> JellyfinImageHelper.finishImageUrl(
|
||||
MediaKind.SERIES -> ImageUrlBuilder.finishImageUrl(
|
||||
prefixImageUrl = series!!.imageUrlPrefix,
|
||||
imageType = ImageType.PRIMARY
|
||||
artworkKind = ArtworkKind.PRIMARY
|
||||
)
|
||||
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package hu.bbara.purefin.feature.shared.library
|
||||
package hu.bbara.purefin.feature.browse.library
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -6,11 +6,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.HomeRepository
|
||||
import hu.bbara.purefin.core.model.LibraryKind
|
||||
import hu.bbara.purefin.core.model.MediaKind
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import hu.bbara.purefin.feature.shared.navigation.MovieDto
|
||||
import hu.bbara.purefin.feature.shared.navigation.NavigationManager
|
||||
import hu.bbara.purefin.feature.shared.navigation.Route
|
||||
import hu.bbara.purefin.feature.shared.navigation.SeriesDto
|
||||
import hu.bbara.purefin.feature.browse.home.PosterItem
|
||||
import hu.bbara.purefin.core.navigation.MovieDto
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
40
feature/content/build.gradle.kts
Normal file
40
feature/content/build.gradle.kts
Normal file
@@ -0,0 +1,40 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "hu.bbara.purefin.feature.content"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 29
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
implementation(project(":core:data"))
|
||||
implementation(project(":core:navigation"))
|
||||
implementation(project(":core:download"))
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.hilt)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
package hu.bbara.purefin.feature.shared.content.episode
|
||||
package hu.bbara.purefin.feature.content.episode
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.MediaCatalogReader
|
||||
import hu.bbara.purefin.core.data.download.DownloadState
|
||||
import hu.bbara.purefin.core.data.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.feature.shared.navigation.NavigationManager
|
||||
import hu.bbara.purefin.feature.shared.navigation.Route
|
||||
import hu.bbara.purefin.feature.shared.navigation.SeriesDto
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -1,14 +1,14 @@
|
||||
package hu.bbara.purefin.feature.shared.content.movie
|
||||
package hu.bbara.purefin.feature.content.movie
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.MediaCatalogReader
|
||||
import hu.bbara.purefin.core.data.download.DownloadState
|
||||
import hu.bbara.purefin.core.data.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.feature.shared.navigation.NavigationManager
|
||||
import hu.bbara.purefin.feature.shared.navigation.Route
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -1,16 +1,16 @@
|
||||
package hu.bbara.purefin.feature.shared.content.series
|
||||
package hu.bbara.purefin.feature.content.series
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.MediaCatalogReader
|
||||
import hu.bbara.purefin.core.data.download.DownloadState
|
||||
import hu.bbara.purefin.core.data.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import hu.bbara.purefin.feature.shared.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.feature.shared.navigation.NavigationManager
|
||||
import hu.bbara.purefin.feature.shared.navigation.Route
|
||||
import hu.bbara.purefin.core.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -1,19 +0,0 @@
|
||||
package hu.bbara.purefin.feature.download
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.core.data.download.MediaDownloadController
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class DownloadControllerModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindMediaDownloadController(
|
||||
impl: MediaDownloadManager
|
||||
): MediaDownloadController
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package hu.bbara.purefin.feature.download
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.CacheEvictor
|
||||
import androidx.media3.datasource.cache.NoOpCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
import javax.inject.Singleton
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
internal object DownloadModuleFactories {
|
||||
fun createDownloadCacheEvictor(): CacheEvictor {
|
||||
return NoOpCacheEvictor()
|
||||
}
|
||||
|
||||
fun newPhonePlaybackDataSourceFactory(
|
||||
upstreamDataSourceFactory: DataSource.Factory
|
||||
): CacheDataSource.Factory {
|
||||
return CacheDataSource.Factory()
|
||||
.setUpstreamDataSourceFactory(upstreamDataSourceFactory)
|
||||
.setCacheWriteDataSinkFactory(null)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DownloadModule {
|
||||
|
||||
private const val DOWNLOAD_CHANNEL_ID = "purefin_downloads"
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDownloadCache(@ApplicationContext context: Context): SimpleCache {
|
||||
val downloadDir = File(context.getExternalFilesDir(null), "downloads")
|
||||
return SimpleCache(
|
||||
downloadDir,
|
||||
DownloadModuleFactories.createDownloadCacheEvictor(),
|
||||
StandaloneDatabaseProvider(context)
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDownloadNotificationHelper(@ApplicationContext context: Context): DownloadNotificationHelper {
|
||||
return DownloadNotificationHelper(context, DOWNLOAD_CHANNEL_ID)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDownloadManager(
|
||||
@ApplicationContext context: Context,
|
||||
cache: SimpleCache,
|
||||
okHttpDataSourceFactory: OkHttpDataSource.Factory
|
||||
): DownloadManager {
|
||||
return DownloadManager(
|
||||
context,
|
||||
StandaloneDatabaseProvider(context),
|
||||
cache,
|
||||
okHttpDataSourceFactory,
|
||||
Executors.newFixedThreadPool(2)
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePlaybackDataSourceFactory(
|
||||
cache: SimpleCache,
|
||||
okHttpDataSourceFactory: OkHttpDataSource.Factory
|
||||
): DataSource.Factory {
|
||||
return DownloadModuleFactories
|
||||
.newPhonePlaybackDataSourceFactory(okHttpDataSourceFactory)
|
||||
.setCache(cache)
|
||||
}
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
package hu.bbara.purefin.feature.download
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.offline.Download
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadRequest
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import hu.bbara.purefin.core.data.OfflineCatalogStore
|
||||
import hu.bbara.purefin.core.data.SmartDownloadStore
|
||||
import hu.bbara.purefin.core.data.client.playbackCustomCacheKey
|
||||
import hu.bbara.purefin.core.data.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.core.data.download.DownloadState
|
||||
import hu.bbara.purefin.core.data.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.core.model.Season
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Singleton
|
||||
class MediaDownloadManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val downloadManager: DownloadManager,
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
private val offlineCatalogStore: OfflineCatalogStore,
|
||||
private val smartDownloadStore: SmartDownloadStore,
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
) : MediaDownloadController {
|
||||
|
||||
private val stateFlows = ConcurrentHashMap<String, MutableStateFlow<DownloadState>>()
|
||||
|
||||
init {
|
||||
downloadManager.resumeDownloads()
|
||||
|
||||
downloadManager.addListener(object : DownloadManager.Listener {
|
||||
override fun onDownloadChanged(
|
||||
manager: DownloadManager,
|
||||
download: Download,
|
||||
finalException: Exception?
|
||||
) {
|
||||
val contentId = download.request.id
|
||||
val state = download.toDownloadState()
|
||||
Log.d(TAG, "Download changed: $contentId -> $state (${download.percentDownloaded}%)")
|
||||
if (finalException != null) {
|
||||
Log.e(TAG, "Download exception for $contentId", finalException)
|
||||
}
|
||||
getOrCreateStateFlow(contentId).value = state
|
||||
}
|
||||
|
||||
override fun onDownloadRemoved(manager: DownloadManager, download: Download) {
|
||||
val contentId = download.request.id
|
||||
Log.d(TAG, "Download removed: $contentId")
|
||||
getOrCreateStateFlow(contentId).value = DownloadState.NotDownloaded
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the download index every 500 ms and emits a map of contentId → progress (0–100)
|
||||
* for every download that is currently queued or in progress.
|
||||
* Uses the download index directly so it always reflects the true download state,
|
||||
* regardless of listener callback timing.
|
||||
*/
|
||||
override fun observeActiveDownloads(): Flow<Map<String, Float>> = flow {
|
||||
while (true) {
|
||||
try {
|
||||
val result = buildMap<String, Float> {
|
||||
val cursor = downloadManager.downloadIndex.getDownloads(
|
||||
Download.STATE_QUEUED,
|
||||
Download.STATE_DOWNLOADING,
|
||||
Download.STATE_RESTARTING
|
||||
)
|
||||
cursor.use {
|
||||
while (it.moveToNext()) {
|
||||
val d = it.download
|
||||
put(d.request.id, d.percentDownloaded)
|
||||
}
|
||||
}
|
||||
}
|
||||
emit(result)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to poll active downloads", e)
|
||||
emit(emptyMap())
|
||||
}
|
||||
delay(500)
|
||||
}
|
||||
}.flowOn(Dispatchers.IO).distinctUntilChanged()
|
||||
|
||||
override fun observeDownloadState(contentId: String): StateFlow<DownloadState> {
|
||||
val flow = getOrCreateStateFlow(contentId)
|
||||
// Initialize from current download index
|
||||
val download = downloadManager.downloadIndex.getDownload(contentId)
|
||||
flow.value = download?.toDownloadState() ?: DownloadState.NotDownloaded
|
||||
return flow
|
||||
}
|
||||
|
||||
fun isDownloaded(contentId: String): Boolean {
|
||||
return downloadManager.downloadIndex.getDownload(contentId)?.state == Download.STATE_COMPLETED
|
||||
}
|
||||
|
||||
override suspend fun downloadMovie(movieId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val sources = jellyfinApiClient.getMediaSources(movieId)
|
||||
val source = sources.firstOrNull() ?: run {
|
||||
Log.e(TAG, "No media sources for $movieId")
|
||||
return@withContext
|
||||
}
|
||||
val shouldTranscode = source.shouldUseTranscodingUrl()
|
||||
|
||||
val url = jellyfinApiClient.getMediaPlaybackUrl(movieId, source) ?: run {
|
||||
Log.e(TAG, "No playback URL for $movieId")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val itemInfo = jellyfinApiClient.getItemInfo(movieId) ?: run {
|
||||
Log.e(TAG, "No item info for $movieId")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val serverUrl = userSessionRepository.serverUrl.first().trim()
|
||||
val movie = Movie(
|
||||
id = itemInfo.id,
|
||||
libraryId = itemInfo.parentId ?: UUID.randomUUID(),
|
||||
title = itemInfo.name ?: "Unknown title",
|
||||
progress = itemInfo.userData?.playedPercentage,
|
||||
watched = itemInfo.userData?.played ?: false,
|
||||
year = itemInfo.productionYear?.toString()
|
||||
?: itemInfo.premiereDate?.year?.toString().orEmpty(),
|
||||
rating = itemInfo.officialRating ?: "NR",
|
||||
runtime = formatRuntime(itemInfo.runTimeTicks),
|
||||
synopsis = itemInfo.overview ?: "No synopsis available",
|
||||
format = itemInfo.container?.uppercase() ?: "VIDEO",
|
||||
imageUrlPrefix = JellyfinImageHelper.toPrefixImageUrl(
|
||||
url = serverUrl,
|
||||
itemId = itemInfo.id
|
||||
),
|
||||
subtitles = "ENG",
|
||||
audioTrack = "ENG",
|
||||
cast = emptyList()
|
||||
)
|
||||
|
||||
offlineCatalogStore.saveMovies(listOf(movie))
|
||||
|
||||
Log.d(TAG, "Starting download for '${movie.title}' from: $url")
|
||||
val request = buildDownloadRequest(
|
||||
mediaId = movieId,
|
||||
playbackUrl = url,
|
||||
title = movie.title,
|
||||
isDirectPlay = !shouldTranscode
|
||||
)
|
||||
PurefinDownloadService.sendAddDownload(context, request)
|
||||
Log.d(TAG, "Download request sent for $movieId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start download for $movieId", e)
|
||||
getOrCreateStateFlow(movieId.toString()).value = DownloadState.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cancelDownload(movieId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
PurefinDownloadService.sendRemoveDownload(context, movieId.toString())
|
||||
try {
|
||||
offlineCatalogStore.deleteMovie(movieId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove movie from offline DB", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun downloadEpisode(episodeId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val serverUrl = userSessionRepository.serverUrl.first().trim()
|
||||
val sources = jellyfinApiClient.getMediaSources(episodeId)
|
||||
val source = sources.firstOrNull() ?: run {
|
||||
Log.e(TAG, "No media sources for episode $episodeId")
|
||||
return@withContext
|
||||
}
|
||||
val shouldTranscode = source.shouldUseTranscodingUrl()
|
||||
|
||||
val url = jellyfinApiClient.getMediaPlaybackUrl(episodeId, source) ?: run {
|
||||
Log.e(TAG, "No playback URL for episode $episodeId")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val episode = jellyfinApiClient.getItemInfo(episodeId)?.toEpisode(serverUrl) ?: run {
|
||||
Log.e(TAG, "Episode not found $episodeId")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val series = jellyfinApiClient.getItemInfo(episode.seriesId)?.toSeries(serverUrl) ?: run {
|
||||
Log.e(TAG, "Series not found ${episode.seriesId}")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val season = jellyfinApiClient.getItemInfo(episode.seasonId)?.toSeason(series.id) ?: run {
|
||||
Log.e(TAG, "Season not found ${episode.seasonId}")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
if (offlineCatalogStore.getSeriesBasic(series.id) == null) {
|
||||
offlineCatalogStore.saveSeries(listOf(series))
|
||||
}
|
||||
|
||||
if (offlineCatalogStore.getSeason(season.id) == null) {
|
||||
offlineCatalogStore.saveSeason(season)
|
||||
}
|
||||
|
||||
offlineCatalogStore.saveEpisode(episode)
|
||||
|
||||
Log.d(TAG, "Starting download for episode '${episode.title}' from: $url")
|
||||
val request = buildDownloadRequest(
|
||||
mediaId = episodeId,
|
||||
playbackUrl = url,
|
||||
title = episode.title,
|
||||
isDirectPlay = !shouldTranscode
|
||||
)
|
||||
PurefinDownloadService.sendAddDownload(context, request)
|
||||
Log.d(TAG, "Download request sent for episode $episodeId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start download for episode $episodeId", e)
|
||||
getOrCreateStateFlow(episodeId.toString()).value = DownloadState.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun downloadEpisodes(episodeIds: List<UUID>) {
|
||||
coroutineScope {
|
||||
for (episodeId in episodeIds) {
|
||||
launch { downloadEpisode(episodeId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cancelEpisodeDownload(episodeId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
PurefinDownloadService.sendRemoveDownload(context, episodeId.toString())
|
||||
try {
|
||||
offlineCatalogStore.deleteEpisodeAndCleanup(episodeId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove episode from offline DB", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Smart Download ──────────────────────────────────────────────────
|
||||
|
||||
override suspend fun enableSmartDownload(seriesId: UUID) {
|
||||
smartDownloadStore.enable(seriesId)
|
||||
syncSmartDownloadsForSeries(seriesId)
|
||||
}
|
||||
|
||||
suspend fun disableSmartDownload(seriesId: UUID) {
|
||||
smartDownloadStore.disable(seriesId)
|
||||
}
|
||||
|
||||
fun isSmartDownloadEnabled(seriesId: UUID): Flow<Boolean> = smartDownloadStore.observe(seriesId)
|
||||
|
||||
override suspend fun syncSmartDownloads() {
|
||||
withContext(Dispatchers.IO) {
|
||||
val enabledSeriesIds = smartDownloadStore.getEnabledSeriesIds()
|
||||
for (seriesId in enabledSeriesIds) {
|
||||
try {
|
||||
syncSmartDownloadsForSeries(seriesId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Smart download sync failed for series $seriesId", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncSmartDownloadsForSeries(seriesId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val serverUrl = userSessionRepository.serverUrl.first().trim()
|
||||
|
||||
// 1. Get currently downloaded episodes for this series
|
||||
val downloadedEpisodes = offlineCatalogStore.getEpisodesBySeries(seriesId)
|
||||
|
||||
// 2. Check watched status from server and delete watched downloads
|
||||
val unwatchedDownloaded = mutableListOf<UUID>()
|
||||
for (episode in downloadedEpisodes) {
|
||||
val itemInfo = jellyfinApiClient.getItemInfo(episode.id)
|
||||
val isWatched = itemInfo?.userData?.played ?: false
|
||||
if (isWatched) {
|
||||
Log.d(TAG, "Smart download: removing watched episode ${episode.title}")
|
||||
cancelEpisodeDownload(episode.id)
|
||||
} else {
|
||||
unwatchedDownloaded.add(episode.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Get all episodes of the series from the server in order
|
||||
val seasons = jellyfinApiClient.getSeasons(seriesId)
|
||||
val allEpisodes = mutableListOf<BaseItemDto>()
|
||||
for (season in seasons) {
|
||||
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, season.id)
|
||||
allEpisodes.addAll(episodes)
|
||||
}
|
||||
|
||||
// 4. Find unwatched episodes not already downloaded
|
||||
val needed = SMART_DOWNLOAD_COUNT - unwatchedDownloaded.size
|
||||
if (needed <= 0) return@withContext
|
||||
|
||||
val toDownload = allEpisodes
|
||||
.filter { ep -> ep.userData?.played != true && ep.id !in unwatchedDownloaded }
|
||||
.take(needed)
|
||||
.map { it.id }
|
||||
|
||||
if (toDownload.isNotEmpty()) {
|
||||
Log.d(TAG, "Smart download: queuing ${toDownload.size} episodes for series $seriesId")
|
||||
downloadEpisodes(toDownload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreateStateFlow(contentId: String): MutableStateFlow<DownloadState> {
|
||||
return stateFlows.getOrPut(contentId) { MutableStateFlow(DownloadState.NotDownloaded) }
|
||||
}
|
||||
|
||||
private fun buildDownloadRequest(
|
||||
mediaId: UUID,
|
||||
playbackUrl: String,
|
||||
title: String,
|
||||
isDirectPlay: Boolean
|
||||
): DownloadRequest {
|
||||
val builder = DownloadRequest.Builder(mediaId.toString(), playbackUrl.toUri())
|
||||
.setData(title.toByteArray(Charsets.UTF_8))
|
||||
|
||||
if (isDirectPlay) {
|
||||
playbackCustomCacheKey(
|
||||
mediaId = mediaId.toString(),
|
||||
playbackUrl = playbackUrl,
|
||||
playMethod = PlayMethod.DIRECT_PLAY
|
||||
)?.let(builder::setCustomCacheKey)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun Download.toDownloadState(): DownloadState = when (state) {
|
||||
Download.STATE_COMPLETED -> DownloadState.Downloaded
|
||||
Download.STATE_DOWNLOADING -> DownloadState.Downloading(percentDownloaded)
|
||||
Download.STATE_QUEUED, Download.STATE_RESTARTING -> DownloadState.Downloading(0f)
|
||||
Download.STATE_FAILED -> DownloadState.Failed
|
||||
Download.STATE_REMOVING -> DownloadState.NotDownloaded
|
||||
Download.STATE_STOPPED -> DownloadState.NotDownloaded
|
||||
else -> DownloadState.NotDownloaded
|
||||
}
|
||||
|
||||
private fun formatRuntime(ticks: Long?): String {
|
||||
if (ticks == null || ticks <= 0) return "—"
|
||||
val totalSeconds = ticks / 10_000_000
|
||||
val hours = java.util.concurrent.TimeUnit.SECONDS.toHours(totalSeconds)
|
||||
val minutes = java.util.concurrent.TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
|
||||
return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
|
||||
}
|
||||
|
||||
private fun BaseItemDto.toEpisode(serverUrl: String): Episode {
|
||||
return Episode(
|
||||
id = id,
|
||||
seriesId = seriesId!!,
|
||||
seasonId = parentId!!,
|
||||
title = name ?: "Unknown title",
|
||||
index = indexNumber ?: 0,
|
||||
releaseDate = productionYear?.toString() ?: "—",
|
||||
rating = officialRating ?: "NR",
|
||||
runtime = formatRuntime(runTimeTicks),
|
||||
progress = userData?.playedPercentage,
|
||||
watched = userData?.played ?: false,
|
||||
format = container?.uppercase() ?: "VIDEO",
|
||||
synopsis = overview ?: "No synopsis available.",
|
||||
imageUrlPrefix = JellyfinImageHelper.toPrefixImageUrl(
|
||||
url = serverUrl,
|
||||
itemId = id
|
||||
),
|
||||
cast = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun BaseItemDto.toSeries(serverUrl: String): Series {
|
||||
return Series(
|
||||
id = id,
|
||||
libraryId = parentId ?: java.util.UUID.randomUUID(),
|
||||
name = name ?: "Unknown",
|
||||
synopsis = overview ?: "No synopsis available",
|
||||
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
|
||||
imageUrlPrefix = JellyfinImageHelper.toPrefixImageUrl(
|
||||
url = serverUrl,
|
||||
itemId = id
|
||||
),
|
||||
unwatchedEpisodeCount = userData?.unplayedItemCount ?: 0,
|
||||
seasonCount = childCount ?: 0,
|
||||
seasons = emptyList(),
|
||||
cast = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun BaseItemDto.toSeason(seriesId: UUID): Season {
|
||||
return Season(
|
||||
id = id,
|
||||
seriesId = this.seriesId ?: seriesId,
|
||||
name = name ?: "Unknown",
|
||||
index = indexNumber ?: 0,
|
||||
unwatchedEpisodeCount = userData?.unplayedItemCount ?: 0,
|
||||
episodeCount = childCount ?: 0,
|
||||
episodes = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MediaDownloadManager"
|
||||
private const val SMART_DOWNLOAD_COUNT = 5
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaSourceInfo.shouldUseTranscodingUrl(): Boolean {
|
||||
return supportsTranscoding == true &&
|
||||
(supportsDirectPlay == false || transcodingUrl != null)
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
package hu.bbara.purefin.feature.download
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.offline.Download
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
|
||||
import androidx.media3.exoplayer.offline.DownloadRequest
|
||||
import androidx.media3.exoplayer.offline.DownloadService
|
||||
import androidx.media3.exoplayer.scheduler.Scheduler
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class PurefinDownloadService : DownloadService(
|
||||
FOREGROUND_NOTIFICATION_ID,
|
||||
DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL,
|
||||
DOWNLOAD_CHANNEL_ID,
|
||||
R.string.download_channel_name,
|
||||
R.string.download_channel_description
|
||||
) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface DownloadServiceEntryPoint {
|
||||
fun downloadManager(): DownloadManager
|
||||
fun downloadNotificationHelper(): DownloadNotificationHelper
|
||||
}
|
||||
|
||||
private val entryPoint: DownloadServiceEntryPoint by lazy {
|
||||
EntryPointAccessors.fromApplication(applicationContext, DownloadServiceEntryPoint::class.java)
|
||||
}
|
||||
|
||||
private var lastBytesDownloaded: Long = 0L
|
||||
private var lastUpdateTimeMs: Long = 0L
|
||||
|
||||
override fun getDownloadManager(): DownloadManager = entryPoint.downloadManager()
|
||||
|
||||
override fun getForegroundNotification(
|
||||
downloads: MutableList<Download>,
|
||||
notMetRequirements: Int
|
||||
): Notification {
|
||||
val activeDownloads = downloads.filter { it.state == Download.STATE_DOWNLOADING }
|
||||
|
||||
if (activeDownloads.isEmpty()) {
|
||||
return entryPoint.downloadNotificationHelper().buildProgressNotification(
|
||||
this,
|
||||
R.drawable.ic_launcher_foreground,
|
||||
null,
|
||||
null,
|
||||
downloads,
|
||||
notMetRequirements
|
||||
)
|
||||
}
|
||||
|
||||
val totalBytes = activeDownloads.sumOf { it.bytesDownloaded }
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
val speedText = if (lastUpdateTimeMs > 0L) {
|
||||
val elapsed = (now - lastUpdateTimeMs).coerceAtLeast(1)
|
||||
val bytesPerSec = (totalBytes - lastBytesDownloaded) * 1000L / elapsed
|
||||
formatSpeed(bytesPerSec)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
lastBytesDownloaded = totalBytes
|
||||
lastUpdateTimeMs = now
|
||||
|
||||
val percent = if (activeDownloads.size == 1) {
|
||||
activeDownloads[0].percentDownloaded
|
||||
} else {
|
||||
activeDownloads.map { it.percentDownloaded }.average().toFloat()
|
||||
}
|
||||
|
||||
val title = if (activeDownloads.size == 1) {
|
||||
activeDownloads[0].request.data
|
||||
?.toString(Charsets.UTF_8)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: "Downloading"
|
||||
} else {
|
||||
"Downloading ${activeDownloads.size} files"
|
||||
}
|
||||
|
||||
val contentText = buildString {
|
||||
append("${percent.toInt()}%")
|
||||
if (speedText.isNotEmpty()) {
|
||||
append(" · $speedText")
|
||||
}
|
||||
}
|
||||
|
||||
return NotificationCompat.Builder(this, DOWNLOAD_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(title)
|
||||
.setContentText(contentText)
|
||||
.setProgress(100, percent.toInt(), false)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getScheduler(): Scheduler? = null
|
||||
|
||||
private fun formatSpeed(bytesPerSec: Long): String {
|
||||
if (bytesPerSec <= 0) return ""
|
||||
return when {
|
||||
bytesPerSec >= 1_000_000 -> String.format("%.1f MB/s", bytesPerSec / 1_000_000.0)
|
||||
bytesPerSec >= 1_000 -> String.format("%.0f KB/s", bytesPerSec / 1_000.0)
|
||||
else -> "$bytesPerSec B/s"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FOREGROUND_NOTIFICATION_ID = 1
|
||||
private const val DOWNLOAD_CHANNEL_ID = "purefin_downloads"
|
||||
|
||||
fun sendAddDownload(context: Context, request: DownloadRequest) {
|
||||
sendAddDownload(
|
||||
context,
|
||||
PurefinDownloadService::class.java,
|
||||
request,
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
fun sendRemoveDownload(context: Context, contentId: String) {
|
||||
sendRemoveDownload(
|
||||
context,
|
||||
PurefinDownloadService::class.java,
|
||||
contentId,
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="download_channel_name">Downloads</string>
|
||||
<string name="download_channel_description">Media download progress</string>
|
||||
</resources>
|
||||
@@ -1,43 +0,0 @@
|
||||
package hu.bbara.purefin.feature.download
|
||||
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.NoOpCacheEvictor
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DownloadModuleTest {
|
||||
|
||||
@Test
|
||||
fun `download cache keeps no-op evictor`() {
|
||||
assertTrue(DownloadModuleFactories.createDownloadCacheEvictor() is NoOpCacheEvictor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `phone playback data source reads download cache and writes upstream only`() {
|
||||
val upstreamFactory = FakeDataSourceFactory()
|
||||
val factory = DownloadModuleFactories.newPhonePlaybackDataSourceFactory(upstreamFactory)
|
||||
|
||||
assertTrue(readPrivateBoolean(factory, "cacheIsReadOnly"))
|
||||
assertNull(readPrivateField(factory, "cacheWriteDataSinkFactory"))
|
||||
assertSame(upstreamFactory, readPrivateField(factory, "upstreamDataSourceFactory"))
|
||||
}
|
||||
|
||||
private fun readPrivateBoolean(instance: CacheDataSource.Factory, fieldName: String): Boolean {
|
||||
return readPrivateField(instance, fieldName) as Boolean
|
||||
}
|
||||
|
||||
private fun readPrivateField(instance: CacheDataSource.Factory, fieldName: String): Any? {
|
||||
val field = instance.javaClass.getDeclaredField(fieldName)
|
||||
field.isAccessible = true
|
||||
return field.get(instance)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeDataSourceFactory : DataSource.Factory {
|
||||
override fun createDataSource(): DataSource {
|
||||
throw UnsupportedOperationException("Unused in unit tests")
|
||||
}
|
||||
}
|
||||
42
feature/downloads/build.gradle.kts
Normal file
42
feature/downloads/build.gradle.kts
Normal file
@@ -0,0 +1,42 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "hu.bbara.purefin.feature.downloads"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 29
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
implementation(project(":core:data"))
|
||||
implementation(project(":core:image"))
|
||||
implementation(project(":core:navigation"))
|
||||
implementation(project(":core:download"))
|
||||
implementation(project(":feature:browse"))
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.hilt)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package hu.bbara.purefin.feature.shared.download
|
||||
package hu.bbara.purefin.feature.downloads
|
||||
|
||||
data class ActiveDownloadItem(
|
||||
val contentId: String,
|
||||
@@ -1,23 +1,23 @@
|
||||
package hu.bbara.purefin.feature.shared.download
|
||||
package hu.bbara.purefin.feature.downloads
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.OfflineCatalogReader
|
||||
import hu.bbara.purefin.core.data.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.core.model.MediaKind
|
||||
import hu.bbara.purefin.feature.shared.navigation.MovieDto
|
||||
import hu.bbara.purefin.feature.shared.navigation.NavigationManager
|
||||
import hu.bbara.purefin.feature.shared.navigation.Route
|
||||
import hu.bbara.purefin.feature.shared.navigation.SeriesDto
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import hu.bbara.purefin.core.navigation.MovieDto
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||
import hu.bbara.purefin.feature.browse.home.PosterItem
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -72,7 +72,7 @@ class DownloadsViewModel @Inject constructor(
|
||||
contentId = contentId,
|
||||
title = movie.title,
|
||||
subtitle = "",
|
||||
imageUrl = JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY),
|
||||
imageUrl = ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.PRIMARY),
|
||||
progress = progress
|
||||
)
|
||||
} else {
|
||||
@@ -82,7 +82,7 @@ class DownloadsViewModel @Inject constructor(
|
||||
contentId = contentId,
|
||||
title = it.title,
|
||||
subtitle = seriesMap[it.seriesId]?.name ?: "",
|
||||
imageUrl = JellyfinImageHelper.finishImageUrl(it.imageUrlPrefix, ImageType.PRIMARY),
|
||||
imageUrl = ImageUrlBuilder.finishImageUrl(it.imageUrlPrefix, ArtworkKind.PRIMARY),
|
||||
progress = progress
|
||||
)
|
||||
}
|
||||
36
feature/login/build.gradle.kts
Normal file
36
feature/login/build.gradle.kts
Normal file
@@ -0,0 +1,36 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "hu.bbara.purefin.feature.login"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 29
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:data"))
|
||||
implementation(libs.hilt)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package hu.bbara.purefin.feature.shared.login
|
||||
package hu.bbara.purefin.feature.login
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.core.data.AuthenticationRepository
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -15,7 +15,7 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
private val authenticationRepository: AuthenticationRepository,
|
||||
) : ViewModel() {
|
||||
private val _username = MutableStateFlow("")
|
||||
val username: StateFlow<String> = _username.asStateFlow()
|
||||
@@ -57,7 +57,7 @@ class LoginViewModel @Inject constructor(
|
||||
suspend fun login(): Boolean {
|
||||
_errorMessage.value = null
|
||||
userSessionRepository.setServerUrl(url.value)
|
||||
val success = jellyfinApiClient.login(url.value, username.value, password.value)
|
||||
val success = authenticationRepository.login(url.value, username.value, password.value)
|
||||
if (!success) {
|
||||
_errorMessage.value = "Login failed. Check your server URL, username, and password."
|
||||
}
|
||||
@@ -8,7 +8,7 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "hu.bbara.purefin.feature.download"
|
||||
namespace = "hu.bbara.purefin.feature.search"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
@@ -30,11 +30,9 @@ kotlin {
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
implementation(project(":core:data"))
|
||||
implementation(project(":core:image"))
|
||||
implementation(libs.hilt)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.medi3.exoplayer)
|
||||
implementation(libs.media3.datasource.okhttp)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.jellyfin.core)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package hu.bbara.purefin.feature.shared.search
|
||||
package hu.bbara.purefin.feature.search
|
||||
|
||||
import hu.bbara.purefin.core.model.MediaKind
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
@@ -1,10 +1,10 @@
|
||||
package hu.bbara.purefin.feature.shared.search
|
||||
package hu.bbara.purefin.feature.search
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.MediaCatalogReader
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
@@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -55,7 +55,7 @@ class SearchViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun createImageUrl(id: UUID) : String {
|
||||
return JellyfinImageHelper.toImageUrl(userSessionRepository.serverUrl.first(), id,
|
||||
ImageType.PRIMARY)
|
||||
return ImageUrlBuilder.toImageUrl(userSessionRepository.serverUrl.first(), id,
|
||||
ArtworkKind.PRIMARY)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package hu.bbara.purefin.feature.shared.navigation
|
||||
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
|
||||
@Serializable
|
||||
data class EpisodeDto(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
val id: UUID,
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
val seasonId: UUID,
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
val seriesId: UUID,
|
||||
)
|
||||
@@ -1,12 +0,0 @@
|
||||
package hu.bbara.purefin.feature.shared.navigation
|
||||
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
|
||||
@Serializable
|
||||
data class LibraryDto(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
val id: UUID,
|
||||
val name: String,
|
||||
)
|
||||
@@ -1,11 +0,0 @@
|
||||
package hu.bbara.purefin.feature.shared.navigation
|
||||
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
|
||||
@Serializable
|
||||
data class MovieDto(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
val id: UUID,
|
||||
)
|
||||
@@ -1,40 +0,0 @@
|
||||
package hu.bbara.purefin.feature.shared.navigation
|
||||
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
sealed interface NavigationCommand {
|
||||
data class Navigate(val route: Route) : NavigationCommand
|
||||
data class ReplaceAll(val route: Route) : NavigationCommand
|
||||
data object Pop : NavigationCommand
|
||||
}
|
||||
|
||||
interface NavigationManager {
|
||||
val commands: SharedFlow<NavigationCommand>
|
||||
fun navigate(route: Route)
|
||||
fun replaceAll(route: Route)
|
||||
fun pop()
|
||||
}
|
||||
|
||||
class DefaultNavigationManager : NavigationManager {
|
||||
private val commandsFlow = MutableSharedFlow<NavigationCommand>(
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
override val commands: SharedFlow<NavigationCommand> = commandsFlow.asSharedFlow()
|
||||
|
||||
override fun navigate(route: Route) {
|
||||
commandsFlow.tryEmit(NavigationCommand.Navigate(route))
|
||||
}
|
||||
|
||||
override fun replaceAll(route: Route) {
|
||||
commandsFlow.tryEmit(NavigationCommand.ReplaceAll(route))
|
||||
}
|
||||
|
||||
override fun pop() {
|
||||
commandsFlow.tryEmit(NavigationCommand.Pop)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package hu.bbara.purefin.feature.shared.navigation
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NavigationManagerModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideNavigationManager(): NavigationManager = DefaultNavigationManager()
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package hu.bbara.purefin.feature.shared.navigation
|
||||
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed interface Route : NavKey {
|
||||
@Serializable
|
||||
data object Home : Route
|
||||
|
||||
@Serializable
|
||||
data class MovieRoute(val item: MovieDto) : Route
|
||||
|
||||
@Serializable
|
||||
data class SeriesRoute(val item: SeriesDto) : Route
|
||||
|
||||
@Serializable
|
||||
data class EpisodeRoute(val item: EpisodeDto) : Route
|
||||
|
||||
@Serializable
|
||||
data class LibraryRoute(val library: LibraryDto) : Route
|
||||
|
||||
@Serializable
|
||||
data object LoginRoute : Route
|
||||
|
||||
@Serializable
|
||||
data class PlayerRoute(val mediaId: String) : Route
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package hu.bbara.purefin.feature.shared.navigation
|
||||
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
|
||||
@Serializable
|
||||
data class SeriesDto(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
val id: UUID,
|
||||
)
|
||||
@@ -1,66 +0,0 @@
|
||||
package hu.bbara.purefin.feature.shared.navigation
|
||||
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RouteSerializationTest {
|
||||
|
||||
private val json = Json
|
||||
|
||||
@Test
|
||||
fun `episode route round trips with nested dto ids`() {
|
||||
val route: Route = Route.EpisodeRoute(
|
||||
item = EpisodeDto(
|
||||
id = UUID.randomUUID(),
|
||||
seasonId = UUID.randomUUID(),
|
||||
seriesId = UUID.randomUUID(),
|
||||
)
|
||||
)
|
||||
|
||||
assertRoundTrip(route)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `library route round trips`() {
|
||||
val route: Route = Route.LibraryRoute(
|
||||
library = LibraryDto(
|
||||
id = UUID.randomUUID(),
|
||||
name = "Shows",
|
||||
)
|
||||
)
|
||||
|
||||
assertRoundTrip(route)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `movie and series routes round trip`() {
|
||||
assertRoundTrip(
|
||||
Route.MovieRoute(
|
||||
item = MovieDto(id = UUID.randomUUID())
|
||||
)
|
||||
)
|
||||
assertRoundTrip(
|
||||
Route.SeriesRoute(
|
||||
item = SeriesDto(id = UUID.randomUUID())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `singleton routes round trip`() {
|
||||
assertRoundTrip(Route.Home)
|
||||
assertRoundTrip(Route.LoginRoute)
|
||||
assertRoundTrip(Route.PlayerRoute(mediaId = UUID.randomUUID().toString()))
|
||||
}
|
||||
|
||||
private fun assertRoundTrip(route: Route) {
|
||||
val encoded = json.encodeToString(route)
|
||||
val decoded = json.decodeFromString<Route>(encoded)
|
||||
|
||||
assertEquals(route, decoded)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user