diff --git a/feature/autoquran/build.gradle b/feature/autoquran/build.gradle index f15330e9b9..2182aad815 100644 --- a/feature/autoquran/build.gradle +++ b/feature/autoquran/build.gradle @@ -1,10 +1,13 @@ plugins { id 'quran.android.library.android' + alias libs.plugins.ksp alias libs.plugins.metro } android.namespace = "com.quran.labs.feature.autoquran" +android.testOptions.unitTests.includeAndroidResources = true + dependencies { implementation libs.androidx.core.ktx implementation libs.androidx.appcompat @@ -13,6 +16,8 @@ dependencies { api(libs.androidx.media3.session) implementation(libs.androidx.media3.ui) implementation(libs.timber) + implementation(libs.moshi) + ksp(libs.moshi.codegen) implementation project(":common:audio") implementation project(":common:data") @@ -20,4 +25,9 @@ dependencies { implementation project(":common:ui:core") implementation project(":common:pages") implementation project(':common:upgrade') + + testImplementation libs.junit + testImplementation libs.truth + testImplementation libs.robolectric + testImplementation libs.kotlinx.coroutines.test } diff --git a/feature/autoquran/src/main/java/com/quran/labs/feature/autoquran/common/BrowsableSurahBuilder.kt b/feature/autoquran/src/main/java/com/quran/labs/feature/autoquran/common/BrowsableSurahBuilder.kt index 64f707e586..b063bce1a0 100644 --- a/feature/autoquran/src/main/java/com/quran/labs/feature/autoquran/common/BrowsableSurahBuilder.kt +++ b/feature/autoquran/src/main/java/com/quran/labs/feature/autoquran/common/BrowsableSurahBuilder.kt @@ -8,6 +8,7 @@ import com.google.common.collect.ImmutableList import com.quran.data.model.audio.Qari import com.quran.data.source.PageProvider import com.quran.labs.androidquran.common.audio.util.AudioExtensionDecider +import com.quran.labs.feature.autoquran.R import com.quran.mobile.di.qualifier.ApplicationContext import dev.zacsweers.metro.Inject import kotlinx.coroutines.Dispatchers @@ -18,6 +19,7 @@ class BrowsableSurahBuilder @Inject constructor( private val pageProvider: PageProvider, private val audioExtensionDecider: AudioExtensionDecider, private val qariArtworkProvider: QariArtworkProvider, + private val recentQariManager: RecentQariManager, ) { private val qariMediaItem: MediaItem by lazy { @@ -25,6 +27,21 @@ class BrowsableSurahBuilder @Inject constructor( .setMediaId(QARI_ID) .setMediaMetadata( MediaMetadata.Builder() + .setTitle(appContext.getString(R.string.autoquran_qaris_title)) + .setIsBrowsable(true) + .setMediaType(MediaMetadata.MEDIA_TYPE_MIXED) + .setIsPlayable(false) + .build() + ) + .build() + } + + private val recentMediaItem: MediaItem by lazy { + MediaItem.Builder() + .setMediaId(RECENT_ID) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(appContext.getString(R.string.autoquran_recently_played_title)) .setIsBrowsable(true) .setMediaType(MediaMetadata.MEDIA_TYPE_MIXED) .setIsPlayable(false) @@ -39,7 +56,13 @@ class BrowsableSurahBuilder @Inject constructor( suspend fun children(parentId: String): ImmutableList { return withContext(Dispatchers.IO) { if (parentId == ROOT_ID) { - ImmutableList.of(qariMediaItem) + if (recentQariManager.getRecentQaris().isNotEmpty()) { + ImmutableList.of(recentMediaItem, qariMediaItem) + } else { + ImmutableList.of(qariMediaItem) + } + } else if (parentId == RECENT_ID) { + recentChildren() } else if (parentId == QARI_ID) { val items = pageProvider.getQaris() .filter { it.isGapless } @@ -98,6 +121,25 @@ class BrowsableSurahBuilder @Inject constructor( } } + /** + * Return playable [MediaItem]s for the most recently played qaris. + * Each item represents the last sura played for that qari. + */ + private fun recentChildren(): ImmutableList { + val recents = recentQariManager.getRecentQaris() + if (recents.isEmpty()) return ImmutableList.of() + val qaris = pageProvider.getQaris() + val items = recents.mapNotNull { recent -> + val qari = qaris.firstOrNull { it.id == recent.qariId } + if (qari != null && recent.lastSura in 1..114) { + makeSuraMediaItem(qari, recent.lastSura, showQariSubtitle = true) + } else { + null + } + } + return ImmutableList.copyOf(items) + } + /** * Given the id of a [Qari], return all the [MediaItem]s for that qari. * Typically, this is a list of 114 [MediaItem]s, one for each sura. @@ -136,7 +178,11 @@ class BrowsableSurahBuilder @Inject constructor( /** * Make a [MediaItem] representing a sura for a [Qari] */ - private fun makeSuraMediaItem(qari: Qari, sura: Int): MediaItem { + private fun makeSuraMediaItem( + qari: Qari, + sura: Int, + showQariSubtitle: Boolean = false + ): MediaItem { val suraName = getSuraName(appContext, sura, wantPrefix = true, wantTranslation = false) val extension = audioExtensionDecider.audioExtensionForQari(qari) val (baseUrl, mimeType) = if (extension == "opus" && qari.opusUrl != null) { @@ -145,6 +191,7 @@ class BrowsableSurahBuilder @Inject constructor( qari.url to MimeTypes.AUDIO_MPEG } val artworkUri = qariArtworkProvider.suraArtworkUriFor(qari, sura) + val qariName = appContext.getString(qari.nameResource) return MediaItem.Builder() .setMediaId("sura_${sura}_${qari.id}") @@ -156,8 +203,11 @@ class BrowsableSurahBuilder @Inject constructor( .setDisplayTitle(suraName) .setTrackNumber(sura) .setTotalTrackCount(114) - .setArtist(appContext.getString(qari.nameResource)) - .apply { setArtworkUri(artworkUri) } + .setArtist(qariName) + .apply { + if (showQariSubtitle) setSubtitle(qariName) + setArtworkUri(artworkUri) + } .build() ) .setMimeType(mimeType) @@ -168,5 +218,6 @@ class BrowsableSurahBuilder @Inject constructor( companion object { const val ROOT_ID = "__ROOT__" const val QARI_ID = "__QARI__" + const val RECENT_ID = "__RECENT__" } } diff --git a/feature/autoquran/src/main/java/com/quran/labs/feature/autoquran/common/RecentQari.kt b/feature/autoquran/src/main/java/com/quran/labs/feature/autoquran/common/RecentQari.kt new file mode 100644 index 0000000000..4a7bc5c1c4 --- /dev/null +++ b/feature/autoquran/src/main/java/com/quran/labs/feature/autoquran/common/RecentQari.kt @@ -0,0 +1,11 @@ +package com.quran.labs.feature.autoquran.common + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class RecentQari( + @Json(name = "qari_id") val qariId: Int, + @Json(name = "last_sura") val lastSura: Int, + val timestamp: Long +) diff --git a/feature/autoquran/src/main/java/com/quran/labs/feature/autoquran/common/RecentQariManager.kt b/feature/autoquran/src/main/java/com/quran/labs/feature/autoquran/common/RecentQariManager.kt new file mode 100644 index 0000000000..7824beefda --- /dev/null +++ b/feature/autoquran/src/main/java/com/quran/labs/feature/autoquran/common/RecentQariManager.kt @@ -0,0 +1,45 @@ +package com.quran.labs.feature.autoquran.common + +import android.content.Context +import android.content.SharedPreferences +import com.quran.mobile.di.qualifier.ApplicationContext +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import dev.zacsweers.metro.Inject +import timber.log.Timber + +class RecentQariManager @Inject constructor( + @ApplicationContext appContext: Context, +) { + + private val prefs: SharedPreferences = + appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private val adapter = Moshi.Builder().build().adapter>( + Types.newParameterizedType(List::class.java, RecentQari::class.java) + ) + + fun getRecentQaris(): List { + val json = prefs.getString(KEY_RECENT_QARIS, null) ?: return emptyList() + return try { + adapter.fromJson(json) ?: emptyList() + } catch (e: Exception) { + Timber.e(e, "Failed to parse recent qaris") + emptyList() + } + } + + fun recordQari(qariId: Int, sura: Int) { + val current = getRecentQaris().toMutableList() + current.removeAll { it.qariId == qariId && it.lastSura == sura } + current.add(0, RecentQari(qariId, sura, System.currentTimeMillis())) + val trimmed = current.take(MAX_RECENT) + prefs.edit().putString(KEY_RECENT_QARIS, adapter.toJson(trimmed)).apply() + } + + companion object { + private const val PREFS_NAME = "autoquran_recent" + private const val KEY_RECENT_QARIS = "recent_qaris" + private const val MAX_RECENT = 5 + } +} diff --git a/feature/autoquran/src/main/java/com/quran/labs/feature/autoquran/service/QuranBrowsableAudioPlaybackService.kt b/feature/autoquran/src/main/java/com/quran/labs/feature/autoquran/service/QuranBrowsableAudioPlaybackService.kt index b80f9c7c7d..0022169e58 100644 --- a/feature/autoquran/src/main/java/com/quran/labs/feature/autoquran/service/QuranBrowsableAudioPlaybackService.kt +++ b/feature/autoquran/src/main/java/com/quran/labs/feature/autoquran/service/QuranBrowsableAudioPlaybackService.kt @@ -14,13 +14,13 @@ import androidx.media3.session.MediaConstants import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaLibraryService.MediaLibrarySession import androidx.media3.session.MediaSession -import androidx.media3.session.MediaSessionService import androidx.media3.session.SessionError import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.SettableFuture import com.quran.labs.feature.autoquran.common.BrowsableSurahBuilder +import com.quran.labs.feature.autoquran.common.RecentQariManager import com.quran.labs.feature.autoquran.di.QuranAutoInjector import com.quran.mobile.di.QuranApplicationComponentProvider import dev.zacsweers.metro.Inject @@ -32,10 +32,13 @@ import kotlinx.coroutines.launch import timber.log.Timber @OptIn(UnstableApi::class) -class QuranBrowsableAudioPlaybackService : MediaSessionService() { +class QuranBrowsableAudioPlaybackService : MediaLibraryService() { @Inject lateinit var surahBuilder: BrowsableSurahBuilder + @Inject + lateinit var recentQariManager: RecentQariManager + private var mediaSession: MediaLibrarySession? = null private val playerListener = PlayerEventListener() @@ -65,11 +68,23 @@ class QuranBrowsableAudioPlaybackService : MediaSessionService() { .build() } + private val recentRootMediaItem: MediaItem by lazy { + MediaItem.Builder() + .setMediaId(BrowsableSurahBuilder.RECENT_ID) + .setMediaMetadata( + MediaMetadata.Builder() + .setIsBrowsable(true) + .setMediaType(MediaMetadata.MEDIA_TYPE_MIXED) + .setIsPlayable(false) + .build() + ) + .build() + } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) override fun onCreate() { super.onCreate() - Timber.d("onCreate()") val injector = (application as? QuranApplicationComponentProvider) ?.provideQuranApplicationComponent() as? QuranAutoInjector if (injector == null) { @@ -100,18 +115,55 @@ class QuranBrowsableAudioPlaybackService : MediaSessionService() { super.onDestroy() } - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? = mediaSession - private class PlayerEventListener : Player.Listener + private inner class PlayerEventListener : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) return + val mediaId = mediaItem?.mediaId ?: return + if (!mediaId.startsWith("sura_")) return + val parts = mediaId.split("_") + if (parts.size != 3) return + val sura = parts[1].toIntOrNull() ?: return + val qariId = parts[2].toIntOrNull() ?: return + if (::recentQariManager.isInitialized) { + recentQariManager.recordQari(qariId, sura) + val recentCount = recentQariManager.getRecentQaris().size + mediaSession?.notifyChildrenChanged( + BrowsableSurahBuilder.RECENT_ID, recentCount, null + ) + mediaSession?.notifyChildrenChanged( + BrowsableSurahBuilder.ROOT_ID, 2, null + ) + } + } + } private inner class QuranServiceCallback : MediaLibrarySession.Callback { + override fun onSubscribe( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + params: MediaLibraryService.LibraryParams? + ): ListenableFuture> { + return Futures.immediateFuture(LibraryResult.ofVoid()) + } + override fun onGetLibraryRoot( session: MediaLibrarySession, browser: MediaSession.ControllerInfo, params: MediaLibraryService.LibraryParams? ): ListenableFuture> { + if (params?.isRecent == true) { + val recentParams = MediaLibraryService.LibraryParams.Builder() + .setRecent(true) + .build() + return Futures.immediateFuture( + LibraryResult.ofItem(recentRootMediaItem, recentParams) + ) + } val rootExtras = Bundle().apply { putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM) @@ -261,4 +313,5 @@ class QuranBrowsableAudioPlaybackService : MediaSessionService() { return settable } } + } diff --git a/feature/autoquran/src/main/res/values/strings.xml b/feature/autoquran/src/main/res/values/strings.xml index e76d5097a7..687bcd8a1b 100644 --- a/feature/autoquran/src/main/res/values/strings.xml +++ b/feature/autoquran/src/main/res/values/strings.xml @@ -1,3 +1,5 @@ Surah %1$s + Qaris + Recently Played diff --git a/feature/autoquran/src/test/java/com/quran/labs/feature/autoquran/common/BrowsableSurahBuilderTest.kt b/feature/autoquran/src/test/java/com/quran/labs/feature/autoquran/common/BrowsableSurahBuilderTest.kt new file mode 100644 index 0000000000..68504b80b1 --- /dev/null +++ b/feature/autoquran/src/test/java/com/quran/labs/feature/autoquran/common/BrowsableSurahBuilderTest.kt @@ -0,0 +1,74 @@ +package com.quran.labs.feature.autoquran.common + +import com.google.common.truth.Truth.assertThat +import com.quran.data.model.audio.Qari +import com.quran.data.source.DisplaySize +import com.quran.data.source.PageProvider +import com.quran.data.source.PageSizeCalculator +import com.quran.data.source.QuranDataSource +import com.quran.labs.androidquran.common.audio.model.QariItem +import com.quran.labs.androidquran.common.audio.util.AudioExtensionDecider +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class BrowsableSurahBuilderTest { + + private lateinit var recentQariManager: RecentQariManager + private lateinit var builder: BrowsableSurahBuilder + + @Before + fun setUp() { + val context = RuntimeEnvironment.getApplication() + recentQariManager = RecentQariManager(context) + builder = BrowsableSurahBuilder( + appContext = context, + pageProvider = object : PageProvider { + override fun getDataSource(): QuranDataSource = throw NotImplementedError() + override fun getPageSizeCalculator(displaySize: DisplaySize): PageSizeCalculator = + throw NotImplementedError() + override fun getImageVersion(): Int = throw NotImplementedError() + override fun getImagesBaseUrl(): String = throw NotImplementedError() + override fun getImagesZipBaseUrl(): String = throw NotImplementedError() + override fun getPatchBaseUrl(): String = throw NotImplementedError() + override fun getAyahInfoBaseUrl(): String = throw NotImplementedError() + override fun getDatabasesBaseUrl(): String = throw NotImplementedError() + override fun getAudioDatabasesBaseUrl(): String = throw NotImplementedError() + override fun getAudioDirectoryName(): String = throw NotImplementedError() + override fun getDatabaseDirectoryName(): String = throw NotImplementedError() + override fun getAyahInfoDirectoryName(): String = throw NotImplementedError() + override fun getImagesDirectoryName(): String = throw NotImplementedError() + override fun getPreviewTitle(): Int = throw NotImplementedError() + override fun getPreviewDescription(): Int = throw NotImplementedError() + override fun getQaris(): List = emptyList() + override fun getDefaultQariId(): Int = throw NotImplementedError() + }, + audioExtensionDecider = object : AudioExtensionDecider { + override fun audioExtensionForQari(qari: Qari): String = throw NotImplementedError() + override fun audioExtensionForQari(qariItem: QariItem): String = throw NotImplementedError() + override fun allowedAudioExtensions(qari: Qari): List = throw NotImplementedError() + override fun allowedAudioExtensions(qariItem: QariItem): List = + throw NotImplementedError() + }, + qariArtworkProvider = QariArtworkProvider(context), + recentQariManager = recentQariManager, + ) + } + + @Test + fun `root has 1 child when recents are empty`() = runTest { + val children = builder.children(BrowsableSurahBuilder.ROOT_ID) + assertThat(children).hasSize(1) + } + + @Test + fun `root has 2 children when recents are non-empty`() = runTest { + recentQariManager.recordQari(qariId = 1, sura = 36) + val children = builder.children(BrowsableSurahBuilder.ROOT_ID) + assertThat(children).hasSize(2) + } +} diff --git a/feature/autoquran/src/test/java/com/quran/labs/feature/autoquran/common/RecentQariManagerTest.kt b/feature/autoquran/src/test/java/com/quran/labs/feature/autoquran/common/RecentQariManagerTest.kt new file mode 100644 index 0000000000..ee780c9d6e --- /dev/null +++ b/feature/autoquran/src/test/java/com/quran/labs/feature/autoquran/common/RecentQariManagerTest.kt @@ -0,0 +1,92 @@ +package com.quran.labs.feature.autoquran.common + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class RecentQariManagerTest { + + private lateinit var manager: RecentQariManager + + @Before + fun setUp() { + manager = RecentQariManager(RuntimeEnvironment.getApplication()) + } + + @Test + fun `empty initial state`() { + assertThat(manager.getRecentQaris()).isEmpty() + } + + @Test + fun `record and retrieve single qari`() { + manager.recordQari(qariId = 1, sura = 36) + val recents = manager.getRecentQaris() + assertThat(recents).hasSize(1) + assertThat(recents[0].qariId).isEqualTo(1) + assertThat(recents[0].lastSura).isEqualTo(36) + } + + @Test + fun `same qari and sura deduplicates and moves to front`() { + manager.recordQari(qariId = 1, sura = 36) + manager.recordQari(qariId = 2, sura = 1) + manager.recordQari(qariId = 1, sura = 36) + + val recents = manager.getRecentQaris() + assertThat(recents).hasSize(2) + assertThat(recents[0].qariId).isEqualTo(1) + assertThat(recents[0].lastSura).isEqualTo(36) + assertThat(recents[1].qariId).isEqualTo(2) + } + + @Test + fun `different suras from same qari are kept separately`() { + manager.recordQari(qariId = 1, sura = 36) + manager.recordQari(qariId = 1, sura = 67) + + val recents = manager.getRecentQaris() + assertThat(recents).hasSize(2) + assertThat(recents[0].lastSura).isEqualTo(67) + assertThat(recents[1].lastSura).isEqualTo(36) + } + + @Test + fun `maintains recency order`() { + manager.recordQari(qariId = 1, sura = 1) + manager.recordQari(qariId = 2, sura = 2) + manager.recordQari(qariId = 3, sura = 3) + + val recents = manager.getRecentQaris() + assertThat(recents.map { it.qariId }).containsExactly(3, 2, 1).inOrder() + } + + @Test + fun `evicts oldest beyond max entries`() { + manager.recordQari(qariId = 1, sura = 1) + manager.recordQari(qariId = 2, sura = 2) + manager.recordQari(qariId = 3, sura = 3) + manager.recordQari(qariId = 4, sura = 4) + manager.recordQari(qariId = 5, sura = 5) + manager.recordQari(qariId = 6, sura = 6) + + val recents = manager.getRecentQaris() + assertThat(recents).hasSize(5) + assertThat(recents.map { it.qariId }).containsExactly(6, 5, 4, 3, 2).inOrder() + } + + @Test + fun `handles corrupt json gracefully`() { + val context = RuntimeEnvironment.getApplication() + val prefs = context.getSharedPreferences("autoquran_recent", Context.MODE_PRIVATE) + prefs.edit().putString("recent_qaris", "not valid json").commit() + + val freshManager = RecentQariManager(context) + assertThat(freshManager.getRecentQaris()).isEmpty() + } +}