refactor: modularize app into multi-module architecture

This commit is contained in:
2026-02-21 00:15:51 +01:00
parent 8601ef0236
commit 7333781f83
123 changed files with 668 additions and 404 deletions

View File

@@ -0,0 +1,39 @@
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.download"
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(libs.hilt)
ksp(libs.hilt.compiler)
implementation(libs.medi3.exoplayer)
implementation(libs.media3.datasource.okhttp)
implementation(libs.okhttp)
implementation(libs.jellyfin.core)
}

View File

@@ -0,0 +1,75 @@
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.cache.CacheDataSource
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 okhttp3.OkHttpClient
import java.io.File
import java.util.concurrent.Executors
import javax.inject.Singleton
@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, NoOpCacheEvictor(), StandaloneDatabaseProvider(context))
}
@Provides
@Singleton
fun provideOkHttpDataSourceFactory(okHttpClient: OkHttpClient): OkHttpDataSource.Factory {
return OkHttpDataSource.Factory(okHttpClient)
}
@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 provideCacheDataSourceFactory(
cache: SimpleCache,
okHttpDataSourceFactory: OkHttpDataSource.Factory
): CacheDataSource.Factory {
return CacheDataSource.Factory()
.setCache(cache)
.setUpstreamDataSourceFactory(okHttpDataSourceFactory)
}
}

View File

@@ -0,0 +1,8 @@
package hu.bbara.purefin.feature.download
sealed class DownloadState {
data object NotDownloaded : DownloadState()
data class Downloading(val progressPercent: Float) : DownloadState()
data object Downloaded : DownloadState()
data object Failed : DownloadState()
}

View File

@@ -0,0 +1,172 @@
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.client.JellyfinApiClient
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.local.room.OfflineDatabase
import hu.bbara.purefin.core.data.local.room.OfflineRoomMediaLocalDataSource
import hu.bbara.purefin.core.data.local.room.dao.MovieDao
import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.model.Movie
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.ImageType
import java.util.UUID
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,
@OfflineDatabase private val offlineDataSource: OfflineRoomMediaLocalDataSource,
@OfflineDatabase private val movieDao: MovieDao,
private val userSessionRepository: UserSessionRepository
) {
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
}
})
}
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
}
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 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",
heroImageUrl = JellyfinImageHelper.toImageUrl(
url = serverUrl,
itemId = itemInfo.id,
type = ImageType.PRIMARY
),
subtitles = "ENG",
audioTrack = "ENG",
cast = emptyList()
)
offlineDataSource.saveMovies(listOf(movie))
Log.d(TAG, "Starting download for '${movie.title}' from: $url")
val request = DownloadRequest.Builder(movieId.toString(), url.toUri()).build()
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
}
}
}
suspend fun cancelDownload(movieId: UUID) {
withContext(Dispatchers.IO) {
PurefinDownloadService.sendRemoveDownload(context, movieId.toString())
try {
movieDao.deleteById(movieId)
} catch (e: Exception) {
Log.e(TAG, "Failed to remove movie from offline DB", e)
}
}
}
private fun getOrCreateStateFlow(contentId: String): MutableStateFlow<DownloadState> {
return stateFlows.getOrPut(contentId) { MutableStateFlow(DownloadState.NotDownloaded) }
}
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"
}
companion object {
private const val TAG = "MediaDownloadManager"
}
}

View File

@@ -0,0 +1,137 @@
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) {
"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
)
}
}
}

View File

@@ -0,0 +1,30 @@
<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>

View File

@@ -0,0 +1,5 @@
<?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>