From 7ebfdb016b5f76e2ab5b19e8ffb135f4f6318d4c Mon Sep 17 00:00:00 2001 From: newmskywalker <3849278+newmskywalker@users.noreply.github.com> Date: Tue, 28 Jan 2025 12:29:53 -0500 Subject: [PATCH] Add progress indicator for downloads --- android/app-newm/build.gradle.kts | 1 + .../java/io/newm/di/android/Dependencies.kt | 17 +- .../screens/library/NFTLibraryPresenter.kt | 14 +- .../screens/library/NFTLibraryScreenUi.kt | 85 ++++++--- .../newm/screens/library/NFTLibraryState.kt | 2 + .../musicplayer/service/DownloadManager.kt | 15 +- .../musicplayer/service/DownloadState.kt | 8 + .../service/DownloadStateManager.kt | 161 ++++++++++++++++++ .../service/NewmDownloadService.kt | 76 +-------- 9 files changed, 282 insertions(+), 97 deletions(-) create mode 100644 android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/DownloadState.kt create mode 100644 android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/DownloadStateManager.kt diff --git a/android/app-newm/build.gradle.kts b/android/app-newm/build.gradle.kts index e5f89e09..963e2e21 100644 --- a/android/app-newm/build.gradle.kts +++ b/android/app-newm/build.gradle.kts @@ -105,6 +105,7 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.material) implementation(libs.androidx.media3.datasource) + implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.database) implementation(libs.androidx.navigation.ui.ktx) implementation(libs.launchdarkly.client) diff --git a/android/app-newm/src/main/java/io/newm/di/android/Dependencies.kt b/android/app-newm/src/main/java/io/newm/di/android/Dependencies.kt index 32949845..7d058d84 100644 --- a/android/app-newm/src/main/java/io/newm/di/android/Dependencies.kt +++ b/android/app-newm/src/main/java/io/newm/di/android/Dependencies.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import androidx.activity.result.contract.ActivityResultContracts import androidx.media3.database.DatabaseProvider import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.cache.Cache import androidx.media3.datasource.cache.NoOpCacheEvictor import androidx.media3.datasource.cache.SimpleCache @@ -22,6 +23,8 @@ import io.newm.feature.login.screen.resetpassword.ResetPasswordScreenPresenter import io.newm.feature.login.screen.welcome.WelcomeScreenPresenter import io.newm.feature.musicplayer.service.DownloadManager import io.newm.feature.musicplayer.service.DownloadManagerImpl +import io.newm.feature.musicplayer.service.DownloadStateManager +import io.newm.feature.musicplayer.service.DownloadStateManagerImpl import io.newm.screens.forceupdate.ForceAppUpdatePresenter import io.newm.screens.library.NFTLibraryPresenter import io.newm.screens.profile.edit.ProfileEditPresenter @@ -33,6 +36,8 @@ import io.newm.utils.AndroidFeatureFlagManager import io.newm.utils.ForceAppUpdateViewModel import org.koin.android.ext.koin.androidContext import org.koin.dsl.module +import java.util.concurrent.Executor +import androidx.media3.exoplayer.offline.DownloadManager as ExoDownloadManager @SuppressLint("UnsafeOptInUsageError") val viewModule = module { @@ -127,7 +132,17 @@ val viewModule = module { val downloadDirectory = androidContext().getExternalFilesDir(null)!! SimpleCache(downloadDirectory, NoOpCacheEvictor(), get()) } - single { DownloadManagerImpl(androidContext()) } + single { DownloadManagerImpl(androidContext(), get()) } + single { DownloadStateManagerImpl(get(), get(), get()) } + single { + ExoDownloadManager( + androidContext(), + get(), + get(), + DefaultHttpDataSource.Factory(), + Executor(Runnable::run) + ) + } } val androidModules = module { diff --git a/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryPresenter.kt b/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryPresenter.kt index da043625..28434c37 100644 --- a/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryPresenter.kt +++ b/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryPresenter.kt @@ -28,7 +28,9 @@ import io.newm.shared.public.usecases.HasWalletConnectionsUseCase import io.newm.shared.public.usecases.SyncWalletConnectionsUseCase import io.newm.shared.public.usecases.WalletNFTTracksUseCase import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch class NFTLibraryPresenter( @@ -134,6 +136,16 @@ class NFTLibraryPresenter( } } + // Collect download states through DownloadManager + val downloadStates by remember(nftTracks) { + combine( + nftTracks.map { track -> + downloadManager.getDownloadState(track.id) + .map { state -> track.id to state } + } + ) { states -> states.toMap() } + }.collectAsRetainedState(initial = emptyMap()) + return when { isLoading -> NFTLibraryState.Loading isWalletConnected == false -> NFTLibraryState.LinkWallet { newmWalletConnectionId -> @@ -141,7 +153,6 @@ class NFTLibraryPresenter( connectWalletUseCase.connect(newmWalletConnectionId) } } - isWalletEmpty -> NFTLibraryState.EmptyWallet else -> { NFTLibraryState.Content( @@ -150,6 +161,7 @@ class NFTLibraryPresenter( showZeroResultFound = showZeroResultFound, filters = filters, refreshing = refreshing, + downloadStates = downloadStates, eventSink = { event -> when (event) { is NFTLibraryEvent.OnDownloadTrack -> { diff --git a/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryScreenUi.kt b/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryScreenUi.kt index e7a6d0b0..83db262d 100644 --- a/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryScreenUi.kt +++ b/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryScreenUi.kt @@ -19,12 +19,15 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -66,6 +69,7 @@ import io.newm.core.ui.text.SearchBar import io.newm.core.ui.utils.ErrorScreen import io.newm.core.ui.utils.drawWithBrush import io.newm.core.ui.utils.textGradient +import io.newm.feature.musicplayer.service.DownloadState import io.newm.screens.library.NFTLibraryEvent.OnApplyFilters import io.newm.screens.library.NFTLibraryEvent.OnDownloadTrack import io.newm.screens.library.NFTLibraryEvent.OnQueryChange @@ -129,7 +133,8 @@ fun NFTLibraryScreenUi( } ErrorScreen( title = stringResource(R.string.nft_library_error_message), - message =state.message) + message = state.message + ) } is NFTLibraryState.Content -> { @@ -164,6 +169,7 @@ fun NFTLibraryScreenUi( onApplyFilters = { filters -> eventSink(OnApplyFilters(filters)) }, currentTrackId = state.currentTrackId, downloadsEnabled = state.downloadsEnabled, + downloadStates = state.downloadStates, ) } } @@ -188,6 +194,7 @@ private fun NFTTracks( onApplyFilters: (NFTLibraryFilters) -> Unit, currentTrackId: String?, downloadsEnabled: Boolean, + downloadStates: Map, ) { val scope = rememberCoroutineScope() val filterSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) @@ -231,13 +238,9 @@ private fun NFTTracks( showZeroResultsFound -> item { ZeroSearchResults() } nftTracks.isNotEmpty() || streamTokenTracks.isNotEmpty() -> { - items(nftTracks + streamTokenTracks, key = { track -> - // Use the unique ID as the key - track.id - }) { track -> + items(nftTracks + streamTokenTracks, key = { track -> track.id }) { track -> Box( - modifier = Modifier - .background(Gray16) + modifier = Modifier.background(Gray16) ) { TrackRowItemWrapper( track = track, @@ -245,6 +248,7 @@ private fun NFTTracks( onDownloadSong = { onDownloadSong(track) }, isSelected = track.id == currentTrackId, downloadsEnabled = downloadsEnabled, + downloadState = downloadStates[track.id] ?: DownloadState.None, ) } } @@ -270,6 +274,7 @@ private fun TrackRowItemWrapper( onDownloadSong: () -> Unit, isSelected: Boolean, downloadsEnabled: Boolean, + downloadState: DownloadState, ) { val swipeableState = rememberSwipeableState(initialValue = false) val deltaX = with(LocalDensity.current) { 82.dp.toPx() } @@ -289,8 +294,17 @@ private fun TrackRowItemWrapper( ), ) ) { - if (!track.isDownloaded && downloadsEnabled) { - RevealedPanel(onDownloadSong) + if (downloadsEnabled) { + val coroutineScope = rememberCoroutineScope() + + RevealedPanel( + onDownloadClick = { + coroutineScope.launch { + swipeableState.animateTo(false) + } + onDownloadSong() + } + ) } TrackRowItem( track = track, @@ -301,7 +315,8 @@ private fun TrackRowItemWrapper( y = 0 ) } else Modifier, - isSelected = isSelected + isSelected = isSelected, + downloadState = downloadState, ) } } @@ -311,7 +326,8 @@ private fun TrackRowItem( track: NFTTrack, onClick: (NFTTrack) -> Unit, modifier: Modifier, - isSelected: Boolean + isSelected: Boolean, + downloadState: DownloadState, ) { Row( modifier = modifier @@ -319,7 +335,6 @@ private fun TrackRowItem( .clickable(onClick = { onClick(track) }) .fillMaxSize(), verticalAlignment = Alignment.CenterVertically - ) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) @@ -345,14 +360,41 @@ private fun TrackRowItem( color = if (isSelected) StatusGreen else White ) Row(verticalAlignment = Alignment.CenterVertically) { - if (track.isDownloaded) { - Icon( - painter = painterResource(id = R.drawable.ic_downloaded), - contentDescription = stringResource(R.string.downloaded_description), - tint = StatusGreen - ) - Spacer(modifier = Modifier.size(4.dp)) + when (downloadState) { + is DownloadState.Downloading -> { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = StatusGreen + ) + Spacer(modifier = Modifier.size(4.dp)) + } + + is DownloadState.Failed -> { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = "Failed", + tint = MaterialTheme.colors.error, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.size(4.dp)) + } + + is DownloadState.Completed -> { + Icon( + painter = painterResource(id = R.drawable.ic_downloaded), + contentDescription = stringResource(R.string.downloaded_description), + tint = StatusGreen, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.size(4.dp)) + } + + DownloadState.None -> { + // Show nothing + } } + Text( text = track.artists.joinToString(","), fontFamily = inter, @@ -379,6 +421,11 @@ fun PreviewNftLibrary() { showShortTracks = false ), refreshing = false, + downloadStates = mapOf( + "track1" to DownloadState.Downloading(0.5f), + "track2" to DownloadState.Completed, + "track3" to DownloadState.Failed("Error message") + ), eventSink = {}, currentTrackId = null, downloadsEnabled = true, diff --git a/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryState.kt b/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryState.kt index 76cb2d59..e0047c19 100644 --- a/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryState.kt +++ b/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryState.kt @@ -1,6 +1,7 @@ package io.newm.screens.library import com.slack.circuit.runtime.CircuitUiState +import io.newm.feature.musicplayer.service.DownloadState import io.newm.shared.public.models.NFTTrack sealed interface NFTLibraryState : CircuitUiState { @@ -16,6 +17,7 @@ sealed interface NFTLibraryState : CircuitUiState { val showZeroResultFound: Boolean, val filters: NFTLibraryFilters, val refreshing: Boolean, + val downloadStates: Map, val eventSink: (NFTLibraryEvent) -> Unit, val currentTrackId: String?, val downloadsEnabled: Boolean diff --git a/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/DownloadManager.kt b/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/DownloadManager.kt index 551ad167..f8900862 100644 --- a/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/DownloadManager.kt +++ b/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/DownloadManager.kt @@ -5,16 +5,23 @@ import android.net.Uri import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.offline.DownloadRequest import androidx.media3.exoplayer.offline.DownloadService +import kotlinx.coroutines.flow.Flow interface DownloadManager { fun download(id: String, url: String) + fun getDownloadState(id: String): Flow } +@UnstableApi class DownloadManagerImpl( - private val context: Context + private val context: Context, + private val downloadStateManager: DownloadStateManager, ) : DownloadManager { + @UnstableApi override fun download(id: String, url: String) { + downloadStateManager.updateDownloadState(id, DownloadState.Downloading(0f)) + val uri = Uri.parse(url) val downloadRequest = DownloadRequest.Builder(id, uri).build() @@ -22,7 +29,11 @@ class DownloadManagerImpl( context, NewmDownloadService::class.java, downloadRequest, - true // isForeground + true ) } + + override fun getDownloadState(id: String): Flow { + return downloadStateManager.getDownloadState(id) + } } \ No newline at end of file diff --git a/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/DownloadState.kt b/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/DownloadState.kt new file mode 100644 index 00000000..aeae1492 --- /dev/null +++ b/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/DownloadState.kt @@ -0,0 +1,8 @@ +package io.newm.feature.musicplayer.service + +sealed class DownloadState { + data object None : DownloadState() + data class Downloading(val progress: Float) : DownloadState() + data class Failed(val error: String) : DownloadState() + data object Completed : DownloadState() +} \ No newline at end of file diff --git a/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/DownloadStateManager.kt b/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/DownloadStateManager.kt new file mode 100644 index 00000000..01a9167a --- /dev/null +++ b/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/DownloadStateManager.kt @@ -0,0 +1,161 @@ +package io.newm.feature.musicplayer.service + +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadManager +import androidx.media3.exoplayer.scheduler.Requirements +import io.newm.shared.NewmAppLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import java.io.IOException + +interface DownloadStateManager { + fun getDownloadState(id: String): Flow + fun updateDownloadState(id: String, state: DownloadState) + fun initializeDownloadState(id: String, state: DownloadState) +} + +@UnstableApi +class DownloadStateManagerImpl( + private val exoDownloadManager: DownloadManager, + private val scope: CoroutineScope, + private val logger: NewmAppLogger, +): DownloadStateManager { + private val downloadStates = mutableMapOf>() + + init { + initializeDownloadStates() + + exoDownloadManager.addListener( + object : DownloadManager.Listener { + override fun onDownloadsPausedChanged( + downloadManager: DownloadManager, + downloadsPaused: Boolean + ) { + super.onDownloadsPausedChanged(downloadManager, downloadsPaused) + println("Downloads paused: $downloadsPaused") + } + + override fun onDownloadChanged( + downloadManager: DownloadManager, + download: Download, + finalException: Exception? + ) { + super.onDownloadChanged(downloadManager, download, finalException) + + when { + finalException != null -> { + updateDownloadState( + download.request.id, + DownloadState.Failed(finalException.message ?: "Download failed") + ) + } + download.state == Download.STATE_COMPLETED -> { + updateDownloadState( + download.request.id, + DownloadState.Completed + ) + } + download.state == Download.STATE_DOWNLOADING -> { + updateDownloadState( + download.request.id, + DownloadState.Downloading(download.percentDownloaded / 100f) + ) + } + } + } + + override fun onDownloadRemoved( + downloadManager: DownloadManager, + download: Download + ) { + super.onDownloadRemoved(downloadManager, download) + println("Download removed: $download") + } + + override fun onIdle(downloadManager: DownloadManager) { + super.onIdle(downloadManager) + println("DownloadManager idle") + } + + override fun onRequirementsStateChanged( + downloadManager: DownloadManager, + requirements: Requirements, + notMetRequirements: Int + ) { + super.onRequirementsStateChanged( + downloadManager, + requirements, + notMetRequirements + ) + println("Requirements state changed: $requirements") + } + + override fun onWaitingForRequirementsChanged( + downloadManager: DownloadManager, + waitingForRequirements: Boolean + ) { + super.onWaitingForRequirementsChanged(downloadManager, waitingForRequirements) + println("Waiting for requirements: $waitingForRequirements") + } + } + ) + } + + private fun initializeDownloadStates() { + scope.launch(Dispatchers.IO) { + try { + exoDownloadManager.downloadIndex.getDownloads().use { cursor -> + while (cursor.moveToNext()) { + val download = cursor.download + when (download.state) { + Download.STATE_COMPLETED -> { + updateDownloadState( + download.request.id, + DownloadState.Completed + ) + } + + Download.STATE_DOWNLOADING -> { + updateDownloadState( + download.request.id, + DownloadState.Downloading(download.percentDownloaded / 100f) + ) + } + + Download.STATE_FAILED -> { + updateDownloadState( + download.request.id, + DownloadState.Failed("Download failed") + ) + } + } + } + } + } catch (e: IOException) { + logger.error("DownloadManager","Failed to initialize download states", e) + } + } + + } + + override fun getDownloadState(id: String): Flow { + return downloadStates.getOrPut(id) { MutableStateFlow(DownloadState.None) } + } + + override fun updateDownloadState(id: String, state: DownloadState) { + downloadStates.getOrPut(id) { MutableStateFlow(DownloadState.None) } + .value = state + } + + override fun initializeDownloadState(id: String, state: DownloadState) { + // Only initialize if state doesn't exist or is None + val currentFlow = downloadStates.getOrPut(id) { MutableStateFlow(state) } + if (currentFlow.value == DownloadState.None) { + currentFlow.value = state + } + } +} \ No newline at end of file diff --git a/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/NewmDownloadService.kt b/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/NewmDownloadService.kt index 6b27922c..8b3b0372 100644 --- a/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/NewmDownloadService.kt +++ b/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/NewmDownloadService.kt @@ -2,19 +2,14 @@ package io.newm.feature.musicplayer.service import android.app.Notification import androidx.media3.common.util.UnstableApi -import androidx.media3.database.DatabaseProvider -import androidx.media3.datasource.DefaultHttpDataSource -import androidx.media3.datasource.cache.Cache import androidx.media3.exoplayer.offline.Download import androidx.media3.exoplayer.offline.DownloadManager import androidx.media3.exoplayer.offline.DownloadNotificationHelper import androidx.media3.exoplayer.offline.DownloadService -import androidx.media3.exoplayer.scheduler.Requirements import androidx.media3.exoplayer.scheduler.Scheduler import androidx.media3.exoplayer.workmanager.WorkManagerScheduler import io.newm.feature.musicplayer.R import org.koin.android.ext.android.inject -import java.util.concurrent.Executor private const val WORK_NAME: String = "NewmDownload" private const val FOREGROUND_NOTIFICATION_ID: Int = 1 @@ -29,78 +24,11 @@ internal class NewmDownloadService : DownloadService( R.string.musicplayer_exo_download_notification_channel_name, 0 ) { - private val databaseProvider : DatabaseProvider by inject() - private val downloadCache : Cache by inject() - // Create a factory for reading the data from the network. - private val dataSourceFactory = DefaultHttpDataSource.Factory() - - private val downloadExecutor = Executor(Runnable::run) + private val exoDownloadManager : DownloadManager by inject() override fun getDownloadManager(): DownloadManager { - val downloadManager = DownloadManager(this, databaseProvider, downloadCache, dataSourceFactory, downloadExecutor) - - downloadManager.addListener( - object : DownloadManager.Listener { - override fun onInitialized(downloadManager: DownloadManager) { - super.onInitialized(downloadManager) - println("DownloadManager initialized") - } - - override fun onDownloadsPausedChanged( - downloadManager: DownloadManager, - downloadsPaused: Boolean - ) { - super.onDownloadsPausedChanged(downloadManager, downloadsPaused) - println("Downloads paused: $downloadsPaused") - } - - override fun onDownloadChanged( - downloadManager: DownloadManager, - download: Download, - finalException: Exception? - ) { - super.onDownloadChanged(downloadManager, download, finalException) - println("Download changed: ${download.request.uri}") - println("${download.percentDownloaded}% downloaded") - } - - override fun onDownloadRemoved( - downloadManager: DownloadManager, - download: Download - ) { - super.onDownloadRemoved(downloadManager, download) - println("Download removed: $download") - } - - override fun onIdle(downloadManager: DownloadManager) { - super.onIdle(downloadManager) - println("DownloadManager idle") - } - - override fun onRequirementsStateChanged( - downloadManager: DownloadManager, - requirements: Requirements, - notMetRequirements: Int - ) { - super.onRequirementsStateChanged( - downloadManager, - requirements, - notMetRequirements - ) - println("Requirements state changed: $requirements") - } - - override fun onWaitingForRequirementsChanged( - downloadManager: DownloadManager, - waitingForRequirements: Boolean - ) { - super.onWaitingForRequirementsChanged(downloadManager, waitingForRequirements) - println("Waiting for requirements: $waitingForRequirements") - } - } - ) - return downloadManager + return exoDownloadManager } override fun getScheduler(): Scheduler {