diff --git a/android/app-newm/build.gradle.kts b/android/app-newm/build.gradle.kts index de9f2de6..52832c73 100644 --- a/android/app-newm/build.gradle.kts +++ b/android/app-newm/build.gradle.kts @@ -97,6 +97,8 @@ dependencies { implementation(libs.firebase.analytics.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.material) + implementation(libs.androidx.media3.datasource) + implementation(libs.androidx.media3.database) implementation(libs.androidx.navigation.ui.ktx) implementation(libs.launchdarkly.client) implementation(libs.play.services.auth) 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 d3472bef..e7b7a9f2 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 @@ -1,6 +1,12 @@ package io.newm.di.android +import android.annotation.SuppressLint import androidx.activity.result.contract.ActivityResultContracts +import androidx.media3.database.DatabaseProvider +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.NoOpCacheEvictor +import androidx.media3.datasource.cache.SimpleCache import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.gms.auth.api.signin.GoogleSignInOptions import com.google.android.gms.common.Scopes @@ -14,6 +20,8 @@ import io.newm.feature.login.screen.createaccount.CreateAccountScreenPresenter import io.newm.feature.login.screen.login.LoginScreenPresenter import io.newm.feature.login.screen.resetpassword.ResetPasswordScreenPresenter import io.newm.feature.login.screen.welcome.WelcomeScreenPresenter +import io.newm.feature.musicplayer.service.DownloadRequestManager +import io.newm.feature.musicplayer.service.DownloadRequestManagerImpl import io.newm.screens.forceupdate.ForceAppUpdatePresenter import io.newm.screens.library.NFTLibraryPresenter import io.newm.screens.profile.edit.ProfileEditPresenter @@ -26,6 +34,7 @@ import io.newm.utils.ForceAppUpdateViewModel import org.koin.android.ext.koin.androidContext import org.koin.dsl.module +@SuppressLint("UnsafeOptInUsageError") val viewModule = module { single { AndroidFeatureFlagManager(get(), get()) } single { ForceAppUpdateViewModel(get(), get()) } @@ -86,7 +95,9 @@ val viewModule = module { get(), get(), get(), - get() + get(), + get(), + get(), ) } factory { params -> @@ -111,6 +122,12 @@ val viewModule = module { params.get(), ) } + single { StandaloneDatabaseProvider(androidContext()) } + single { + val downloadDirectory = androidContext().getExternalFilesDir(null)!! + SimpleCache(downloadDirectory, NoOpCacheEvictor(), get()) + } + single { DownloadRequestManagerImpl(androidContext()) } } 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 ccf6cd70..afe024bb 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 @@ -16,9 +16,12 @@ import io.newm.feature.musicplayer.models.PlaybackState import io.newm.feature.musicplayer.models.Playlist import io.newm.feature.musicplayer.models.Track import io.newm.feature.musicplayer.rememberMediaPlayer +import io.newm.feature.musicplayer.service.DownloadRequestManager import io.newm.feature.musicplayer.service.MusicPlayer import io.newm.shared.public.analytics.NewmAppEventLogger import io.newm.shared.public.analytics.events.AppScreens +import io.newm.shared.public.featureflags.FeatureFlagManager +import io.newm.shared.public.featureflags.FeatureFlags import io.newm.shared.public.models.NFTTrack import io.newm.shared.public.usecases.ConnectWalletUseCase import io.newm.shared.public.usecases.HasWalletConnectionsUseCase @@ -35,12 +38,17 @@ class NFTLibraryPresenter( private val syncWalletConnectionsUseCase: SyncWalletConnectionsUseCase, private val walletNFTTracksUseCase: WalletNFTTracksUseCase, private val scope: CoroutineScope, - private val eventLogger: NewmAppEventLogger + private val eventLogger: NewmAppEventLogger, + private val downloadRequestManager: DownloadRequestManager, + private val featureFlagManager: FeatureFlagManager, ) : Presenter { @Composable override fun present(): NFTLibraryState { val musicPlayer: MusicPlayer? = rememberMediaPlayer(eventLogger) + val downloadsEnabled = + remember { featureFlagManager.isEnabled(FeatureFlags.DownloadTracks) } + LaunchedEffect(Unit) { syncWalletConnectionsUseCase.syncWalletConnectionsFromNetworkToDevice() } @@ -144,7 +152,13 @@ class NFTLibraryPresenter( refreshing = refreshing, eventSink = { event -> when (event) { - is NFTLibraryEvent.OnDownloadTrack -> TODO("Not implemented yet") + is NFTLibraryEvent.OnDownloadTrack -> { + downloadRequestManager.download( + event.tackId, + playList.tracks.first { it.id == event.tackId }.url + ) + } + is NFTLibraryEvent.OnQueryChange -> { eventLogger.logEvent( AppScreens.NFTLibraryScreen.SEARCH_BUTTON, @@ -176,7 +190,8 @@ class NFTLibraryPresenter( } } }, - currentTrackId = currentTrackId + currentTrackId = currentTrackId, + downloadsEnabled = downloadsEnabled, ) } } 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 c7007e18..7f4d052b 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 @@ -162,7 +162,8 @@ fun NFTLibraryScreenUi( refreshing = state.refreshing, eventLogger = eventLogger, onApplyFilters = { filters -> eventSink(OnApplyFilters(filters)) }, - currentTrackId = state.currentTrackId + currentTrackId = state.currentTrackId, + downloadsEnabled = state.downloadsEnabled, ) } } @@ -185,7 +186,8 @@ private fun NFTTracks( refreshing: Boolean, eventLogger: NewmAppEventLogger, onApplyFilters: (NFTLibraryFilters) -> Unit, - currentTrackId: String? + currentTrackId: String?, + downloadsEnabled: Boolean, ) { val scope = rememberCoroutineScope() val filterSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) @@ -241,7 +243,8 @@ private fun NFTTracks( track = track, onPlaySong = onPlaySong, onDownloadSong = { onDownloadSong(track.id) }, - isSelected = track.id == currentTrackId + isSelected = track.id == currentTrackId, + downloadsEnabled = downloadsEnabled, ) } } @@ -259,16 +262,14 @@ private fun NFTTracks( } } -// TODO: Remove this flag once the download functionality is implemented -private const val DOWNLOAD_UI_ENABLED = false - @OptIn(ExperimentalMaterialApi::class) @Composable private fun TrackRowItemWrapper( track: NFTTrack, onPlaySong: (NFTTrack) -> Unit, onDownloadSong: () -> Unit, - isSelected: Boolean + isSelected: Boolean, + downloadsEnabled: Boolean, ) { val swipeableState = rememberSwipeableState(initialValue = false) val deltaX = with(LocalDensity.current) { 82.dp.toPx() } @@ -288,13 +289,13 @@ private fun TrackRowItemWrapper( ), ) ) { - if (!track.isDownloaded && DOWNLOAD_UI_ENABLED) { + if (!track.isDownloaded && downloadsEnabled) { RevealedPanel(onDownloadSong) } TrackRowItem( track = track, onClick = onPlaySong, - modifier = if (DOWNLOAD_UI_ENABLED) Modifier.offset { + modifier = if (downloadsEnabled) Modifier.offset { IntOffset( x = -swipeableState.offset.value.roundToInt(), y = 0 @@ -379,7 +380,8 @@ fun PreviewNftLibrary() { ), refreshing = false, eventSink = {}, - currentTrackId = null + currentTrackId = null, + downloadsEnabled = true, ), eventLogger = NewmAppEventLogger() ) 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 a76c8171..76cb2d59 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 @@ -17,7 +17,8 @@ sealed interface NFTLibraryState : CircuitUiState { val filters: NFTLibraryFilters, val refreshing: Boolean, val eventSink: (NFTLibraryEvent) -> Unit, - val currentTrackId: String? + val currentTrackId: String?, + val downloadsEnabled: Boolean ) : NFTLibraryState data class Error(val message: String) : NFTLibraryState diff --git a/android/features/music-player/build.gradle.kts b/android/features/music-player/build.gradle.kts index 6fb842fa..b896eb3e 100644 --- a/android/features/music-player/build.gradle.kts +++ b/android/features/music-player/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.session) implementation(libs.androidx.media3.ui) + implementation(libs.androidx.media3.workmanager) implementation(libs.androidx.palette.ktx) implementation(libs.koin.android) implementation(libs.play.services.auth) diff --git a/android/features/music-player/src/main/AndroidManifest.xml b/android/features/music-player/src/main/AndroidManifest.xml index de2992db..2a3095b2 100644 --- a/android/features/music-player/src/main/AndroidManifest.xml +++ b/android/features/music-player/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + @@ -13,6 +15,15 @@ + + + + + + + diff --git a/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/DownloadRequestManager.kt b/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/DownloadRequestManager.kt new file mode 100644 index 00000000..facc504e --- /dev/null +++ b/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/DownloadRequestManager.kt @@ -0,0 +1,28 @@ +package io.newm.feature.musicplayer.service + +import android.content.Context +import android.net.Uri +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService + +interface DownloadRequestManager { + fun download(id: String, url: String) +} + +class DownloadRequestManagerImpl( + private val context: Context +) : DownloadRequestManager { + @UnstableApi + override fun download(id: String, url: String) { + val uri = Uri.parse(url) + val downloadRequest = DownloadRequest.Builder(id, uri).build() + + DownloadService.sendAddDownload( + context, + NewmDownloadService::class.java, + downloadRequest, + true // isForeground + ) + } +} \ No newline at end of file diff --git a/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/MusicService.kt b/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/MusicService.kt index 1c94e65c..74f10ddf 100644 --- a/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/MusicService.kt +++ b/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/MusicService.kt @@ -6,10 +6,19 @@ import android.content.Intent import android.media.AudioAttributes import android.media.AudioFocusRequest import android.media.AudioManager +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService +import org.koin.android.ext.android.inject +@UnstableApi class MediaService : MediaSessionService() { private lateinit var mediaSession: MediaSession private lateinit var audioManager: AudioManager @@ -20,6 +29,8 @@ class MediaService : MediaSessionService() { private var playBackAuthorized = false private var resumeOnFocusGain = false + private val downloadCache: Cache by inject() + companion object { private const val DUCKING_VOLUME = 0.2f private const val NORMAL_VOLUME = 1.0f @@ -75,10 +86,14 @@ class MediaService : MediaSessionService() { .build() } + @OptIn(UnstableApi::class) override fun onCreate() { super.onCreate() audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager - player = ExoPlayer.Builder(this).build() + + player = ExoPlayer.Builder(this) + .setMediaSourceFactory(buildMediaSourceFactory()) + .build() mediaSession = MediaSession.Builder(this, player) .setSessionActivity() @@ -87,6 +102,18 @@ class MediaService : MediaSessionService() { } + private fun buildMediaSourceFactory(): DefaultMediaSourceFactory { + val httpDataSourceFactory = DefaultHttpDataSource.Factory() + + val cacheDataSourceFactory: DataSource.Factory = + CacheDataSource.Factory() + .setCache(downloadCache) + .setUpstreamDataSourceFactory(httpDataSourceFactory) + .setCacheWriteDataSinkFactory(null) // Disable writing. + + return DefaultMediaSourceFactory(this).setDataSourceFactory(cacheDataSourceFactory) + } + private fun MediaSession.Builder.setSessionActivity(): MediaSession.Builder { val launchIntentForPackage = packageManager.getLaunchIntentForPackage(packageName) 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 new file mode 100644 index 00000000..6b27922c --- /dev/null +++ b/android/features/music-player/src/main/java/io/newm/feature/musicplayer/service/NewmDownloadService.kt @@ -0,0 +1,128 @@ +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 +private const val NOTIFICATION_UPDATE_INTERVAL: Long = 1_000 +const val DOWNLOAD_NOTIFICATION_CHANNEL_ID = "download_channel" + +@UnstableApi +internal class NewmDownloadService : DownloadService( + FOREGROUND_NOTIFICATION_ID, + NOTIFICATION_UPDATE_INTERVAL, + DOWNLOAD_NOTIFICATION_CHANNEL_ID, + 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) + + 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 + } + + override fun getScheduler(): Scheduler { + return WorkManagerScheduler(this, WORK_NAME) + } + + override fun getForegroundNotification( + downloads: MutableList, + notMetRequirements: Int + ): Notification { + val downloadNotificationHelper = DownloadNotificationHelper( + this, + DOWNLOAD_NOTIFICATION_CHANNEL_ID + ) + + return downloadNotificationHelper.buildProgressNotification( + this, + R.drawable.musicplayer_ic_download, + null, + null, + downloads, + notMetRequirements + ) + } +} \ No newline at end of file diff --git a/android/features/music-player/src/main/res/drawable/musicplayer_ic_download.xml b/android/features/music-player/src/main/res/drawable/musicplayer_ic_download.xml new file mode 100644 index 00000000..a8b409b1 --- /dev/null +++ b/android/features/music-player/src/main/res/drawable/musicplayer_ic_download.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/android/features/music-player/src/main/res/values/strings.xml b/android/features/music-player/src/main/res/values/strings.xml new file mode 100644 index 00000000..6ff30569 --- /dev/null +++ b/android/features/music-player/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Download + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c7823147..171d8b16 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -60,9 +60,12 @@ androidx-datastore-preferences = { module = "androidx.datastore:datastore-prefer androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } androidx-media3-common = { module = "androidx.media3:media3-common", version.ref = "media3" } +androidx-media3-database = { module = "androidx.media3:media3-database", version.ref = "media3" } +androidx-media3-datasource = { module = "androidx.media3:media3-datasource", version.ref = "media3" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } +androidx-media3-workmanager = { module = "androidx.media3:media3-exoplayer-workmanager", version.ref = "media3" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigationCompose" } androidx-palette-ktx = { module = "androidx.palette:palette-ktx", version.ref = "paletteKtx" } diff --git a/shared/src/commonMain/kotlin/io.newm.shared/public/featureflags/FeatureFlags.kt b/shared/src/commonMain/kotlin/io.newm.shared/public/featureflags/FeatureFlags.kt index a31fdb56..b8417356 100644 --- a/shared/src/commonMain/kotlin/io.newm.shared/public/featureflags/FeatureFlags.kt +++ b/shared/src/commonMain/kotlin/io.newm.shared/public/featureflags/FeatureFlags.kt @@ -8,4 +8,8 @@ object FeatureFlags { object ShowRecordStore : FeatureFlag { override val key = "mobile-app-show-recordstore" } + + object DownloadTracks : FeatureFlag { + override val key = "mobile-app-track-downloads" + } } \ No newline at end of file