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.