diff --git a/.gitignore b/.gitignore index ff8a39399..007a38e86 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ captures/ .idea/dictionaries .idea/libraries .idea/caches +.idea/kotlinc.xml # Keystore files # Uncomment the following line if you do not want to check your keystore files in. diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 8d81632f8..fe63bb677 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7cf318518..b9d5c1190 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,8 @@ android { defaultConfig { applicationId = "com.kafka.user" - versionCode = 53 - versionName = libs.versions.versionname.toString() + versionCode = 56 + versionName = "0.16.0" val properties = Properties() properties.load(project.rootProject.file("local.properties").inputStream()) @@ -26,7 +26,7 @@ android { buildConfigField( "String", "GOOGLE_SERVER_CLIENT_ID", - properties["PIPELESS_AUTH_TOKEN"]?.toString() ?: System.getenv("PIPELESS_AUTH_TOKEN") + properties["GOOGLE_SERVER_CLIENT_ID"]?.toString() ?: System.getenv("GOOGLE_SERVER_CLIENT_ID") ) buildConfigField( "String", diff --git a/app/src/main/java/com/kafka/user/home/AppNavigation.kt b/app/src/main/java/com/kafka/user/home/AppNavigation.kt index 55a6632a6..8bc158c27 100644 --- a/app/src/main/java/com/kafka/user/home/AppNavigation.kt +++ b/app/src/main/java/com/kafka/user/home/AppNavigation.kt @@ -141,6 +141,7 @@ private fun NavGraphBuilder.addLibraryRoot() { addPlayer(RootScreen.Library) addWebView(RootScreen.Library) addOnlineReader(RootScreen.Library) + addLogin(RootScreen.Library) } } diff --git a/app/src/main/java/com/kafka/user/initializer/AudioProgressInitializer.kt b/app/src/main/java/com/kafka/user/initializer/AudioProgressInitializer.kt index 073a87c25..3748fd669 100644 --- a/app/src/main/java/com/kafka/user/initializer/AudioProgressInitializer.kt +++ b/app/src/main/java/com/kafka/user/initializer/AudioProgressInitializer.kt @@ -1,39 +1,48 @@ package com.kafka.user.initializer import android.app.Application -import org.kafka.base.AppInitializer import com.kafka.data.dao.RecentAudioDao import com.kafka.data.entities.RecentAudioItem -import org.kafka.base.ProcessLifetime import com.sarahang.playback.core.PlaybackConnection +import com.sarahang.playback.core.albumId import com.sarahang.playback.core.fileId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch +import org.kafka.base.AppInitializer import org.kafka.base.CoroutineDispatchers +import org.kafka.base.ProcessLifetime import org.kafka.base.debug import javax.inject.Inject +/** + * An [AppInitializer] that updates the the currently playing audio. + * + * It listens to the [PlaybackConnection.nowPlaying] and updates the recent audio item + * so that it can be played from last item when the user plays the album again. + */ class AudioProgressInitializer @Inject constructor( private val playbackConnection: PlaybackConnection, private val dispatchers: CoroutineDispatchers, private val recentAudioDao: RecentAudioDao, @ProcessLifetime private val coroutineScope: CoroutineScope, ) : AppInitializer { + override fun init(application: Application) { coroutineScope.launch(dispatchers.io) { - playbackConnection.playbackProgress - .filter { it.position % 5L == 0L && it.position != 0L } + playbackConnection.nowPlaying .collectLatest { timestamp -> debug { "Updating progress for $timestamp" } - playbackConnection.nowPlaying.value.fileId?.let { fileId -> - val audioItem = recentAudioDao.get(fileId) - if (audioItem == null) { - val audio = RecentAudioItem(fileId, timestamp.position, timestamp.total) - recentAudioDao.insert(audio) - } else { - recentAudioDao.updateTimestamp(fileId, timestamp.position) + + playbackConnection.nowPlaying.value.albumId?.let { albumId -> + playbackConnection.nowPlaying.value.fileId?.let { fileId -> + val audioItem = recentAudioDao.getByAlbumId(albumId) + if (audioItem == null) { + val audio = RecentAudioItem(fileId = fileId, albumId = albumId) + recentAudioDao.insert(audio) + } else { + recentAudioDao.updateNowPlaying(albumId = albumId, fileId = fileId) + } } } } diff --git a/app/src/main/java/com/kafka/user/injection/AppModule.kt b/app/src/main/java/com/kafka/user/injection/AppModule.kt index 355d052ef..a59e6fa72 100644 --- a/app/src/main/java/com/kafka/user/injection/AppModule.kt +++ b/app/src/main/java/com/kafka/user/injection/AppModule.kt @@ -17,6 +17,7 @@ import com.kafka.recommendations.topic.FirebaseTopicsImpl import com.kafka.recommendations.topic.FirebaseTopicsInitializer import com.kafka.user.BuildConfig import com.kafka.user.deeplink.FirebaseDynamicDeepLinkHandler +import com.kafka.user.initializer.AudioProgressInitializer import com.kafka.user.initializer.FirebaseInitializer import com.kafka.user.initializer.LoggerInitializer import com.kafka.user.initializer.ReaderProgressInitializer @@ -110,7 +111,7 @@ class AppModule { @Provides - fun provideGoogleClientIdProvider() = object : SecretsProvider { + fun provideSecretsProvider() = object : SecretsProvider { override val googleServerClientId: String = BuildConfig.GOOGLE_SERVER_CLIENT_ID override val pipelessAuthToken: String = BuildConfig.PIPELESS_AUTH_TOKEN } @@ -155,6 +156,10 @@ abstract class AppModuleBinds { @IntoSet abstract fun provideFirebaseTopicsInitializer(bind: FirebaseTopicsInitializer): AppInitializer + @Binds + @IntoSet + abstract fun provideAudioProgressInitializer(bind: AudioProgressInitializer): AppInitializer + @Singleton @Binds abstract fun provideNotificationManager(bind: NotificationManagerImpl): NotificationManager diff --git a/base/domain/src/main/java/org/kafka/base/Combine.kt b/base/domain/src/main/java/org/kafka/base/Combine.kt new file mode 100644 index 000000000..5ef62a65e --- /dev/null +++ b/base/domain/src/main/java/org/kafka/base/Combine.kt @@ -0,0 +1,133 @@ +@file:Suppress("UNCHECKED_CAST") + +package org.kafka.base + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6) -> R, +): Flow = combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + ) +} + +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R, +): Flow = combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + ) +} + +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + flow8: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8) -> R, +): Flow = combine(flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + args[7] as T8, + ) +} + +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + flow8: Flow, + flow9: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9) -> R, +): Flow = + combine(flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + args[7] as T8, + args[8] as T9, + ) + } + +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + flow8: Flow, + flow9: Flow, + flow10: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10) -> R, +): Flow = combine( + flow, + flow2, + flow3, + flow4, + flow5, + flow6, + flow7, + flow8, + flow9, + flow10 +) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + args[7] as T8, + args[8] as T9, + args[9] as T10, + ) +} diff --git a/build.gradle.kts b/build.gradle.kts index 57b5bce85..75162aa71 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,5 @@ + import com.android.build.gradle.BaseExtension -import com.diffplug.gradle.spotless.SpotlessExtension import org.jetbrains.kotlin.gradle.plugin.KaptExtension import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask @@ -141,9 +141,3 @@ fun Project.configureAndroidProject() { } } } - -tasks.register("appVersionName") { - doLast { - println(libs.versions.versionname) - } -} diff --git a/data/database/schemas/com.kafka.data.db.KafkaRoomDatabase/5.json b/data/database/schemas/com.kafka.data.db.KafkaRoomDatabase/5.json new file mode 100644 index 000000000..9793fc17c --- /dev/null +++ b/data/database/schemas/com.kafka.data.db.KafkaRoomDatabase/5.json @@ -0,0 +1,512 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "069fe8a01d037697ddf32ddb517545ac", + "entities": [ + { + "tableName": "ItemDetail", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`itemId` TEXT NOT NULL, `language` TEXT, `title` TEXT, `description` TEXT, `creator` TEXT, `collection` TEXT, `mediaType` TEXT, `coverImage` TEXT, `files` TEXT, `metadata` TEXT, `primaryFile` TEXT, `subject` TEXT, `rating` REAL, PRIMARY KEY(`itemId`))", + "fields": [ + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "creator", + "columnName": "creator", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "collection", + "columnName": "collection", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverImage", + "columnName": "coverImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "files", + "columnName": "files", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "primaryFile", + "columnName": "primaryFile", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rating", + "columnName": "rating", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "itemId" + ] + }, + "indices": [ + { + "name": "index_ItemDetail_itemId", + "unique": true, + "columnNames": [ + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemDetail_itemId` ON `${TABLE_NAME}` (`itemId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "File", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` TEXT NOT NULL, `itemId` TEXT NOT NULL, `itemTitle` TEXT, `size` INTEGER, `name` TEXT NOT NULL, `title` TEXT NOT NULL, `extension` TEXT, `creator` TEXT, `time` TEXT, `format` TEXT NOT NULL, `playbackUrl` TEXT, `readerUrl` TEXT, `downloadUrl` TEXT, `coverImage` TEXT, `localUri` TEXT, PRIMARY KEY(`fileId`))", + "fields": [ + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemTitle", + "columnName": "itemTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extension", + "columnName": "extension", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "creator", + "columnName": "creator", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playbackUrl", + "columnName": "playbackUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readerUrl", + "columnName": "readerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "downloadUrl", + "columnName": "downloadUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverImage", + "columnName": "coverImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localUri", + "columnName": "localUri", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "fileId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`itemId` TEXT NOT NULL, `language` TEXT, `title` TEXT, `description` TEXT, `mediaType` TEXT, `coverImage` TEXT, `collection` TEXT, `genre` TEXT, `subject` TEXT, `uploader` TEXT, `position` INTEGER NOT NULL, `rating` REAL, `creator_id` TEXT, `creator_name` TEXT, PRIMARY KEY(`itemId`))", + "fields": [ + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverImage", + "columnName": "coverImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "collection", + "columnName": "collection", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rating", + "columnName": "rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "creator.id", + "columnName": "creator_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "creator.name", + "columnName": "creator_name", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "itemId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "queue_meta_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `currentSeekPos` INTEGER NOT NULL, `currentSongId` TEXT, `isPlaying` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentSeekPos", + "columnName": "currentSeekPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentSongId", + "columnName": "currentSongId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPlaying", + "columnName": "isPlaying", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "recent_search", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `search_term` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "searchTerm", + "columnName": "search_term", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_recent_search_search_term", + "unique": true, + "columnNames": [ + "search_term" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_recent_search_search_term` ON `${TABLE_NAME}` (`search_term`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "recent_text", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` TEXT NOT NULL, `currentPage` INTEGER NOT NULL, `localUri` TEXT NOT NULL, `type` TEXT NOT NULL, `pages` TEXT NOT NULL, PRIMARY KEY(`fileId`))", + "fields": [ + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentPage", + "columnName": "currentPage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUri", + "columnName": "localUri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pages", + "columnName": "pages", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "fileId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "recent_audio", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `fileId` TEXT NOT NULL, `currentTimestamp` INTEGER NOT NULL, `duration` INTEGER NOT NULL, PRIMARY KEY(`albumId`))", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentTimestamp", + "columnName": "currentTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `entity_type` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `created_at` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityType", + "columnName": "entity_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestId", + "columnName": "request_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '069fe8a01d037697ddf32ddb517545ac')" + ] + } +} \ No newline at end of file diff --git a/data/database/src/main/java/com/kafka/data/dao/FileDao.kt b/data/database/src/main/java/com/kafka/data/dao/FileDao.kt index e9ae17a10..d10cb3a86 100644 --- a/data/database/src/main/java/com/kafka/data/dao/FileDao.kt +++ b/data/database/src/main/java/com/kafka/data/dao/FileDao.kt @@ -24,7 +24,6 @@ abstract class FileDao : EntityDao { .filter { it.key.contains("mp3", true) } .filterKeys { it.contains("mp3", true) } .values.flatten() - .sortedBy { it.name } } @Query("select * from File where fileId = :fileId") diff --git a/data/database/src/main/java/com/kafka/data/dao/RecentTextDao.kt b/data/database/src/main/java/com/kafka/data/dao/RecentsDao.kt similarity index 57% rename from data/database/src/main/java/com/kafka/data/dao/RecentTextDao.kt rename to data/database/src/main/java/com/kafka/data/dao/RecentsDao.kt index 45e44612d..cb6b12455 100644 --- a/data/database/src/main/java/com/kafka/data/dao/RecentTextDao.kt +++ b/data/database/src/main/java/com/kafka/data/dao/RecentsDao.kt @@ -20,15 +20,12 @@ abstract class RecentTextDao : EntityDao { @Dao abstract class RecentAudioDao : EntityDao { - @Query("select * from recent_audio where fileId = :fileId") - abstract suspend fun get(fileId: String): RecentAudioItem? + @Query("select * from recent_audio where albumId = :albumId") + abstract suspend fun getByAlbumId(albumId: String): RecentAudioItem? - @Query("select * from recent_audio where fileId = :fileId") - abstract fun observe(fileId: String): Flow + @Query("select * from recent_audio where albumId = :albumId") + abstract fun observeByAlbumId(albumId: String): Flow - @Query("select * from recent_audio where fileId IN (:fileIds)") - abstract fun observe(fileIds: List): Flow> - - @Query("update recent_audio set currentTimestamp = :timestamp where fileId = :fileId") - abstract suspend fun updateTimestamp(fileId: String, timestamp: Long) + @Query("update recent_audio set fileId = :fileId where albumId = :albumId") + abstract suspend fun updateNowPlaying(albumId: String, fileId: String) } diff --git a/data/database/src/main/java/com/kafka/data/db/KafkaDatabase.kt b/data/database/src/main/java/com/kafka/data/db/KafkaDatabase.kt index cc6db2a36..3d8ca309a 100644 --- a/data/database/src/main/java/com/kafka/data/db/KafkaDatabase.kt +++ b/data/database/src/main/java/com/kafka/data/db/KafkaDatabase.kt @@ -3,6 +3,7 @@ package com.kafka.data.db import androidx.room.AutoMigration import androidx.room.Database import androidx.room.DeleteTable +import androidx.room.RenameColumn import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.room.migration.AutoMigrationSpec @@ -43,14 +44,17 @@ interface KafkaDatabase { RecentAudioItem::class, DownloadRequest::class, ], - version = 4, + version = 5, exportSchema = true, autoMigrations = [ AutoMigration(from = 3, to = 4, spec = KafkaRoomDatabase.UserRemovalMigration::class), + AutoMigration(from = 4, to = 5, spec = KafkaRoomDatabase.RecentAudioMigration::class), ], ) @TypeConverters(AppTypeConverters::class) abstract class KafkaRoomDatabase : RoomDatabase(), KafkaDatabase { @DeleteTable(tableName = "user") class UserRemovalMigration : AutoMigrationSpec + @RenameColumn(tableName = "recent_audio", fromColumnName = "fileId", toColumnName = "albumId") + class RecentAudioMigration : AutoMigrationSpec } diff --git a/data/database/src/main/java/com/kafka/data/entities/RecentItem.kt b/data/database/src/main/java/com/kafka/data/entities/RecentItem.kt index 35fcc4358..d202914c0 100644 --- a/data/database/src/main/java/com/kafka/data/entities/RecentItem.kt +++ b/data/database/src/main/java/com/kafka/data/entities/RecentItem.kt @@ -31,9 +31,9 @@ data class RecentItem( ) companion object { - fun fromItem(item: ItemDetail): RecentItem { + fun fromItem(item: ItemDetail, fileId: String = item.files!!.first()): RecentItem { return RecentItem( - fileId = item.files!!.first(), + fileId = fileId, itemId = item.itemId, title = item.title.orEmpty(), coverUrl = item.coverImage.orEmpty(), @@ -73,9 +73,10 @@ data class RecentTextItem( @Immutable @Entity(tableName = "recent_audio") data class RecentAudioItem( - @PrimaryKey val fileId: String, - val currentTimestamp: Long, - val duration: Long, + @PrimaryKey val albumId: String, + val fileId: String, + val currentTimestamp: Long = 0, + val duration: Long = 0, ) : BaseEntity { val progress: Int get() = (currentTimestamp * 100 / duration).toInt() diff --git a/data/models/src/main/java/com/kafka/data/model/MediaType.kt b/data/models/src/main/java/com/kafka/data/model/MediaType.kt index f9c7443f3..df9e15c99 100644 --- a/data/models/src/main/java/com/kafka/data/model/MediaType.kt +++ b/data/models/src/main/java/com/kafka/data/model/MediaType.kt @@ -1,6 +1,6 @@ package com.kafka.data.model sealed class MediaType { - object Text : MediaType() - object Audio : MediaType() + data object Text : MediaType() + data object Audio : MediaType() } diff --git a/data/models/src/main/java/com/kafka/data/model/StringListSerializer.kt b/data/models/src/main/java/com/kafka/data/model/StringListSerializer.kt index 247ce8700..b20b193c2 100644 --- a/data/models/src/main/java/com/kafka/data/model/StringListSerializer.kt +++ b/data/models/src/main/java/com/kafka/data/model/StringListSerializer.kt @@ -1,27 +1,14 @@ package com.kafka.data.model -import kotlinx.serialization.KSerializer import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -object StringListSerializer : KSerializer> { - override val descriptor: SerialDescriptor = String.serializer().descriptor - - override fun deserialize(decoder: Decoder): List { - val surrogate = try { - decoder.decodeSerializableValue(ListSerializer(String.serializer())) - .joinToString(",") - } catch (ex: Exception) { - decoder.decodeSerializableValue(String.serializer()) - } - return surrogate.split(",") - } - - override fun serialize(encoder: Encoder, value: List) { - val surrogate = value.joinToString(",") - encoder.encodeSerializableValue(String.serializer(), surrogate) - } +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonTransformingSerializer + +object StringListSerializer : + JsonTransformingSerializer>(ListSerializer(String.serializer())) { + // If response is not an array, then it is a single object that should be wrapped into the array + override fun transformDeserialize(element: JsonElement): JsonElement = + if (element !is JsonArray) JsonArray(listOf(element)) else element } diff --git a/data/models/src/main/java/com/kafka/data/model/SubjectListSerializer.kt b/data/models/src/main/java/com/kafka/data/model/SubjectListSerializer.kt deleted file mode 100644 index bcc85e0fb..000000000 --- a/data/models/src/main/java/com/kafka/data/model/SubjectListSerializer.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.kafka.data.model - -import kotlinx.serialization.KSerializer -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -object SubjectListSerializer : KSerializer> { - override val descriptor: SerialDescriptor = String.serializer().descriptor - - override fun deserialize(decoder: Decoder): List { - val surrogate = try { - decoder.decodeSerializableValue(ListSerializer(String.serializer())) - .joinToString(";") - } catch (ex: Exception) { - decoder.decodeSerializableValue(String.serializer()) - } - return surrogate.split(";") - } - - override fun serialize(encoder: Encoder, value: List) { - val surrogate = value.joinToString(";") - encoder.encodeSerializableValue(String.serializer(), surrogate) - } -} diff --git a/data/models/src/main/java/com/kafka/data/model/item/Metadata.kt b/data/models/src/main/java/com/kafka/data/model/item/Metadata.kt index e76b1d072..a424ce0cc 100644 --- a/data/models/src/main/java/com/kafka/data/model/item/Metadata.kt +++ b/data/models/src/main/java/com/kafka/data/model/item/Metadata.kt @@ -1,7 +1,6 @@ package com.kafka.data.model.item import com.kafka.data.model.StringListSerializer -import com.kafka.data.model.SubjectListSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -22,7 +21,7 @@ data class Metadata( @Serializable(with = StringListSerializer::class) val description: List? = null, @SerialName("subject") - @Serializable(with = SubjectListSerializer::class) + @Serializable(with = StringListSerializer::class) val subject: List? = null, @SerialName("identifier") val identifier: String, @@ -43,6 +42,6 @@ data class Metadata( @SerialName("year") val year: String? = null, @SerialName("language") - @Serializable(with = SubjectListSerializer::class) + @Serializable(with = StringListSerializer::class) val languages: List? = null, ) diff --git a/data/repo/src/main/java/com/kafka/data/feature/item/ItemDetailMapper.kt b/data/repo/src/main/java/com/kafka/data/feature/item/ItemDetailMapper.kt index 281d1368d..689643afe 100644 --- a/data/repo/src/main/java/com/kafka/data/feature/item/ItemDetailMapper.kt +++ b/data/repo/src/main/java/com/kafka/data/feature/item/ItemDetailMapper.kt @@ -20,7 +20,7 @@ class ItemDetailMapper @Inject constructor( return ItemDetail( itemId = from.metadata.identifier, - language = from.metadata.languages?.joinToString(), + language = from.metadata.languages?.map { it.split(";") }?.flatten()?.joinToString(), title = from.metadata.title?.dismissUpperCase(), description = from.metadata.description?.joinToString()?.format() ?: "", creator = from.metadata.creator?.take(5)?.joinToString()?.sanitizeForRoom(), @@ -30,11 +30,13 @@ class ItemDetailMapper @Inject constructor( coverImage = from.findCoverImage(), metadata = from.metadata.collection, primaryFile = from.files.primaryFile(from.metadata.mediatype)?.fileId, - subject = from.metadata.subject - ?.subList(0, from.metadata.subject!!.size.coerceAtMost(12)) - ?.map { it.substring(0, it.length.coerceAtMost(50)) } - ?.map { it.trim() } - ?.filter { it.isNotEmpty() }, + subject = run { + val subjects = from.metadata.subject?.map { it.split(";") }?.flatten() + subjects?.subList(0, subjects.size.coerceAtMost(12)) + ?.map { it.substring(0, it.length.coerceAtMost(50)) } + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + }, rating = itemDao.getOrNull(from.metadata.identifier)?.rating, ).also { insertFiles(from, it) diff --git a/domain/build.gradle b/domain/build.gradle index aafe731c4..ecd817feb 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -17,16 +17,19 @@ android { dependencies { api project(':core:remote-config') api project(':data:repo') - implementation libs.kotlin.coroutines.android - implementation platform(libs.google.bom) implementation project(':base:domain') implementation project(':core:analytics') implementation project(':core:downloader') + implementation project(':core-playback') + + implementation platform(libs.google.bom) + implementation libs.firestore.ktx implementation libs.fetch implementation libs.google.auth implementation libs.google.coroutines implementation libs.google.firestore implementation libs.google.playservices.auth + implementation libs.kotlin.coroutines.android } diff --git a/domain/src/main/java/org/kafka/domain/interactors/ResumeAlbum.kt b/domain/src/main/java/org/kafka/domain/interactors/ResumeAlbum.kt new file mode 100644 index 000000000..2bbc0f1f4 --- /dev/null +++ b/domain/src/main/java/org/kafka/domain/interactors/ResumeAlbum.kt @@ -0,0 +1,30 @@ +package org.kafka.domain.interactors + +import com.kafka.data.dao.RecentAudioDao +import com.sarahang.playback.core.PlaybackConnection +import com.sarahang.playback.core.apis.AudioDataSource +import org.kafka.base.domain.Interactor +import javax.inject.Inject + +/** + * Resumes an album from the last played audio + * or start from the beginning if last played audio is not found or in case of an error. + * */ +class ResumeAlbum @Inject constructor( + private val playbackConnection: PlaybackConnection, + private val recentAudioDao: RecentAudioDao, + private val audioDataSource: AudioDataSource +) : Interactor() { + + override suspend fun doWork(params: String) { + val audio = recentAudioDao.getByAlbumId(params) + val files = audioDataSource.findAudiosByItemId(params) + + val index = files + .map { it.id } + .indexOf(audio?.fileId) + .coerceAtLeast(0) + + playbackConnection.playAlbum(params, index) + } +} diff --git a/domain/src/main/java/org/kafka/domain/interactors/UpdateCurrentTimestamp.kt b/domain/src/main/java/org/kafka/domain/interactors/UpdateCurrentTimestamp.kt deleted file mode 100644 index 32bf25d52..000000000 --- a/domain/src/main/java/org/kafka/domain/interactors/UpdateCurrentTimestamp.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.kafka.domain.interactors - -import com.kafka.data.dao.RecentAudioDao -import kotlinx.coroutines.withContext -import org.kafka.base.CoroutineDispatchers -import org.kafka.base.domain.Interactor -import javax.inject.Inject - -class UpdateCurrentTimestamp @Inject constructor( - private val dispatchers: CoroutineDispatchers, - private val recentAudioDao: RecentAudioDao, -) : Interactor() { - - override suspend fun doWork(params: Params) { - withContext(dispatchers.io) { - recentAudioDao.updateTimestamp(params.fileId, params.timestamp) - } - } - - data class Params(val fileId: String, val timestamp: Long) -} diff --git a/domain/src/main/java/org/kafka/domain/interactors/recent/IsResumableAudio.kt b/domain/src/main/java/org/kafka/domain/interactors/recent/IsResumableAudio.kt new file mode 100644 index 000000000..bf778143e --- /dev/null +++ b/domain/src/main/java/org/kafka/domain/interactors/recent/IsResumableAudio.kt @@ -0,0 +1,32 @@ +package org.kafka.domain.interactors.recent + +import com.kafka.data.dao.RecentAudioDao +import com.sarahang.playback.core.apis.AudioDataSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import org.kafka.base.CoroutineDispatchers +import org.kafka.base.domain.SubjectInteractor +import javax.inject.Inject + +/** + * Checks if the item has been played before. + * It does not explicitly check if the item is an audio item but if it exists in recent audios then it must be an audio. + * */ +class IsResumableAudio @Inject constructor( + private val dispatchers: CoroutineDispatchers, + private val recentAudioDao: RecentAudioDao, + private val audioDataSource: AudioDataSource +) : SubjectInteractor() { + + override fun createObservable(params: Params): Flow { + return recentAudioDao.observeByAlbumId(params.itemId).map { recentAudio -> + val files = audioDataSource.findAudiosByItemId(params.itemId) + + files.map { it.id }.indexOf(recentAudio?.fileId) > 0 + }.flowOn(dispatchers.io) + } + + data class Params(val itemId: String) + +} diff --git a/domain/src/main/java/org/kafka/domain/observers/ObserveHomepage.kt b/domain/src/main/java/org/kafka/domain/observers/ObserveHomepage.kt index 15c2258fc..a0707b729 100644 --- a/domain/src/main/java/org/kafka/domain/observers/ObserveHomepage.kt +++ b/domain/src/main/java/org/kafka/domain/observers/ObserveHomepage.kt @@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import org.kafka.base.CoroutineDispatchers -import org.kafka.base.debug import org.kafka.base.domain.SubjectInteractor import javax.inject.Inject @@ -23,7 +22,6 @@ class ObserveHomepage @Inject constructor( observeRecentItems.execute(Unit), homepageRepository.observeHomepageCollection(), ) { recentItems, collection -> - debug { "ObserveHomepage: collection=$collection" } val collectionWithRecentItems = collection.mapNotNull { when (it) { is HomepageCollection.RecentItems -> { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 32160df46..e8d178911 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,18 +1,18 @@ [versions] accompanist = "0.34.0" -agp = "8.3.0" +agp = "8.3.2" androidxhilt = "1.2.0" androidxlifecycle = "2.7.0" navigation = "2.7.7" coil = "2.6.0" compose-alpha = "2024.01.00-alpha01" -compose-bom = "2024.02.02" +compose-bom = "2024.04.00" constraintlayout = "1.1.0-alpha13" -composecompiler = "1.5.10" +composecompiler = "1.5.12" coroutines = "1.8.0" dagger = "2.51" icons = "1.0.0" -kotlin = "1.9.22" +kotlin = "1.9.23" kotlin-immutable = "0.3.7" material3 = "1.2.1" mixpanel = "7.0.0" @@ -21,23 +21,21 @@ paging = "3.2.1" retrofit = "2.9.0" room = "2.6.1" threetenbp = "1.5.2" -serialization = "1.5.0" +serialization = "1.6.3" exoplayer = "2.19.1" pdfviewer = "3.2.0-beta.1" compileSdk = "34" minSdk = "24" targetSdk = "33" -core-ktx = "1.12.0" +core-ktx = "1.13.0" androidx-test-ext-junit = "1.1.5" espresso-core = "3.5.1" uiautomator = "2.3.0" -benchmark-macro-junit4 = "1.2.3" -androidx-baselineprofile = "1.2.3" +benchmark-macro-junit4 = "1.2.4" +androidx-baselineprofile = "1.2.4" profileinstaller = "1.3.1" review = "2.0.1" -versionname = "0.13.0" - [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } @@ -129,7 +127,7 @@ fetch-okhttp = "androidx.tonyodev.fetch2okhttp:xfetch2okhttp:3.1.6" dagger-dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" } -google-bom = "com.google.firebase:firebase-bom:32.7.4" +google-bom = "com.google.firebase:firebase-bom:32.8.0" google-analytics = { module = "com.google.firebase:firebase-analytics" } google-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } google-dynamic_links = { module = "com.google.firebase:firebase-dynamic-links" } diff --git a/ui/components/src/main/java/org/kafka/ui/components/MessageBox.kt b/ui/components/src/main/java/org/kafka/ui/components/MessageBox.kt index 99abbd791..2aebd4651 100644 --- a/ui/components/src/main/java/org/kafka/ui/components/MessageBox.kt +++ b/ui/components/src/main/java/org/kafka/ui/components/MessageBox.kt @@ -23,9 +23,10 @@ import ui.common.theme.theme.Dimens fun MessageBox( text: String, modifier: Modifier = Modifier, - icon: ImageVector? = null, + leadingIcon: ImageVector? = null, + trailingIcon: ImageVector? = null, onClick: () -> Unit = {}, - onIconClick: () -> Unit = {} + onIconClick: () -> Unit = onClick ) { Surface( modifier = modifier, @@ -41,18 +42,27 @@ fun MessageBox( .fillMaxWidth() .clickable { onClick() } .padding(Dimens.Spacing16), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(Dimens.Spacing12), verticalAlignment = Alignment.CenterVertically ) { + if (leadingIcon != null) { + IconResource( + imageVector = leadingIcon, + modifier = Modifier.size(Dimens.Spacing20), + tint = MaterialTheme.colorScheme.primary + ) + } + Text( text = text, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) ) - if (icon != null) { + if (trailingIcon != null) { IconResource( - imageVector = icon, + imageVector = trailingIcon, modifier = Modifier.size(Dimens.Spacing20), tint = MaterialTheme.colorScheme.primary, onClick = onIconClick diff --git a/ui/homepage/src/main/java/org/kafka/homepage/Homepage.kt b/ui/homepage/src/main/java/org/kafka/homepage/Homepage.kt index f8a67701e..dd8a2106a 100644 --- a/ui/homepage/src/main/java/org/kafka/homepage/Homepage.kt +++ b/ui/homepage/src/main/java/org/kafka/homepage/Homepage.kt @@ -163,7 +163,7 @@ private fun HomepageFeedItems( item(key = "search_prompt") { MessageBox( text = stringResource(R.string.find_many_more_on_the_search_page), - icon = Icons.ArrowForward, + trailingIcon = Icons.ArrowForward, onClick = { goToSearch() }, modifier = Modifier .padding(Dimens.Gutter) diff --git a/ui/item/src/main/java/org/kafka/item/detail/ItemDetail.kt b/ui/item/src/main/java/org/kafka/item/detail/ItemDetail.kt index 053870950..65793a08a 100644 --- a/ui/item/src/main/java/org/kafka/item/detail/ItemDetail.kt +++ b/ui/item/src/main/java/org/kafka/item/detail/ItemDetail.kt @@ -134,7 +134,6 @@ private fun ItemDetail( modifier: Modifier = Modifier, lazyGridState: LazyGridState = rememberLazyGridState() ) { - Box(modifier.fillMaxSize()) { InfiniteProgressBar( show = state.isFullScreenLoading, @@ -159,7 +158,7 @@ private fun ItemDetail( item(span = { GridItemSpan(GridItemSpan) }) { ItemDetailActions( itemId = state.itemDetail!!.itemId, - isAudio = state.itemDetail.isAudio, + ctaText = state.ctaText.orEmpty(), onPrimaryAction = onPrimaryAction, openFiles = openFiles, isFavorite = state.isFavorite, diff --git a/ui/item/src/main/java/org/kafka/item/detail/ItemDetailActions.kt b/ui/item/src/main/java/org/kafka/item/detail/ItemDetailActions.kt index 8748ae01c..eca562712 100644 --- a/ui/item/src/main/java/org/kafka/item/detail/ItemDetailActions.kt +++ b/ui/item/src/main/java/org/kafka/item/detail/ItemDetailActions.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -35,13 +34,13 @@ import ui.common.theme.theme.Dimens @Composable fun ItemDetailActions( itemId: String, - isAudio: Boolean, + ctaText: String, onPrimaryAction: (String) -> Unit, openFiles: (String) -> Unit, isFavorite: Boolean, toggleFavorite: () -> Unit ) { - BoxWithConstraints(Modifier.fillMaxWidth()) { + Box(Modifier.fillMaxWidth()) { Row( Modifier .widthIn(max = WIDE_LAYOUT_MIN_WIDTH) @@ -69,9 +68,9 @@ fun ItemDetailActions( } FloatingButton( - text = stringResource(if (isAudio) R.string.play else R.string.read), + text = ctaText, modifier = Modifier.weight(0.8f), - onClickLabel = stringResource(if (isAudio) R.string.play else R.string.read), + onClickLabel = ctaText, onClicked = { onPrimaryAction(itemId) } ) } @@ -139,7 +138,7 @@ private fun ActionsPreview() { AppTheme { ItemDetailActions( itemId = "123", - isAudio = false, + ctaText = "Read", onPrimaryAction = {}, openFiles = {}, isFavorite = false, diff --git a/ui/item/src/main/java/org/kafka/item/detail/ItemDetailViewModel.kt b/ui/item/src/main/java/org/kafka/item/detail/ItemDetailViewModel.kt index 769d39e85..588faae15 100644 --- a/ui/item/src/main/java/org/kafka/item/detail/ItemDetailViewModel.kt +++ b/ui/item/src/main/java/org/kafka/item/detail/ItemDetailViewModel.kt @@ -1,11 +1,13 @@ package org.kafka.item.detail import android.app.Activity +import android.app.Application import android.content.Context import android.content.ContextWrapper import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.kafka.data.entities.ItemDetail import com.kafka.data.feature.item.DownloadStatus import com.kafka.data.model.ArchiveQuery import com.kafka.data.model.SearchFilter.Creator @@ -14,25 +16,26 @@ import com.kafka.data.model.booksByAuthor import com.kafka.remote.config.RemoteConfig import com.kafka.remote.config.isOnlineReaderEnabled import com.kafka.remote.config.isShareEnabled -import com.sarahang.playback.core.PlaybackConnection import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.kafka.analytics.AppReviewManager import org.kafka.analytics.logger.Analytics +import org.kafka.base.combine import org.kafka.base.extensions.stateInDefault import org.kafka.common.ObservableLoadingCounter import org.kafka.common.collectStatus import org.kafka.common.shareText import org.kafka.common.snackbar.SnackbarManager import org.kafka.common.snackbar.UiMessage +import org.kafka.domain.interactors.ResumeAlbum import org.kafka.domain.interactors.UpdateFavorite import org.kafka.domain.interactors.UpdateItemDetail import org.kafka.domain.interactors.UpdateItems import org.kafka.domain.interactors.recent.AddRecentItem +import org.kafka.domain.interactors.recent.IsResumableAudio import org.kafka.domain.interactors.recommendation.PostRecommendationEvent import org.kafka.domain.interactors.recommendation.PostRecommendationEvent.RecommendationEvent import org.kafka.domain.observers.ObserveCreatorItems @@ -56,6 +59,7 @@ import javax.inject.Inject class ItemDetailViewModel @Inject constructor( observeItemDetail: ObserveItemDetail, observeDownloadByItemId: ObserveDownloadByItemId, + isResumableAudio: IsResumableAudio, private val updateItemDetail: UpdateItemDetail, private val observeCreatorItems: ObserveCreatorItems, private val updateItems: UpdateItems, @@ -63,12 +67,13 @@ class ItemDetailViewModel @Inject constructor( private val observeFavoriteStatus: ObserveFavoriteStatus, private val updateFavorite: UpdateFavorite, private val postRecommendationEvent: PostRecommendationEvent, - private val playbackConnection: PlaybackConnection, + private val resumeAlbum: ResumeAlbum, private val navigator: Navigator, private val remoteConfig: RemoteConfig, private val snackbarManager: SnackbarManager, private val analytics: Analytics, private val appReviewManager: AppReviewManager, + private val application: Application, savedStateHandle: SavedStateHandle ) : ViewModel() { private val itemId: String = checkNotNull(savedStateHandle["itemId"]) @@ -81,14 +86,16 @@ class ItemDetailViewModel @Inject constructor( observeCreatorItems.flow, observeFavoriteStatus.flow, loadingState.observable, - observeDownloadByItemId.flow - ) { itemDetail, itemsByCreator, isFavorite, isLoading, downloadItem -> + observeDownloadByItemId.flow, + isResumableAudio.flow + ) { itemDetail, itemsByCreator, isFavorite, isLoading, downloadItem, isResumableAudio -> ItemDetailViewState( itemDetail = itemDetail, itemsByCreator = itemsByCreator, isFavorite = isFavorite, isLoading = isLoading, - downloadItem = downloadItem + downloadItem = downloadItem, + ctaText = itemDetail?.let { ctaText(itemDetail, isResumableAudio) }.orEmpty() ) }.stateInDefault( scope = viewModelScope, @@ -98,6 +105,7 @@ class ItemDetailViewModel @Inject constructor( init { observeItemDetail(ObserveItemDetail.Param(itemId)) observeFavoriteStatus(ObserveFavoriteStatus.Params(itemId)) + isResumableAudio(IsResumableAudio.Params(itemId)) observeDownloadByItemId( ObserveDownloadByItemId.Params( itemId = itemId, @@ -122,7 +130,7 @@ class ItemDetailViewModel @Inject constructor( if (state.value.itemDetail!!.isAudio) { addRecentItem(itemId) analytics.log { playItem(itemId) } - playbackConnection.playAlbum(itemId) + viewModelScope.launch { resumeAlbum(itemId).collect() } } else { openReader(itemId) } @@ -228,6 +236,17 @@ class ItemDetailViewModel @Inject constructor( is ContextWrapper -> baseContext.getActivity() else -> null } + + private fun ctaText(itemDetail: ItemDetail, isResumableAudio: Boolean) = + if (itemDetail.isAudio) { + if (isResumableAudio) { + application.getString(R.string.resume) + } else { + application.getString(R.string.play) + } + } else { + application.getString(R.string.read) + } } private const val itemOpenThresholdForAppReview = 20 diff --git a/ui/item/src/main/java/org/kafka/item/detail/ItemDetailViewState.kt b/ui/item/src/main/java/org/kafka/item/detail/ItemDetailViewState.kt index 41fe0deb3..cca6d97ed 100644 --- a/ui/item/src/main/java/org/kafka/item/detail/ItemDetailViewState.kt +++ b/ui/item/src/main/java/org/kafka/item/detail/ItemDetailViewState.kt @@ -13,7 +13,8 @@ data class ItemDetailViewState( val itemDetail: ItemDetail? = null, val itemsByCreator: ImmutableList? = null, val isLoading: Boolean = false, - val downloadItem: ItemWithDownload? = null + val downloadItem: ItemWithDownload? = null, + val ctaText: String? = null, ) { val hasItemsByCreator get() = !itemsByCreator.isNullOrEmpty() diff --git a/ui/item/src/main/java/org/kafka/item/files/FilesViewModel.kt b/ui/item/src/main/java/org/kafka/item/files/FilesViewModel.kt index a38d0b0b9..e395c2322 100644 --- a/ui/item/src/main/java/org/kafka/item/files/FilesViewModel.kt +++ b/ui/item/src/main/java/org/kafka/item/files/FilesViewModel.kt @@ -95,6 +95,7 @@ fun File.asAudio() = Audio( title = title, artist = creator, album = itemTitle, + albumId = itemId, duration = duration, playbackUrl = playbackUrl.orEmpty(), coverImage = coverImage diff --git a/ui/item/src/main/res/values/strings.xml b/ui/item/src/main/res/values/strings.xml index fffeff1b7..f822c7012 100644 --- a/ui/item/src/main/res/values/strings.xml +++ b/ui/item/src/main/res/values/strings.xml @@ -2,6 +2,7 @@ File type is not supported Play + Resume Read More by author "\n Check out %1$s on Kafka\n \n %2$s\n " diff --git a/ui/library/src/main/java/org/kafka/library/LibraryScreen.kt b/ui/library/src/main/java/org/kafka/library/LibraryScreen.kt index 8976344b8..f95bd8c30 100644 --- a/ui/library/src/main/java/org/kafka/library/LibraryScreen.kt +++ b/ui/library/src/main/java/org/kafka/library/LibraryScreen.kt @@ -19,7 +19,7 @@ import org.kafka.ui.components.scaffoldPadding fun LibraryScreen() { Scaffold { padding -> ProvideScaffoldPadding(padding = padding) { - val pagerState = rememberPagerState(pageCount = { LibraryTab.values().size }) + val pagerState = rememberPagerState(pageCount = { LibraryTab.entries.size }) Column( modifier = Modifier @@ -28,12 +28,12 @@ fun LibraryScreen() { ) { Tabs( pagerState = pagerState, - tabs = LibraryTab.values().map { it.name }.toPersistentList(), + tabs = LibraryTab.entries.map { it.name }.toPersistentList(), modifier = Modifier.fillMaxWidth() ) HorizontalPager(modifier = Modifier.fillMaxSize(), state = pagerState) { page -> - when (LibraryTab.values()[page]) { + when (LibraryTab.entries[page]) { LibraryTab.Favorites -> Favorites() LibraryTab.Downloads -> Downloads() } diff --git a/ui/library/src/main/java/org/kafka/library/favorites/FavoriteViewModel.kt b/ui/library/src/main/java/org/kafka/library/favorites/FavoriteViewModel.kt index 08b6125fe..960ea8006 100644 --- a/ui/library/src/main/java/org/kafka/library/favorites/FavoriteViewModel.kt +++ b/ui/library/src/main/java/org/kafka/library/favorites/FavoriteViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import org.kafka.analytics.logger.Analytics import org.kafka.base.extensions.stateInDefault +import org.kafka.domain.observers.ObserveUser import org.kafka.domain.observers.library.ObserveFavorites import org.kafka.navigation.Navigator import org.kafka.navigation.Screen @@ -22,6 +23,7 @@ import javax.inject.Inject class FavoriteViewModel @Inject constructor( observeFavorites: ObserveFavorites, preferencesStore: PreferencesStore, + observeUser: ObserveUser, private val analytics: Analytics, private val navigator: Navigator, ) : ViewModel() { @@ -34,13 +36,23 @@ class FavoriteViewModel @Inject constructor( val state: StateFlow = combine( observeFavorites.flow, layoutType.map { LayoutType.valueOf(it) }, - ) { favorites, layout -> - FavoriteViewState(favoriteItems = favorites, layoutType = layout) + observeUser.flow + ) { favorites, layout, user -> + FavoriteViewState( + favoriteItems = favorites, + layoutType = layout, + isUserLoggedIn = user != null + ) }.stateInDefault( scope = viewModelScope, initialValue = FavoriteViewState(), ) + init { + observeFavorites(Unit) + observeUser(ObserveUser.Params()) + } + fun updateLayoutType(layoutType: LayoutType) { this.layoutType.value = layoutType.name } @@ -50,13 +62,14 @@ class FavoriteViewModel @Inject constructor( navigator.navigate(Screen.ItemDetail.createRoute(navigator.currentRoot.value, itemId)) } - init { - observeFavorites(Unit) + fun goToLogin() { + navigator.navigate(Screen.Login.createRoute(navigator.currentRoot.value)) } } @Immutable data class FavoriteViewState( val favoriteItems: List? = null, - val layoutType: LayoutType = LayoutType.List + val layoutType: LayoutType = LayoutType.List, + val isUserLoggedIn: Boolean = false ) diff --git a/ui/library/src/main/java/org/kafka/library/favorites/Favorites.kt b/ui/library/src/main/java/org/kafka/library/favorites/Favorites.kt index 8009b13a3..16d6ef1f4 100644 --- a/ui/library/src/main/java/org/kafka/library/favorites/Favorites.kt +++ b/ui/library/src/main/java/org/kafka/library/favorites/Favorites.kt @@ -1,6 +1,7 @@ package org.kafka.library.favorites import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -17,10 +18,12 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.kafka.data.entities.Item +import org.kafka.common.image.Icons import org.kafka.common.plus import org.kafka.common.snackbar.UiMessage import org.kafka.common.widgets.FullScreenMessage import org.kafka.favorites.R +import org.kafka.ui.components.MessageBox import org.kafka.ui.components.bottomScaffoldPadding import org.kafka.ui.components.item.Item import org.kafka.ui.components.item.LayoutType @@ -31,32 +34,43 @@ import ui.common.theme.theme.Dimens internal fun Favorites(favoriteViewModel: FavoriteViewModel = hiltViewModel()) { val favoriteViewState by favoriteViewModel.state.collectAsStateWithLifecycle() - favoriteViewState.favoriteItems?.let { items -> - if (items.isEmpty()) { - FullScreenMessage(UiMessage(stringResource(id = R.string.no_favorites_items_message))) - } else { - when (favoriteViewState.layoutType) { - LayoutType.List -> FavoriteItemList( - favoriteItems = items, - openItemDetail = favoriteViewModel::openItemDetail, - header = { - LayoutType( - layoutType = favoriteViewState.layoutType, - changeViewType = favoriteViewModel::updateLayoutType - ) - } - ) + Column { + if (!favoriteViewState.isUserLoggedIn) { + MessageBox( + text = stringResource(R.string.log_in_to_sync_favorite), + trailingIcon = Icons.ArrowForward, + modifier = Modifier.padding(Dimens.Spacing16), + onClick = favoriteViewModel::goToLogin + ) + } + + favoriteViewState.favoriteItems?.let { items -> + if (items.isEmpty()) { + FullScreenMessage(UiMessage(stringResource(id = R.string.no_favorites_items_message))) + } else { + when (favoriteViewState.layoutType) { + LayoutType.List -> FavoriteItemList( + favoriteItems = items, + openItemDetail = favoriteViewModel::openItemDetail, + header = { + LayoutType( + layoutType = favoriteViewState.layoutType, + changeViewType = favoriteViewModel::updateLayoutType + ) + } + ) - LayoutType.Grid -> FavoriteItemGrid( - favoriteItems = items, - openItemDetail = favoriteViewModel::openItemDetail, - header = { - LayoutType( - layoutType = favoriteViewState.layoutType, - changeViewType = favoriteViewModel::updateLayoutType - ) - } - ) + LayoutType.Grid -> FavoriteItemGrid( + favoriteItems = items, + openItemDetail = favoriteViewModel::openItemDetail, + header = { + LayoutType( + layoutType = favoriteViewState.layoutType, + changeViewType = favoriteViewModel::updateLayoutType + ) + } + ) + } } } } diff --git a/ui/library/src/main/res/values/strings.xml b/ui/library/src/main/res/values/strings.xml index 1e1e6f631..602f7cf49 100644 --- a/ui/library/src/main/res/values/strings.xml +++ b/ui/library/src/main/res/values/strings.xml @@ -5,4 +5,5 @@ No downloaded items Your downloaded items will appear here Downloads are saved on your device. They can be opened or shared freely using any files app. + Log in to sync your favorite items to your account.