From 8566afdbd0bfcd38985ffce3226a31241509a1ed Mon Sep 17 00:00:00 2001 From: tuuhin Date: Fri, 24 Oct 2025 19:50:47 +0530 Subject: [PATCH 01/25] Included testing-runtime module This module will provide HiltTestRunner.kt required for hilt based android test build.gradle.kts in testing-runtime is normally defined without any custom plugins as we dont need the overhead of library configuration Updated few versions in libs.versions.toml Included hilt test and hilt compiler for kspAndroidTest in ConfigureHiltPlugin.kt Again included some more core dependencies like kotlin test in ConfigureAndroidLibraryPlugin.kt --- .idea/gradle.xml | 2 + .../kotlin/ConfigureAndroidLibraryPlugin.kt | 50 +++++++++++-------- .../src/main/kotlin/ConfigureHiltPlugin.kt | 14 ++++++ build-logic/src/main/kotlin/Constants.kt | 3 ++ gradle/libs.versions.toml | 22 ++++++-- settings.gradle.kts | 1 + testing/runtime/.gitignore | 1 + testing/runtime/build.gradle.kts | 47 +++++++++++++++++ testing/runtime/consumer-rules.pro | 0 testing/runtime/proguard-rules.pro | 21 ++++++++ .../com/eva/testing/runtime/HiltTestRunner.kt | 13 +++++ 11 files changed, 149 insertions(+), 25 deletions(-) create mode 100644 build-logic/src/main/kotlin/Constants.kt create mode 100644 testing/runtime/.gitignore create mode 100644 testing/runtime/build.gradle.kts create mode 100644 testing/runtime/consumer-rules.pro create mode 100644 testing/runtime/proguard-rules.pro create mode 100644 testing/runtime/src/main/java/com/eva/testing/runtime/HiltTestRunner.kt diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 735c72ce..c02e1831 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -49,6 +49,8 @@ diff --git a/build-logic/src/main/kotlin/ConfigureAndroidLibraryPlugin.kt b/build-logic/src/main/kotlin/ConfigureAndroidLibraryPlugin.kt index c2f5b838..ab3eafcb 100644 --- a/build-logic/src/main/kotlin/ConfigureAndroidLibraryPlugin.kt +++ b/build-logic/src/main/kotlin/ConfigureAndroidLibraryPlugin.kt @@ -29,37 +29,47 @@ class ConfigureAndroidLibraryPlugin : Plugin { private fun Project.addDependencies() = dependencies.apply { - val dependenciesMap = mapOf( - "implementation" to listOf( - "androidx.core.ktx", - "androidx.lifecycle.runtime.ktx", - "androidx.activity.compose" - ), - "testImplementation" to listOf("junit"), - "androidTestImplementation" to listOf("androidx.junit", "androidx.espresso.core") + val implementations = listOf( + "androidx.core.ktx", + "androidx.lifecycle.runtime.ktx", + "androidx.activity.compose" + ) + implementations.forEach { + catalog.findLibrary(it).ifPresent { dependency -> + add("implementation", dependency.get()) + } + } + + val testImplementations = listOf("junit", "kotlin.test", "kotlinx.coroutines.test") + + testImplementations.forEach { + catalog.findLibrary(it).ifPresent { dependency -> + add("testImplementation", dependency.get()) + } + } + + val androidTestImplementations = listOf( + "androidx.junit", + "androidx.espresso.core", + "kotlin.test", + "kotlinx.coroutines.test" ) - dependenciesMap.forEach { (key, dependencies) -> - dependencies.forEach { - catalog.findLibrary(it).ifPresent { dependency -> - add(key, dependency.get()) - } + androidTestImplementations.forEach { + catalog.findLibrary(it).ifPresent { dependency -> + add("androidTestImplementation", dependency.get()) } } } private fun Project.configureLibrary() = extensions.configure { - val compilerSdkVersion = catalog.findVersion("compileSdk") - .getOrNull()?.toString()?.toInt() ?: 36 - val minSdkVersion = catalog.findVersion("minSdk") - .getOrNull()?.toString()?.toInt() ?: 29 + compileSdk = catalog.findVersion("compileSdk").getOrNull()?.toString()?.toInt() ?: 36 - compileSdk = compilerSdkVersion defaultConfig { - minSdk = minSdkVersion + minSdk = catalog.findVersion("minSdk").getOrNull()?.toString()?.toInt() ?: 29 - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = Constants.TEST_RUNNER_CLASS_NAME consumerProguardFiles("consumer-rules.pro") } diff --git a/build-logic/src/main/kotlin/ConfigureHiltPlugin.kt b/build-logic/src/main/kotlin/ConfigureHiltPlugin.kt index ffa172ee..f845b658 100644 --- a/build-logic/src/main/kotlin/ConfigureHiltPlugin.kt +++ b/build-logic/src/main/kotlin/ConfigureHiltPlugin.kt @@ -33,6 +33,20 @@ class ConfigureHiltPlugin : Plugin { add("ksp", dependency) } } + val androidTest = listOf("hilt.test") + androidTest.forEach { + catalog.findLibrary(it).ifPresent { androidTestDependency -> + val dependency = androidTestDependency.get() + add("androidTestImplementation", dependency) + } + } + val kspAndroidText = listOf("hilt.android.compiler") + kspAndroidText.forEach { + catalog.findLibrary(it).ifPresent { testDependency -> + val dependency = testDependency.get() + add("kspAndroidTest", dependency) + } + } } } \ No newline at end of file diff --git a/build-logic/src/main/kotlin/Constants.kt b/build-logic/src/main/kotlin/Constants.kt new file mode 100644 index 00000000..6257bad9 --- /dev/null +++ b/build-logic/src/main/kotlin/Constants.kt @@ -0,0 +1,3 @@ +internal object Constants { + const val TEST_RUNNER_CLASS_NAME = "com.eva.testing.runtime.HiltTestRunner" +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6a815459..8248b1b6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,33 +4,36 @@ concurrentFuturesKtx = "1.3.0" datastore = "1.1.7" coreSplashscreen = "1.0.1" glance = "1.1.1" -graphicsShapes = "1.0.1" +graphicsShapes = "1.1.0" hiltNavigation = "1.3.0" kotlin = "2.2.20" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" +runner = "1.7.0" espressoCore = "3.7.0" kotlinxCollectionsImmutable = "0.4.0" kotlinxDatetime = "0.7.1" kotlinxSerializationJson = "1.9.0" lifecycleRuntimeKtx = "2.9.4" activityCompose = "1.11.0" -composeBom = "2025.10.00" +composeBom = "2025.10.01" ksp = "2.2.20-2.0.2" hilt = "2.57.2" media3Common = "1.8.0" navigationCompose = "2.9.5" playServicesLocationVersion = "21.3.0" -roomCompiler = "2.8.2" -uiTextGoogleFonts = "1.9.3" -workRuntimeKtxVersion = "2.10.5" +roomCompiler = "2.8.3" +uiTextGoogleFonts = "1.9.4" +workRuntimeKtxVersion = "2.11.0" hiltWork = "1.3.0" protobufJavalite = "4.33.0" protobuf_version = "0.9.5" protobuf_gen_java_lite = "3.0.0" materialIconExtended = "1.7.8" moduleGrapher = "0.13.0" +mockk = "1.14.6" +coroutines-test = "1.10.2" compileSdk = "36" minSdk = "29" @@ -80,9 +83,12 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3" hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-test = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines-test" } +kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" } work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtxVersion" } androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" } androidx-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconExtended" } @@ -94,6 +100,11 @@ protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", ve protobuf-protoc = { module = "com.google.protobuf:protoc", version.ref = "protobufJavalite" } protobuf-gen-javalite = { module = "com.google.protobuf:protoc-gen-javalite", version.ref = "protobuf_gen_java_lite" } +#testing +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } +mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } + #gradle android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } @@ -101,6 +112,7 @@ compose-compiler-gradlePlugin = { group = "org.jetbrains.kotlin", name = "compos ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } room-gradlePlugin = { group = "androidx.room", name = "room-gradle-plugin", version.ref = "roomCompiler" } protobuf-gradlePlugin = { group = "com.google.protobuf", name = "protobuf-gradle-plugin", version.ref = "protobuf_version" } +androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 766c4500..de463b3f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,3 +45,4 @@ include(":feature:editor") include(":data:editor") include(":feature:player-shared") include(":feature:onboarding") +include(":testing:runtime") diff --git a/testing/runtime/.gitignore b/testing/runtime/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/testing/runtime/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/testing/runtime/build.gradle.kts b/testing/runtime/build.gradle.kts new file mode 100644 index 00000000..5b25da3a --- /dev/null +++ b/testing/runtime/build.gradle.kts @@ -0,0 +1,47 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.eva.testing.runtime" + + compileSdk = libs.versions.compileSdk.getOrNull()?.toInt() ?: 36 + defaultConfig { + minSdk = libs.versions.minSdk.getOrNull()?.toInt() ?: 29 + + testInstrumentationRunner = "com.eva.testing.runtime.HiltTestRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } +} + +dependencies { + implementation(libs.hilt.android) + implementation(libs.hilt.test) + implementation(libs.androidx.runner) + ksp(libs.hilt.android.compiler) +} \ No newline at end of file diff --git a/testing/runtime/consumer-rules.pro b/testing/runtime/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/testing/runtime/proguard-rules.pro b/testing/runtime/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/testing/runtime/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/testing/runtime/src/main/java/com/eva/testing/runtime/HiltTestRunner.kt b/testing/runtime/src/main/java/com/eva/testing/runtime/HiltTestRunner.kt new file mode 100644 index 00000000..189aef96 --- /dev/null +++ b/testing/runtime/src/main/java/com/eva/testing/runtime/HiltTestRunner.kt @@ -0,0 +1,13 @@ +package com.eva.testing.runtime + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class HiltTestRunner : AndroidJUnitRunner() { + + override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} \ No newline at end of file From 1be43fd105485ebe7f7365dbdf56af95baa18509 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Fri, 24 Oct 2025 22:32:54 +0530 Subject: [PATCH 02/25] Bookmarks export corrections Corrected the logic to save bookmarks and also file deletion when coroutine is cancelled Included testing-runtime as android test impl for hilt test BookmarksTestModule.kt provides the test module BookMarksToCsvConvertorTest.kt , android test for creating bookmarks from list of audio bookmarks ExportURIProvider.kt provides the directory and getFileUri methods for main and test implementations --- data/bookmarks/build.gradle.kts | 2 + .../data/BookMarksToCsvConvertorTest.kt | 61 ++++++++++++++ .../data/data/TestExportURIProvider.kt | 16 ++++ .../bookmarks/data/di/BookmarksTestModule.kt | 45 ++++++++++ .../bookmarks/BookMarksToCsvFileConvertor.kt | 4 - .../data/AndroidExportURIProvider.kt | 18 ++++ .../data/BookMarksToCsvFileConvertor.kt | 82 +++++++++---------- .../com/eva/bookmarks/di/BookmarksModule.kt | 15 +++- .../provider/BookMarksExportRepository.kt | 9 ++ .../provider/ExportBookMarkUriProvider.kt | 8 -- .../domain/provider/ExportURIProvider.kt | 22 +++++ 11 files changed, 226 insertions(+), 56 deletions(-) create mode 100644 data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/BookMarksToCsvConvertorTest.kt create mode 100644 data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/data/TestExportURIProvider.kt create mode 100644 data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/di/BookmarksTestModule.kt delete mode 100644 data/bookmarks/src/main/java/com/eva/bookmarks/BookMarksToCsvFileConvertor.kt create mode 100644 data/bookmarks/src/main/java/com/eva/bookmarks/data/AndroidExportURIProvider.kt create mode 100644 data/bookmarks/src/main/java/com/eva/bookmarks/domain/provider/BookMarksExportRepository.kt delete mode 100644 data/bookmarks/src/main/java/com/eva/bookmarks/domain/provider/ExportBookMarkUriProvider.kt create mode 100644 data/bookmarks/src/main/java/com/eva/bookmarks/domain/provider/ExportURIProvider.kt diff --git a/data/bookmarks/build.gradle.kts b/data/bookmarks/build.gradle.kts index 8ecbe8a5..d1954c74 100644 --- a/data/bookmarks/build.gradle.kts +++ b/data/bookmarks/build.gradle.kts @@ -11,4 +11,6 @@ dependencies { implementation(project(":core:utils")) implementation(project(":data:recordings")) implementation(project(":data:database")) + + androidTestImplementation(project(":testing:runtime")) } \ No newline at end of file diff --git a/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/BookMarksToCsvConvertorTest.kt b/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/BookMarksToCsvConvertorTest.kt new file mode 100644 index 00000000..1bf26725 --- /dev/null +++ b/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/BookMarksToCsvConvertorTest.kt @@ -0,0 +1,61 @@ +package com.eva.bookmarks.data + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.eva.bookmarks.domain.AudioBookmarkModel +import com.eva.bookmarks.domain.provider.BookMarksExportRepository +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalTime +import org.junit.Rule +import org.junit.runner.RunWith +import javax.inject.Inject +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +class BookMarksToCsvConvertorTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var exporter: BookMarksExportRepository + + @BeforeTest + fun setUp() = hiltRule.inject() + + @Test + fun check_if_export_bookmarks_creates_a_file() = runTest { + + val entries = List(20) { + AudioBookmarkModel( + bookMarkId = it.toLong(), + text = "A bookmark for $it", + recordingId = 0, + timeStamp = LocalTime.fromSecondOfDay(it * 10 + 40) + ) + } + + val uriString = exporter.invoke(entries) + advanceUntilIdle() + + assertTrue(uriString != null, "A CSV File created") + } + + @Test + fun check_no_bookmark_file_created_if_bookmarks_empty() = runTest { + + val entries = emptyList() + val uriString = exporter.invoke(entries) + advanceUntilIdle() + + assertTrue(uriString == null, "Doesn't create a file as there is no entries") + } + +} \ No newline at end of file diff --git a/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/data/TestExportURIProvider.kt b/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/data/TestExportURIProvider.kt new file mode 100644 index 00000000..0998ec71 --- /dev/null +++ b/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/data/TestExportURIProvider.kt @@ -0,0 +1,16 @@ +package com.eva.bookmarks.data.data + +import android.content.Context +import com.eva.bookmarks.domain.provider.ExportURIProvider +import java.io.File + +class TestExportURIProvider(private val context: Context) : ExportURIProvider { + + override val filesDirectory: File + get() = File(context.cacheDir, "test_bookmarks") + .apply(File::mkdirs) + + override fun getURIFromFile(file: File): String? { + return file.toString() + } +} \ No newline at end of file diff --git a/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/di/BookmarksTestModule.kt b/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/di/BookmarksTestModule.kt new file mode 100644 index 00000000..94d853c1 --- /dev/null +++ b/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/di/BookmarksTestModule.kt @@ -0,0 +1,45 @@ +package com.eva.bookmarks.data.di + +import android.content.Context +import com.eva.bookmarks.data.BookMarksToCsvFileConvertor +import com.eva.bookmarks.data.RecordingBookMarkProviderImpl +import com.eva.bookmarks.data.data.TestExportURIProvider +import com.eva.bookmarks.di.BookmarksModule +import com.eva.bookmarks.domain.provider.BookMarksExportRepository +import com.eva.bookmarks.domain.provider.ExportURIProvider +import com.eva.bookmarks.domain.provider.RecordingBookmarksProvider +import com.eva.database.dao.RecordingsBookmarkDao +import com.eva.recordings.domain.provider.RecordingsSecondaryDataProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import javax.inject.Singleton + + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [BookmarksModule::class] +) +object BookmarksTestModule { + + @Provides + @Singleton + fun providesTestExportURIProvider(@ApplicationContext context: Context): ExportURIProvider = + TestExportURIProvider(context) + + @Provides + @Singleton + fun providesBookmarksToCsvConvertor( + provider: ExportURIProvider + ): BookMarksExportRepository = BookMarksToCsvFileConvertor(provider) + + @Provides + @Singleton + fun providesBookMarksProvider( + dao: RecordingsBookmarkDao, + provider: RecordingsSecondaryDataProvider, + ): RecordingBookmarksProvider = RecordingBookMarkProviderImpl(dao, provider) +} \ No newline at end of file diff --git a/data/bookmarks/src/main/java/com/eva/bookmarks/BookMarksToCsvFileConvertor.kt b/data/bookmarks/src/main/java/com/eva/bookmarks/BookMarksToCsvFileConvertor.kt deleted file mode 100644 index 63a17f16..00000000 --- a/data/bookmarks/src/main/java/com/eva/bookmarks/BookMarksToCsvFileConvertor.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.eva.bookmarks - - - diff --git a/data/bookmarks/src/main/java/com/eva/bookmarks/data/AndroidExportURIProvider.kt b/data/bookmarks/src/main/java/com/eva/bookmarks/data/AndroidExportURIProvider.kt new file mode 100644 index 00000000..3ae15d38 --- /dev/null +++ b/data/bookmarks/src/main/java/com/eva/bookmarks/data/AndroidExportURIProvider.kt @@ -0,0 +1,18 @@ +package com.eva.bookmarks.data + +import android.content.Context +import androidx.core.content.FileProvider +import com.eva.bookmarks.domain.provider.ExportURIProvider +import java.io.File + +internal class AndroidExportURIProvider(private val context: Context) : ExportURIProvider { + + override val filesDirectory: File + get() = File(context.cacheDir, "bookmarks").apply(File::mkdirs) + + override fun getURIFromFile(file: File): String? { + val contentURI = FileProvider + .getUriForFile(context, "${context.packageName}.provider", file) + return contentURI?.toString() + } +} \ No newline at end of file diff --git a/data/bookmarks/src/main/java/com/eva/bookmarks/data/BookMarksToCsvFileConvertor.kt b/data/bookmarks/src/main/java/com/eva/bookmarks/data/BookMarksToCsvFileConvertor.kt index 05560fd0..d1fecbac 100644 --- a/data/bookmarks/src/main/java/com/eva/bookmarks/data/BookMarksToCsvFileConvertor.kt +++ b/data/bookmarks/src/main/java/com/eva/bookmarks/data/BookMarksToCsvFileConvertor.kt @@ -1,64 +1,64 @@ package com.eva.bookmarks.data -import android.content.Context -import android.net.Uri import android.util.Log -import androidx.core.content.FileProvider import com.eva.bookmarks.domain.AudioBookmarkModel -import com.eva.bookmarks.domain.provider.ExportBookMarkUriProvider +import com.eva.bookmarks.domain.provider.BookMarksExportRepository +import com.eva.bookmarks.domain.provider.ExportURIProvider import com.eva.utils.LocalTimeFormats +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import kotlinx.datetime.format import java.io.File +import java.util.UUID private const val TAG = "BOOK_MARKS_EXPORTER" -internal class BookMarksToCsvFileConvertor(private val context: Context) : - ExportBookMarkUriProvider { +internal class BookMarksToCsvFileConvertor( + private val provider: ExportURIProvider +) : BookMarksExportRepository { - private val filesDir: File - // as this files will be a one time export no need to save them to the files dir - get() = File(context.cacheDir, "bookmarks").apply(File::mkdirs) + override suspend fun invoke(points: List): String? { - private fun File.toContentUri(): Uri = - FileProvider.getUriForFile(context, "${context.packageName}.provider", this) + val fileName = "bookmarks_${UUID.randomUUID()}.csv" + val file = File(provider.filesDirectory, fileName) - override suspend operator fun invoke(points: Collection): String? { - return withContext(Dispatchers.IO) { + if (points.isEmpty()) { + Log.e(TAG, "EMPTY FILE CONTENT") + return null + } + + return try { + withContext(Dispatchers.IO) { + val isNewFile = file.createNewFile() + Log.d(TAG, "IS NEW FILE :$isNewFile") - val content = buildString { - append("BOOKMARK_ID,TEXT,TIMESTAMP\n") - points.forEach { entry -> - val readableTimestamp = entry.timeStamp - .format(LocalTimeFormats.LOCALTIME_HH_MM_SS_FORMAT) - append("${entry.bookMarkId},${entry.text},$readableTimestamp\n") + file.outputStream().bufferedWriter().use { writer -> + writer.write("\"BOOKMARK_ID\",\"TEXT\",\"TIMESTAMP\"") + writer.newLine() + points.forEach { entry -> + val timestamp = + entry.timeStamp.format(LocalTimeFormats.LOCALTIME_HH_MM_SS_FORMAT) + writer.write(escapeCsv("${entry.bookMarkId},${entry.text},$timestamp")) + writer.newLine() + } } + Log.d(TAG, "CONTENT COPIED TO FILE") + val contentUri = provider.getURIFromFile(file) + contentUri.toString() } - Log.d(TAG, "CONTENT PREPARED") - Log.d(TAG, content) - - try { - val file = File(filesDir, "bookmarks.csv").apply(File::createNewFile) - - if (file.exists()) - // just a log message to check if the file is present or not - Log.d(TAG, "FILE_WAS_PRESENT_THIS_WILL_BE_OVERRIDE") - - // write new contents to the file - file.writeText(content) - Log.d(TAG, "FILE_WRITTEN") - - val contentUri = file.toContentUri() - - Log.d(TAG, "CONTENT URI :$contentUri") - - return@withContext contentUri.toString() - } catch (e: Exception) { - e.printStackTrace() - null + } catch (e: CancellationException) { + withContext(NonCancellable) { + if (file.exists()) file.delete() } + throw e + } catch (e: Exception) { + e.printStackTrace() + null } } + private fun escapeCsv(value: String) = + "\"${value.replace("\"", "\"\"")}\"" } \ No newline at end of file diff --git a/data/bookmarks/src/main/java/com/eva/bookmarks/di/BookmarksModule.kt b/data/bookmarks/src/main/java/com/eva/bookmarks/di/BookmarksModule.kt index c3a091c8..62b3098d 100644 --- a/data/bookmarks/src/main/java/com/eva/bookmarks/di/BookmarksModule.kt +++ b/data/bookmarks/src/main/java/com/eva/bookmarks/di/BookmarksModule.kt @@ -1,9 +1,11 @@ package com.eva.bookmarks.di import android.content.Context +import com.eva.bookmarks.data.AndroidExportURIProvider import com.eva.bookmarks.data.BookMarksToCsvFileConvertor import com.eva.bookmarks.data.RecordingBookMarkProviderImpl -import com.eva.bookmarks.domain.provider.ExportBookMarkUriProvider +import com.eva.bookmarks.domain.provider.BookMarksExportRepository +import com.eva.bookmarks.domain.provider.ExportURIProvider import com.eva.bookmarks.domain.provider.RecordingBookmarksProvider import com.eva.database.dao.RecordingsBookmarkDao import com.eva.recordings.domain.provider.RecordingsSecondaryDataProvider @@ -20,8 +22,14 @@ object BookmarksModule { @Provides @Singleton - fun providesBookmarksToCsvConvertor(@ApplicationContext context: Context) - : ExportBookMarkUriProvider = BookMarksToCsvFileConvertor(context) + fun providesBookURIProvider(@ApplicationContext context: Context): ExportURIProvider = + AndroidExportURIProvider(context) + + @Provides + @Singleton + fun providesBookmarksToCsvConvertor( + provider: ExportURIProvider + ): BookMarksExportRepository = BookMarksToCsvFileConvertor(provider) @Provides @Singleton @@ -29,4 +37,5 @@ object BookmarksModule { dao: RecordingsBookmarkDao, provider: RecordingsSecondaryDataProvider, ): RecordingBookmarksProvider = RecordingBookMarkProviderImpl(dao, provider) + } \ No newline at end of file diff --git a/data/bookmarks/src/main/java/com/eva/bookmarks/domain/provider/BookMarksExportRepository.kt b/data/bookmarks/src/main/java/com/eva/bookmarks/domain/provider/BookMarksExportRepository.kt new file mode 100644 index 00000000..60b2ea19 --- /dev/null +++ b/data/bookmarks/src/main/java/com/eva/bookmarks/domain/provider/BookMarksExportRepository.kt @@ -0,0 +1,9 @@ +package com.eva.bookmarks.domain.provider + +import com.eva.bookmarks.domain.AudioBookmarkModel + +interface BookMarksExportRepository { + + suspend fun invoke(points: List): String? + +} \ No newline at end of file diff --git a/data/bookmarks/src/main/java/com/eva/bookmarks/domain/provider/ExportBookMarkUriProvider.kt b/data/bookmarks/src/main/java/com/eva/bookmarks/domain/provider/ExportBookMarkUriProvider.kt deleted file mode 100644 index e9a9274e..00000000 --- a/data/bookmarks/src/main/java/com/eva/bookmarks/domain/provider/ExportBookMarkUriProvider.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.eva.bookmarks.domain.provider - -import com.eva.bookmarks.domain.AudioBookmarkModel - -fun interface ExportBookMarkUriProvider { - - suspend operator fun invoke(points: Collection): String? -} \ No newline at end of file diff --git a/data/bookmarks/src/main/java/com/eva/bookmarks/domain/provider/ExportURIProvider.kt b/data/bookmarks/src/main/java/com/eva/bookmarks/domain/provider/ExportURIProvider.kt new file mode 100644 index 00000000..102bfcbf --- /dev/null +++ b/data/bookmarks/src/main/java/com/eva/bookmarks/domain/provider/ExportURIProvider.kt @@ -0,0 +1,22 @@ +package com.eva.bookmarks.domain.provider + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.annotations.VisibleForTesting +import java.io.File + +interface ExportURIProvider { + + val filesDirectory: File + + fun getURIFromFile(file: File): String? + + @VisibleForTesting + suspend fun clearAll(): Result { + return runCatching { + withContext(Dispatchers.IO) { + filesDirectory.deleteRecursively() + } + } + } +} \ No newline at end of file From ec290303045c30e1a35d16008ae432c71a399f4e Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sat, 25 Oct 2025 15:59:50 +0530 Subject: [PATCH 03/25] Included Bookmarks provider test Created room in memory db for testing in RecorderDataBase.kt and DatabaseTestModule.kt as di test module RecordingsBookmarkDao.kt included option to delete all the entries this is used in testing part RecordingsBookmarkProviderTest.kt tests the methods and check is everything is expected as in RecordingBookMarkProviderImpl.kt where unwanted with context block is removed and cancellation exception throw is added Included turbine dependency to test flows --- data/bookmarks/build.gradle.kts | 1 + .../data/RecordingsBookmarkProviderTest.kt | 165 ++++++++++++++++++ .../data/RecordingBookMarkProviderImpl.kt | 105 ++++++----- .../com/eva/database/di/DatabaseTestModule.kt | 48 +++++ .../java/com/eva/database/RecorderDataBase.kt | 32 ++-- .../eva/database/dao/RecordingsBookmarkDao.kt | 4 + .../data/ShareRecordingsUtilImpl.kt | 6 +- .../eva/interactions/di/InteractionsModule.kt | 4 +- gradle/libs.versions.toml | 2 + 9 files changed, 295 insertions(+), 72 deletions(-) create mode 100644 data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/RecordingsBookmarkProviderTest.kt create mode 100644 data/database/src/androidTest/java/com/eva/database/di/DatabaseTestModule.kt diff --git a/data/bookmarks/build.gradle.kts b/data/bookmarks/build.gradle.kts index d1954c74..2f43c659 100644 --- a/data/bookmarks/build.gradle.kts +++ b/data/bookmarks/build.gradle.kts @@ -13,4 +13,5 @@ dependencies { implementation(project(":data:database")) androidTestImplementation(project(":testing:runtime")) + androidTestImplementation(libs.turbine) } \ No newline at end of file diff --git a/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/RecordingsBookmarkProviderTest.kt b/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/RecordingsBookmarkProviderTest.kt new file mode 100644 index 00000000..d724caad --- /dev/null +++ b/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/RecordingsBookmarkProviderTest.kt @@ -0,0 +1,165 @@ +package com.eva.bookmarks.data + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.eva.bookmarks.domain.AudioBookmarkModel +import com.eva.bookmarks.domain.provider.RecordingBookmarksProvider +import com.eva.database.dao.RecordingsBookmarkDao +import com.eva.recordings.domain.exceptions.InvalidRecordingIdException +import com.eva.utils.Resource +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalTime +import org.junit.Rule +import org.junit.runner.RunWith +import javax.inject.Inject +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +class RecordingsBookmarkProviderTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var provider: RecordingBookmarksProvider + + @Inject + lateinit var bookmarkDao: RecordingsBookmarkDao + + @BeforeTest + fun setup() = runTest { + hiltRule.inject() + bookmarkDao.clearAllBookmarkData() + } + + @Test + fun create_bookMark_single_entity_successfully() = runTest { + val recordingId = 0L + + val time = LocalTime(0, 0) + val text = "Start Point" + + val result = provider.createBookMark(recordingId, time, text) + + assertTrue(result is Resource.Success) + val bookMarks = provider.getRecordingBookmarksFromIdAsList(recordingId) + assertEquals(1, bookMarks.size) + } + + @Test + fun create_bookmark_multiple_entries_successfully() = runTest { + val recordingId = 0L + val points = List(10) { LocalTime(it, 0) } + provider.createBookMarks(recordingId, points) + + val result = provider.getRecordingBookmarksFromIdAsList(recordingId) + assertEquals(10, result.size) + } + + @Test + fun update_bookMark_successfully() = runTest { + val recordingId = 0L + val oldTextValue = "Some Value" + val timeStamp = LocalTime(0, 0) + val result = provider.createBookMark(recordingId, timeStamp, text = oldTextValue) + + assertTrue(result is Resource.Success, "NEW ENTRY ADDED") + + val newText = "New label" + provider.updateBookMark(result.data, newText) + val updated = provider.getRecordingBookmarksFromIdAsList(recordingId).firstOrNull() + + assertEquals(newText, updated?.text, "The updated enty has the updated text") + } + + @Test + fun updating_bookMark_with_invalid_id_creates_new_bookmark() = runTest { + val invalidBookmark = AudioBookmarkModel( + bookMarkId = 9999L, + recordingId = 100L, + timeStamp = LocalTime(0, 2), + text = "Invalid test" + ) + val result = provider.updateBookMark(invalidBookmark, "Updated-test") + + assertTrue(result is Resource.Error, "Cannot update the bookmark") + assertIs( + result.error, + "Recording id was not present thus invalid recording id" + ) + } + + @Test + fun checking_adding_new_bookmarks_flow() = runTest { + val recordingId = 0L + provider.getRecordingBookmarksFromId(recordingId).test { + val firstEmit = awaitItem() + + assertTrue(firstEmit.isEmpty()) + + provider.createBookMark(recordingId, LocalTime(0, 0), "Something") + val secondEmit = awaitItem() + + assertTrue(secondEmit.isNotEmpty()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun checking_bookmarks_add_update_delete_operations() = runTest { + val recordingId = 0L + + // create new + val newBookMark = provider.createBookMark(recordingId, LocalTime(0, 0), "Something") + assertTrue(newBookMark is Resource.Success, "Create New bookmark is success") + + // read the number of items + val items1 = provider.getRecordingBookmarksFromIdAsList(recordingId) + assertEquals(1, items1.size, "New entry so size is 1") + + val updatedName = "New Name" + + // update + val updatedResult = provider.updateBookMark(newBookMark.data, updatedName) + assertTrue(updatedResult is Resource.Success, "Bookmark updated") + assertEquals(updatedName, updatedResult.data.text, "The updated the item ") + + //delete + val deleteResult = provider.deleteBookMarks(listOf(updatedResult.data)) + assertTrue(deleteResult is Resource.Success, "Bookmark deleted") + + val items2 = provider.getRecordingBookmarksFromIdAsList(recordingId) + assertEquals(0, items2.size, "Old item deleted so 0") + + } + + @Test + fun deleting_bookMarks() = runTest { + val recordingId = 0L + val points = List(10) { LocalTime(it, 0) } + provider.createBookMarks(recordingId, points) + + val newlyAdded = provider.getRecordingBookmarksFromIdAsList(recordingId) + + assertEquals(10, newlyAdded.size) + + val deleteResult = provider.deleteBookMarks(newlyAdded) + + assertTrue(deleteResult is Resource.Success) + + val bookMarkList = provider.getRecordingBookmarksFromIdAsList(recordingId) + + assertTrue(bookMarkList.isEmpty()) + } + +} \ No newline at end of file diff --git a/data/bookmarks/src/main/java/com/eva/bookmarks/data/RecordingBookMarkProviderImpl.kt b/data/bookmarks/src/main/java/com/eva/bookmarks/data/RecordingBookMarkProviderImpl.kt index 69ef5e38..5d8b608a 100644 --- a/data/bookmarks/src/main/java/com/eva/bookmarks/data/RecordingBookMarkProviderImpl.kt +++ b/data/bookmarks/src/main/java/com/eva/bookmarks/data/RecordingBookMarkProviderImpl.kt @@ -6,13 +6,14 @@ import com.eva.bookmarks.domain.exceptions.InvalidBookMarkIdException import com.eva.bookmarks.domain.provider.RecordingBookmarksProvider import com.eva.database.dao.RecordingsBookmarkDao import com.eva.database.entity.RecordingBookMarkEntity +import com.eva.recordings.domain.exceptions.InvalidRecordingIdException import com.eva.recordings.domain.provider.RecordingsSecondaryDataProvider import com.eva.utils.Resource +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.withContext import kotlinx.datetime.LocalTime internal class RecordingBookMarkProviderImpl( @@ -23,36 +24,34 @@ internal class RecordingBookMarkProviderImpl( override fun getRecordingBookmarksFromId(audioId: Long): Flow> { return bookmarkDao.getBookMarksFromRecordingIdAsFlow(audioId) .map { entities -> entities.map { it.toModel() } } + .flowOn(Dispatchers.IO) } override suspend fun getRecordingBookmarksFromIdAsList(audioId: Long): List { - return withContext(Dispatchers.IO) { - bookmarkDao.getBookMarksFromRecordingId(audioId) - .map { it.toModel() } - } + return bookmarkDao.getBookMarksFromRecordingId(audioId) + .map { it.toModel() } } - override suspend fun createBookMarks(recordingId: Long, points: Collection) - : Resource { + override suspend fun createBookMarks( + recordingId: Long, + points: Collection + ): Resource { val entities = points.map { point -> RecordingBookMarkEntity(recordingId = recordingId, timeStamp = point) } return try { // check if metadata exists - withContext(Dispatchers.IO) { - // get the current recording - val isPresent = provider.checkRecordingIdExists(recordingId) - if (isPresent != true) { - val result = provider.insertRecordingMetaData(recordingId) - if (result is Resource.Error) - return@withContext Resource.Error( - error = result.error, - message = result.message - ) - } - bookmarkDao.insertOrUpdateBookmarks(entities) + val isPresent = provider.checkRecordingIdExists(recordingId) ?: false + if (!isPresent) { + val result = provider.insertRecordingMetaData(recordingId) + if (result is Resource.Error) + return Resource.Error(error = result.error, message = result.message) } + bookmarkDao.insertOrUpdateBookmarks(entities) + Resource.Success(Unit) + } catch (e: CancellationException) { + throw e } catch (e: SQLiteException) { e.printStackTrace() Resource.Error(e, "SQL EXCEPTION") @@ -65,16 +64,20 @@ internal class RecordingBookMarkProviderImpl( override suspend fun updateBookMark(bookmark: AudioBookmarkModel, text: String?) : Resource { return try { + // check if its present + val isPresent = provider.checkRecordingIdExists(bookmark.recordingId) ?: false + if (!isPresent) return Resource.Error(InvalidRecordingIdException()) + // create or update the bookmark val entity = bookmark.toEntity().copy(text = text ?: "") - // get the current recording - val result = withContext(Dispatchers.IO) { - // create or update the bookmark - bookmarkDao.insertOrUpdateBookmark(entity) - // then get it - bookmarkDao.getBookMarkFromBookMarkId(bookmark.bookMarkId) - } - result?.let { Resource.Success(data = it.toModel()) } - ?: Resource.Error(InvalidBookMarkIdException()) + val newId = bookmarkDao.insertOrUpdateBookmark(entity) + val bookMarkId = if (newId == -1L) bookmark.bookMarkId else newId + // then get it + val updatedEntity = bookmarkDao.getBookMarkFromBookMarkId(bookMarkId) + ?: return Resource.Error(InvalidBookMarkIdException()) + + Resource.Success(data = updatedEntity.toModel()) + } catch (e: CancellationException) { + throw e } catch (e: SQLiteException) { Resource.Error(e, "SQL EXCEPTION") } catch (e: Exception) { @@ -86,28 +89,25 @@ internal class RecordingBookMarkProviderImpl( override suspend fun createBookMark(recordingId: Long, time: LocalTime, text: String) : Resource { return try { - withContext(Dispatchers.IO) { - val isPresent = provider.checkRecordingIdExists(recordingId) - if (isPresent != true) { - // insert the metadata - val result = provider.insertRecordingMetaData(recordingId) - if (result is Resource.Error) - return@withContext Resource.Error( - error = result.error, - message = result.message - ) - } - // if recording id not exists - val entity = RecordingBookMarkEntity( - text = text, - recordingId = recordingId, - timeStamp = time - ) - val id = async { bookmarkDao.insertOrUpdateBookmark(entity) } - bookmarkDao.getBookMarkFromBookMarkId(id.await()) - ?.let { Resource.Success(data = it.toModel()) } - ?: Resource.Error(InvalidBookMarkIdException()) + val isPresent = provider.checkRecordingIdExists(recordingId) ?: false + if (!isPresent) { + // insert the metadata + val result = provider.insertRecordingMetaData(recordingId) + if (result is Resource.Error) + return Resource.Error(error = result.error, message = result.message) } + // if recording id not exists + val entity = RecordingBookMarkEntity( + text = text, + recordingId = recordingId, + timeStamp = time + ) + val id = bookmarkDao.insertOrUpdateBookmark(entity) + val updatedEntity = bookmarkDao.getBookMarkFromBookMarkId(id) + ?: return Resource.Error(InvalidBookMarkIdException()) + Resource.Success(data = updatedEntity.toModel()) + } catch (e: CancellationException) { + throw e } catch (e: SQLiteException) { Resource.Error(e, "SQL EXCEPTION") } catch (e: Exception) { @@ -119,10 +119,7 @@ internal class RecordingBookMarkProviderImpl( override suspend fun deleteBookMarks(bookmarks: Collection): Resource { return try { val entities = bookmarks.map { it.toEntity() } - - withContext(Dispatchers.IO) { - bookmarkDao.deleteBookMarks(entities) - } + bookmarkDao.deleteBookMarks(entities) Resource.Success(Unit) } catch (e: SQLiteException) { Resource.Error(e, "SQL EXCEPTION") diff --git a/data/database/src/androidTest/java/com/eva/database/di/DatabaseTestModule.kt b/data/database/src/androidTest/java/com/eva/database/di/DatabaseTestModule.kt new file mode 100644 index 00000000..cdd271e0 --- /dev/null +++ b/data/database/src/androidTest/java/com/eva/database/di/DatabaseTestModule.kt @@ -0,0 +1,48 @@ +package com.eva.database.di + +import android.content.Context +import com.eva.database.RecorderDataBase +import com.eva.database.dao.RecordingCategoryDao +import com.eva.database.dao.RecordingsBookmarkDao +import com.eva.database.dao.RecordingsMetadataDao +import com.eva.database.dao.TrashFileDao +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DatabaseModule::class] +) +object DatabaseTestModule { + + @Provides + @Singleton + fun providesInMemoryRoomDatabase( + @ApplicationContext context: Context, + ): RecorderDataBase = RecorderDataBase.createInMemoryDatabase(context) + + @Provides + @Singleton + fun providesTrashDataDao(dataBase: RecorderDataBase) + : TrashFileDao = dataBase.trashMetadataEntityDao() + + @Provides + @Singleton + fun providesCategoryDao(dataBase: RecorderDataBase) + : RecordingCategoryDao = dataBase.categoriesDao() + + @Provides + @Singleton + fun providesRecordingsMetadataDao(dataBase: RecorderDataBase) + : RecordingsMetadataDao = dataBase.recordingMetaData() + + @Provides + @Singleton + fun providesBookMarkDao(dataBase: RecorderDataBase) + : RecordingsBookmarkDao = dataBase.recordingBookMarkDao() +} \ No newline at end of file diff --git a/data/database/src/main/java/com/eva/database/RecorderDataBase.kt b/data/database/src/main/java/com/eva/database/RecorderDataBase.kt index 901f6b01..a123d20d 100644 --- a/data/database/src/main/java/com/eva/database/RecorderDataBase.kt +++ b/data/database/src/main/java/com/eva/database/RecorderDataBase.kt @@ -60,20 +60,26 @@ abstract class RecorderDataBase : RoomDatabase() { private val localtimeConvertor = LocalTimeConvertors() fun createDataBase(context: Context): RecorderDataBase { - return synchronized(this) { - if (instance == null) { - instance = Room.databaseBuilder( - context, - RecorderDataBase::class.java, - DataBaseConstants.DATABASE_NAME - ) - .addTypeConverter(localtimeConvertor) - .addTypeConverter(localDateTimeConvertor) - .setQueryExecutor(Dispatchers.IO.asExecutor()) - .build() - } - instance!! + return instance ?: synchronized(this) { + Room.databaseBuilder( + context, + RecorderDataBase::class.java, + DataBaseConstants.DATABASE_NAME + ) + .addTypeConverter(localtimeConvertor) + .addTypeConverter(localDateTimeConvertor) + .setQueryExecutor(Dispatchers.IO.asExecutor()) + .build() + .also { db -> instance = db } } } + + fun createInMemoryDatabase(context: Context): RecorderDataBase { + return Room.inMemoryDatabaseBuilder(context, RecorderDataBase::class.java) + .addTypeConverter(localtimeConvertor) + .addTypeConverter(localDateTimeConvertor) + .setQueryExecutor(Dispatchers.IO.asExecutor()) + .build() + } } } \ No newline at end of file diff --git a/data/database/src/main/java/com/eva/database/dao/RecordingsBookmarkDao.kt b/data/database/src/main/java/com/eva/database/dao/RecordingsBookmarkDao.kt index 365cae92..880b4d06 100644 --- a/data/database/src/main/java/com/eva/database/dao/RecordingsBookmarkDao.kt +++ b/data/database/src/main/java/com/eva/database/dao/RecordingsBookmarkDao.kt @@ -6,6 +6,7 @@ import androidx.room.Query import androidx.room.Upsert import com.eva.database.entity.RecordingBookMarkEntity import kotlinx.coroutines.flow.Flow +import org.jetbrains.annotations.VisibleForTesting @Dao interface RecordingsBookmarkDao { @@ -34,4 +35,7 @@ interface RecordingsBookmarkDao { @Delete suspend fun deleteBookMarks(bookmarks: Collection) + @VisibleForTesting + @Query("DELETE FROM RECORDING_BOOKMARK_TABLE") + suspend fun clearAllBookmarkData() } \ No newline at end of file diff --git a/data/interactions/src/main/java/com/eva/interactions/data/ShareRecordingsUtilImpl.kt b/data/interactions/src/main/java/com/eva/interactions/data/ShareRecordingsUtilImpl.kt index 9904f095..7f87b0fd 100644 --- a/data/interactions/src/main/java/com/eva/interactions/data/ShareRecordingsUtilImpl.kt +++ b/data/interactions/src/main/java/com/eva/interactions/data/ShareRecordingsUtilImpl.kt @@ -7,7 +7,7 @@ import android.net.Uri import androidx.core.net.toUri import com.eva.bookmarks.domain.AudioBookmarkModel import com.eva.bookmarks.domain.exceptions.ExportBookMarksFailedException -import com.eva.bookmarks.domain.provider.ExportBookMarkUriProvider +import com.eva.bookmarks.domain.provider.BookMarksExportRepository import com.eva.interactions.R import com.eva.interactions.domain.ShareRecordingsUtil import com.eva.recordings.domain.models.AudioFileModel @@ -16,7 +16,7 @@ import com.eva.utils.Resource internal class ShareRecordingsUtilImpl( private val context: Context, - private val exportBookMarkUriProvider: ExportBookMarkUriProvider, + private val bookMarksExportRepository: BookMarksExportRepository, ) : ShareRecordingsUtil { override fun shareAudioFiles(collection: List): Resource { @@ -74,7 +74,7 @@ internal class ShareRecordingsUtilImpl( override suspend fun shareBookmarksCsv(bookmarks: Collection): Resource { - val uri = exportBookMarkUriProvider.invoke(bookmarks.toSet())?.toUri() + val uri = bookMarksExportRepository.invoke(bookmarks.toList())?.toUri() ?: return Resource.Error(ExportBookMarksFailedException()) val intent = Intent(Intent.ACTION_SEND).apply { diff --git a/data/interactions/src/main/java/com/eva/interactions/di/InteractionsModule.kt b/data/interactions/src/main/java/com/eva/interactions/di/InteractionsModule.kt index f0dd4a48..6e9d9145 100644 --- a/data/interactions/src/main/java/com/eva/interactions/di/InteractionsModule.kt +++ b/data/interactions/src/main/java/com/eva/interactions/di/InteractionsModule.kt @@ -2,7 +2,7 @@ package com.eva.interactions.di import android.content.Context import android.os.Build -import com.eva.bookmarks.domain.provider.ExportBookMarkUriProvider +import com.eva.bookmarks.domain.provider.BookMarksExportRepository import com.eva.interactions.data.AppShortcutsUtilsImpl import com.eva.interactions.data.ShareRecordingsUtilImpl import com.eva.interactions.data.bluetooth.BluetoothScoConnectImpl @@ -49,6 +49,6 @@ object InteractionsModule { @Singleton fun providesShareRecordingHelper( @ApplicationContext context: Context, - bookmarkProvider: ExportBookMarkUriProvider, + bookmarkProvider: BookMarksExportRepository, ): ShareRecordingsUtil = ShareRecordingsUtilImpl(context, bookmarkProvider) } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8248b1b6..539436b1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,7 @@ materialIconExtended = "1.7.8" moduleGrapher = "0.13.0" mockk = "1.14.6" coroutines-test = "1.10.2" +turbine_version = "1.2.1" compileSdk = "36" minSdk = "29" @@ -104,6 +105,7 @@ protobuf-gen-javalite = { module = "com.google.protobuf:protoc-gen-javalite", ve mockk = { module = "io.mockk:mockk", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine_version" } #gradle android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } From 050b18093ba4ec2824e5e02221afbb601279c567 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sat, 25 Oct 2025 21:18:37 +0530 Subject: [PATCH 04/25] Included test cases for categories and code corrections RecordingsCategoryProviderImpl.kt included CancellationException in all methods which is thrown to the parent coroutine For update it first checks if we already have a category with the given id if not Resource.Error is returned, RecordingsBookmarkProviderTest.kt tests the following methods for errors and correct flow Again method to delete all entries method included in RecordingCategoryDao.kt for testing purpose Included turbine dependency in ConfigureAndroidLibraryPlugin.kt so no need to mention it in dependencies block In bookmarks test the clear entries should happen in teardown --- .../kotlin/ConfigureAndroidLibraryPlugin.kt | 10 +- data/bookmarks/build.gradle.kts | 1 - .../data/BookMarksToCsvConvertorTest.kt | 11 ++ .../data/RecordingsBookmarkProviderTest.kt | 8 +- data/categories/build.gradle.kts | 2 + .../RecordingsCategoryProviderTest.kt | 152 ++++++++++++++++++ .../data/RecordingsCategoryProviderImpl.kt | 84 ++++++---- .../eva/database/dao/RecordingCategoryDao.kt | 6 +- 8 files changed, 233 insertions(+), 41 deletions(-) create mode 100644 data/categories/src/androidTest/java/com/eva/categories/RecordingsCategoryProviderTest.kt diff --git a/build-logic/src/main/kotlin/ConfigureAndroidLibraryPlugin.kt b/build-logic/src/main/kotlin/ConfigureAndroidLibraryPlugin.kt index ab3eafcb..65653bcb 100644 --- a/build-logic/src/main/kotlin/ConfigureAndroidLibraryPlugin.kt +++ b/build-logic/src/main/kotlin/ConfigureAndroidLibraryPlugin.kt @@ -40,7 +40,12 @@ class ConfigureAndroidLibraryPlugin : Plugin { } } - val testImplementations = listOf("junit", "kotlin.test", "kotlinx.coroutines.test") + val testImplementations = listOf( + "junit", + "kotlin.test", + "kotlinx.coroutines.test", + "turbine" + ) testImplementations.forEach { catalog.findLibrary(it).ifPresent { dependency -> @@ -52,7 +57,8 @@ class ConfigureAndroidLibraryPlugin : Plugin { "androidx.junit", "androidx.espresso.core", "kotlin.test", - "kotlinx.coroutines.test" + "kotlinx.coroutines.test", + "turbine" ) androidTestImplementations.forEach { diff --git a/data/bookmarks/build.gradle.kts b/data/bookmarks/build.gradle.kts index 2f43c659..d1954c74 100644 --- a/data/bookmarks/build.gradle.kts +++ b/data/bookmarks/build.gradle.kts @@ -13,5 +13,4 @@ dependencies { implementation(project(":data:database")) androidTestImplementation(project(":testing:runtime")) - androidTestImplementation(libs.turbine) } \ No newline at end of file diff --git a/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/BookMarksToCsvConvertorTest.kt b/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/BookMarksToCsvConvertorTest.kt index 1bf26725..14a174e1 100644 --- a/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/BookMarksToCsvConvertorTest.kt +++ b/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/BookMarksToCsvConvertorTest.kt @@ -3,15 +3,18 @@ package com.eva.bookmarks.data import androidx.test.ext.junit.runners.AndroidJUnit4 import com.eva.bookmarks.domain.AudioBookmarkModel import com.eva.bookmarks.domain.provider.BookMarksExportRepository +import com.eva.bookmarks.domain.provider.ExportURIProvider import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.datetime.LocalTime import org.junit.Rule import org.junit.runner.RunWith import javax.inject.Inject +import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertTrue @@ -27,9 +30,17 @@ class BookMarksToCsvConvertorTest { @Inject lateinit var exporter: BookMarksExportRepository + @Inject + lateinit var uriProvider: ExportURIProvider + @BeforeTest fun setUp() = hiltRule.inject() + @AfterTest + fun tearDown() = runBlocking { + uriProvider.clearAll() + } + @Test fun check_if_export_bookmarks_creates_a_file() = runTest { diff --git a/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/RecordingsBookmarkProviderTest.kt b/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/RecordingsBookmarkProviderTest.kt index d724caad..82c68c10 100644 --- a/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/RecordingsBookmarkProviderTest.kt +++ b/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/RecordingsBookmarkProviderTest.kt @@ -10,11 +10,13 @@ import com.eva.utils.Resource import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import kotlinx.datetime.LocalTime import org.junit.Rule import org.junit.runner.RunWith import javax.inject.Inject +import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -36,8 +38,10 @@ class RecordingsBookmarkProviderTest { lateinit var bookmarkDao: RecordingsBookmarkDao @BeforeTest - fun setup() = runTest { - hiltRule.inject() + fun setUp() = hiltRule.inject() + + @AfterTest + fun tearDown() = runBlocking { bookmarkDao.clearAllBookmarkData() } diff --git a/data/categories/build.gradle.kts b/data/categories/build.gradle.kts index aaf2e4c4..27728b52 100644 --- a/data/categories/build.gradle.kts +++ b/data/categories/build.gradle.kts @@ -11,4 +11,6 @@ dependencies { implementation(project(":core:ui")) implementation(project(":core:utils")) implementation(project(":data:database")) + + androidTestImplementation(project(":testing:runtime")) } \ No newline at end of file diff --git a/data/categories/src/androidTest/java/com/eva/categories/RecordingsCategoryProviderTest.kt b/data/categories/src/androidTest/java/com/eva/categories/RecordingsCategoryProviderTest.kt new file mode 100644 index 00000000..cf46897f --- /dev/null +++ b/data/categories/src/androidTest/java/com/eva/categories/RecordingsCategoryProviderTest.kt @@ -0,0 +1,152 @@ +package com.eva.categories + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.eva.categories.domain.exceptions.RecordingCategoryNotFoundException +import com.eva.categories.domain.exceptions.UnModifiableRecordingCategoryException +import com.eva.categories.domain.models.CategoryColor +import com.eva.categories.domain.models.CategoryType +import com.eva.categories.domain.models.RecordingCategoryModel +import com.eva.categories.domain.provider.RecordingCategoryProvider +import com.eva.database.dao.RecordingCategoryDao +import com.eva.utils.Resource +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.runner.RunWith +import javax.inject.Inject +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class RecordingsCategoryProviderTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var provider: RecordingCategoryProvider + + @Inject + lateinit var dao: RecordingCategoryDao + + @BeforeTest + fun setUp() = hiltRule.inject() + + @AfterTest + fun tearDown() = runBlocking { + dao.deleteAllCategories() + } + + @Test + fun create_new_category_simple() = runTest { + val entry = provider.createCategory("Category") + assertTrue(entry is Resource.Success, "New entry is successfully created") + + val check = provider.getCategoryFromId(entry.data.id) + assertTrue(check is Resource.Success, "The entry is actually present") + } + + @Test + fun create_new_category() = runTest { + val entry = provider.createCategory( + name = "Category", + color = CategoryColor.COLOR_INDIGO, + type = CategoryType.CATEGORY_GROUP + ) + assertTrue(entry is Resource.Success, "New entry is successfully created") + assertEquals( + CategoryColor.COLOR_INDIGO, + entry.data.categoryColor, + "Same color created as mentioned" + ) + assertEquals( + CategoryType.CATEGORY_GROUP, + entry.data.categoryType, + "Same category type created" + ) + + val check = provider.getCategoryFromId(entry.data.id) + assertTrue(check is Resource.Success, "The entry is actually present") + } + + @Test + fun create_and_then_update_category() = runTest { + val originalName = "Category" + val create = provider.createCategory(originalName) + assertTrue(create is Resource.Success, "New entry is successfully created") + assertEquals(originalName, create.data.name) + + val updatedName = "Updated Category" + val update = provider.updateCategory(create.data.copy(name = updatedName)) + assertTrue(update is Resource.Success, "New entry is successfully created") + assertEquals(updatedName, update.data.name) + } + + @Test + fun try_updating_a_fake_category() = runTest { + val fakeCategory = RecordingCategoryModel(id = 20L, name = "Fakes") + val update = provider.updateCategory(fakeCategory) + assertFalse(update is Resource.Success, "Cannot update the category as it not exists") + } + + @Test + fun create_category_then_delete_it() = runTest { + val originalName = "Category" + val create = provider.createCategory(originalName) + assertTrue(create is Resource.Success, "New entry is successfully created") + assertEquals(originalName, create.data.name) + + val delete = provider.deleteCategory(create.data) + assertTrue(delete is Resource.Success, "Entry is deleted") + + val entry = provider.getCategoryFromId(create.data.id) + assertTrue(entry is Resource.Error, "Entry cannot be found") + assertIs(entry.error) + } + + @Test + fun try_deleting_the_all_category_model() = runTest { + val delete = provider.deleteCategory(RecordingCategoryModel.ALL_CATEGORY) + assertTrue(delete is Resource.Error, "Entry cannot be deleted") + assertIs(delete.error) + } + + @Test + fun check_adding_update_effect_flow() = runTest { + provider.recordingCategoryAsResourceFlow.test { + val firstEmit = awaitItem() + assertIs(firstEmit) + + val secondEmit = awaitItem() + assertIs, Exception>>(secondEmit) + // it will always have the default the recording added + assertEquals(1, secondEmit.data.size) + + // now add an item + val category = provider.createCategory("Category") + assertIs>(category) + + val thirdEmit = awaitItem() + assertIs, Exception>>(thirdEmit) + assertEquals(2, thirdEmit.data.size) + + // delete the item + provider.deleteCategory(category.data) + + val fourthEmit = awaitItem() + assertIs, Exception>>(fourthEmit) + assertEquals(1, fourthEmit.data.size) + + cancelAndIgnoreRemainingEvents() + } + } +} \ No newline at end of file diff --git a/data/categories/src/main/java/com/eva/categories/data/RecordingsCategoryProviderImpl.kt b/data/categories/src/main/java/com/eva/categories/data/RecordingsCategoryProviderImpl.kt index 7160487f..23dc997c 100644 --- a/data/categories/src/main/java/com/eva/categories/data/RecordingsCategoryProviderImpl.kt +++ b/data/categories/src/main/java/com/eva/categories/data/RecordingsCategoryProviderImpl.kt @@ -2,7 +2,6 @@ package com.eva.categories.data import android.content.Context import android.database.sqlite.SQLiteException -import com.eva.categories.R import com.eva.categories.domain.exceptions.RecordingCategoryNotFoundException import com.eva.categories.domain.exceptions.UnModifiableRecordingCategoryException import com.eva.categories.domain.models.CategoryColor @@ -12,7 +11,9 @@ import com.eva.categories.domain.provider.RecordingCategoriesModels import com.eva.categories.domain.provider.RecordingCategoryProvider import com.eva.database.dao.RecordingCategoryDao import com.eva.database.entity.RecordingCategoryEntity +import com.eva.ui.R import com.eva.utils.Resource +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map @@ -34,32 +35,28 @@ internal class RecordingsCategoryProviderImpl( override val recordingCategoryAsResourceFlow: Flow> get() = categoryDao.getAllCategoryAsFlow() - .map { entries -> - val result = buildList { + .map { entities -> + val categories = buildList { add(RecordingCategoryModel.ALL_CATEGORY) - addAll(entries.map(RecordingCategoryEntity::toModel)) + addAll(entities.map(RecordingCategoryEntity::toModel)) } - Resource.Success, Exception>(result) - as Resource, Exception> + Resource.Success(categories) as Resource } .onStart { emit(Resource.Loading) } - .catch { err -> - if (err is SQLiteException) - emit(Resource.Error(err, "SQL EXCEPTION")) - else { - err.printStackTrace() - emit(Resource.Error(Exception(err), err.message ?: "")) - } + .catch { error -> + error.printStackTrace() + val message = if (error is SQLiteException) "SQL EXCEPTION" + else error.message ?: "Unknown error occurred" + emit(Resource.Error(Exception(error), message)) } - override suspend fun getCategoryFromId(id: Long): Resource { return try { //get the value val result = categoryDao.getCategoryFromId(id = id) + ?: return Resource.Error(RecordingCategoryNotFoundException()) - return result?.let { entity -> Resource.Success(entity.toModel()) } - ?: Resource.Error(RecordingCategoryNotFoundException()) + return Resource.Success(result.toModel()) } catch (e: SQLiteException) { Resource.Error(e, "SQL EXCEPTION") } catch (e: Exception) { @@ -72,16 +69,18 @@ internal class RecordingsCategoryProviderImpl( return try { val entity = RecordingCategoryEntity( categoryName = name, - createdAt = localtimeNow + createdAt = localtimeNow, ) // creates the entry val entityId = categoryDao.insertOrUpdateCategory(entity) //get the value val result = categoryDao.getCategoryFromId(entityId) + ?: return Resource.Error(RecordingCategoryNotFoundException()) val message = context.getString(R.string.categories_create_success) - return result?.let { entity -> Resource.Success(entity.toModel(), message = message) } - ?: Resource.Error(RecordingCategoryNotFoundException()) + return Resource.Success(result.toModel(), message = message) + } catch (e: CancellationException) { + throw e } catch (e: SQLiteException) { Resource.Error(e, "SQL EXCEPTION") } catch (e: Exception) { @@ -90,8 +89,11 @@ internal class RecordingsCategoryProviderImpl( } } - override suspend fun createCategory(name: String, color: CategoryColor, type: CategoryType) - : Resource { + override suspend fun createCategory( + name: String, + color: CategoryColor, + type: CategoryType + ): Resource { return try { val entity = RecordingCategoryEntity( @@ -105,9 +107,12 @@ internal class RecordingsCategoryProviderImpl( //get the value val result = categoryDao.getCategoryFromId(entityId) + ?: return Resource.Error(RecordingCategoryNotFoundException()) + val message = context.getString(R.string.categories_create_success) - return result?.let { entity -> Resource.Success(entity.toModel(), message = message) } - ?: Resource.Error(RecordingCategoryNotFoundException()) + return Resource.Success(result.toModel(), message = message) + } catch (e: CancellationException) { + throw e } catch (e: SQLiteException) { Resource.Error(e, "SQL EXCEPTION") } catch (e: Exception) { @@ -118,17 +123,23 @@ internal class RecordingsCategoryProviderImpl( override suspend fun updateCategory(category: RecordingCategoryModel): Resource { + if (category == RecordingCategoryModel.ALL_CATEGORY) + return Resource.Error(UnModifiableRecordingCategoryException()) return try { - if (category == RecordingCategoryModel.ALL_CATEGORY) - return Resource.Error(UnModifiableRecordingCategoryException()) - // update it or insert it + // check if exists is not return absent + categoryDao.getCategoryFromId(category.id) + ?: return Resource.Error(RecordingCategoryNotFoundException()) + // update this categoryDao.insertOrUpdateCategory(entity = category.toEntity()) //get the value - val result = categoryDao.getCategoryFromId(id = category.id) - return result?.let { entity -> - val message = context.getString(R.string.categories_updated, entity.categoryName) - Resource.Success(entity.toModel(), message) - } ?: Resource.Error(RecordingCategoryNotFoundException()) + val entity = categoryDao.getCategoryFromId(id = category.id) + ?: return Resource.Error(RecordingCategoryNotFoundException()) + + val message = context.getString(R.string.categories_updated, entity.categoryName) + return Resource.Success(entity.toModel(), message) + + } catch (e: CancellationException) { + throw e } catch (e: SQLiteException) { Resource.Error(e, "SQL EXCEPTION") } catch (e: Exception) { @@ -138,14 +149,15 @@ internal class RecordingsCategoryProviderImpl( } override suspend fun deleteCategory(category: RecordingCategoryModel): Resource { - return try { - if (category == RecordingCategoryModel.ALL_CATEGORY) - return Resource.Error(UnModifiableRecordingCategoryException()) + if (category == RecordingCategoryModel.ALL_CATEGORY) + return Resource.Error(UnModifiableRecordingCategoryException()) + return try { categoryDao.deleteCategory(entity = category.toEntity()) - val message = context.getString(R.string.categories_deleted) Resource.Success(true, message = message) + } catch (e: CancellationException) { + throw e } catch (e: SQLiteException) { Resource.Error(e, "SQL EXCEPTION") } catch (e: Exception) { @@ -163,6 +175,8 @@ internal class RecordingsCategoryProviderImpl( categoryDao.deleteCategoriesBulk(entities = entities) val message = context.getString(R.string.categories_deleted) Resource.Success(true, message = message) + } catch (e: CancellationException) { + throw e } catch (e: SQLiteException) { Resource.Error(e, "SQL EXCEPTION") } catch (e: Exception) { diff --git a/data/database/src/main/java/com/eva/database/dao/RecordingCategoryDao.kt b/data/database/src/main/java/com/eva/database/dao/RecordingCategoryDao.kt index 2afdc298..849e3049 100644 --- a/data/database/src/main/java/com/eva/database/dao/RecordingCategoryDao.kt +++ b/data/database/src/main/java/com/eva/database/dao/RecordingCategoryDao.kt @@ -1,5 +1,6 @@ package com.eva.database.dao +import androidx.annotation.VisibleForTesting import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert @@ -10,7 +11,6 @@ import androidx.room.Upsert import com.eva.database.entity.RecordingCategoryEntity import kotlinx.coroutines.flow.Flow - @Dao interface RecordingCategoryDao { @@ -46,4 +46,8 @@ interface RecordingCategoryDao { @Delete suspend fun deleteCategoriesBulk(entities: Collection) + + @VisibleForTesting + @Query("DELETE FROM RECORDINGS_CATEGORY") + suspend fun deleteAllCategories() } \ No newline at end of file From 94b71bbd3ab56815f18bbe265cdddd32d98f63fd Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sun, 26 Oct 2025 00:10:37 +0530 Subject: [PATCH 05/25] Corrected LocalDateTimeConvertors.kt and included migration tests LocalDateTimeConvertors.kt were using system timezone to convert local datetime to instant which is wrong as timezone can change we need to use a constant like UTC So in DBMigrations.kt included a migration to increase the time value with the specific offset time in millis DBMigrationsTests.kt checks if the migration has been validated and the results are correct ConfigureRoomPlugin.kt included schemas in android Test --- .../src/main/kotlin/ConfigureRoomPlugin.kt | 10 + data/database/build.gradle.kts | 1 + .../com.eva.database.RecorderDataBase/6.json | 228 ++++++++++++++++++ .../com/eva/database/DBMigrationsTests.kt | 100 ++++++++ .../java/com/eva/database/DBMigrations.kt | 34 +++ .../java/com/eva/database/RecorderDataBase.kt | 4 +- .../convertors/LocalDateTimeConvertors.kt | 7 +- 7 files changed, 379 insertions(+), 5 deletions(-) create mode 100644 data/database/schemas/com.eva.database.RecorderDataBase/6.json create mode 100644 data/database/src/androidTest/java/com/eva/database/DBMigrationsTests.kt create mode 100644 data/database/src/main/java/com/eva/database/DBMigrations.kt diff --git a/build-logic/src/main/kotlin/ConfigureRoomPlugin.kt b/build-logic/src/main/kotlin/ConfigureRoomPlugin.kt index e4606b79..299bb052 100644 --- a/build-logic/src/main/kotlin/ConfigureRoomPlugin.kt +++ b/build-logic/src/main/kotlin/ConfigureRoomPlugin.kt @@ -1,4 +1,5 @@ import androidx.room.gradle.RoomExtension +import com.android.build.gradle.LibraryExtension import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.getByType @@ -9,6 +10,7 @@ class ConfigureRoomPlugin : Plugin { target.addPlugins() target.addDependencies() target.configureRoom() + target.configureRoomTesting() } private fun Project.addPlugins() = plugins.apply { @@ -50,4 +52,12 @@ class ConfigureRoomPlugin : Plugin { schemaDirectory("$projectDir/schemas") } + private fun Project.configureRoomTesting() = extensions.getByType() + .apply { + sourceSets { + val androidTestSourceSet = getByName("androidTest") + androidTestSourceSet.assets.srcDir("$projectDir/schemas") + } + } + } \ No newline at end of file diff --git a/data/database/build.gradle.kts b/data/database/build.gradle.kts index 60fdbd5a..581a4557 100644 --- a/data/database/build.gradle.kts +++ b/data/database/build.gradle.kts @@ -9,4 +9,5 @@ android { dependencies { implementation(project(":core:utils")) + androidTestImplementation(project(":testing:runtime")) } \ No newline at end of file diff --git a/data/database/schemas/com.eva.database.RecorderDataBase/6.json b/data/database/schemas/com.eva.database.RecorderDataBase/6.json new file mode 100644 index 00000000..20464b14 --- /dev/null +++ b/data/database/schemas/com.eva.database.RecorderDataBase/6.json @@ -0,0 +1,228 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "edcf0a79617bde92c1854605c306993a", + "entities": [ + { + "tableName": "trash_files_data_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ID` INTEGER NOT NULL, `TITLE` TEXT NOT NULL, `DISPLAY_NAME` TEXT NOT NULL, `MIME_TYPE` TEXT NOT NULL, `DATE_ADDED` INTEGER NOT NULL, `DATE_EXPIRES` INTEGER, `FILE` TEXT NOT NULL, PRIMARY KEY(`ID`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "ID", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "TITLE", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "DISPLAY_NAME", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "MIME_TYPE", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dateAdded", + "columnName": "DATE_ADDED", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expiresAt", + "columnName": "DATE_EXPIRES", + "affinity": "INTEGER" + }, + { + "fieldPath": "file", + "columnName": "FILE", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "ID" + ] + } + }, + { + "tableName": "recording_meta_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`RECORDING_ID` INTEGER NOT NULL, `IS_FAVOURITE` INTEGER NOT NULL, `CATEGORY_ID` INTEGER, PRIMARY KEY(`RECORDING_ID`), FOREIGN KEY(`CATEGORY_ID`) REFERENCES `recordings_category`(`CATEGORY_ID`) ON UPDATE CASCADE ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "recordingId", + "columnName": "RECORDING_ID", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavourite", + "columnName": "IS_FAVOURITE", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "CATEGORY_ID", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "RECORDING_ID" + ] + }, + "indices": [ + { + "name": "index_recording_meta_data_CATEGORY_ID", + "unique": false, + "columnNames": [ + "CATEGORY_ID" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_recording_meta_data_CATEGORY_ID` ON `${TABLE_NAME}` (`CATEGORY_ID`)" + } + ], + "foreignKeys": [ + { + "table": "recordings_category", + "onDelete": "SET NULL", + "onUpdate": "CASCADE", + "columns": [ + "CATEGORY_ID" + ], + "referencedColumns": [ + "CATEGORY_ID" + ] + } + ] + }, + { + "tableName": "recordings_category", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`CATEGORY_ID` INTEGER PRIMARY KEY AUTOINCREMENT, `CATEGORY_NAME` TEXT NOT NULL, `CREATED_AT` INTEGER NOT NULL, `COLOR` TEXT, `type` TEXT)", + "fields": [ + { + "fieldPath": "categoryId", + "columnName": "CATEGORY_ID", + "affinity": "INTEGER" + }, + { + "fieldPath": "categoryName", + "columnName": "CATEGORY_NAME", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "CREATED_AT", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "COLOR", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "CATEGORY_ID" + ] + }, + "indices": [ + { + "name": "index_recordings_category_CATEGORY_NAME", + "unique": true, + "columnNames": [ + "CATEGORY_NAME" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_recordings_category_CATEGORY_NAME` ON `${TABLE_NAME}` (`CATEGORY_NAME`)" + } + ] + }, + { + "tableName": "recording_bookmark_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`BOOKMARK_ID` INTEGER PRIMARY KEY AUTOINCREMENT, `BOOKMARK_TEXT` TEXT NOT NULL, `RECORDING_ID` INTEGER NOT NULL, `BOOKMARK_TIMESTAMP` INTEGER NOT NULL, FOREIGN KEY(`RECORDING_ID`) REFERENCES `recording_meta_data`(`RECORDING_ID`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookMarkId", + "columnName": "BOOKMARK_ID", + "affinity": "INTEGER" + }, + { + "fieldPath": "text", + "columnName": "BOOKMARK_TEXT", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordingId", + "columnName": "RECORDING_ID", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeStamp", + "columnName": "BOOKMARK_TIMESTAMP", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "BOOKMARK_ID" + ] + }, + "indices": [ + { + "name": "index_recording_bookmark_table_RECORDING_ID", + "unique": false, + "columnNames": [ + "RECORDING_ID" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_recording_bookmark_table_RECORDING_ID` ON `${TABLE_NAME}` (`RECORDING_ID`)" + } + ], + "foreignKeys": [ + { + "table": "recording_meta_data", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "RECORDING_ID" + ], + "referencedColumns": [ + "RECORDING_ID" + ] + } + ] + } + ], + "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, 'edcf0a79617bde92c1854605c306993a')" + ] + } +} \ No newline at end of file diff --git a/data/database/src/androidTest/java/com/eva/database/DBMigrationsTests.kt b/data/database/src/androidTest/java/com/eva/database/DBMigrationsTests.kt new file mode 100644 index 00000000..36dbc768 --- /dev/null +++ b/data/database/src/androidTest/java/com/eva/database/DBMigrationsTests.kt @@ -0,0 +1,100 @@ +@file:OptIn(ExperimentalTime::class) + +package com.eva.database + +import androidx.room.testing.MigrationTestHelper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.datetime.TimeZone +import kotlinx.datetime.offsetAt +import org.junit.Rule +import org.junit.runner.RunWith +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +private const val TEST_DB_NAME = "migration-test-db" + +@RunWith(AndroidJUnit4::class) +class DBMigrationsTests { + + @get:Rule + val testHelper = MigrationTestHelper( + instrumentation = InstrumentationRegistry.getInstrumentation(), + databaseClass = RecorderDataBase::class.java, + ) + + private val offsetMs: Long + get() { + val timeZone = TimeZone.currentSystemDefault() + val instant = Clock.System.now() + + val utcOffset = timeZone.offsetAt(instant) + // Convert the UtcOffset to milliseconds + return utcOffset.totalSeconds * 1000L + } + + @Test + fun migrate_5_to_6_correcting_timestamp_in_bookmarks_and_category() { + + var db = testHelper.createDatabase(TEST_DB_NAME, 5).apply { + execSQL("INSERT INTO recording_meta_data (RECORDING_ID,IS_FAVOURITE) VALUES (1, 0)") + execSQL("INSERT INTO recording_bookmark_table (BOOKMARK_ID,BOOKMARK_TEXT,RECORDING_ID, BOOKMARK_TIMESTAMP) VALUES (1,\"RANDOM TEXT\",1, 1000)") + execSQL("INSERT INTO recordings_category VALUES (1, \"SOME NAME\", 1000, null, null)") + } + db.close() + + db = testHelper.runMigrationsAndValidate( + TEST_DB_NAME, + 6, + true, + DBMigrations.MIGRATE_5_6 + ) + + db.query("SELECT BOOKMARK_TIMESTAMP FROM recording_bookmark_table").use { cursor -> + assertTrue(cursor.moveToFirst()) + val colIdx = cursor.getColumnIndex("BOOKMARK_TIMESTAMP") + val updated = cursor.getLong(colIdx) + assertEquals(1000 + offsetMs, updated) + } + + db.query("SELECT CREATED_AT FROM recordings_category").use { cursor -> + assertTrue(cursor.moveToFirst()) + val colIdx = cursor.getColumnIndex("CREATED_AT") + val updated = cursor.getLong(colIdx) + assertEquals(1000 + offsetMs, updated) + } + + db.close() + } + + @Test + fun migrate_5_to_6_correcting_trash_file_entity() { + var db = testHelper.createDatabase(TEST_DB_NAME, 5).apply { + execSQL("INSERT INTO trash_files_data_table VALUES (1, \"TRASH_ENTRY\", \"DISPLAY NAME\", \"audio/*\", 2000, 5000, \"some_location\")") + } + db.close() + + db = testHelper.runMigrationsAndValidate( + TEST_DB_NAME, + 6, + true, + DBMigrations.MIGRATE_5_6 + ) + + db.query("SELECT DATE_ADDED, DATE_EXPIRES FROM trash_files_data_table").use { cursor -> + assertTrue(cursor.moveToFirst()) + val colIdx1 = cursor.getColumnIndex("DATE_ADDED") + val colIdx2 = cursor.getColumnIndex("DATE_EXPIRES") + val newDateAdded = cursor.getLong(colIdx1) + val newDateExpires = cursor.getLong(colIdx2) + + + assertEquals(2000 + offsetMs, newDateAdded) + assertEquals(5000 + offsetMs, newDateExpires) + } + db.close() + } +} \ No newline at end of file diff --git a/data/database/src/main/java/com/eva/database/DBMigrations.kt b/data/database/src/main/java/com/eva/database/DBMigrations.kt new file mode 100644 index 00000000..63668917 --- /dev/null +++ b/data/database/src/main/java/com/eva/database/DBMigrations.kt @@ -0,0 +1,34 @@ +@file:OptIn(ExperimentalTime::class) + +package com.eva.database + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import kotlinx.datetime.TimeZone +import kotlinx.datetime.offsetAt +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +object DBMigrations { + + // Updated the timezone issue in localtime + val MIGRATE_5_6 = object : Migration(5, 6) { + + private val offset: Long + get() { + val timeZone = TimeZone.currentSystemDefault() + val instant = Clock.System.now() + + val utcOffset = timeZone.offsetAt(instant) + // Convert the UtcOffset to milliseconds + return utcOffset.totalSeconds * 1000L + } + + override fun migrate(db: SupportSQLiteDatabase) { + + db.execSQL("UPDATE recording_bookmark_table set BOOKMARK_TIMESTAMP = BOOKMARK_TIMESTAMP + $offset") + db.execSQL("UPDATE recordings_category set CREATED_AT = CREATED_AT + $offset") + db.execSQL("UPDATE trash_files_data_table set DATE_ADDED = DATE_ADDED + $offset , DATE_EXPIRES = DATE_EXPIRES + $offset") + } + } +} \ No newline at end of file diff --git a/data/database/src/main/java/com/eva/database/RecorderDataBase.kt b/data/database/src/main/java/com/eva/database/RecorderDataBase.kt index a123d20d..1f296198 100644 --- a/data/database/src/main/java/com/eva/database/RecorderDataBase.kt +++ b/data/database/src/main/java/com/eva/database/RecorderDataBase.kt @@ -26,7 +26,7 @@ import kotlinx.coroutines.asExecutor RecordingCategoryEntity::class, RecordingBookMarkEntity::class, ], - version = 5, + version = 6, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -68,6 +68,7 @@ abstract class RecorderDataBase : RoomDatabase() { ) .addTypeConverter(localtimeConvertor) .addTypeConverter(localDateTimeConvertor) + .addMigrations(DBMigrations.MIGRATE_5_6) .setQueryExecutor(Dispatchers.IO.asExecutor()) .build() .also { db -> instance = db } @@ -78,6 +79,7 @@ abstract class RecorderDataBase : RoomDatabase() { return Room.inMemoryDatabaseBuilder(context, RecorderDataBase::class.java) .addTypeConverter(localtimeConvertor) .addTypeConverter(localDateTimeConvertor) + .addMigrations(DBMigrations.MIGRATE_5_6) .setQueryExecutor(Dispatchers.IO.asExecutor()) .build() } diff --git a/data/database/src/main/java/com/eva/database/convertors/LocalDateTimeConvertors.kt b/data/database/src/main/java/com/eva/database/convertors/LocalDateTimeConvertors.kt index 85941d2b..70d2ea1f 100644 --- a/data/database/src/main/java/com/eva/database/convertors/LocalDateTimeConvertors.kt +++ b/data/database/src/main/java/com/eva/database/convertors/LocalDateTimeConvertors.kt @@ -14,7 +14,7 @@ import kotlin.time.Instant internal class LocalDateTimeConvertors { private val timeZone: TimeZone - get() = TimeZone.currentSystemDefault() + get() = TimeZone.UTC @TypeConverter fun fromLocalDateTime(dateTime: LocalDateTime): Long = dateTime.toInstant(timeZone) @@ -22,8 +22,7 @@ internal class LocalDateTimeConvertors { @TypeConverter - fun toLocalDateTime(seconds: Long) = - Instant.fromEpochMilliseconds(seconds).toLocalDateTime(timeZone) - + fun toLocalDateTime(milliSeconds: Long) = + Instant.fromEpochMilliseconds(milliSeconds).toLocalDateTime(timeZone) } \ No newline at end of file From 3096b77ec34c58a6358cc4a452bef35b288d09f5 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Mon, 27 Oct 2025 00:20:41 +0530 Subject: [PATCH 06/25] Corrections in editor and included option to cancel transformation AudioFileToComposition.kt gap is only added in between edited item excluded the last gap which was being added to the end AudioTransformer.kt renamed the methods and transform audio return a file not a uri AudioTransformerImpl.kt corrected the transformation logic and also included io exception if temp file failed to created In cleanUp included cancel method Using a new directory inside the cache dir thus after saving if the file is in transformation directory then delete it mentioned the seekable audio types and transformer audio output type. Removed the GainAudioProcessor.kt as this is already present in androidx.media3 Corrected Extension function in Transformer Now in Ui layer add option to cancel the ongoing transformation, only a cancel button is added it will be later worked on Made TransformationState.kt progress defer thus we only read the value when its drawn --- data/editor/build.gradle.kts | 9 +- .../data/processor/GainAudioProcessor.kt | 112 -------- .../transformer/AudioFileToComposition.kt | 63 +++-- .../data/transformer/AudioTransformerImpl.kt | 256 ++++++++++-------- .../transformer/TransformationProgress.kt | 35 ++- .../transformer/TransformerAwaitResult.kt | 88 +++--- .../com/eva/editor/domain/AudioTransformer.kt | 9 +- .../domain/exceptions/ExportFileException.kt | 3 + .../composables/TransformBottomSheet.kt | 5 +- .../composables/TransformsSheetContent.kt | 81 +++--- .../feature_editor/event/EditorScreenEvent.kt | 1 + .../event/TransformationState.kt | 2 +- .../viewmodel/AudioEditorViewModel.kt | 42 ++- 13 files changed, 354 insertions(+), 352 deletions(-) delete mode 100644 data/editor/src/main/java/com/eva/editor/data/processor/GainAudioProcessor.kt create mode 100644 data/editor/src/main/java/com/eva/editor/domain/exceptions/ExportFileException.kt diff --git a/data/editor/build.gradle.kts b/data/editor/build.gradle.kts index f9cc7b1c..ee208b6e 100644 --- a/data/editor/build.gradle.kts +++ b/data/editor/build.gradle.kts @@ -5,6 +5,10 @@ plugins { android { namespace = "com.eva.editor" + + buildFeatures { + buildConfig = true + } } dependencies { @@ -15,13 +19,12 @@ dependencies { implementation(libs.androidx.media3.transformer) implementation(libs.androidx.media3.effects) - //local implementation(project(":core:utils")) implementation(project(":data:player")) implementation(project(":data:recordings")) implementation(project(":data:worker")) implementation(project(":data:datastore")) - //test - testImplementation(kotlin("test")) + androidTestImplementation(libs.androidx.media3.test.utils) + androidTestImplementation(project(":testing:runtime")) } \ No newline at end of file diff --git a/data/editor/src/main/java/com/eva/editor/data/processor/GainAudioProcessor.kt b/data/editor/src/main/java/com/eva/editor/data/processor/GainAudioProcessor.kt deleted file mode 100644 index 73140a1b..00000000 --- a/data/editor/src/main/java/com/eva/editor/data/processor/GainAudioProcessor.kt +++ /dev/null @@ -1,112 +0,0 @@ -package com.eva.editor.data.processor - -import android.util.Log -import androidx.media3.common.audio.AudioProcessor -import androidx.media3.common.util.UnstableApi -import java.nio.ByteBuffer -import java.nio.ByteOrder -import kotlin.math.pow - -const val TWO_POWER_15 = 32768f - -private const val TAG = "GAIN_AUDIO_PROCESSOR" - -// TODO: FIX THERE IS SOME ISSUES REMAIN.. - -@UnstableApi -class GainAudioProcessor(private val gainDb: Float) : AudioProcessor { - - private var _format = AudioProcessor.AudioFormat.NOT_SET - private var _buffer: ByteBuffer? = null - private var _outputBuffer: ByteBuffer? = null - private var _isActive: Boolean = false - private var _isEnded = false - - override fun configure(inputAudioFormat: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat { - _format = inputAudioFormat - _isActive = true - Log.d(TAG, "AUDIO FILE IS CONFIGURED :AUDIO FORMAT :${inputAudioFormat} ") - return _format - } - - override fun isActive() = _isActive - override fun isEnded() = _isEnded - - override fun queueInput(inputBuffer: ByteBuffer) { - _buffer = inputBuffer - } - - override fun queueEndOfStream() { - Log.d(TAG, "END OF STREAM REACHED") - _isEnded = true - } - - override fun getOutput(): ByteBuffer { - if (!isActive || _buffer == null) return EMPTY_BUFFER - - val inputBuffer = _buffer!! - val bytesPerSample = _format.encoding - val channelCount = _format.channelCount - - val remainingBytes = inputBuffer.remaining() - - val sampleCount = remainingBytes / bytesPerSample / channelCount - val bufferCapacity = _outputBuffer?.capacity() ?: 0 - - val outputBufferSize = sampleCount * bytesPerSample * channelCount - if (bufferCapacity < outputBufferSize) { - _outputBuffer = ByteBuffer.allocateDirect(outputBufferSize) - .order(ByteOrder.nativeOrder()) - } else { - _outputBuffer?.clear() - } - - val checkedGain = if (gainDb !in -24f..24f) 0f else gainDb - - val gainMultiplier = 10.0.pow(checkedGain / 20.0).toFloat() - - repeat(sampleCount) { - repeat(channelCount) { - val sample = inputBuffer.getShort().toFloat() - val amplified = sample * gainMultiplier - val limitAmplification = amplified.coerceIn(-TWO_POWER_15, TWO_POWER_15) - _outputBuffer?.putShort(limitAmplification.toInt().toShort()) - } - } - - inputBuffer.clear() - val resultBuffer = _outputBuffer - _outputBuffer = EMPTY_BUFFER - - //check if input buffer is empty - _isEnded = inputBuffer.remaining() == 0 - Log.d(TAG, "PREPARING END BUFFER") - return resultBuffer ?: EMPTY_BUFFER - } - - - override fun flush() { - _buffer?.clear() - _outputBuffer?.clear() - - // set empty buffer - _buffer = EMPTY_BUFFER - _outputBuffer = EMPTY_BUFFER - - _isEnded = false - Log.d(TAG, "BYTE DATA FLUSHED") - } - - override fun reset() { - flush() - _format = AudioProcessor.AudioFormat.NOT_SET - _isActive = false - _buffer = null - _outputBuffer = null - Log.d(TAG, "AUDIO PROCESSOR WORK DONE") - } - - companion object { - private val EMPTY_BUFFER = ByteBuffer.allocateDirect(0).order(ByteOrder.nativeOrder()) - } -} \ No newline at end of file diff --git a/data/editor/src/main/java/com/eva/editor/data/transformer/AudioFileToComposition.kt b/data/editor/src/main/java/com/eva/editor/data/transformer/AudioFileToComposition.kt index 2b993556..04187fa8 100644 --- a/data/editor/src/main/java/com/eva/editor/data/transformer/AudioFileToComposition.kt +++ b/data/editor/src/main/java/com/eva/editor/data/transformer/AudioFileToComposition.kt @@ -16,58 +16,61 @@ import com.eva.recordings.domain.models.AudioFileModel import kotlin.math.abs import kotlin.time.Duration -private const val TAG = "AUDIO_FILE_COMPOSER" +private const val TAG = "AUDIO_COMPOSER" @OptIn(UnstableApi::class) internal fun AudioFileModel.toComposition( - configs: AudioConfigToActionList, + actions: AudioConfigToActionList, gap: Duration = Duration.ZERO, ): Composition { - val composedConfigs = EditorComposer.applyLogicalEditSequence(duration, configs) + val composedConfigs = EditorComposer.applyLogicalEditSequence(duration, actions) + val ranges = mutableListOf>() val editableItems = buildList { - composedConfigs.forEach { config -> + for (config in composedConfigs) { val startMs = (config.start.inWholeMilliseconds / 10) * 10 val endMs = (config.end.inWholeMilliseconds / 10) * 10 val duration = abs(endMs - startMs) - if (duration >= 0L) { - val clippingConfig = MediaItem.ClippingConfiguration.Builder() - .setStartPositionMs(startMs) - .setEndPositionMs(endMs) - .build() + // duration should not be empty ensuring its least one millisecond + if (duration <= 0) continue - Log.d(TAG, "CLIPPING APPLIED :$startMs->$endMs") + val clippingConfig = MediaItem.ClippingConfiguration.Builder() + .setStartPositionMs(startMs) + .setEndPositionMs(endMs) + .build() - val mediaItem = MediaItem.Builder() - .setUri(fileUri) - .setClippingConfiguration(clippingConfig) - .build() + ranges.add(config.start..config.end) - val videoEffects = emptyList() - val audioEffect = listOf() + val mediaItem = MediaItem.Builder() + .setUri(fileUri) + .setClippingConfiguration(clippingConfig) + .build() - val editableItem = EditedMediaItem.Builder(mediaItem) - .setEffects(Effects(audioEffect, videoEffects)) - .build() + val videoEffects = emptyList() + val audioEffect = listOf() - add(editableItem) - } - } - } - val itemSequenceBuilder = EditedMediaItemSequence.Builder() + val editableItem = EditedMediaItem.Builder(mediaItem) + .setEffects(Effects(audioEffect, videoEffects)) + .build() - editableItems.forEach { - itemSequenceBuilder.addItem(it) - if (gap > Duration.ZERO) - itemSequenceBuilder.addGap(gap.inWholeMicroseconds) + add(editableItem) + } } + Log.d(TAG, "CLIPPING :${ranges.joinToString("|")}") - val itemSequence = itemSequenceBuilder.build() + val itemSequence = EditedMediaItemSequence.Builder().also { builder -> + editableItems.forEachIndexed { idx, item -> + builder.addItem(item) + if (gap > Duration.ZERO && idx + 1 < editableItems.size) + builder.addGap(gap.inWholeMicroseconds) + } + }.build() - return Composition.Builder(itemSequence).build() + return Composition.Builder(itemSequence) + .build() } diff --git a/data/editor/src/main/java/com/eva/editor/data/transformer/AudioTransformerImpl.kt b/data/editor/src/main/java/com/eva/editor/data/transformer/AudioTransformerImpl.kt index 4d779954..1c5b877d 100644 --- a/data/editor/src/main/java/com/eva/editor/data/transformer/AudioTransformerImpl.kt +++ b/data/editor/src/main/java/com/eva/editor/data/transformer/AudioTransformerImpl.kt @@ -2,6 +2,7 @@ package com.eva.editor.data.transformer import android.content.Context import android.text.format.Formatter +import android.webkit.MimeTypeMap import androidx.core.net.toFile import androidx.core.net.toUri import androidx.media3.common.MediaItem @@ -10,9 +11,11 @@ import androidx.media3.common.util.Log import androidx.media3.common.util.UnstableApi import androidx.media3.transformer.ExportException import androidx.media3.transformer.Transformer +import com.eva.editor.BuildConfig import com.eva.editor.domain.AudioConfigToActionList import com.eva.editor.domain.AudioTransformer import com.eva.editor.domain.TransformationProgress +import com.eva.editor.domain.exceptions.ExportFileException import com.eva.editor.domain.exceptions.TransformRunningException import com.eva.editor.domain.exceptions.TransformerInvalidException import com.eva.editor.domain.exceptions.TransformerWrongMimeTypeException @@ -21,11 +24,10 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest @@ -33,6 +35,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import java.io.File +import java.io.IOException private const val TAG = "AUDIO_TRANSFORMER" @@ -42,25 +45,41 @@ internal class AudioTransformerImpl(private val context: Context) : AudioTransfo private var _transformer: Transformer? = null private val _isTransforming = MutableStateFlow(false) - override val isTransformerRunning: StateFlow + private val _transformerDirectory: File + get() = File(context.cacheDir, "transformers") + .apply(File::mkdirs) + + private val _transformerMimetypes = arrayOf( + MimeTypes.AUDIO_AAC, + MimeTypes.AUDIO_AMR_NB, + MimeTypes.AUDIO_AMR_WB + ) + + private val _notSeekableFormats = arrayOf( + MimeTypes.AUDIO_AMR, + MimeTypes.AUDIO_AMR_NB, + MimeTypes.AUDIO_AMR_WB, + MimeTypes.AUDIO_MIDI, + MimeTypes.AUDIO_EXOPLAYER_MIDI, + ) + + private val _mimeMap = MimeTypeMap.getSingleton() + + override val isTransformationRunning: StateFlow get() = _isTransforming @OptIn(ExperimentalCoroutinesApi::class) - override val progress: Flow - get() = _isTransforming.flatMapLatest { - _transformer?.transformerProgress(it) ?: emptyFlow() + override val transformationProgress: Flow + get() = _isTransforming.flatMapLatest { isRunning -> + _transformer?.transformerProgress(isRunning) ?: emptyFlow() } + .catch { err -> Log.d(TAG, "ERROR IN PROGRESS ", err) } .onStart { emit(TransformationProgress.Idle) } .distinctUntilChanged() - fun prepareTransformer(mimeType: String? = null): Result { - val transformerMimetypes = arrayOf( - MimeTypes.AUDIO_AAC, - MimeTypes.AUDIO_AMR_NB, - MimeTypes.AUDIO_AMR_WB - ) + private fun prepareTransformer(outputMimeType: String? = null): Result { - val mimeType = transformerMimetypes.find { it == mimeType } ?: MimeTypes.AUDIO_AAC + val mimeType = _transformerMimetypes.find { it == outputMimeType } ?: MimeTypes.AUDIO_AAC return try { _transformer = Transformer.Builder(context) @@ -68,7 +87,7 @@ internal class AudioTransformerImpl(private val context: Context) : AudioTransfo .experimentalSetTrimOptimizationEnabled(true) .build() - Log.d(TAG, "PREPARED TRANSFORMER WITH MIME TYPE:$mimeType") + Log.d(TAG, "PREPARED TRANSFORMER, OUTPUT MIME TYPE:$mimeType") Result.success(Unit) } catch (_: IllegalStateException) { Result.failure(TransformerWrongMimeTypeException()) @@ -77,30 +96,34 @@ internal class AudioTransformerImpl(private val context: Context) : AudioTransfo } } - override suspend fun transformAudio(model: AudioFileModel, actionsList: AudioConfigToActionList) - : Result { - val transformerMimetype = when (model.mimeType) { - MimeTypes.AUDIO_AMR -> MimeTypes.AUDIO_AMR_NB - MimeTypes.AUDIO_AMR_NB -> MimeTypes.AUDIO_AMR_NB + override suspend fun transformAudio(model: AudioFileModel, actions: AudioConfigToActionList) + : Result { + + val outputMimetype = when (model.mimeType) { + MimeTypes.AUDIO_AMR_NB, MimeTypes.AUDIO_AMR -> MimeTypes.AUDIO_AMR_NB MimeTypes.AUDIO_AMR_WB -> MimeTypes.AUDIO_AMR_WB else -> MimeTypes.AUDIO_AAC } - if (_transformer == null) prepareTransformer(transformerMimetype) + if (_transformer == null) { + val result = prepareTransformer(outputMimetype) + if (result.isFailure) { + val exception = result.exceptionOrNull()!! + return Result.failure(exception) + } + } if (_isTransforming.value) { Log.d(TAG, "TRANSFORMATION RUNNING CANNOT EDIT..") return Result.failure(TransformRunningException()) } - val seekAbleFormats = arrayOf(MimeTypes.AUDIO_MP4, MimeTypes.AUDIO_AAC) - - // set transforming to true - _isTransforming.update { true } return try { - if (model.mimeType in seekAbleFormats) - createFileAndTransform(model, actionsList) - else createFileAndTransformIntoAcc(model, actionsList) + _isTransforming.update { true } + val seekable = model.mimeType !in _notSeekableFormats + Log.d(TAG, "IS MEDIA SEEKABLE :$seekable") + if (seekable) createFileAndTransform(model, actions) + else createFileAndTransformIntoAcc(model, actions) } catch (_: IllegalArgumentException) { Result.failure(TransformerWrongMimeTypeException()) } catch (e: IllegalStateException) { @@ -119,13 +142,8 @@ internal class AudioTransformerImpl(private val context: Context) : AudioTransfo val file = uri.toUri().toFile() if (!file.exists()) return@withContext Result.failure(Exception("Wrong file provided")) - if (file.extension != "tmp") - return@withContext Result.failure(Exception("Wrong type provided")) - if (!file.absolutePath.startsWith(context.cacheDir.absolutePath)) { - Log.w(TAG, "FILE SHOULD BE IN CACHE DIRECTORY ${file.absolutePath}") - return@withContext Result.failure(Exception("Wrong path for temporaries")) - } - + if (file.parentFile == _transformerDirectory) + return@withContext Result.failure(Exception("Invalid file location")) val delete = file.delete() Log.d(TAG, "TEMP REMOVED :$delete") Result.success(delete) @@ -139,94 +157,120 @@ internal class AudioTransformerImpl(private val context: Context) : AudioTransfo private suspend fun createFileAndTransform( model: AudioFileModel, - actionsList: AudioConfigToActionList - ): Result = coroutineScope { - val file = File.createTempFile("temp_", ".tmp", context.cacheDir) - try { - // if not acc type - val composition = model.toComposition(actionsList) - _transformer?.awaitResults(composition, file.path) - ?: return@coroutineScope Result.failure(TransformerWrongMimeTypeException()) - - if (file.exists()) { - val fileSize = Formatter.formatFileSize(context, file.length()) - Log.d(TAG, "FILE CREATED :${file} $fileSize") - } + actions: AudioConfigToActionList + ): Result { + - Result.success(file.toUri().toString()) - } catch (e: CancellationException) { - // delete the file as this export was cancelled - withContext(NonCancellable) { file.delete() } - throw e - } catch (e: ExportException) { - // delete the file as this export failed - withContext(NonCancellable) { file.delete() } + val transformer = _transformer ?: return Result.failure(TransformerInvalidException()) + + return try { + val file = withContext(Dispatchers.IO) { + val extension = _mimeMap.getExtensionFromMimeType(model.mimeType) + File.createTempFile("temp_", ".$extension", _transformerDirectory) + } + try { + // if not acc type + val composition = model.toComposition(actions) + transformer.awaitResults(composition, file.path) + if (file.exists() && BuildConfig.DEBUG) { + val fileSize = Formatter.formatFileSize(context, file.length()) + Log.d(TAG, "FILE CREATED :NAME ${file.name} SIZE:$fileSize") + } + Result.success(file) + } catch (e: CancellationException) { + // delete the file as this export was cancelled + withContext(NonCancellable) { file.delete() } + throw e + } catch (e: ExportException) { + // delete the file as this export failed + withContext(NonCancellable) { file.delete() } + Result.failure(e) + } + } catch (e: IOException) { + Log.e(TAG, "FAILED TO CREATE TEMP FILE", e) + Result.failure(ExportFileException()) + } catch (e: Exception) { + if (e is CancellationException) throw e Result.failure(e) } } private suspend fun createFileAndTransformIntoAcc( model: AudioFileModel, - actionsList: AudioConfigToActionList - ): Result = coroutineScope { - val firstTransform = - async { File.createTempFile("first_conversion", ".tmp", context.cacheDir) } - val finalFile = - async { File.createTempFile("final_conversion_", ".tmp", context.cacheDir) } - try { - // convert the file into acc - val mediaItem = MediaItem.fromUri(model.fileUri) - val filePathAfterAccConvert = _transformer?.buildUpon() - ?.setAudioMimeType(MimeTypes.AUDIO_AAC) - ?.build() - ?.awaitResults(mediaItem, firstTransform.await().path) - ?: return@coroutineScope Result.failure(TransformerInvalidException()) - - Log.d(TAG, "CONVERTED TO ACC FORMAT") - - // prepare the composition - val newModel = model.copy(fileUri = filePathAfterAccConvert) - val composition = newModel.toComposition(configs = actionsList) - - val endFile = finalFile.await() - // final transformation - _transformer - ?.buildUpon() - ?.setAudioMimeType(MimeTypes.AUDIO_AAC) - ?.build() - ?.awaitResults(composition, endFile.path) - ?: return@coroutineScope Result.failure(TransformerWrongMimeTypeException()) - - Log.d(TAG, "TRANSFORMATION COMPLETED") - - if (endFile.exists()) { - val fileSize = Formatter.formatFileSize(context, endFile.length()) - Log.d(TAG, "FILE CREATED :${endFile.path} $fileSize") + actions: AudioConfigToActionList + ): Result { + + val transformer = _transformer ?: return Result.failure(TransformerInvalidException()) + + val aacTransformer = transformer.buildUpon() + .setAudioMimeType(MimeTypes.AUDIO_AAC) + .build() + + return try { + val firstTransform = withContext(Dispatchers.IO) { + val extension = _mimeMap.getExtensionFromMimeType(MimeTypes.AUDIO_AAC) + File.createTempFile("first_conversion", ".$extension", context.cacheDir) + } + val finalFile = withContext(Dispatchers.IO) { + val extension = _mimeMap.getExtensionFromMimeType(model.mimeType) + File.createTempFile("final_conversion_", ".$extension", context.cacheDir) } + try { + // convert the file into acc + val mediaItem = MediaItem.fromUri(model.fileUri) + val firstConversion = aacTransformer.awaitResults(mediaItem, firstTransform.path) - Result.success(endFile.toUri().toString()) - } catch (e: CancellationException) { - // delete the file as this export was cancelled - withContext(NonCancellable) { finalFile.await().delete() } - throw e - } catch (e: ExportException) { - e.printStackTrace() - // delete the file as this export failed - withContext(NonCancellable) { finalFile.await().delete() } - Result.failure(e) + Log.d(TAG, "CONVERTED FILE TO ACC FORMAT") + + // prepare the composition + val newModel = model.copy(fileUri = firstConversion) + val composition = newModel.toComposition(actions = actions) + + // final transformation + transformer.awaitResults(composition, finalFile.path) + + Log.d(TAG, "TRANSFORMATION COMPLETED") + + if (finalFile.exists() && BuildConfig.DEBUG) { + val fileSize = Formatter.formatFileSize(context, finalFile.length()) + Log.d(TAG, "FILE CREATED :NAME ${finalFile.name} SIZE:$fileSize") + } + + Result.success(finalFile) + } catch (e: CancellationException) { + // delete the file as this export was cancelled + withContext(NonCancellable) { + if (finalFile.exists()) finalFile.delete() + } + throw e + } catch (e: ExportException) { + e.printStackTrace() + // delete the file as this export failed + withContext(NonCancellable) { + if (finalFile.exists()) finalFile.delete() + } + Result.failure(e) + } catch (e: Exception) { + e.printStackTrace() + Result.failure(e) + } finally { + // first transform should be deleted everytime + withContext(NonCancellable) { + if (firstTransform.exists()) firstTransform.delete() + } + } + } catch (e: IOException) { + Log.e(TAG, "FAILED TO CREATE TEMP FILES", e) + Result.failure(ExportFileException()) } catch (e: Exception) { - e.printStackTrace() + if (e is CancellationException) throw e Result.failure(e) - } finally { - // first transform should be deleted everytime - withContext(NonCancellable) { - firstTransform.await().delete() - } } } override fun cleanUp() { + _transformer?.cancel() _transformer?.removeAllListeners() _transformer = null } diff --git a/data/editor/src/main/java/com/eva/editor/data/transformer/TransformationProgress.kt b/data/editor/src/main/java/com/eva/editor/data/transformer/TransformationProgress.kt index 326a718c..6a88be6f 100644 --- a/data/editor/src/main/java/com/eva/editor/data/transformer/TransformationProgress.kt +++ b/data/editor/src/main/java/com/eva/editor/data/transformer/TransformationProgress.kt @@ -6,30 +6,37 @@ import androidx.media3.transformer.ProgressHolder import androidx.media3.transformer.Transformer import com.eva.editor.domain.TransformationProgress import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext -import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds @OptIn(UnstableApi::class) -internal fun Transformer.transformerProgress(isTransformerRunning: Boolean) = flow { +internal fun Transformer.transformerProgress( + isTransformerRunning: Boolean, + uiDelay: Duration = 16.milliseconds +) = flow { val holder = ProgressHolder() - while (isTransformerRunning) { - val state = withContext(Dispatchers.Main) { getProgress(holder) } - when (state) { + while (isTransformerRunning && currentCoroutineContext().isActive) { + // process the delay then check for issues + delay(uiDelay) + + val transformerProgress = withContext(Dispatchers.Main) { getProgress(holder) } + val progressState = when (transformerProgress) { Transformer.PROGRESS_STATE_AVAILABLE -> { val progress = holder.progress - emit(TransformationProgress.Progress(progress)) + TransformationProgress.Progress(progress) } - Transformer.PROGRESS_STATE_NOT_STARTED -> emit(TransformationProgress.Idle) - Transformer.PROGRESS_STATE_UNAVAILABLE -> emit(TransformationProgress.UnAvailable) - Transformer.PROGRESS_STATE_WAITING_FOR_AVAILABILITY -> - emit(TransformationProgress.Waiting) + Transformer.PROGRESS_STATE_NOT_STARTED -> TransformationProgress.Idle + Transformer.PROGRESS_STATE_UNAVAILABLE -> TransformationProgress.UnAvailable + Transformer.PROGRESS_STATE_WAITING_FOR_AVAILABILITY -> TransformationProgress.Waiting - else -> {} + else -> continue } - delay(10.nanoseconds) + emit(progressState) } -}.flowOn(Dispatchers.IO) \ No newline at end of file +} \ No newline at end of file diff --git a/data/editor/src/main/java/com/eva/editor/data/transformer/TransformerAwaitResult.kt b/data/editor/src/main/java/com/eva/editor/data/transformer/TransformerAwaitResult.kt index 113f9653..fc3809b3 100644 --- a/data/editor/src/main/java/com/eva/editor/data/transformer/TransformerAwaitResult.kt +++ b/data/editor/src/main/java/com/eva/editor/data/transformer/TransformerAwaitResult.kt @@ -1,6 +1,7 @@ package com.eva.editor.data.transformer import android.util.Log +import androidx.annotation.MainThread import androidx.media3.common.MediaItem import androidx.media3.common.util.UnstableApi import androidx.media3.transformer.Composition @@ -8,54 +9,77 @@ import androidx.media3.transformer.EditedMediaItem import androidx.media3.transformer.EditedMediaItemSequence import androidx.media3.transformer.ExportException import androidx.media3.transformer.ExportResult +import androidx.media3.transformer.TransformationRequest import androidx.media3.transformer.Transformer -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume private const val TAG = "TRANSFORMATION_RESULTS" -@OptIn(ExperimentalCoroutinesApi::class) @UnstableApi -internal suspend fun Transformer.awaitResults(composition: Composition, outputUri: String): String = - suspendCancellableCoroutine { continuation -> - val listener = object : Transformer.Listener { - override fun onCompleted(composition: Composition, exportResult: ExportResult) { - Log.d(TAG, "COMPOSITION SUCCESSES") - continuation.resume(outputUri, null) - // work is done so remove the listener - removeListener(this) - Log.d(TAG, "REMOVING LISTENER") - } +@MainThread +internal suspend fun Transformer.awaitResults( + composition: Composition, + outputUri: String +): String = suspendCancellableCoroutine { continuation -> + val listener = object : Transformer.Listener { + override fun onCompleted(composition: Composition, exportResult: ExportResult) { + Log.d(TAG, "COMPOSITION SUCCESSES") + // work is done so remove the listener + if (continuation.isActive) continuation.resume(outputUri) - override fun onError( - composition: Composition, - exportResult: ExportResult, - exportException: ExportException - ) { - val message = buildString { - append("COMPOSITION FAILED : ") - append("MESSAGE :${exportException.message}") - append("CAUSE :${exportException.cause?.message}") - } - Log.e(TAG, message) - // it's a cancellation - continuation.cancel(exportResult.exportException) - } + removeListener(this) + Log.d(TAG, "REMOVING LISTENER") } - // add the listener - addListener(listener) - // start the composition + + override fun onError( + composition: Composition, + exportResult: ExportResult, + exportException: ExportException, + ) { + Log.e(TAG, "COMPOSITION FAILED", exportException) + // it's a cancellation + if (continuation.isActive) continuation.cancel(exportException) + removeListener(this) + Log.d(TAG, "REMOVING LISTENER") + } + + override fun onFallbackApplied( + composition: Composition, + originalTransformationRequest: TransformationRequest, + fallbackTransformationRequest: TransformationRequest + ) { + Log.d(TAG, "CANNOT APPLY TRANSFORMATION") + Log.i( + TAG, + "ORIGINAL :${originalTransformationRequest.audioMimeType} FALLBACK :${fallbackTransformationRequest.audioMimeType}" + ) + } + } + // add the listener + addListener(listener) + // start the composition + try { start(composition, outputUri) + Log.d(TAG, "STARTING TRANSFORMATION") + } catch (_: IllegalStateException) { + Log.d(TAG, "EXPORT RUNNING NEED TO WAIT") + removeListener(listener) + cancel() + return@suspendCancellableCoroutine + } - continuation.invokeOnCancellation { + continuation.invokeOnCancellation { + Log.d(TAG, "COROUTINE WAS CANCELLED") + try { removeListener(listener) cancel() - Log.d(TAG, "COROUTINE WAS CANCELLED REMOVING LISTENER AND CANCELLING TRANSFORMATION") + } catch (_: Exception) { } } +} -@OptIn(ExperimentalCoroutinesApi::class) @UnstableApi suspend fun Transformer.awaitResults(mediaItem: MediaItem, outputUri: String): String { val editMediaItem = EditedMediaItem.Builder(mediaItem).build() diff --git a/data/editor/src/main/java/com/eva/editor/domain/AudioTransformer.kt b/data/editor/src/main/java/com/eva/editor/domain/AudioTransformer.kt index d9a71a29..bbcf72d2 100644 --- a/data/editor/src/main/java/com/eva/editor/domain/AudioTransformer.kt +++ b/data/editor/src/main/java/com/eva/editor/domain/AudioTransformer.kt @@ -5,6 +5,7 @@ import com.eva.editor.domain.model.AudioEditAction import com.eva.recordings.domain.models.AudioFileModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import java.io.File typealias AudioConfigToAction = Pair typealias AudioConfigToActionList = List @@ -12,12 +13,12 @@ typealias AudioConfigsList = List interface AudioTransformer { - val progress: Flow + val transformationProgress: Flow - val isTransformerRunning: StateFlow + val isTransformationRunning: StateFlow - suspend fun transformAudio(model: AudioFileModel, actionsList: AudioConfigToActionList) - : Result + suspend fun transformAudio(model: AudioFileModel, actions: AudioConfigToActionList) + : Result suspend fun removeTransformsFile(uri: String): Result diff --git a/data/editor/src/main/java/com/eva/editor/domain/exceptions/ExportFileException.kt b/data/editor/src/main/java/com/eva/editor/domain/exceptions/ExportFileException.kt new file mode 100644 index 00000000..dbab0298 --- /dev/null +++ b/data/editor/src/main/java/com/eva/editor/domain/exceptions/ExportFileException.kt @@ -0,0 +1,3 @@ +package com.eva.editor.domain.exceptions + +internal class ExportFileException : Exception("Export File cannot be created") diff --git a/feature/editor/src/main/java/com/eva/feature_editor/composables/TransformBottomSheet.kt b/feature/editor/src/main/java/com/eva/feature_editor/composables/TransformBottomSheet.kt index 5d4beda0..3e956bf5 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/composables/TransformBottomSheet.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/composables/TransformBottomSheet.kt @@ -20,9 +20,10 @@ private fun TransformBottomSheet( onTransform: () -> Unit, onExport: () -> Unit, modifier: Modifier = Modifier, + onCancelTransform: () -> Unit = {}, showSheet: Boolean = true, state: TransformationState = TransformationState(), - sheetState: SheetState = rememberModalBottomSheetState() + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ) { if (!showSheet) return @@ -36,6 +37,7 @@ private fun TransformBottomSheet( state = state, onExport = onExport, onTransform = onTransform, + onCancelTransform = onCancelTransform, contentPadding = PaddingValues(dimensionResource(R.dimen.bottom_sheet_padding_lg)) ) } @@ -62,6 +64,7 @@ internal fun TransformBottomSheet( state = state, onTransform = { onEvent(EditorScreenEvent.BeginTransformation) }, onExport = { onEvent(EditorScreenEvent.OnSaveExportFile) }, + onCancelTransform = { onEvent(EditorScreenEvent.OnCancelTransformation) }, modifier = modifier, ) } diff --git a/feature/editor/src/main/java/com/eva/feature_editor/composables/TransformsSheetContent.kt b/feature/editor/src/main/java/com/eva/feature_editor/composables/TransformsSheetContent.kt index b6064049..df49dd84 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/composables/TransformsSheetContent.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/composables/TransformsSheetContent.kt @@ -1,5 +1,6 @@ package com.eva.feature_editor.composables +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.core.EaseIn import androidx.compose.animation.core.EaseInOut @@ -15,6 +16,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -25,7 +28,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color @@ -47,13 +50,13 @@ import com.eva.feature_editor.event.TransformationState import com.eva.ui.R import com.eva.ui.theme.RecorderAppTheme - @Composable internal fun TransformsSheetContent( state: TransformationState, onExport: () -> Unit, onTransform: () -> Unit, modifier: Modifier = Modifier, + onCancelTransform: () -> Unit = {}, contentPadding: PaddingValues = PaddingValues(10.dp) ) { @@ -98,7 +101,7 @@ internal fun TransformsSheetContent( TransformType.TRANSFORMING -> TransformationProgressIndicator(progress = state.progress) } } - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(12.dp)) Crossfade( targetState = transformType, animationSpec = tween(durationMillis = 200, easing = EaseInOut) @@ -111,21 +114,15 @@ internal fun TransformsSheetContent( Text( text = message, color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.labelMedium, + style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center, ) } HorizontalDivider( - modifier = Modifier.padding(vertical = 4.dp), + modifier = Modifier.padding(vertical = 8.dp), color = MaterialTheme.colorScheme.outline ) - val message = when (transformType) { - TransformType.IDLE -> stringResource(R.string.action_transform) - TransformType.TRANSFORMING -> stringResource(R.string.action_transforming) - TransformType.READY_FOR_EXPORT -> stringResource(R.string.action_export) - } - Button( onClick = if (state.isExportFileReady) onExport else onTransform, shape = MaterialTheme.shapes.extraLarge, @@ -134,16 +131,33 @@ internal fun TransformsSheetContent( contentPadding = PaddingValues(horizontal = 20.dp, vertical = 12.dp) ) { Text( - text = message, + text = when (transformType) { + TransformType.IDLE -> stringResource(R.string.action_transform) + TransformType.TRANSFORMING -> stringResource(R.string.action_transforming) + TransformType.READY_FOR_EXPORT -> stringResource(R.string.action_export) + }, style = MaterialTheme.typography.titleMedium ) } + Spacer(modifier = Modifier.height(4.dp)) + AnimatedVisibility(state.isTransforming) { + FilledTonalButton( + onClick = onCancelTransform, + shape = MaterialTheme.shapes.extraLarge, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + ) { + Text(text = stringResource(R.string.action_cancel)) + } + } } } @Composable private fun TransformationProgressIndicator( - progress: TransformationProgress, + progress: () -> TransformationProgress, modifier: Modifier = Modifier, arcColor: Color = MaterialTheme.colorScheme.secondary, textStyle: TextStyle = MaterialTheme.typography.titleMedium, @@ -154,33 +168,30 @@ private fun TransformationProgressIndicator( Spacer( modifier = modifier .size(120.dp) - .drawWithCache { + .drawBehind { - val progressAmount = (progress as? TransformationProgress.Progress)?.amount ?: 0 + val progressAmount = (progress() as? TransformationProgress.Progress)?.amount ?: 0 val rotateAmount = (progressAmount * 360f / 100).coerceIn(0f, 360f) - onDrawBehind { - drawArc( - color = arcColor, - startAngle = 90f, - size = Size(width = size.width * .9f, height = size.height * .9f), - sweepAngle = rotateAmount, - useCenter = false, - style = Stroke(cap = StrokeCap.Round, width = 10.dp.toPx()) - ) - - val textResults = textMeasurer.measure("$progressAmount %", style = textStyle) + drawArc( + color = arcColor, + startAngle = 90f, + size = Size(width = size.width * .9f, height = size.height * .9f), + sweepAngle = rotateAmount, + useCenter = false, + style = Stroke(cap = StrokeCap.Round, width = 10.dp.toPx()) + ) - val centerPos = - center - with(textResults.size) { Offset(width / 2f, height / 2f) } + val textResults = textMeasurer.measure("$progressAmount %", style = textStyle) - drawText( - textLayoutResult = textResults, - topLeft = centerPos, - color = textColor - ) - } + val centerPos = + center - with(textResults.size) { Offset(width / 2f, height / 2f) } + drawText( + textLayoutResult = textResults, + topLeft = centerPos, + color = textColor + ) }, ) } @@ -199,7 +210,7 @@ private class TransformsSheetContentPreviewParams : TransformationState(exportFileUri = ""), TransformationState( isTransforming = true, - progress = TransformationProgress.Progress(10) + progress = { TransformationProgress.Progress(10) } ) ) ) diff --git a/feature/editor/src/main/java/com/eva/feature_editor/event/EditorScreenEvent.kt b/feature/editor/src/main/java/com/eva/feature_editor/event/EditorScreenEvent.kt index b8e9c7e4..562cbdf7 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/event/EditorScreenEvent.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/event/EditorScreenEvent.kt @@ -18,6 +18,7 @@ sealed interface EditorScreenEvent { data object OnRedoEdit : EditorScreenEvent data object BeginTransformation : EditorScreenEvent + data object OnCancelTransformation : EditorScreenEvent data object OnDismissExportSheet : EditorScreenEvent data object OnSaveExportFile : EditorScreenEvent } \ No newline at end of file diff --git a/feature/editor/src/main/java/com/eva/feature_editor/event/TransformationState.kt b/feature/editor/src/main/java/com/eva/feature_editor/event/TransformationState.kt index fc80ace3..a1bd06fd 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/event/TransformationState.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/event/TransformationState.kt @@ -4,7 +4,7 @@ import com.eva.editor.domain.TransformationProgress data class TransformationState( val isTransforming: Boolean = false, - val progress: TransformationProgress = TransformationProgress.Idle, + val progress: () -> TransformationProgress = { TransformationProgress.Idle }, val exportFileUri: String? = null, ) { val isExportFileReady: Boolean diff --git a/feature/editor/src/main/java/com/eva/feature_editor/viewmodel/AudioEditorViewModel.kt b/feature/editor/src/main/java/com/eva/feature_editor/viewmodel/AudioEditorViewModel.kt index 9678b183..9de14f1b 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/viewmodel/AudioEditorViewModel.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/viewmodel/AudioEditorViewModel.kt @@ -1,5 +1,6 @@ package com.eva.feature_editor.viewmodel +import androidx.core.net.toUri import androidx.lifecycle.viewModelScope import com.eva.editor.domain.AudioConfigToAction import com.eva.editor.domain.AudioTransformer @@ -18,6 +19,7 @@ import com.eva.ui.viewmodel.UIEvents import dagger.assisted.Assisted import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -63,14 +65,16 @@ internal class AudioEditorViewModel @AssistedInject constructor( private val _exportBegin = Channel() val exportBegun = _exportBegin.consumeAsFlow() + private var _exportJob: Job? = null + val transformationState = combine( - transformer.isTransformerRunning, - transformer.progress, + transformer.isTransformationRunning, + transformer.transformationProgress, _exportFileUri ) { isRunning, progress, exportUri -> TransformationState( isTransforming = isRunning, - progress = progress, + progress = { progress }, exportFileUri = exportUri ) }.stateIn( @@ -113,6 +117,7 @@ internal class AudioEditorViewModel @AssistedInject constructor( EditorScreenEvent.OnSaveExportFile -> onSaveExportFile() EditorScreenEvent.OnRedoEdit -> onUndoOrRedoConfigs(false) EditorScreenEvent.OnUndoEdit -> onUndoOrRedoConfigs(true) + EditorScreenEvent.OnCancelTransformation -> cancelFinalExport() } suspend fun setPlayerItem() = player.prepareAudioFile(fileModel) @@ -218,19 +223,28 @@ internal class AudioEditorViewModel @AssistedInject constructor( transformer.removeTransformsFile(fileUri) } - private fun finalExport() = viewModelScope.launch { + private fun cancelFinalExport() { + _exportJob?.cancel() + _exportJob = null + viewModelScope.launch { _uiEvents.emit(UIEvents.ShowToast("Cancelled")) } + } - val filterValidConfigs = _undoRedoManager.allActions.value - .filter { (config, _) -> config.hasMinimumDuration } + private fun finalExport() { + _exportJob?.cancel() + _exportJob = viewModelScope.launch { - val result = transformer.transformAudio(fileModel, filterValidConfigs) - result.fold( - onSuccess = { data -> _exportFileUri.update { data } }, - onFailure = { exp -> - val message = exp.message ?: "Some transformation error" - _uiEvents.emit(UIEvents.ShowToast(message)) - }, - ) + val filterValidConfigs = _undoRedoManager.allActions.value + .filter { (config, _) -> config.hasMinimumDuration } + + val result = transformer.transformAudio(fileModel, filterValidConfigs) + result.fold( + onSuccess = { data -> _exportFileUri.update { data.toUri().toString() } }, + onFailure = { exp -> + val message = exp.message ?: "Some transformation error" + _uiEvents.emit(UIEvents.ShowToast(message)) + }, + ) + } } From 20f58219f450c992379fd7d97e614fae2cb2c811 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Wed, 29 Oct 2025 03:00:03 +0530 Subject: [PATCH 07/25] Ui corrections in player and editor Player shared Removed ContentLoadState.kt functions OnContentOrOther and OnContent in each places when statement is used Most of PlayerTrack data receivers are made lazy read PlayerDurationText.kt corrected duration format and made track data read lazy and computed when changed via derived stateOf PlayerTrackSlider2.kt uses SliderState api to provide a non recomposing slider, the track data with snapshot flow help to keep track of the positions PlayerSliderController.kt moved to separate file Keeping the old composable api for future Now for player AudioBookmarksList.kt kept the list state and the empty state inside the list only if the list is empty then the lazy column will hold the absent placeholder As mentioned track data is now being lazy read at most parts Moved AudioPlayerScreenContent.kt to separate file Corrected clips in PlayerBookmarks.kt In Editor Removed surface and used box in AudioClipClipRow.kt PlayerTrimSelector.kt modifiers DetectClipConfig.kt and EditorTrimOverlay.kt qualified name and inspector info mentioned and for pointerInput using Mutex so that no two operations overlaps and now these modifier are marked non-composable but uses compose modifier for composition scope Included text for editor actions. Some changes were also made to data layer will be corrected and commited later. --- .../eva/feature_editor/AudioEditorRoute.kt | 20 +-- .../eva/feature_editor/AudioEditorScreen.kt | 25 ++- .../composables/AudioClipClipRow.kt | 119 ++++++++----- .../composables/DetectClipConfig.kt | 131 +++++++++----- .../composables/EditorActionsAndControls.kt | 164 +++++++++--------- .../composables/EditorTrimOverlay.kt | 42 +++-- .../composables/PlayerTrimSelector.kt | 74 ++++---- .../composables/AnimatedPlayPauseButton.kt | 36 ++-- .../composables/PlayerDurationText.kt | 13 +- .../{PlayerSlider.kt => PlayerTrackSlider.kt} | 57 ++---- .../composables/PlayerTrackSlider2.kt | 92 ++++++++++ .../player_shared/state/ContentLoadState.kt | 15 -- .../state/PlayerSliderController.kt | 43 +++++ .../eva/feature_player/AudioPlayerRoute.kt | 7 +- .../eva/feature_player/AudioPlayerScreen.kt | 86 +-------- .../bookmarks/composable/AudioBookmarkCard.kt | 10 +- .../composable/AudioBookmarksList.kt | 102 +++++------ .../composable/AudioPlayerScreenContent.kt | 92 ++++++++++ .../composable/AudioPlayerScreenTopBar.kt | 16 +- .../composable/FileMetadataDetailsSheet.kt | 23 ++- .../composable/PlayerActionsAndSlider.kt | 9 +- .../composable/PlayerAmplitudeGraph.kt | 13 +- .../composable/PlayerBookmarks.kt | 36 ++-- 23 files changed, 708 insertions(+), 517 deletions(-) rename feature/player-shared/src/main/java/com/eva/player_shared/composables/{PlayerSlider.kt => PlayerTrackSlider.kt} (58%) create mode 100644 feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerTrackSlider2.kt create mode 100644 feature/player-shared/src/main/java/com/eva/player_shared/state/PlayerSliderController.kt create mode 100644 feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerScreenContent.kt diff --git a/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorRoute.kt b/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorRoute.kt index 53b8c6d4..7772eb14 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorRoute.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorRoute.kt @@ -1,7 +1,5 @@ package com.eva.feature_editor -import androidx.compose.animation.SizeTransform -import androidx.compose.animation.core.tween import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Icon @@ -39,11 +37,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.merge fun NavGraphBuilder.audioEditorRoute(controller: NavController) = - animatedComposable( - sizeTransform = { - SizeTransform(clip = false) { _, _ -> tween(durationMillis = 300) } - }, - ) { backstackEntry -> + animatedComposable { backstackEntry -> val sharedViewmodel = backstackEntry.sharedViewmodel(controller) val visualsViewmodel = backstackEntry.sharedViewmodel(controller) @@ -74,10 +68,8 @@ fun NavGraphBuilder.audioEditorRoute(controller: NavController) = } }, navigation = { - if (controller.previousBackStackEntry?.destination?.route != null) { - IconButton( - onClick = dropUnlessResumed(block = controller::popBackStack), - ) { + if (controller.previousBackStackEntry != null) { + IconButton(onClick = dropUnlessResumed(block = controller::popBackStack)) { Icon( imageVector = Icons.AutoMirrored.Default.ArrowBack, contentDescription = stringResource(R.string.back_arrow) @@ -92,7 +84,7 @@ fun NavGraphBuilder.audioEditorRoute(controller: NavController) = } @Composable -fun AudioEditorScreenStateful( +private fun AudioEditorScreenStateful( fileModel: AudioFileModel, visualization: PlayerGraphData, onClipDataUpdate: (AudioConfigToActionList) -> Unit, @@ -135,12 +127,12 @@ fun AudioEditorScreenStateful( derivedStateOf { totalConfigs.count() >= 1 } } - AudioEditorScreenContent( + AudioEditorScreen( graphData = visualization, isVisualsReady = isVisualsReady, isPlaying = isPlaying, clipConfig = clipConfig, - trackData = trackData, + trackData = { trackData }, isMediaEdited = isMediaEdited, undoRedoState = undoRedoState, transformationState = transformationState, diff --git a/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorScreen.kt b/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorScreen.kt index b364702a..c7029eaa 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorScreen.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn @@ -25,6 +26,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -94,8 +96,8 @@ internal fun AudioEditorScreenContainer( @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun AudioEditorScreenContent( - trackData: PlayerTrackData, +internal fun AudioEditorScreen( + trackData: () -> PlayerTrackData, graphData: PlayerGraphData, onEvent: (EditorScreenEvent) -> Unit, modifier: Modifier = Modifier, @@ -114,6 +116,8 @@ internal fun AudioEditorScreenContent( val bottomSheetState = rememberModalBottomSheetState() val scope = rememberCoroutineScope() + val totalTrackDuration by remember { derivedStateOf { trackData().total } } + TransformBottomSheet( onDismiss = { scope.launch { bottomSheetState.hide() } @@ -160,7 +164,7 @@ internal fun AudioEditorScreenContent( .align(Alignment.Center) .offset(y = (-80).dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { PlayerTrimSelector( graphData = graphData, @@ -168,19 +172,24 @@ internal fun AudioEditorScreenContent( enabled = isVisualsReady, clipConfig = clipConfig, onClipConfigChange = { onEvent(EditorScreenEvent.OnClipConfigChange(it)) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues( + horizontal = dimensionResource(R.dimen.graph_card_padding), + vertical = dimensionResource(R.dimen.graph_card_padding_other) + ) ) AudioClipChipRow( clipConfig = clipConfig, onEvent = onEvent, - trackDuration = trackData.total + trackDuration = totalTrackDuration ) } Box( modifier = Modifier .heightIn(min = 180.dp) .fillMaxWidth() - .align(Alignment.BottomCenter), + .align(Alignment.BottomCenter) + .offset(y = (-20).dp), contentAlignment = Alignment.Center ) { EditorActionsAndControls( @@ -203,8 +212,8 @@ private fun AudioEditorScreenPreview( AudioEditorScreenContainer( loadState = loadState, content = { model -> - AudioEditorScreenContent( - trackData = PlayerTrackData(total = model.duration), + AudioEditorScreen( + trackData = { PlayerTrackData(total = model.duration) }, graphData = { PlayerPreviewFakes.loadAmplitudeGraph(model.duration) }, clipConfig = AudioClipConfig(end = model.duration), onEvent = {}, diff --git a/feature/editor/src/main/java/com/eva/feature_editor/composables/AudioClipClipRow.kt b/feature/editor/src/main/java/com/eva/feature_editor/composables/AudioClipClipRow.kt index 140fdd23..313c359f 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/composables/AudioClipClipRow.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/composables/AudioClipClipRow.kt @@ -1,5 +1,7 @@ package com.eva.feature_editor.composables +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.Row @@ -7,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn -import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.CornerSize import androidx.compose.material.icons.Icons @@ -15,22 +16,25 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.eva.editor.domain.model.AudioClipConfig import com.eva.feature_editor.event.EditorScreenEvent import com.eva.ui.composables.DurationText import com.eva.ui.theme.DownloadableFonts +import com.eva.ui.theme.RecorderAppTheme import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @Composable @@ -45,53 +49,78 @@ private fun AudioClipChip( numberFontFamily: FontFamily? = DownloadableFonts.PLUS_CODE_LATIN_FONT_FAMILY, ) { Row( - modifier = modifier.wrapContentWidth(), - verticalAlignment = Alignment.CenterVertically + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Surface( - onClick = { onMinus(duration - 1.seconds) }, - shape = cornerShape.copy(topStart = CornerSize(24.dp), bottomStart = CornerSize(24.dp)), - color = containerColor, - contentColor = contentColor, - modifier = Modifier.sizeIn(minWidth = 32.dp, minHeight = 32.dp), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Default.Remove, - contentDescription = "Subtract Action", - modifier = Modifier.size(24.dp) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .clip( + cornerShape.copy( + topStart = CornerSize(24.dp), + bottomStart = CornerSize(24.dp) + ), ) - } - } - Surface( - color = containerColor, - contentColor = contentColor, - shape = cornerShape, - modifier = Modifier.sizeIn(minHeight = 32.dp), - ) { - Box(contentAlignment = Alignment.Center) { - DurationText( - duration = duration, - fontFamily = numberFontFamily, - modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp) + .clickable(onClick = { onMinus(duration - 1.seconds) }) + .background( + color = containerColor, + shape = cornerShape.copy( + topStart = CornerSize(24.dp), + bottomStart = CornerSize(24.dp) + ) ) - } + .sizeIn(minWidth = 32.dp, minHeight = 32.dp) + ) { + Icon( + imageVector = Icons.Default.Remove, + contentDescription = "Subtract Action", + tint = contentColor, + modifier = Modifier.size(24.dp) + ) } - Surface( - onClick = { onPlus(duration + 1.seconds) }, - shape = cornerShape.copy(topEnd = CornerSize(24.dp), bottomEnd = CornerSize(24.dp)), - color = containerColor, - contentColor = contentColor, - modifier = Modifier.sizeIn(minWidth = 32.dp, minHeight = 32.dp), + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .clip(cornerShape) + .background(color = containerColor, shape = cornerShape) + .sizeIn(minWidth = 32.dp, minHeight = 32.dp) ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = "Add Action", - modifier = Modifier.size(24.dp) + DurationText( + duration = duration, + fontFamily = numberFontFamily, + color = contentColor, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp) + ) + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .clip( + cornerShape.copy( + topEnd = CornerSize(24.dp), + bottomEnd = CornerSize(24.dp) + ), + ) + .clickable(onClick = { onPlus(duration + 1.seconds) }) + .background( + color = containerColor, + shape = cornerShape.copy( + topEnd = CornerSize(24.dp), + bottomEnd = CornerSize(24.dp) + ) ) - } + .sizeIn(minWidth = 32.dp, minHeight = 32.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add Action", + tint = contentColor, + modifier = Modifier.size(24.dp) + ) } } } @@ -139,3 +168,9 @@ internal fun AudioClipChipRow( ) } } + +@PreviewLightDark +@Composable +private fun AudioClipChipRowPreview() = RecorderAppTheme { + AudioClipChipRow(trackDuration = 10.minutes, onEvent = {}) +} \ No newline at end of file diff --git a/feature/editor/src/main/java/com/eva/feature_editor/composables/DetectClipConfig.kt b/feature/editor/src/main/java/com/eva/feature_editor/composables/DetectClipConfig.kt index c828b848..29bc8022 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/composables/DetectClipConfig.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/composables/DetectClipConfig.kt @@ -1,18 +1,21 @@ package com.eva.feature_editor.composables import android.util.Log +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.MutatorMutex import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.unit.dp import com.eva.editor.domain.model.AudioClipConfig import com.eva.player_shared.util.PlayerGraphData @@ -20,6 +23,7 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -28,7 +32,6 @@ import kotlin.time.Duration.Companion.seconds private const val TAG = "CLIP CONFIG" @OptIn(FlowPreview::class) -@Composable internal fun Modifier.detectClipConfig( graph: PlayerGraphData, onClipChange: (AudioClipConfig) -> Unit, @@ -36,13 +39,26 @@ internal fun Modifier.detectClipConfig( maxGraphPoints: Int = 100, clipConfig: AudioClipConfig? = null, minClipAmount: Duration = 1.seconds, - enabled: Boolean=true, + enabled: Boolean = true, onMinClipAmountCrossed: () -> Unit = {}, -) = composed { +) = composed( + fullyQualifiedName = "com.eva.feature_editor.composables.detectClipConfig", + keys = arrayOf(totalLength), + inspectorInfo = debugInspectorInfo { + name = "editor_detect_clip_config" + properties["clip_config"] = clipConfig + properties["track_duration"] = totalLength + properties["min_clip_amount"] = minClipAmount + properties["enabled"] = enabled + }, +) { // total length if lesser than min clip amount then no pointer input allowed if (totalLength <= minClipAmount || !enabled) return@composed Modifier + val mutex = remember { MutatorMutex() } + val scope = rememberCoroutineScope() + var localClipConfig by remember(totalLength) { val supposeToBe = AudioClipConfig(0.milliseconds, totalLength) mutableStateOf(clipConfig ?: supposeToBe) @@ -77,57 +93,78 @@ internal fun Modifier.detectClipConfig( detectHorizontalDragGestures( onDragStart = { offset -> - with(localClipConfig) { - val startPOs = (start / totalLength).toFloat() * width - val endPos = (end / totalLength).toFloat() * width - // set is dragging - isStartDragging = abs(offset.x - startPOs) <= 50.dp.toPx() - isEndDragging = abs(offset.x - endPos) <= 50.dp.toPx() + scope.launch { + mutex.mutate(MutatePriority.Default) { + with(localClipConfig) { + val startPOs = (start / totalLength).toFloat() * width + val endPos = (end / totalLength).toFloat() * width + // set is dragging + isStartDragging = abs(offset.x - startPOs) <= 50.dp.toPx() + isEndDragging = abs(offset.x - endPos) <= 50.dp.toPx() + } + // set start offset + startX = offset.x + endX = offset.x + } } - // set start offset - startX = offset.x - endX = offset.x }, onHorizontalDrag = { change, amount -> - val timeDelta = (totalLength.inWholeMilliseconds * (amount / width)).toLong() - if (isStartDragging) { - val dragNewDuration = localClipConfig.start + timeDelta.milliseconds - - val startDifference = localClipConfig.end - dragNewDuration - val finalNewDuration = if (dragNewDuration <= Duration.ZERO) Duration.ZERO - else if (startDifference <= minClipAmount) { - Log.d(TAG, "CLIP FOUND $startDifference SHOULD BE $minClipAmount") - localClipConfig.start - } else dragNewDuration - - localClipConfig = localClipConfig.copy(start = finalNewDuration) - startX += amount - currentOnClipChange(localClipConfig) - } else if (isEndDragging) { - val dragNewDuration = localClipConfig.end + timeDelta.milliseconds - val endDifference = dragNewDuration - localClipConfig.start - val finalNewDuration = if (dragNewDuration >= totalLength) totalLength - else if (endDifference <= minClipAmount) { - Log.d(TAG, "CLIP FOUND $endDifference SHOULD BE $minClipAmount") - localClipConfig.end - } else dragNewDuration - - localClipConfig = localClipConfig.copy(end = finalNewDuration) - endX += amount - - currentOnClipChange(localClipConfig) + scope.launch { + mutex.mutate(priority = MutatePriority.UserInput) { + val timeDelta = + (totalLength.inWholeMilliseconds * (amount / width)).toLong() + if (isStartDragging) { + val dragNewDuration = localClipConfig.start + timeDelta.milliseconds + + val startDifference = localClipConfig.end - dragNewDuration + val finalNewDuration = + if (dragNewDuration <= Duration.ZERO) Duration.ZERO + else if (startDifference <= minClipAmount) { + Log.d( + TAG, + "CLIP FOUND $startDifference SHOULD BE $minClipAmount" + ) + localClipConfig.start + } else dragNewDuration + + localClipConfig = localClipConfig.copy(start = finalNewDuration) + startX += amount + currentOnClipChange(localClipConfig) + } else if (isEndDragging) { + val dragNewDuration = localClipConfig.end + timeDelta.milliseconds + val endDifference = dragNewDuration - localClipConfig.start + val finalNewDuration = if (dragNewDuration >= totalLength) totalLength + else if (endDifference <= minClipAmount) { + Log.d(TAG, "CLIP FOUND $endDifference SHOULD BE $minClipAmount") + localClipConfig.end + } else dragNewDuration + + localClipConfig = localClipConfig.copy(end = finalNewDuration) + endX += amount + + currentOnClipChange(localClipConfig) + } + // consume the changes + if (isStartDragging || isEndDragging) + change.consume() + } } - // consume the changes - if (isStartDragging || isEndDragging) - change.consume() }, onDragEnd = { - isStartDragging = false - isEndDragging = false + scope.launch { + mutex.mutate(MutatePriority.PreventUserInput) { + isStartDragging = false + isEndDragging = false + } + } }, onDragCancel = { - isStartDragging = false - isEndDragging = false + scope.launch { + mutex.mutate(MutatePriority.PreventUserInput) { + isStartDragging = false + isEndDragging = false + } + } } ) } diff --git a/feature/editor/src/main/java/com/eva/feature_editor/composables/EditorActionsAndControls.kt b/feature/editor/src/main/java/com/eva/feature_editor/composables/EditorActionsAndControls.kt index 7d76952e..6b0454e7 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/composables/EditorActionsAndControls.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/composables/EditorActionsAndControls.kt @@ -4,10 +4,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Crop -import androidx.compose.material3.ButtonColors -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon @@ -18,25 +14,29 @@ import androidx.compose.material3.Text import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.contentColorFor import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.eva.editor.domain.model.AudioEditAction import com.eva.feature_editor.event.EditorScreenEvent import com.eva.player.domain.model.PlayerTrackData import com.eva.player_shared.composables.AnimatedPlayPauseButton -import com.eva.player_shared.composables.PlayerSlider +import com.eva.player_shared.composables.PlayerTrackSlider2 import com.eva.ui.R import kotlin.time.Duration @OptIn(ExperimentalMaterial3Api::class) @Composable private fun EditorActionsAndControls( - trackData: PlayerTrackData, + trackData: () -> PlayerTrackData, onSeek: (Duration) -> Unit, isItemPlaying: Boolean, modifier: Modifier = Modifier, @@ -44,106 +44,109 @@ private fun EditorActionsAndControls( onCutMedia: () -> Unit, onPlay: () -> Unit, onPause: () -> Unit, - playButtonColors: ButtonColors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ), - actionButtonColors: ButtonColors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondary, - contentColor = MaterialTheme.colorScheme.onSecondary - ) + playButtonColor: Color = MaterialTheme.colorScheme.primary, + actionButtonColor: Color = MaterialTheme.colorScheme.secondary, ) { Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(40.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - PlayerSlider(trackData = trackData, onSeekComplete = onSeek) + PlayerTrackSlider2( + trackData = trackData, + onSeekComplete = onSeek + ) //actions Row( horizontalArrangement = Arrangement.spacedBy(40.dp), verticalAlignment = Alignment.CenterVertically ) { - // cut option - TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider( - TooltipAnchorPosition.Above - ), - tooltip = { - RichTooltip( - title = { - Text( - text = stringResource(R.string.action_cut), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary - ) - }, - text = { Text(text = stringResource(R.string.tooltip_text_editor_cut)) }, - shape = MaterialTheme.shapes.medium, - ) - }, - state = rememberTooltipState() + SecondaryActions( + title = stringResource(R.string.action_cut), + text = stringResource(R.string.tooltip_text_editor_cut), + onClick = onCutMedia, + containerColor = actionButtonColor, ) { - SmallFloatingActionButton( - onClick = onCutMedia, - shape = CircleShape, - containerColor = actionButtonColors.containerColor, - contentColor = actionButtonColors.contentColor, - elevation = FloatingActionButtonDefaults.loweredElevation() - ) { - Icon( - painter = painterResource(R.drawable.ic_cut), - contentDescription = "Action Cut" - ) - } + Icon( + painter = painterResource(R.drawable.ic_cut), + contentDescription = "Action Cut" + ) } AnimatedPlayPauseButton( isPlaying = isItemPlaying, onPlay = onPlay, onPause = onPause, - colors = playButtonColors, + containerColor = playButtonColor ) + SecondaryActions( + title = stringResource(R.string.action_crop), + text = stringResource(R.string.tooltip_text_editor_crop), + onClick = onCropMedia, + containerColor = actionButtonColor, + ) { + Icon( + painter = painterResource(R.drawable.ic_crop), + contentDescription = "Action Crop" + ) + } + } + } +} - //crop option - TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider( - TooltipAnchorPosition.Above - ), - tooltip = { - RichTooltip( - title = { - Text( - text = stringResource(R.string.action_crop), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary - ) - }, - text = { Text(text = stringResource(R.string.tooltip_text_editor_crop)) }, - shape = MaterialTheme.shapes.medium, - ) - }, - state = rememberTooltipState() +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SecondaryActions( + title: String, + text: String, + onClick: () -> Unit, + containerColor: Color = MaterialTheme.colorScheme.secondaryContainer, + contentDescription: String? = null, + action: @Composable () -> Unit, +) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.semantics { + this.contentDescription = contentDescription ?: title + } + ) { + //crop option + TooltipBox( + positionProvider = TooltipDefaults + .rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { + RichTooltip( + title = { Text(text = title) }, + text = { Text(text = text) }, + colors = TooltipDefaults.richTooltipColors( + titleContentColor = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onSurface + ), + shape = MaterialTheme.shapes.medium, + ) + }, + state = rememberTooltipState(), + ) { + SmallFloatingActionButton( + onClick = onClick, + shape = CircleShape, + containerColor = containerColor, + contentColor = contentColorFor(containerColor), + elevation = FloatingActionButtonDefaults.loweredElevation() ) { - SmallFloatingActionButton( - onClick = onCropMedia, - shape = CircleShape, - containerColor = actionButtonColors.containerColor, - contentColor = actionButtonColors.contentColor, - elevation = FloatingActionButtonDefaults.loweredElevation() - ) { - Icon( - imageVector = Icons.Default.Crop, - contentDescription = "Action Crop" - ) - } + action() } } + Text( + text = title, + style = MaterialTheme.typography.labelMedium + ) } } @Composable internal fun EditorActionsAndControls( - trackData: PlayerTrackData, + trackData: () -> PlayerTrackData, isMediaPlaying: Boolean, onEvent: (EditorScreenEvent) -> Unit, modifier: Modifier = Modifier, @@ -156,6 +159,5 @@ internal fun EditorActionsAndControls( onPlay = { onEvent(EditorScreenEvent.PlayAudio) }, onPause = { onEvent(EditorScreenEvent.PauseAudio) }, onCropMedia = { onEvent(EditorScreenEvent.OnEditAction(AudioEditAction.CROP)) }, - onCutMedia = { onEvent(EditorScreenEvent.OnEditAction(AudioEditAction.CUT)) } - ) + onCutMedia = { onEvent(EditorScreenEvent.OnEditAction(AudioEditAction.CUT)) }) } \ No newline at end of file diff --git a/feature/editor/src/main/java/com/eva/feature_editor/composables/EditorTrimOverlay.kt b/feature/editor/src/main/java/com/eva/feature_editor/composables/EditorTrimOverlay.kt index 5b73984b..7d2f6afb 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/composables/EditorTrimOverlay.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/composables/EditorTrimOverlay.kt @@ -1,8 +1,7 @@ package com.eva.feature_editor.composables import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.drawWithCache @@ -16,6 +15,7 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawOutline import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp @@ -24,28 +24,33 @@ import com.eva.player_shared.util.PlayerGraphData import com.eva.ui.R import kotlin.time.Duration -@Composable internal fun Modifier.trimOverlay( graph: PlayerGraphData, trackDuration: Duration, maxGraphPoints: Int = 100, clipConfig: AudioClipConfig? = null, - overlayColor: Color = MaterialTheme.colorScheme.tertiary, + overlayColor: Color = Color(0x30f52891), enabled: Boolean = true, - shape: Shape = MaterialTheme.shapes.medium, -) = composed { - + shape: Shape = RoundedCornerShape(2.dp), +): Modifier = composed( + fullyQualifiedName = "com.eva.feature_editor.composables.trimOverlay", + keys = arrayOf(clipConfig), + inspectorInfo = debugInspectorInfo { + name = "editor_trim_overlay" + properties["clip_config"] = clipConfig + properties["track_duration"] = trackDuration + properties["enabled"] = enabled + }, +) { if (!enabled) return@composed Modifier val cutPainter = painterResource(R.drawable.ic_cut) - defaultMinSize(minHeight = dimensionResource(id = R.dimen.line_graph_min_height)) + Modifier + .defaultMinSize(minHeight = dimensionResource(id = R.dimen.line_graph_min_height)) .drawWithCache { - - val (startRatio, endRatio) = clipConfig?.let { config -> - (config.start / trackDuration).toFloat() to - (config.end / trackDuration).toFloat() - } ?: (0f to 1f) + val sampleSize = graph().size + val isCompressedGraph = sampleSize >= maxGraphPoints val pathEffect = PathEffect.dashPathEffect( intervals = floatArrayOf(10.dp.toPx(), 10.dp.toPx()) @@ -53,13 +58,13 @@ internal fun Modifier.trimOverlay( val eachPointSize = size.width / maxGraphPoints onDrawBehind { - - val samples = graph() - val isCompressedGraph = samples.size >= maxGraphPoints + val startRatio = clipConfig + ?.let { config -> (config.start / trackDuration).toFloat() } ?: 0f + val endRatio = clipConfig + ?.let { config -> (config.end / trackDuration).toFloat() } ?: 1f val width = if (isCompressedGraph) size.width - else samples.size * eachPointSize - + else sampleSize * eachPointSize val topLeftCorner = Offset(startRatio * width, 0f) val bottomRightCorner = Offset(endRatio * width, size.height) @@ -111,5 +116,4 @@ internal fun Modifier.trimOverlay( } } } - } diff --git a/feature/editor/src/main/java/com/eva/feature_editor/composables/PlayerTrimSelector.kt b/feature/editor/src/main/java/com/eva/feature_editor/composables/PlayerTrimSelector.kt index d21744bc..3308ba9c 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/composables/PlayerTrimSelector.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/composables/PlayerTrimSelector.kt @@ -1,22 +1,23 @@ package com.eva.feature_editor.composables -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.eva.editor.domain.model.AudioClipConfig import com.eva.player.domain.model.PlayerTrackData import com.eva.player_shared.util.PlayerGraphData import com.eva.player_shared.util.PlayerPreviewFakes -import com.eva.ui.R import com.eva.ui.theme.DownloadableFonts import com.eva.ui.theme.RecorderAppTheme import com.eva.utils.RecorderConstants @@ -24,53 +25,50 @@ import com.eva.utils.RecorderConstants @Composable internal fun PlayerTrimSelector( graphData: PlayerGraphData, - trackData: PlayerTrackData, + trackData: () -> PlayerTrackData, onClipConfigChange: (AudioClipConfig) -> Unit, modifier: Modifier = Modifier, clipConfig: AudioClipConfig? = null, enabled: Boolean = true, maxGraphPoints: Int = RecorderConstants.RECORDER_AMPLITUDES_BUFFER_SIZE, - shape: Shape = MaterialTheme.shapes.small, + shape: Shape = MaterialTheme.shapes.large, overlayColor: Color = MaterialTheme.colorScheme.tertiary, containerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh, - contentPadding: PaddingValues = PaddingValues( - horizontal = dimensionResource(id = R.dimen.graph_card_padding), - vertical = dimensionResource(id = R.dimen.graph_card_padding_other) - ), + contentPadding: PaddingValues = PaddingValues(28.dp), ) { + val totalTrackDuration by remember { derivedStateOf { trackData().total } } + Surface( color = containerColor, shape = shape, modifier = modifier.aspectRatio(1.7f), ) { - Box(modifier = Modifier.padding(contentPadding)) { - EditorAmplitudeGraph( - playRatio = { trackData.playRatio }, - totalTrackDuration = trackData.total, - maxGraphPoints = maxGraphPoints, - graphData = graphData, - timelineFontFamily = DownloadableFonts.PLUS_CODE_LATIN_FONT_FAMILY, - modifier = Modifier - .matchParentSize() - .trimOverlay( - graph = graphData, - enabled = enabled, - trackDuration = trackData.total, - clipConfig = clipConfig, - maxGraphPoints = maxGraphPoints, - overlayColor = overlayColor, - shape = shape, - ) - .detectClipConfig( - graph = graphData, - enabled = enabled, - onClipChange = onClipConfigChange, - totalLength = trackData.total, - clipConfig = clipConfig, - maxGraphPoints = maxGraphPoints - ), - ) - } + EditorAmplitudeGraph( + playRatio = { trackData().playRatio }, + totalTrackDuration = totalTrackDuration, + maxGraphPoints = maxGraphPoints, + graphData = graphData, + timelineFontFamily = DownloadableFonts.PLUS_CODE_LATIN_FONT_FAMILY, + modifier = Modifier + .padding(contentPadding) + .trimOverlay( + graph = graphData, + enabled = enabled, + trackDuration = totalTrackDuration, + clipConfig = clipConfig, + maxGraphPoints = maxGraphPoints, + overlayColor = overlayColor, + shape = shape, + ) + .detectClipConfig( + graph = graphData, + enabled = enabled, + onClipChange = onClipConfigChange, + totalLength = totalTrackDuration, + clipConfig = clipConfig, + maxGraphPoints = maxGraphPoints + ) + ) } } @@ -80,7 +78,7 @@ internal fun PlayerTrimSelector( private fun PlayerTrimSelectorPreview() = RecorderAppTheme { PlayerTrimSelector( graphData = { PlayerPreviewFakes.PREVIEW_RECORDER_AMPLITUDES }, - trackData = PlayerPreviewFakes.FAKE_TRACK_DATA, + trackData = { PlayerPreviewFakes.FAKE_TRACK_DATA }, onClipConfigChange = {} ) } \ No newline at end of file diff --git a/feature/player-shared/src/main/java/com/eva/player_shared/composables/AnimatedPlayPauseButton.kt b/feature/player-shared/src/main/java/com/eva/player_shared/composables/AnimatedPlayPauseButton.kt index fd4f935c..7c274e30 100644 --- a/feature/player-shared/src/main/java/com/eva/player_shared/composables/AnimatedPlayPauseButton.kt +++ b/feature/player-shared/src/main/java/com/eva/player_shared/composables/AnimatedPlayPauseButton.kt @@ -17,10 +17,8 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn -import androidx.compose.material3.ButtonColors -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -29,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource @@ -52,15 +51,11 @@ fun AnimatedPlayPauseButton( enabled: Boolean = true, tonalElevation: Dp = 0.dp, shadowElevation: Dp = 0.dp, - colors: ButtonColors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ), + containerColor: Color = MaterialTheme.colorScheme.primary, + disabledColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = .1f) ) { - val buttonContainerColor = if (enabled) colors.containerColor - else colors.disabledContainerColor - + val buttonContainerColor = if (enabled) containerColor else disabledColor val infiniteTransition = rememberInfiniteTransition( label = "Infinite transition for rotating shape" @@ -68,9 +63,9 @@ fun AnimatedPlayPauseButton( val rotation by infiniteTransition.animateFloat( initialValue = 0f, - targetValue = 360f, + targetValue = if (isPlaying) 360f else 0f, animationSpec = infiniteRepeatable( - animation = tween(10_000, easing = LinearEasing), + animation = tween(8_000, easing = LinearEasing), repeatMode = RepeatMode.Restart ), label = "Amount of rotation for the play button", @@ -85,7 +80,7 @@ fun AnimatedPlayPauseButton( clip = true shape = RoundedPolygonShape( polygon = CustomShapes.ROUNDED_STAR_8_CORNERS, - rotation = if (isPlaying) rotation else 0f + rotation = rotation ) }, tonalElevation = tonalElevation, @@ -103,16 +98,17 @@ fun AnimatedPlayPauseButton( transitionSpec = { isPlayingAnimation() }, label = "Transform between playing states", contentAlignment = Alignment.Center, - modifier = Modifier.padding(8.dp) ) { playing -> if (playing) Icon( painter = painterResource(id = R.drawable.ic_pause), contentDescription = stringResource(R.string.recorder_action_pause), + modifier = Modifier.size(32.dp), ) else Icon( painter = painterResource(id = R.drawable.ic_play), contentDescription = stringResource(R.string.recorder_action_resume), + modifier = Modifier.size(32.dp), ) } } @@ -121,14 +117,12 @@ fun AnimatedPlayPauseButton( private fun isPlayingAnimation(): ContentTransform { - val fadeSpec = tween(durationMillis = 200, easing = EaseInBounce) - - val scaleSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium, - ) + val fadeSpec = tween(durationMillis = 200, delayMillis = 100, easing = EaseInBounce) + val scaleSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy) - val enter = fadeIn(animationSpec = fadeSpec) + scaleIn(animationSpec = scaleSpec) + val enter = + fadeIn(animationSpec = fadeSpec, initialAlpha = .2f) + + scaleIn(animationSpec = scaleSpec, initialScale = .4f) val exit = fadeOut(animationSpec = fadeSpec) + scaleOut(animationSpec = scaleSpec) return enter togetherWith exit diff --git a/feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerDurationText.kt b/feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerDurationText.kt index 6e6586d2..7a672b35 100644 --- a/feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerDurationText.kt +++ b/feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerDurationText.kt @@ -31,8 +31,8 @@ private fun PlayerDurationText( derivedStateOf { val time = LocalTime.fromMillisecondOfDay(totalDurationInMillis.toInt()) with(time) { - if (hour > 0) format(LocalTimeFormats.LOCALTIME_FORMAT_HH_MM_SS_SF2) - else format(LocalTimeFormats.LOCALTIME_FORMAT_MM_SS_SF2) + if (hour > 0) format(LocalTimeFormats.LOCALTIME_FORMAT_HH_MM_SS) + else format(LocalTimeFormats.LOCALTIME_FORMAT_MM_SS) } } } @@ -85,13 +85,16 @@ fun PlayerDurationText( @Composable fun PlayerDurationText( - track: PlayerTrackData, + track: () -> PlayerTrackData, modifier: Modifier = Modifier, fontFamily: FontFamily = FontFamily.Monospace, ) { + val playedDuration by remember { derivedStateOf { track().current.inWholeMilliseconds } } + val totalDuration by remember { derivedStateOf { track().total.inWholeMilliseconds } } + PlayerDurationText( - playedDurationInMillis = track.current.inWholeMilliseconds, - totalDurationInMillis = track.total.inWholeMilliseconds, + playedDurationInMillis = playedDuration, + totalDurationInMillis = totalDuration, modifier = modifier, fontFamily = fontFamily, ) diff --git a/feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerSlider.kt b/feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerTrackSlider.kt similarity index 58% rename from feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerSlider.kt rename to feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerTrackSlider.kt index 6e36bfb8..086d9b3e 100644 --- a/feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerSlider.kt +++ b/feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerTrackSlider.kt @@ -8,29 +8,26 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.eva.player.domain.model.PlayerTrackData -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.update +import com.eva.player_shared.state.PlayerSliderController +import kotlinx.coroutines.launch import kotlin.math.round import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds @Composable -fun PlayerSlider( +fun PlayerTrackSlider( trackData: PlayerTrackData, onSeekComplete: (Duration) -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true ) { - val controller = rememberPlayerSliderController() + val controller = remember { PlayerSliderController() } + val scope = rememberCoroutineScope() + var sliderPosition by remember { mutableFloatStateOf(trackData.playRatio) } val isUserControlled by controller.isSeekByUser.collectAsStateWithLifecycle(false) @@ -48,12 +45,17 @@ fun PlayerSlider( Slider( value = sliderPosition, onValueChange = { newPosition -> - val playerSeekAmount = trackData.calculateSeekAmount(newPosition) - controller.onSliderSlide(playerSeekAmount) + scope.launch { + val playerSeekAmount = trackData.calculateSeekAmount(newPosition) + controller.onSliderSlide(playerSeekAmount) + } }, onValueChangeFinished = { - onSeekComplete(seekAmountByUser) - controller.sliderCleanUp() + scope.launch { + controller.sliderCleanUp { + onSeekComplete(seekAmountByUser) + } + } }, colors = SliderDefaults.colors( activeTrackColor = MaterialTheme.colorScheme.primary, @@ -69,31 +71,4 @@ private fun Float.roundToNDecimals(decimals: Int = 2): Float { var multiplier = 1.0f repeat(decimals) { multiplier *= 10 } return round(this * multiplier) / multiplier -} - -@Composable -private fun rememberPlayerSliderController(): PlayerSliderController { - return remember { PlayerSliderController() } -} - -@OptIn(FlowPreview::class) -private class PlayerSliderController() { - - private val _seekAmountByUser = MutableStateFlow(Duration.ZERO) - val seekAmountByUser = _seekAmountByUser.asStateFlow() - - private val _isSeekByUser = MutableStateFlow(false) - val isSeekByUser = _isSeekByUser.debounce { controlled -> - if (controlled) 0.seconds - else 75.milliseconds - }.distinctUntilChanged() - - fun onSliderSlide(amount: Duration) { - _isSeekByUser.update { true } - _seekAmountByUser.update { amount } - } - - fun sliderCleanUp() { - _isSeekByUser.update { false } - } } \ No newline at end of file diff --git a/feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerTrackSlider2.kt b/feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerTrackSlider2.kt new file mode 100644 index 00000000..8e53b9e1 --- /dev/null +++ b/feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerTrackSlider2.kt @@ -0,0 +1,92 @@ +package com.eva.player_shared.composables + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.SliderState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.eva.player.domain.model.PlayerTrackData +import com.eva.player_shared.state.PlayerSliderController +import com.eva.ui.theme.RecorderAppTheme +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlayerTrackSlider2( + trackData: () -> PlayerTrackData, + onSeekComplete: (Duration) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + // slider controller + val controller = remember { PlayerSliderController() } + val isUserControlled by controller.isSeekByUser.collectAsStateWithLifecycle(false) + val seekAmountByUser by controller.seekAmountByUser.collectAsStateWithLifecycle() + + val currentOnSeekComplete by rememberUpdatedState(onSeekComplete) + + val state = remember { SliderState(value = trackData().playRatio) } + + LaunchedEffect(state) { + + // Basic state update updated by the player + snapshotFlow { trackData().playRatio } + .filter { !state.isDragging } + .onEach { state.value = it } + .launchIn(this) + + // If the slider is being drag , updated by the user + snapshotFlow { state.value } + .filter { state.isDragging } + .onEach { seek -> + val playerSeekAmount = trackData().calculateSeekAmount(seek) + controller.onSliderSlide(playerSeekAmount) + }.launchIn(this) + + // now if it's not being dragged but user controlled then send seek completed + snapshotFlow { !state.isDragging && isUserControlled } + .filter { it } + .onEach { + controller.sliderCleanUp() + currentOnSeekComplete(seekAmountByUser) + } + .launchIn(this) + } + + Slider( + state = state, + enabled = enabled, + colors = SliderDefaults.colors( + activeTrackColor = MaterialTheme.colorScheme.primary, + thumbColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.outlineVariant + ), + modifier = modifier + ) +} + +@Preview +@Composable +private fun PlayerTrackSlider2Preview() = RecorderAppTheme { + var trackState by remember { mutableStateOf(PlayerTrackData(0.seconds, 10.seconds)) } + + PlayerTrackSlider2( + trackData = { trackState }, + onSeekComplete = { trackState = trackState.copy(current = it) }, + ) +} \ No newline at end of file diff --git a/feature/player-shared/src/main/java/com/eva/player_shared/state/ContentLoadState.kt b/feature/player-shared/src/main/java/com/eva/player_shared/state/ContentLoadState.kt index 05240d57..8e78be11 100644 --- a/feature/player-shared/src/main/java/com/eva/player_shared/state/ContentLoadState.kt +++ b/feature/player-shared/src/main/java/com/eva/player_shared/state/ContentLoadState.kt @@ -1,6 +1,5 @@ package com.eva.player_shared.state -import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable @Stable @@ -11,18 +10,4 @@ sealed class ContentLoadState { data class Content(val data: T) : ContentLoadState() data object Unknown : ContentLoadState() - - - @Composable - fun OnContentOrOther(content: @Composable (T) -> Unit, onOther: @Composable () -> Unit) { - when (this) { - is Content -> content(data) - else -> onOther() - } - } - - @Composable - fun OnContent(action: @Composable (T) -> Unit) { - if (this is Content) action(this.data) - } } \ No newline at end of file diff --git a/feature/player-shared/src/main/java/com/eva/player_shared/state/PlayerSliderController.kt b/feature/player-shared/src/main/java/com/eva/player_shared/state/PlayerSliderController.kt new file mode 100644 index 00000000..42949b50 --- /dev/null +++ b/feature/player-shared/src/main/java/com/eva/player_shared/state/PlayerSliderController.kt @@ -0,0 +1,43 @@ +package com.eva.player_shared.state + +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.MutatorMutex +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.update +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@OptIn(FlowPreview::class) +internal class PlayerSliderController { + + private val _mutex = MutatorMutex() + + private val _seekAmountByUser = MutableStateFlow(Duration.ZERO) + val seekAmountByUser = _seekAmountByUser.asStateFlow() + + private val _isSeekByUser = MutableStateFlow(false) + + val isSeekByUser = _isSeekByUser.debounce { controlled -> + if (controlled) 0.seconds + else 75.milliseconds + }.distinctUntilChanged() + + suspend fun onSliderSlide(amount: Duration) { + _mutex.mutate(MutatePriority.UserInput) { + _isSeekByUser.update { true } + _seekAmountByUser.update { amount } + } + } + + suspend fun sliderCleanUp(onDone: suspend () -> Unit = {}) { + _mutex.mutate(MutatePriority.PreventUserInput) { + _isSeekByUser.update { false } + onDone() + } + } +} \ No newline at end of file diff --git a/feature/player/src/main/java/com/eva/feature_player/AudioPlayerRoute.kt b/feature/player/src/main/java/com/eva/feature_player/AudioPlayerRoute.kt index df277fad..d2549ba8 100644 --- a/feature/player/src/main/java/com/eva/feature_player/AudioPlayerRoute.kt +++ b/feature/player/src/main/java/com/eva/feature_player/AudioPlayerRoute.kt @@ -1,8 +1,6 @@ package com.eva.feature_player import android.content.Intent -import androidx.compose.animation.SizeTransform -import androidx.compose.animation.core.tween import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Icon @@ -43,7 +41,6 @@ fun NavGraphBuilder.audioPlayerRoute(controller: NavHostController) = action = Intent.ACTION_VIEW }, ), - sizeTransform = { SizeTransform(clip = false) { _, _ -> tween(durationMillis = 300) } } ) { backStackEntry -> val route = backStackEntry.toRoute() @@ -93,12 +90,12 @@ fun NavGraphBuilder.audioPlayerRoute(controller: NavHostController) = val lifeCycleState by backStackEntry.lifecycle.currentStateFlow.collectAsStateWithLifecycle() CompositionLocalProvider(LocalSharedTransitionVisibilityScopeProvider provides this) { - AudioPlayerScreenContainer( + AudioPlayerScreen( audioId = route.audioId, loadState = contentState, bookmarks = bookMarks, waveforms = { visuals }, - trackData = trackData, + trackData = { trackData }, playerMetaData = playerMetadata, isControllerReady = isControllerReady, bookMarkState = bookMarkState, diff --git a/feature/player/src/main/java/com/eva/feature_player/AudioPlayerScreen.kt b/feature/player/src/main/java/com/eva/feature_player/AudioPlayerScreen.kt index 377ebcb9..641dc50c 100644 --- a/feature/player/src/main/java/com/eva/feature_player/AudioPlayerScreen.kt +++ b/feature/player/src/main/java/com/eva/feature_player/AudioPlayerScreen.kt @@ -7,11 +7,8 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -25,7 +22,6 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -42,11 +38,9 @@ import com.eva.bookmarks.domain.AudioBookmarkModel import com.eva.feature_player.bookmarks.state.BookMarkEvents import com.eva.feature_player.bookmarks.state.CreateBookmarkState import com.eva.feature_player.bookmarks.utils.BookmarksPreviewFakes +import com.eva.feature_player.composable.AudioPlayerScreenContent import com.eva.feature_player.composable.AudioPlayerScreenTopBar import com.eva.feature_player.composable.FileMetadataDetailsSheet -import com.eva.feature_player.composable.PlayerActionsAndSlider -import com.eva.feature_player.composable.PlayerAmplitudeGraph -import com.eva.feature_player.composable.PlayerBookMarks import com.eva.feature_player.state.PlayerEvents import com.eva.player.domain.model.PlayerMetaData import com.eva.player.domain.model.PlayerTrackData @@ -54,19 +48,15 @@ import com.eva.player_shared.UserAudioAction import com.eva.player_shared.composables.AudioFileNotFoundBox import com.eva.player_shared.composables.ContentLoadStatePreviewParams import com.eva.player_shared.composables.ContentStateAnimatedContainer -import com.eva.player_shared.composables.PlayerDurationText import com.eva.player_shared.util.AudioFileModelLoadState import com.eva.player_shared.util.PlayerGraphData import com.eva.player_shared.util.PlayerPreviewFakes -import com.eva.recordings.domain.models.AudioFileModel import com.eva.ui.R import com.eva.ui.animation.SharedElementTransitionKeys import com.eva.ui.animation.sharedBoundsWrapper -import com.eva.ui.theme.DownloadableFonts import com.eva.ui.theme.RecorderAppTheme import com.eva.ui.utils.LocalSnackBarProvider import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch @OptIn( @@ -74,12 +64,12 @@ import kotlinx.coroutines.launch ExperimentalMaterial3Api::class ) @Composable -internal fun AudioPlayerScreenContainer( +internal fun AudioPlayerScreen( audioId: Long, loadState: AudioFileModelLoadState, waveforms: PlayerGraphData, bookMarkState: CreateBookmarkState, - trackData: PlayerTrackData, + trackData: () -> PlayerTrackData, playerMetaData: PlayerMetaData, bookmarks: ImmutableList, onPlayerEvents: (PlayerEvents) -> Unit, @@ -170,83 +160,17 @@ internal fun AudioPlayerScreenContainer( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun AudioPlayerScreenContent( - fileModel: AudioFileModel, - waveforms: PlayerGraphData, - bookMarkState: CreateBookmarkState, - trackData: PlayerTrackData, - playerMetaData: PlayerMetaData, - bookmarks: ImmutableList, - onPlayerEvents: (PlayerEvents) -> Unit, - modifier: Modifier = Modifier, - onBookmarkEvent: (BookMarkEvents) -> Unit = {}, - isControllerReady: Boolean = false, -) { - val bookMarkTimeStamps by remember(bookmarks) { - derivedStateOf { - bookmarks.map(AudioBookmarkModel::timeStamp) - .toImmutableList() - } - } - - Box( - modifier = modifier.fillMaxSize() - ) { - PlayerDurationText( - track = trackData, - fileModel = fileModel, - fontFamily = DownloadableFonts.SPLINE_SANS_MONO_FONT_FAMILY, - modifier = Modifier.align(Alignment.TopCenter), - ) - Column( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center) - .offset(y = (-80).dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - PlayerAmplitudeGraph( - trackData = trackData, - bookMarksTimeStamps = bookMarkTimeStamps, - graphData = waveforms, - timelineFontFamily = DownloadableFonts.PLUS_CODE_LATIN_FONT_FAMILY, - modifier = Modifier.fillMaxWidth() - ) - PlayerBookMarks( - trackData = trackData, - bookmarks = bookmarks, - bookMarkState = bookMarkState, - onBookmarkEvent = onBookmarkEvent, - modifier = Modifier.fillMaxWidth() - ) - } - PlayerActionsAndSlider( - metaData = playerMetaData, - trackData = trackData, - isControllerSet = isControllerReady, - onPlayerAction = onPlayerEvents, - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - ) - } -} - - @PreviewLightDark @Composable private fun AudioPlayerScreenPreview( @PreviewParameter(ContentLoadStatePreviewParams::class) loadState: AudioFileModelLoadState ) = RecorderAppTheme { - AudioPlayerScreenContainer( + AudioPlayerScreen( audioId = 0, loadState = loadState, waveforms = { PlayerPreviewFakes.PREVIEW_RECORDER_AMPLITUDES }, - trackData = PlayerTrackData(total = PlayerPreviewFakes.FAKE_AUDIO_MODEL.duration), + trackData = { PlayerTrackData(total = PlayerPreviewFakes.FAKE_AUDIO_MODEL.duration) }, playerMetaData = PlayerMetaData(), bookMarkState = CreateBookmarkState(), isControllerReady = true, diff --git a/feature/player/src/main/java/com/eva/feature_player/bookmarks/composable/AudioBookmarkCard.kt b/feature/player/src/main/java/com/eva/feature_player/bookmarks/composable/AudioBookmarkCard.kt index c8c58212..e0bc185e 100644 --- a/feature/player/src/main/java/com/eva/feature_player/bookmarks/composable/AudioBookmarkCard.kt +++ b/feature/player/src/main/java/com/eva/feature_player/bookmarks/composable/AudioBookmarkCard.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Badge import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults @@ -54,15 +53,16 @@ internal fun AudioBookMarkCard( verticalAlignment = Alignment.CenterVertically, modifier = modifier.padding(contentPadding), ) { - Badge( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + shape = MaterialTheme.shapes.medium, ) { Text( text = timeText, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp) + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) ) } Box( diff --git a/feature/player/src/main/java/com/eva/feature_player/bookmarks/composable/AudioBookmarksList.kt b/feature/player/src/main/java/com/eva/feature_player/bookmarks/composable/AudioBookmarksList.kt index b5e6a4ee..798cf118 100644 --- a/feature/player/src/main/java/com/eva/feature_player/bookmarks/composable/AudioBookmarksList.kt +++ b/feature/player/src/main/java/com/eva/feature_player/bookmarks/composable/AudioBookmarksList.kt @@ -1,9 +1,5 @@ package com.eva.feature_player.bookmarks.composable -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.Crossfade -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -40,7 +36,6 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewParameter @@ -91,66 +86,59 @@ internal fun AudioBookmarksList( ) { Text( text = stringResource(R.string.bookmarks_list_title), - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, ) - AnimatedVisibility( - visible = canExportBookmarks, + + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Below + ), + tooltip = { + PlainTooltip { + Text(text = stringResource(R.string.bookmark_action_export)) + } + }, + state = rememberTooltipState() ) { - TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider( - TooltipAnchorPosition.Start - ), - tooltip = { - PlainTooltip { - Text(text = stringResource(R.string.bookmark_action_export)) - } - }, - state = rememberTooltipState() + IconButton( + onClick = onExportBookmarks, + enabled = canExportBookmarks, + colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.primary) ) { - IconButton( - onClick = onExportBookmarks, - colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.primary) - ) { - Icon( - painter = painterResource(R.drawable.ic_export), - contentDescription = stringResource(R.string.bookmark_action_export) - ) - } + Icon( + painter = painterResource(R.drawable.ic_export), + contentDescription = stringResource(R.string.bookmark_action_export) + ) } } } - Crossfade( - targetState = canExportBookmarks, - label = "Bookmarks empty ot fill animation", - modifier = modifier, - animationSpec = tween( - durationMillis = 600, - delayMillis = 100, - easing = FastOutSlowInEasing - ) - ) { isNotEmpty -> - if (isNotEmpty) { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - itemsIndexed( - items = bookmarks, - key = keys, - contentType = contentType - ) { _, bookmark -> - AudioBookMarkCard( - bookmark = bookmark, - onDelete = { onDeleteBookMark(bookmark) }, - onEdit = { onEditBookMark(bookmark) }, - modifier = Modifier - .fillMaxWidth() - .animateItem() - ) - } + LazyColumn( + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(vertical = 4.dp) + ) { + if (bookmarks.isNotEmpty()) { + itemsIndexed( + items = bookmarks, + key = keys, + contentType = contentType + ) { _, bookmark -> + AudioBookMarkCard( + bookmark = bookmark, + onDelete = { onDeleteBookMark(bookmark) }, + onEdit = { onEditBookMark(bookmark) }, + modifier = Modifier + .fillMaxWidth() + .animateItem() + ) } - } else NoBookmarksPlaceHolder() + } else { + item(key = "absent_items") { + NoBookmarksPlaceHolder(modifier = Modifier + .padding(top = 40.dp) + .animateItem()) + } + } } } } diff --git a/feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerScreenContent.kt b/feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerScreenContent.kt new file mode 100644 index 00000000..eee07ea1 --- /dev/null +++ b/feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerScreenContent.kt @@ -0,0 +1,92 @@ +package com.eva.feature_player.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.eva.bookmarks.domain.AudioBookmarkModel +import com.eva.feature_player.bookmarks.state.BookMarkEvents +import com.eva.feature_player.bookmarks.state.CreateBookmarkState +import com.eva.feature_player.state.PlayerEvents +import com.eva.player.domain.model.PlayerMetaData +import com.eva.player.domain.model.PlayerTrackData +import com.eva.player_shared.composables.PlayerDurationText +import com.eva.player_shared.util.PlayerGraphData +import com.eva.recordings.domain.models.AudioFileModel +import com.eva.ui.theme.DownloadableFonts +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun AudioPlayerScreenContent( + fileModel: AudioFileModel, + waveforms: PlayerGraphData, + bookMarkState: CreateBookmarkState, + trackData: () -> PlayerTrackData, + playerMetaData: PlayerMetaData, + bookmarks: ImmutableList, + onPlayerEvents: (PlayerEvents) -> Unit, + modifier: Modifier = Modifier, + onBookmarkEvent: (BookMarkEvents) -> Unit = {}, + isControllerReady: Boolean = false, +) { + val bookMarkTimeStamps by remember(bookmarks) { + derivedStateOf { + bookmarks.map(AudioBookmarkModel::timeStamp) + .toImmutableList() + } + } + + Box( + modifier = modifier.fillMaxSize() + ) { + PlayerDurationText( + track = trackData, + fontFamily = DownloadableFonts.SPLINE_SANS_MONO_FONT_FAMILY, + modifier = Modifier.align(Alignment.TopCenter), + ) + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center) + .offset(y = (-80).dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + PlayerAmplitudeGraph( + trackData = trackData, + bookMarksTimeStamps = bookMarkTimeStamps, + graphData = waveforms, + timelineFontFamily = DownloadableFonts.PLUS_CODE_LATIN_FONT_FAMILY, + modifier = Modifier.fillMaxWidth() + ) + PlayerBookMarks( + trackData = trackData, + bookmarks = bookmarks, + bookMarkState = bookMarkState, + onBookmarkEvent = onBookmarkEvent, + modifier = Modifier.fillMaxWidth() + ) + } + PlayerActionsAndSlider( + metaData = playerMetaData, + trackData = trackData, + isControllerSet = isControllerReady, + onPlayerAction = onPlayerEvents, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) + } +} diff --git a/feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerScreenTopBar.kt b/feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerScreenTopBar.kt index 154695f3..324512b1 100644 --- a/feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerScreenTopBar.kt +++ b/feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerScreenTopBar.kt @@ -64,8 +64,9 @@ internal fun AudioPlayerScreenTopBar( ) { var showDropDown by remember { mutableStateOf(false) } - loadState.OnContentOrOther( - content = { model -> + when (loadState) { + is ContentLoadState.Content -> { + val model = loadState.data TopAppBar( title = { Text( @@ -190,8 +191,9 @@ internal fun AudioPlayerScreenTopBar( navigationIcon = navigation, modifier = modifier, ) - }, - onOther = { + } + + ContentLoadState.Unknown -> { TopAppBar( title = {}, colors = colors, @@ -199,8 +201,10 @@ internal fun AudioPlayerScreenTopBar( navigationIcon = navigation, modifier = modifier, ) - }, - ) + } + + else -> {} + } } private fun favouriteAudioAnimation(): ContentTransform { diff --git a/feature/player/src/main/java/com/eva/feature_player/composable/FileMetadataDetailsSheet.kt b/feature/player/src/main/java/com/eva/feature_player/composable/FileMetadataDetailsSheet.kt index 08fb8cb2..aa83a197 100644 --- a/feature/player/src/main/java/com/eva/feature_player/composable/FileMetadataDetailsSheet.kt +++ b/feature/player/src/main/java/com/eva/feature_player/composable/FileMetadataDetailsSheet.kt @@ -21,18 +21,17 @@ fun FileMetadataDetailsSheet( modifier: Modifier = Modifier, sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { - if (!showBottomSheet) return + if (!showBottomSheet || contentLoadState !is ContentLoadState.Content) return + val fileModel = contentLoadState.data - contentLoadState.OnContent { fileModel -> - ModalBottomSheet( - sheetState = sheetState, - onDismissRequest = onSheetDismiss, - modifier = modifier, - ) { - FileMetaDataSheetContent( - audio = fileModel, - contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.bottom_sheet_padding_lg)) - ) - } + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = onSheetDismiss, + modifier = modifier, + ) { + FileMetaDataSheetContent( + audio = fileModel, + contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.bottom_sheet_padding_lg)) + ) } } \ No newline at end of file diff --git a/feature/player/src/main/java/com/eva/feature_player/composable/PlayerActionsAndSlider.kt b/feature/player/src/main/java/com/eva/feature_player/composable/PlayerActionsAndSlider.kt index 74ebee0e..5e43c1bf 100644 --- a/feature/player/src/main/java/com/eva/feature_player/composable/PlayerActionsAndSlider.kt +++ b/feature/player/src/main/java/com/eva/feature_player/composable/PlayerActionsAndSlider.kt @@ -15,13 +15,14 @@ import androidx.compose.ui.unit.dp import com.eva.feature_player.state.PlayerEvents import com.eva.player.domain.model.PlayerMetaData import com.eva.player.domain.model.PlayerTrackData -import com.eva.player_shared.composables.PlayerSlider +import com.eva.player_shared.composables.PlayerTrackSlider2 import com.eva.ui.theme.RecorderAppTheme +import kotlin.time.Duration.Companion.minutes @Composable internal fun PlayerActionsAndSlider( metaData: PlayerMetaData, - trackData: PlayerTrackData, + trackData: () -> PlayerTrackData, onPlayerAction: (PlayerEvents) -> Unit, modifier: Modifier = Modifier, isControllerSet: Boolean = true, @@ -33,7 +34,7 @@ internal fun PlayerActionsAndSlider( modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp) ) { - PlayerSlider( + PlayerTrackSlider2( trackData = trackData, onSeekComplete = { amount -> onPlayerAction(PlayerEvents.OnSeekPlayer(amount)) }, enabled = isControllerSet @@ -61,7 +62,7 @@ private fun PlayerActionsAndSliderPreview() = RecorderAppTheme { Surface { PlayerActionsAndSlider( metaData = PlayerMetaData(isPlaying = true), - trackData = PlayerTrackData(), + trackData = { PlayerTrackData(total = 2.minutes) }, onPlayerAction = {}, modifier = Modifier.padding(12.dp) ) diff --git a/feature/player/src/main/java/com/eva/feature_player/composable/PlayerAmplitudeGraph.kt b/feature/player/src/main/java/com/eva/feature_player/composable/PlayerAmplitudeGraph.kt index 7238fb95..e816a12b 100644 --- a/feature/player/src/main/java/com/eva/feature_player/composable/PlayerAmplitudeGraph.kt +++ b/feature/player/src/main/java/com/eva/feature_player/composable/PlayerAmplitudeGraph.kt @@ -9,6 +9,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.graphics.Color @@ -133,7 +136,7 @@ private fun PlayerAmplitudeGraph( @Composable internal fun PlayerAmplitudeGraph( - trackData: PlayerTrackData, + trackData: () -> PlayerTrackData, graphData: PlayerGraphData, modifier: Modifier = Modifier, bookMarksTimeStamps: ImmutableList = persistentListOf(), @@ -152,9 +155,11 @@ internal fun PlayerAmplitudeGraph( vertical = dimensionResource(id = R.dimen.graph_card_padding_other) ), ) { + val totalDuration by remember { derivedStateOf { trackData().total } } + PlayerAmplitudeGraph( - trackPlayRatio = { trackData.playRatio }, - totalTrackDuration = trackData.total, + trackPlayRatio = { trackData().playRatio }, + totalTrackDuration = totalDuration, graphData = graphData, bookMarkTimeStamps = bookMarksTimeStamps, modifier = modifier, @@ -176,7 +181,7 @@ internal fun PlayerAmplitudeGraph( @Composable private fun PlayerAmplitudeGraphPreview() = RecorderAppTheme { PlayerAmplitudeGraph( - trackData = PlayerPreviewFakes.FAKE_TRACK_DATA, + trackData = { PlayerPreviewFakes.FAKE_TRACK_DATA }, graphData = { PlayerPreviewFakes.PREVIEW_RECORDER_AMPLITUDES }, bookMarksTimeStamps = persistentListOf( LocalTime.fromSecondOfDay(2), diff --git a/feature/player/src/main/java/com/eva/feature_player/composable/PlayerBookmarks.kt b/feature/player/src/main/java/com/eva/feature_player/composable/PlayerBookmarks.kt index 346d6d0f..7eb09a16 100644 --- a/feature/player/src/main/java/com/eva/feature_player/composable/PlayerBookmarks.kt +++ b/feature/player/src/main/java/com/eva/feature_player/composable/PlayerBookmarks.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size -import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api @@ -26,6 +25,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.window.DialogProperties import com.eva.bookmarks.domain.AudioBookmarkModel import com.eva.feature_player.bookmarks.composable.AddBookmarkDialogContent @@ -43,9 +43,11 @@ import kotlin.time.Duration private fun PlayerBookMarks( trackCurrentTime: () -> Duration, bookmarks: ImmutableList, - bookMarkState: CreateBookmarkState, onBookmarkEvent: (BookMarkEvents) -> Unit, modifier: Modifier = Modifier, + showCreateDialog: Boolean = false, + textFieldState: TextFieldValue = TextFieldValue(), + isBookmarkUpdate: Boolean = false, ) { val scope = rememberCoroutineScope() val bookmarkSheet = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -67,14 +69,14 @@ private fun PlayerBookMarks( } } - if (bookMarkState.showDialog) { + if (showCreateDialog) { BasicAlertDialog( onDismissRequest = { onBookmarkEvent(BookMarkEvents.OnCloseDialog) }, properties = DialogProperties(dismissOnClickOutside = false) ) { AddBookmarkDialogContent( - isUpdate = bookMarkState.isUpdate, - textFieldValue = bookMarkState.textValue, + isUpdate = isBookmarkUpdate, + textFieldValue = textFieldState, onValueChange = { onBookmarkEvent(BookMarkEvents.OnUpdateTextField(it)) }, onDismiss = { onBookmarkEvent(BookMarkEvents.OnCloseDialog) }, onConfirm = { @@ -89,7 +91,7 @@ private fun PlayerBookMarks( modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween ) { - AssistChip( + SuggestionChip( onClick = { scope.launch { bookmarkSheet.show() } .invokeOnCompletion { isSheetOpen = true } @@ -100,18 +102,22 @@ private fun PlayerBookMarks( style = MaterialTheme.typography.labelMedium, ) }, - leadingIcon = { + icon = { Icon( painter = painterResource(R.drawable.ic_list), contentDescription = stringResource(id = R.string.player_action_show_bookmarks), modifier = Modifier.size(AssistChipDefaults.IconSize) ) }, + border = SuggestionChipDefaults.suggestionChipBorder( + enabled = true, + borderColor = MaterialTheme.colorScheme.onSecondaryContainer + ), shape = MaterialTheme.shapes.medium, - colors = AssistChipDefaults.assistChipColors( + colors = SuggestionChipDefaults.suggestionChipColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, labelColor = MaterialTheme.colorScheme.onSecondaryContainer, - leadingIconContentColor = MaterialTheme.colorScheme.onSecondaryContainer + iconContentColor = MaterialTheme.colorScheme.onSecondaryContainer ), ) SuggestionChip( @@ -130,6 +136,10 @@ private fun PlayerBookMarks( ) }, shape = MaterialTheme.shapes.medium, + border = SuggestionChipDefaults.suggestionChipBorder( + enabled = true, + borderColor = MaterialTheme.colorScheme.onTertiaryContainer + ), colors = SuggestionChipDefaults.suggestionChipColors( containerColor = MaterialTheme.colorScheme.tertiaryContainer, labelColor = MaterialTheme.colorScheme.onTertiaryContainer, @@ -141,7 +151,7 @@ private fun PlayerBookMarks( @Composable internal fun PlayerBookMarks( - trackData: PlayerTrackData, + trackData: () -> PlayerTrackData, bookmarks: ImmutableList, bookMarkState: CreateBookmarkState, onBookmarkEvent: (BookMarkEvents) -> Unit, @@ -149,8 +159,10 @@ internal fun PlayerBookMarks( ) { PlayerBookMarks( bookmarks = bookmarks, - trackCurrentTime = { trackData.current }, - bookMarkState = bookMarkState, + trackCurrentTime = { trackData().current }, + showCreateDialog = bookMarkState.showDialog, + textFieldState = bookMarkState.textValue, + isBookmarkUpdate = bookMarkState.isUpdate, onBookmarkEvent = onBookmarkEvent, modifier = modifier.fillMaxWidth() ) From 0a3bad61532682a689af404b3c6df866ac305350 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Wed, 29 Oct 2025 18:03:25 +0530 Subject: [PATCH 08/25] Corrections in player in both player and editor First PlayerMetaData.kt now doesn't provide playing info, the playing data can be received through AudioFilePlayer.isPlaying In PlayerTrackDataFlow.kt also checking the timeline for any changes, thus when the media is loaded the timeline is prepared Player other than isplaying info we may need buffering info too combining them PlayerPlayState.kt PlayerIsPlayingFlow.kt provides flow for both isPlaying and PlayerPlayState.kt As media controller is also a player following the same principle with AudioFilePlayer.kt the AudioFilePlayerImpl.kt controls the player and its controlled with MediaControllerProvider.kt Included AudioMetadataRetriever.kt which maybe used in later future IN EditableAudioPlayerImpl.kt a few corrections made here and there MutexExt.kt provides a mutex extension to manage locks --- core/utils/build.gradle.kts | 1 + .../java/com/eva/utils/LocalTimeFormats.kt | 8 + .../src/main/java/com/eva/utils/MutexExt.kt | 22 ++ .../editor/data/EditableAudioPlayerImpl.kt | 372 ++++++++---------- .../exceptions/InvalidPlayerException.kt | 3 + .../player/data/AudioMetadataRetrieverImpl.kt | 35 ++ .../player/data/MediaControllerProvider.kt | 126 ------ .../data/{ => player}/AudioFilePlayerImpl.kt | 175 ++++---- .../{ => player}/AudioFilePlayerListener.kt | 72 +--- .../data/player/MediaControllerProvider.kt | 168 ++++++++ .../player/data/util/PlayerIsPlayingFlow.kt | 58 ++- .../player/data/util/PlayerTrackDataFlow.kt | 170 ++++---- .../com/eva/player/domain/AudioFilePlayer.kt | 41 +- .../player/domain/AudioMetadataRetriever.kt | 9 + .../eva/player/domain/model/PlayerMetaData.kt | 11 +- .../player/domain/model/PlayerPlayState.kt | 31 ++ .../eva/feature_player/AudioPlayerRoute.kt | 2 + .../eva/feature_player/AudioPlayerScreen.kt | 2 + .../composable/AudioPlayerActions.kt | 3 +- .../composable/AudioPlayerScreenContent.kt | 2 + .../composable/PlayerActionsAndSlider.kt | 5 +- .../viewmodel/AudioPlayerViewModel.kt | 59 ++- 22 files changed, 764 insertions(+), 611 deletions(-) create mode 100644 core/utils/src/main/java/com/eva/utils/MutexExt.kt create mode 100644 data/editor/src/main/java/com/eva/editor/domain/exceptions/InvalidPlayerException.kt create mode 100644 data/player/src/main/java/com/eva/player/data/AudioMetadataRetrieverImpl.kt delete mode 100644 data/player/src/main/java/com/eva/player/data/MediaControllerProvider.kt rename data/player/src/main/java/com/eva/player/data/{ => player}/AudioFilePlayerImpl.kt (53%) rename data/player/src/main/java/com/eva/player/data/{ => player}/AudioFilePlayerListener.kt (56%) create mode 100644 data/player/src/main/java/com/eva/player/data/player/MediaControllerProvider.kt create mode 100644 data/player/src/main/java/com/eva/player/domain/AudioMetadataRetriever.kt create mode 100644 data/player/src/main/java/com/eva/player/domain/model/PlayerPlayState.kt diff --git a/core/utils/build.gradle.kts b/core/utils/build.gradle.kts index 3708fc73..b95e32ac 100644 --- a/core/utils/build.gradle.kts +++ b/core/utils/build.gradle.kts @@ -14,6 +14,7 @@ kotlin { } dependencies { + implementation(libs.kotlinx.coroutines.core) // kotlinx datetime api(libs.kotlinx.datetime) api(libs.kotlinx.collections.immutable) diff --git a/core/utils/src/main/java/com/eva/utils/LocalTimeFormats.kt b/core/utils/src/main/java/com/eva/utils/LocalTimeFormats.kt index 189f5c96..2596bd5e 100644 --- a/core/utils/src/main/java/com/eva/utils/LocalTimeFormats.kt +++ b/core/utils/src/main/java/com/eva/utils/LocalTimeFormats.kt @@ -55,6 +55,14 @@ object LocalTimeFormats { second() } + val LOCALTIME_FORMAT_HH_MM_SS = LocalTime.Format { + hour() + char(':') + minute() + char(':') + second() + } + val RECORDING_RECORD_TIME_FORMAT = LocalDateTime.Format { day(padding = Padding.ZERO) char(' ') diff --git a/core/utils/src/main/java/com/eva/utils/MutexExt.kt b/core/utils/src/main/java/com/eva/utils/MutexExt.kt new file mode 100644 index 00000000..ed8815c8 --- /dev/null +++ b/core/utils/src/main/java/com/eva/utils/MutexExt.kt @@ -0,0 +1,22 @@ +package com.eva.utils + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class OwnersHoldLockException : + Exception("Owner is holding the lock cannot perform operation") + +suspend inline fun Mutex.tryWithLock( + owner: Any, + action: () -> T, +): Result { + if (holdsLock(owner)) return Result.failure(OwnersHoldLockException()) + + return withLock(owner = owner) { + try { + Result.success(action()) + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/data/editor/src/main/java/com/eva/editor/data/EditableAudioPlayerImpl.kt b/data/editor/src/main/java/com/eva/editor/data/EditableAudioPlayerImpl.kt index b8f9c632..71909438 100644 --- a/data/editor/src/main/java/com/eva/editor/data/EditableAudioPlayerImpl.kt +++ b/data/editor/src/main/java/com/eva/editor/data/EditableAudioPlayerImpl.kt @@ -1,42 +1,40 @@ package com.eva.editor.data -import android.content.Context import android.util.Log import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.ConcatenatingMediaSource2 -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import androidx.media3.exoplayer.source.MediaSource import com.eva.editor.domain.AudioConfigToActionList +import com.eva.editor.domain.AudioConfigsList import com.eva.editor.domain.EditorComposer import com.eva.editor.domain.SimpleAudioPlayer import com.eva.editor.domain.exceptions.AudioClipException +import com.eva.editor.domain.exceptions.InvalidPlayerException import com.eva.editor.domain.model.AudioClipConfig import com.eva.player.data.util.computeIsPlayerPlaying import com.eva.player.data.util.computePlayerTrackData import com.eva.player.data.util.isMediaItemChange +import com.eva.player.di.MediaSourceFactory import com.eva.player.domain.model.PlayerTrackData import com.eva.recordings.domain.models.AudioFileModel +import com.eva.utils.tryWithLock import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds -private const val TAG = "EDITABLE_ITEM_PLAYER" +private const val TAG = "EDITOR_AUDIO_PLAYER" internal class EditableAudioPlayerImpl( private val player: Player, - private val sourceFactory: MediaSource.Factory + private val sourceFactory: MediaSourceFactory, ) : SimpleAudioPlayer { - constructor(player: Player, context: Context) : this(player, DefaultMediaSourceFactory(context)) - private val _lock = Mutex() override val isPlaying: Flow @@ -45,7 +43,6 @@ internal class EditableAudioPlayerImpl( override val isMediaItemChanged: Flow get() = player.isMediaItemChange() - @OptIn(ExperimentalCoroutinesApi::class) override val trackInfoAsFlow: Flow get() = player.computePlayerTrackData() @@ -64,22 +61,25 @@ internal class EditableAudioPlayerImpl( } override suspend fun prepareAudioFile(audio: AudioFileModel) { - val command = player.isCommandAvailable(Player.COMMAND_SET_MEDIA_ITEM) - if (!command) return - - return _lock.checkLockAndPerformOperation( - action = { - // add media item - val mediaItem = MediaItem.fromUri(audio.fileUri) - player.setMediaItem(mediaItem) - - if (player.playbackState == Player.STATE_IDLE) { - player.prepare() - Log.d(TAG, "PLAYER PREPARED AND READY TO PLAY AUDIO") - player.playWhenReady = false - } - }, - ) + val commands = player.isCommandAvailable(Player.COMMAND_SET_MEDIA_ITEM) && + player.isCommandAvailable(Player.COMMAND_PLAY_PAUSE) + if (!commands) { + Log.i(TAG, "MISSING COMMANDS") + return + } + + _lock.tryWithLock(owner = this) { + // add media item + val mediaItem = MediaItem.fromUri(audio.fileUri) + + player.setMediaItem(mediaItem) + + if (player.playbackState == Player.STATE_IDLE) { + player.prepare() + Log.d(TAG, "PLAYER PREPARED AND READY TO PLAY AUDIO") + player.playWhenReady = false + } + } } override suspend fun cropMediaPortion(audio: AudioFileModel, config: AudioClipConfig) @@ -87,31 +87,30 @@ internal class EditableAudioPlayerImpl( if (!config.validate(audio.duration)) return Result.failure(AudioClipException()) - return _lock.runOtherwiseCancelIfLocked( - action = { - val mediaItem = player.currentMediaItem ?: MediaItem.fromUri(audio.fileUri) - .buildUpon() - .setMediaId("${audio.id}") - .build() - - val clippingConfig = MediaItem.ClippingConfiguration.Builder() - .setStartPositionMs(config.start.inWholeMilliseconds) - .setEndPositionMs(config.end.inWholeMilliseconds) - .build() - - val metaData = mediaItem.mediaMetadata.buildUpon() - .setDurationMs(config.end.inWholeMilliseconds - config.start.inWholeMilliseconds) - .build() - - val clippedMediaItem = mediaItem.buildUpon() - .setClippingConfiguration(clippingConfig) - .setMediaMetadata(metaData) - .build() - Log.d(TAG, "CHANGING CURRENT MEDIA ITEM WITH CLIP CONFIG : $config") - // set the new clipped media and start position to 0 - player.setMediaItem(clippedMediaItem, 0L) - }, - ) + return _lock.tryWithLock(this) { + val mediaItem = player.currentMediaItem ?: MediaItem.fromUri(audio.fileUri) + .buildUpon() + .setMediaId("${audio.id}") + .build() + + val clippingConfig = MediaItem.ClippingConfiguration.Builder() + .setStartPositionMs(config.start.inWholeMilliseconds) + .setEndPositionMs(config.end.inWholeMilliseconds) + .build() + + val metaData = mediaItem.mediaMetadata.buildUpon() + .setDurationMs(config.end.inWholeMilliseconds - config.start.inWholeMilliseconds) + .build() + + val clippedMediaItem = mediaItem.buildUpon() + .setClippingConfiguration(clippingConfig) + .setMediaMetadata(metaData) + .build() + Log.d(TAG, "CHANGING CURRENT MEDIA ITEM WITH CLIP CONFIG : $config") + // set the new clipped media and start position to 0 + player.setMediaItem(clippedMediaItem, 0L) + + } } @UnstableApi @@ -120,52 +119,58 @@ internal class EditableAudioPlayerImpl( if (!config.validate(audio.duration)) return Result.failure(AudioClipException()) - return _lock.runOtherwiseCancelIfLocked( - action = { - val mediaItem = player.currentMediaItem ?: MediaItem.fromUri(audio.fileUri) - .buildUpon() - .setMediaId("${audio.id}") - .build() - - val totalDurationInMs = mediaItem.mediaMetadata.durationMs - ?: audio.duration.inWholeMilliseconds - - val firstPartClip = mediaItem.buildUpon() - .setClippingConfiguration( - MediaItem.ClippingConfiguration.Builder() - .setStartPositionMs(0) - .setEndPositionMs(config.start.inWholeMilliseconds) - .build() - ).build() - - val secondClip = mediaItem.buildUpon() - .setClippingConfiguration( - MediaItem.ClippingConfiguration.Builder() - .setStartPositionMs(config.end.inWholeMilliseconds) - .setEndPositionMs(totalDurationInMs) - .build() - ).build() - - val concatItemDuration = firstPartClip.clippingConfiguration.clipDuration + - secondClip.clippingConfiguration.clipDuration - - Log.d(TAG, "CONCAT ITEM DURATION :$concatItemDuration") - - val concatMetaData = mediaItem.mediaMetadata.buildUpon() - .setDurationMs(concatItemDuration.inWholeMilliseconds).build() - - val concatSources = ConcatenatingMediaSource2.Builder() - .setMediaItem(mediaItem.buildUpon().setMediaMetadata(concatMetaData).build()) - .setMediaSourceFactory(sourceFactory) - .add(firstPartClip) - .add(secondClip) - .build() - - Log.d(TAG, "CHANGING CURRENT MEDIA ITEM WITH CUT :${concatSources.mediaItem}") - // only allow this if this is a exoplayer instance - (player as? ExoPlayer)?.setMediaSource(concatSources, 0L) - }, - ) + return _lock.tryWithLock(owner = this) { + val mediaItem = player.currentMediaItem ?: MediaItem.fromUri(audio.fileUri) + .buildUpon() + .setMediaId("${audio.id}") + .build() + + val totalDurationInMs = mediaItem.mediaMetadata.durationMs + ?: audio.duration.inWholeMilliseconds + + val firstPartClip = mediaItem.buildUpon() + .setClippingConfiguration( + MediaItem.ClippingConfiguration.Builder() + .setStartPositionMs(0) + .setEndPositionMs(config.start.inWholeMilliseconds) + .build() + ).build() + + val secondClip = mediaItem.buildUpon() + .setClippingConfiguration( + MediaItem.ClippingConfiguration.Builder() + .setStartPositionMs(config.end.inWholeMilliseconds) + .setEndPositionMs(totalDurationInMs) + .build() + ).build() + + val concatItemDuration = firstPartClip.clippingConfiguration.clipDuration + + secondClip.clippingConfiguration.clipDuration + + Log.d(TAG, "CONCAT ITEM DURATION :$concatItemDuration") + + val concatMetaData = mediaItem.mediaMetadata.buildUpon() + .setDurationMs(concatItemDuration.inWholeMilliseconds) + .build() + + val concatMediaItem = mediaItem.buildUpon() + .setMediaMetadata(concatMetaData) + .build() + + val concatSources = ConcatenatingMediaSource2.Builder() + .setMediaItem(concatMediaItem) + .setMediaSourceFactory(sourceFactory) + .add(firstPartClip) + .add(secondClip) + .build() + + Log.d(TAG, "CHANGING CURRENT MEDIA ITEM WITH CUT :${concatSources.mediaItem}") + + // finally play the concatenated source + val exoPlayer = (player as? ExoPlayer) + ?: return Result.failure(InvalidPlayerException()) + exoPlayer.setMediaSource(concatSources, 0L) + } } @UnstableApi @@ -173,58 +178,52 @@ internal class EditableAudioPlayerImpl( audio: AudioFileModel, configs: AudioConfigToActionList ): Result { - return _lock.runOtherwiseCancelIfLocked( - action = { - val composition = withContext(Dispatchers.Default) { - EditorComposer.applyLogicalEditSequence(audio.duration, configs) - } - - val mediaItem = player.currentMediaItem ?: MediaItem.fromUri(audio.fileUri) - .buildUpon() - .setMediaId("${audio.id}") - .build() - - val message = buildString { - composition.forEach { config -> - append("${config.start}--${config.end}") - } - } - Log.d(TAG,"CLIPPING :$message") - - val allClippedMedia = composition.map { config -> - mediaItem.buildUpon() - .setClippingConfiguration( - MediaItem.ClippingConfiguration.Builder() - .setStartPositionMs(config.start.inWholeMilliseconds) - .setEndPositionMs(config.end.inWholeMilliseconds) - .build() - ).build() - } - - val totalDurationInMicroSeconds = composition.sumOf { config -> - val diff = config.end - config.start - val duration = if (diff.inWholeMicroseconds > 0L) diff - else 0.milliseconds - duration.inWholeMilliseconds - } - - val concatMetaData = mediaItem.mediaMetadata.buildUpon() - .setDurationMs(totalDurationInMicroSeconds).build() - - val concatSourcesBuilder = ConcatenatingMediaSource2.Builder() - .setMediaItem(mediaItem.buildUpon().setMediaMetadata(concatMetaData).build()) - .setMediaSourceFactory(sourceFactory) - - allClippedMedia.forEach { concatSourcesBuilder.add(it) } - - val finalSource = concatSourcesBuilder.build() - // finally play the concatenated source - (player as? ExoPlayer)?.setMediaSource(finalSource, 0L) - }, - ) + return _lock.tryWithLock(owner = this) { - } + val composition = withContext(Dispatchers.Default) { + EditorComposer.applyLogicalEditSequence(audio.duration, configs) + } + + val mediaItem = player.currentMediaItem ?: MediaItem.fromUri(audio.fileUri) + .buildUpon() + .setMediaId("${audio.id}") + .build() + + Log.d(TAG, "CLIPPING CONTENT") + Log.d(TAG, composition.joinToString("|")) + + val totalDurationInMicroSeconds = composition.sumOf { config -> + val diff = config.end - config.start + val duration = if (diff.inWholeMicroseconds > 0L) diff + else 0.milliseconds + duration.inWholeMilliseconds + } + + val concatMetaData = mediaItem.mediaMetadata + .buildUpon() + .setDurationMs(totalDurationInMicroSeconds) + .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) + .build() + + val concatMediaItem = mediaItem.buildUpon() + .setMediaMetadata(concatMetaData) + .build() + val concatSourcesBuilder = ConcatenatingMediaSource2.Builder() + .setMediaItem(concatMediaItem) + .setMediaSourceFactory(sourceFactory) + + val clippedMediaItems = composition.mapToMediaItem(mediaItem) + clippedMediaItems.forEach { concatSourcesBuilder.add(it) } + + val finalSource = concatSourcesBuilder.build() + + val exoPlayer = (player as? ExoPlayer) + ?: return Result.failure(InvalidPlayerException()) + // finally play the concatenated source + exoPlayer.setMediaSource(finalSource, 0L) + } + } override suspend fun pausePlayer() { val command = player.isCommandAvailable(Player.COMMAND_PLAY_PAUSE) @@ -232,12 +231,10 @@ internal class EditableAudioPlayerImpl( Log.w(TAG, "PLAYER PLAY PAUSE COMMAND NOT FOUND") return } - return _lock.checkLockAndPerformOperation( - action = { - player.pause() - Log.d(TAG, "PLAYER PAUSED") - }, - ) + _lock.tryWithLock(this) { + player.pause() + Log.d(TAG, "PLAYER PAUSED") + } } override suspend fun startOrResumePlayer() { @@ -246,62 +243,39 @@ internal class EditableAudioPlayerImpl( Log.w(TAG, "PLAYER PLAY PAUSE COMMAND NOT FOUND") return } - return _lock.checkLockAndPerformOperation( - action = { - player.play() - Log.d(TAG, "PLAYER RESUMED") - }, - ) + _lock.tryWithLock(owner = this) { + player.play() + Log.d(TAG, "PLAYER RESUMED") + } } override suspend fun stopPlayer() { - return _lock.checkLockAndPerformOperation( - action = { - player.stop() - Log.d(TAG, "PLAYER STOPPED AND RESET") - }, - ) + _lock.tryWithLock(owner = this) { + player.stop() + Log.d(TAG, "PLAYER STOPPED ") + } } override fun cleanUp() { + if (player.isCommandAvailable(Player.COMMAND_RELEASE)) { + Log.d(TAG, "RELEASING THE PLAYER") + player.release() + } + Log.d(TAG, "CLEARING MEDIA ITEMS") player.clearMediaItems() } - private suspend inline fun Mutex.checkLockAndPerformOperation( - action: () -> Unit, - onError: (Exception) -> Unit = {}, - ) { - if (holdsLock(this)) { - Log.d(TAG, "CANNOT PERFORM OPERATION") - return - } - withLock { - try { - action() - } catch (e: Exception) { - Log.e(TAG, "SOME ERROR", e) - onError(e) - } - } - } + private fun AudioConfigsList.mapToMediaItem(mediaItem: MediaItem): List { + return map { config -> - private suspend inline fun Mutex.runOtherwiseCancelIfLocked( - action: () -> Unit, - onError: (Exception) -> Unit = {}, - ): Result { - if (holdsLock(this)) { - Log.d(TAG, "CANNOT PERFORM OPERATION") - return Result.failure(Exception("Cannot perform operation")) - } - return withLock { - try { - action() - Result.success(Unit) - } catch (e: Exception) { - Log.e(TAG, "SOME ERROR", e) - onError(e) - Result.failure(e) - } + val clipConfig = MediaItem.ClippingConfiguration.Builder() + .setStartPositionMs(config.start.inWholeMilliseconds) + .setEndPositionMs(config.end.inWholeMilliseconds) + .build() + + mediaItem.buildUpon() + .setClippingConfiguration(clipConfig) + .build() } } diff --git a/data/editor/src/main/java/com/eva/editor/domain/exceptions/InvalidPlayerException.kt b/data/editor/src/main/java/com/eva/editor/domain/exceptions/InvalidPlayerException.kt new file mode 100644 index 00000000..43d996fb --- /dev/null +++ b/data/editor/src/main/java/com/eva/editor/domain/exceptions/InvalidPlayerException.kt @@ -0,0 +1,3 @@ +package com.eva.editor.domain.exceptions + +class InvalidPlayerException : Exception("Required a exoplayer instance to continue") \ No newline at end of file diff --git a/data/player/src/main/java/com/eva/player/data/AudioMetadataRetrieverImpl.kt b/data/player/src/main/java/com/eva/player/data/AudioMetadataRetrieverImpl.kt new file mode 100644 index 00000000..6a70dde3 --- /dev/null +++ b/data/player/src/main/java/com/eva/player/data/AudioMetadataRetrieverImpl.kt @@ -0,0 +1,35 @@ +package com.eva.player.data + +import android.content.Context +import androidx.concurrent.futures.await +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.MetadataRetriever +import androidx.media3.exoplayer.source.MediaSource +import com.eva.player.domain.AudioMetadataRetriever +import com.eva.recordings.domain.models.AudioFileModel +import kotlin.time.Duration +import kotlin.time.Duration.Companion.microseconds +import kotlin.time.Duration.Companion.seconds + +@UnstableApi +internal class AudioMetadataRetrieverImpl( + private val context: Context, + private val mediaSource: MediaSource.Factory +) : AudioMetadataRetriever { + + override suspend fun retrieveAudioDuration(model: AudioFileModel): Duration? { + return try { + val mediaItem = MediaItem.fromUri(model.fileUri) + + val metadata = MetadataRetriever.Builder(context, mediaItem) + .setMediaSourceFactory(mediaSource) + .build() + // returns microseconds long + val microSeconds = metadata.use { retriever -> retriever.retrieveDurationUs().await() } + microSeconds.microseconds + } catch (_: Exception) { + 0.seconds + } + } +} \ No newline at end of file diff --git a/data/player/src/main/java/com/eva/player/data/MediaControllerProvider.kt b/data/player/src/main/java/com/eva/player/data/MediaControllerProvider.kt deleted file mode 100644 index 6ad4c6a0..00000000 --- a/data/player/src/main/java/com/eva/player/data/MediaControllerProvider.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.eva.player.data - -import android.content.ComponentName -import android.content.Context -import android.util.Log -import androidx.concurrent.futures.await -import androidx.core.os.bundleOf -import androidx.media3.common.util.UnstableApi -import androidx.media3.session.MediaController -import androidx.media3.session.SessionError -import androidx.media3.session.SessionToken -import com.eva.player.data.service.MediaPlayerService -import com.eva.player.domain.AudioFilePlayer -import com.eva.player.domain.model.PlayerMetaData -import com.eva.player.domain.model.PlayerTrackData -import com.eva.recordings.domain.models.AudioFileModel -import com.eva.utils.Resource -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.withContext - -private const val TAG = "MEDIA_CONTROLLER_PROVIDER" - -@OptIn(ExperimentalCoroutinesApi::class) -class MediaControllerProvider(private val context: Context) { - - private var _controller: MediaController? = null - private var _player: AudioFilePlayer? = null - - private val _isConnected = MutableStateFlow(false) - val isControllerConnected: StateFlow - get() = _isConnected - - val trackInfoAsFlow: Flow - get() = _isConnected.filter { it } - .flatMapLatest { _player?.trackInfoAsFlow ?: emptyFlow() } - - val playerMetaDataFlow: Flow - get() = _isConnected.filter { it } - .flatMapLatest { _player?.playerMetaDataFlow ?: emptyFlow() } - - val player: AudioFilePlayer? - get() = _player - - @androidx.annotation.OptIn(UnstableApi::class) - private val controllerListener = object : MediaController.Listener { - - override fun onDisconnected(controller: MediaController) { - super.onDisconnected(controller) - Log.i(TAG, "MEDIA CONTROLLER DISCONNECTED") - // update is connected - _isConnected.update { controller.isConnected } - // clear the player - _player?.cleanUp() - _player = null - } - - override fun onError(controller: MediaController, sessionError: SessionError) { - super.onError(controller, sessionError) - Log.e(TAG, "MEDIA CONTROLLER ERROR :${sessionError.message}") - } - } - - - suspend fun prepareController(audioId: Long) { - - val sessionExtras = bundleOf( - MediaPlayerService.PLAYER_AUDIO_FILE_ID_KEY to audioId - ) - - val sessionToken = SessionToken( - context, - ComponentName(context, MediaPlayerService::class.java) - ) - try { - Log.d(TAG, "PREPARING THE PLAYER") - // prepare the controller future - _controller = withContext(Dispatchers.Main.immediate) { - MediaController.Builder(context, sessionToken) - .setConnectionHints(sessionExtras) - .setListener(controllerListener) - .buildAsync() - .await() - } - val controller = _controller ?: return - Log.i(TAG, "CONTROLLER CREATED") - _player = controller.appPlayer - _isConnected.update { controller.isConnected } - - } catch (e: Exception) { - Log.e(TAG, "FAILED TO RESOLVE FUTURE", e) - e.printStackTrace() - } - } - - fun releaseController() { - Log.d(TAG, "CLEARING UP CONTROLLER") - // release the controller if not released - _controller?.release() - _controller = null - // perform player cleanup - _player?.cleanUp() - _player = null - } - - suspend fun preparePlayer(audio: AudioFileModel): Resource? { - if (_controller == null) { - Log.d(TAG, "CONTROLLER IS NOT SET") - return null - } - Log.d(TAG, "PREPARING PLAYER") - val results = player?.preparePlayer(audio) - return results - } - -} - -private val MediaController.appPlayer: AudioFilePlayer - get() = AudioFilePlayerImpl(this) \ No newline at end of file diff --git a/data/player/src/main/java/com/eva/player/data/AudioFilePlayerImpl.kt b/data/player/src/main/java/com/eva/player/data/player/AudioFilePlayerImpl.kt similarity index 53% rename from data/player/src/main/java/com/eva/player/data/AudioFilePlayerImpl.kt rename to data/player/src/main/java/com/eva/player/data/player/AudioFilePlayerImpl.kt index d38edb23..b4f81501 100644 --- a/data/player/src/main/java/com/eva/player/data/AudioFilePlayerImpl.kt +++ b/data/player/src/main/java/com/eva/player/data/player/AudioFilePlayerImpl.kt @@ -1,18 +1,19 @@ -package com.eva.player.data +package com.eva.player.data.player import android.util.Log import androidx.media3.common.Player +import com.eva.player.data.util.computeIsPlayerPlaying import com.eva.player.data.util.computePlayerTrackData import com.eva.player.data.util.toMediaItem import com.eva.player.domain.AudioFilePlayer -import com.eva.player.domain.exceptions.CannotStartPlayerException import com.eva.player.domain.exceptions.SetPlayerCommandNotFound import com.eva.player.domain.model.PlayerMetaData import com.eva.player.domain.model.PlayerPlayBackSpeed import com.eva.player.domain.model.PlayerTrackData import com.eva.recordings.domain.models.AudioFileModel -import com.eva.utils.Resource +import com.eva.utils.tryWithLock import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.sync.Mutex import kotlin.time.Duration @@ -20,7 +21,8 @@ private const val LOGGER = "AUDIO_FILE_PLAYER" internal class AudioFilePlayerImpl(private val player: Player) : AudioFilePlayer { - private val _listener = AudioFilePlayerListener(player) + private val _listener by lazy { AudioFilePlayerListener(player) } + private val _lock = Mutex() override val playerMetaDataFlow: Flow get() = _listener.playerMetaDataFlow @@ -28,7 +30,11 @@ internal class AudioFilePlayerImpl(private val player: Player) : AudioFilePlayer override val trackInfoAsFlow: Flow get() = player.computePlayerTrackData() - private val lock = Mutex() + override val isPlaying: Flow + get() = player.computeIsPlayerPlaying() + + override val isControllerReady: Flow + get() = flowOf(false) override fun setPlayBackSpeed(playBackSpeed: PlayerPlayBackSpeed) { val command = player.isCommandAvailable(Player.COMMAND_SET_SPEED_AND_PITCH) @@ -59,50 +65,39 @@ internal class AudioFilePlayerImpl(private val player: Player) : AudioFilePlayer player.volume = if (isStreamMuted) 1f else 0f } - override suspend fun preparePlayer(audio: AudioFileModel): Resource { + override suspend fun preparePlayer(audio: AudioFileModel): Result { val command = player.isCommandAvailable(Player.COMMAND_SET_MEDIA_ITEM) - if (!command) { - // command is not available - return Resource.Error(SetPlayerCommandNotFound()) - } - - if (lock.holdsLock(this)) { - Log.d(LOGGER, "METHOD IS LOCKED") - // cannot start as the method is locked - return Resource.Error(CannotStartPlayerException()) - } - // locking this w.r.t to this class - lock.lock(this) - try { - player.addListener(_listener) - Log.d(LOGGER, "PLAYER LISTENER ADDED") - // current mediaId is same as the file audioId - val areMediaIdSame = player.currentMediaItem?.mediaId == audio.id.toString() - - if (areMediaIdSame) { - Log.d(LOGGER, "UPDATING PLAYER PARAMETERS") - // player media item is set so need to update the parameters - _listener.updateStateFromCurrentPlayerConfig() - } else { - Log.i(LOGGER, "MEDIA ITEM FOR AUDIO_ID: ${audio.id}") - // normally adding the audio file to the player - addAudioItemToPlayer(audio) - } - // prepare the player if the state is idle - if (player.playbackState == Player.STATE_IDLE) { - player.prepare() - Log.d(LOGGER, "PLAYER PREPARED AND READY TO PLAY AUDIO") - player.playWhenReady = true + if (!command) return Result.failure(SetPlayerCommandNotFound()) + + return _lock.tryWithLock(this) { + try { + player.addListener(_listener) + Log.d(LOGGER, "PLAYER LISTENER ADDED") + // current mediaId is same as the file audioId + val areMediaIdSame = player.currentMediaItem?.mediaId == audio.id.toString() + + if (areMediaIdSame) { + Log.d(LOGGER, "UPDATING PLAYER PARAMETERS") + // player media item is set so need to update the parameters + _listener.updateStateFromCurrentPlayerConfig() + } else { + Log.i(LOGGER, "MEDIA ITEM FOR AUDIO_ID: ${audio.id}") + // normally adding the audio file to the player + addAudioItemToPlayer(audio) + } + // prepare the player if the state is idle + if (player.playbackState == Player.STATE_IDLE) { + player.prepare() + Log.d(LOGGER, "PLAYER PREPARED AND READY TO PLAY AUDIO") + } + player.playbackState == Player.STATE_READY + } catch (e: IllegalStateException) { + Log.e(LOGGER, "PLAYER IS NOT CONFIGURED PROPERLY", e) + return Result.failure(e) + } catch (e: Exception) { + e.printStackTrace() + return Result.failure(e) } - return Resource.Success(true) - } catch (e: IllegalStateException) { - Log.e(LOGGER, "PLAYER IS NOT CONFIGURED PROPERLY", e) - return Resource.Error(e, message = "PLAYER IS NOT CONFIGURED PROPERLY") - } catch (e: Exception) { - e.printStackTrace() - return Resource.Error(e) - } finally { - lock.unlock(this) } } @@ -113,18 +108,13 @@ internal class AudioFilePlayerImpl(private val player: Player) : AudioFilePlayer Log.w(LOGGER, "PLAYER PLAY PAUSE COMMAND NOT FOUND") return } - if (lock.holdsLock(this)) { - Log.d(LOGGER, "OTHER FUNCTION IS HOLDING LOCK CANNOT PERFORM OPERATION") - return - } - lock.lock(this) - try { - player.pause() - Log.d(LOGGER, "PLAYER PAUSED") - } catch (e: IllegalStateException) { - Log.e(LOGGER, "PLAYER IS NOT CONFIGURED", e) - } finally { - lock.unlock(this) + _lock.tryWithLock(this) { + try { + player.pause() + Log.d(LOGGER, "PLAYER PAUSED") + } catch (e: IllegalStateException) { + Log.e(LOGGER, "PLAYER IS NOT CONFIGURED", e) + } } } @@ -134,37 +124,29 @@ internal class AudioFilePlayerImpl(private val player: Player) : AudioFilePlayer Log.w(LOGGER, "PLAYER PLAY PAUSE COMMAND NOT FOUND") return } - if (lock.holdsLock(this)) { - Log.d(LOGGER, "OTHER FUNCTION IS HOLDING LOCK CANNOT PERFORM OPERATION") - return - } - lock.lock(this) - try { - player.play() - Log.d(LOGGER, "PLAYER RESUMED") - } catch (e: IllegalStateException) { - Log.e(LOGGER, "PLAYER IS NOT CONFIGURED", e) - } finally { - lock.unlock(this) + _lock.tryWithLock(this) { + try { + player.play() + Log.d(LOGGER, "PLAYER RESUMED") + } catch (e: IllegalStateException) { + Log.e(LOGGER, "PLAYER IS NOT CONFIGURED", e) + } } } override suspend fun stopPlayer() { - if (lock.holdsLock(this)) { - Log.d(LOGGER, "CANNOT STOP PLAYER ITS LOCKED") - return - } + val command = player.isCommandAvailable(Player.COMMAND_STOP) + if (!command) return // locking this w.r.t to this class - lock.lock(this) - try { - player.stop() - Log.d(LOGGER, "PLAYER STOPPED AND RESET") - } catch (e: IllegalStateException) { - Log.e(LOGGER, "PLAYER MAY NOT BE CONFIGURED", e) - } catch (e: Exception) { - e.printStackTrace() - } finally { - lock.unlock(this) + _lock.tryWithLock(this) { + try { + player.stop() + Log.d(LOGGER, "PLAYER STOPPED AND RESET") + } catch (e: IllegalStateException) { + Log.e(LOGGER, "PLAYER MAY NOT BE CONFIGURED", e) + } catch (e: Exception) { + e.printStackTrace() + } } } @@ -174,11 +156,10 @@ internal class AudioFilePlayerImpl(private val player: Player) : AudioFilePlayer Log.w(LOGGER, "PLAYER SEEK IN MEDIA COMMAND NOT FOUND") return } - val totalDuration = player.duration val changedDuration = duration.inWholeMilliseconds - if (changedDuration <= totalDuration) { + if (changedDuration in 0..player.duration) { Log.d(LOGGER, "SEEK POSITION $duration") - player.seekTo(duration.inWholeMilliseconds) + player.seekTo(changedDuration) } } @@ -191,19 +172,15 @@ internal class AudioFilePlayerImpl(private val player: Player) : AudioFilePlayer val amount = duration.inWholeMilliseconds.run { if (rewind) unaryMinus() else this } - val seekPosition = player.currentPosition + amount + val seekPosition = (player.currentPosition + amount) + .coerceIn(0..player.duration) - if (seekPosition >= player.duration) { - // seek to max duration - Log.d(LOGGER, "SEEK POSITION IS PLAYER DURATION") - player.seekTo(player.duration) - } else if (seekPosition < 0) { - Log.d(LOGGER, "SEEK POSITION IS LESSER THAN 0") - player.seekTo(0) - } else { - Log.d(LOGGER, "PLAYER POSITION CHANGED $seekPosition") - player.seekTo(seekPosition) + when { + seekPosition >= player.duration -> player.seekTo(player.duration) + seekPosition < 0 -> player.seekTo(0) + else -> player.seekTo(seekPosition) } + Log.d(LOGGER, "PLAYER SEEK TO $seekPosition") } override fun cleanUp() { diff --git a/data/player/src/main/java/com/eva/player/data/AudioFilePlayerListener.kt b/data/player/src/main/java/com/eva/player/data/player/AudioFilePlayerListener.kt similarity index 56% rename from data/player/src/main/java/com/eva/player/data/AudioFilePlayerListener.kt rename to data/player/src/main/java/com/eva/player/data/player/AudioFilePlayerListener.kt index 3269e7f3..2987802a 100644 --- a/data/player/src/main/java/com/eva/player/data/AudioFilePlayerListener.kt +++ b/data/player/src/main/java/com/eva/player/data/player/AudioFilePlayerListener.kt @@ -1,4 +1,4 @@ -package com.eva.player.data +package com.eva.player.data.player import android.util.Log import androidx.media3.common.PlaybackException @@ -7,12 +7,12 @@ import androidx.media3.common.Player import com.eva.player.domain.model.PlayerMetaData import com.eva.player.domain.model.PlayerPlayBackSpeed import com.eva.player.domain.model.PlayerState +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update -import kotlin.time.Duration.Companion.milliseconds private const val TAG = "AUDIO_PLAYER_LISTENER" @@ -22,53 +22,8 @@ internal class AudioFilePlayerListener(private val player: Player) : Player.List private val _playBackSpeed = MutableStateFlow(PlayerPlayBackSpeed.Normal) private val _isLooping = MutableStateFlow(false) private val _isStreamMuted = MutableStateFlow(false) - private val _isPlaying = MutableStateFlow(false) - private val _isSeeking = MutableStateFlow(false) - private val _userPlay = MutableStateFlow(false) - override fun onIsPlayingChanged(isPlaying: Boolean) { - // only updates to play or pause mode can be done both by the player or the user - _isPlaying.update { isPlaying } - Log.d(TAG, "PLAYER IS PLAYING :$isPlaying") - } - - override fun onPositionDiscontinuity( - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int - ) { - val message = buildString { - append("PLAYER IS SEEK ") - append("${oldPosition.positionMs.milliseconds}") - append(" -> ") - append("${newPosition.positionMs.milliseconds}") - } - - Log.d(TAG, message) - _isSeeking.update { reason == Player.DISCONTINUITY_REASON_SEEK } - } - - override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { - if (reason == Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) { - Log.d(TAG, "PLAY REQUESTED BY THE USER") - _userPlay.update { playWhenReady } - } - } - - override fun onPlaybackStateChanged(playbackState: Int) { - val newState = when (playbackState) { - Player.STATE_IDLE -> PlayerState.IDLE - Player.STATE_ENDED -> PlayerState.COMPLETED - Player.STATE_READY -> PlayerState.PLAYER_READY - else -> return - } - // only updates idle ready and ended - if (playbackState == Player.STATE_READY) { - _isSeeking.update { false } - } - _playerState.update { newState } - Log.d(TAG, "PLAYER STATE :$newState") - } + private val _errorsFlow = Channel(capacity = Channel.CONFLATED) override fun onRepeatModeChanged(repeatMode: Int) { val isLooping = repeatMode == Player.REPEAT_MODE_ONE @@ -91,27 +46,19 @@ internal class AudioFilePlayerListener(private val player: Player) : Player.List _isStreamMuted.update { volume == 0f } } - override fun onPlayerError(error: PlaybackException) { // need to check for exceptions + _errorsFlow.trySend(error) Log.e(TAG, error.message ?: "PLAYER_ERROR", error) } - private val _isPlayingOrSeeking = - combine(_userPlay, _isPlaying, _isSeeking) { playByUser, isPlaying, isSeeking -> - // play is dependent on user action then the player - playByUser && (isPlaying || isSeeking) - }.distinctUntilChanged() - val playerMetaDataFlow: Flow = combine( - _isPlayingOrSeeking, _playerState, _isLooping, _playBackSpeed, _isStreamMuted - ) { playing, state, repeat, speed, muted -> + ) { state, repeat, speed, muted -> PlayerMetaData( - isPlaying = playing, playerState = state, isRepeating = repeat, playBackSpeed = speed, @@ -119,6 +66,9 @@ internal class AudioFilePlayerListener(private val player: Player) : Player.List ) } + val errorFlow: Flow + get() = _errorsFlow.receiveAsFlow() + fun updateStateFromCurrentPlayerConfig() { Log.d(TAG, "UPDATING PLAYER CONFIG") // update the repeat mode @@ -137,11 +87,7 @@ internal class AudioFilePlayerListener(private val player: Player) : Player.List else -> return@update oldState } } - // update the playing state - _isPlaying.update { player.isPlaying } // update volume _isStreamMuted.update { player.volume == 0f } - // user is playing - _userPlay.update { player.playWhenReady } } } \ No newline at end of file diff --git a/data/player/src/main/java/com/eva/player/data/player/MediaControllerProvider.kt b/data/player/src/main/java/com/eva/player/data/player/MediaControllerProvider.kt new file mode 100644 index 00000000..2d23d655 --- /dev/null +++ b/data/player/src/main/java/com/eva/player/data/player/MediaControllerProvider.kt @@ -0,0 +1,168 @@ +package com.eva.player.data.player + +import android.content.ComponentName +import android.content.Context +import android.util.Log +import androidx.concurrent.futures.await +import androidx.core.os.bundleOf +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaController +import androidx.media3.session.SessionError +import androidx.media3.session.SessionToken +import com.eva.player.data.service.MediaPlayerService +import com.eva.player.domain.AudioFilePlayer +import com.eva.player.domain.model.PlayerMetaData +import com.eva.player.domain.model.PlayerPlayBackSpeed +import com.eva.player.domain.model.PlayerTrackData +import com.eva.recordings.domain.models.AudioFileModel +import com.eva.utils.tryWithLock +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withContext +import kotlin.time.Duration + +private const val TAG = "PLAYED_MEDIA_CONTROLLER" + +@OptIn(ExperimentalCoroutinesApi::class) +internal class MediaControllerProvider(private val context: Context) : AudioFilePlayer { + + @Volatile + private var _controller: MediaController? = null + private var _lock = Mutex() + + private val _playerFlow = MutableStateFlow(null) + private val _isConnected = MutableStateFlow(false) + + private val _currentPlayer: Flow = + combine(_isConnected, _playerFlow) { connected, player -> + if (connected && player != null) player else null + }.filterNotNull() + + private val player: AudioFilePlayer? + get() = _playerFlow.value + + override val trackInfoAsFlow: Flow + get() = _currentPlayer.flatMapLatest { player -> player.trackInfoAsFlow } + + override val playerMetaDataFlow: Flow + get() = _currentPlayer.flatMapLatest { player -> player.playerMetaDataFlow } + + override val isPlaying: Flow + get() = _currentPlayer.flatMapLatest { player -> player.isPlaying } + + override val isControllerReady: Flow + get() = _isConnected + + @androidx.annotation.OptIn(UnstableApi::class) + private val _controllerListener = object : MediaController.Listener { + + override fun onDisconnected(controller: MediaController) { + super.onDisconnected(controller) + Log.i(TAG, "MEDIA CONTROLLER DISCONNECTED") + // update is connected + _isConnected.update { false } + // clear the player + val oldInstance = _playerFlow.getAndUpdate { null } + oldInstance?.cleanUp() + } + + override fun onError(controller: MediaController, sessionError: SessionError) { + super.onError(controller, sessionError) + Log.e(TAG, "MEDIA CONTROLLER ERROR :${sessionError.message}") + } + } + + override suspend fun prepareController(audioId: Long) { + + val sessionExtras = bundleOf(MediaPlayerService.PLAYER_AUDIO_FILE_ID_KEY to audioId) + + val sessionToken = SessionToken( + context, + ComponentName(context, MediaPlayerService::class.java) + ) + _lock.tryWithLock(this) { + if (_controller != null) { + Log.d(TAG, "CONTROLLER IS ALREADY SET ") + return + } + try { + Log.d(TAG, "PREPARING THE PLAYER") + // prepare the controller future + withContext(Dispatchers.Main.immediate) { + MediaController.Builder(context, sessionToken) + .setConnectionHints(sessionExtras) + .setListener(_controllerListener) + .buildAsync() + .await() + .also { instance -> _controller = instance } + } + Log.i(TAG, "CONTROLLER CREATED") + // set the player instance + _isConnected.update { _controller?.isConnected ?: false } + _playerFlow.value = _controller?.let { player -> AudioFilePlayerImpl(player) } + } catch (e: Exception) { + Log.e(TAG, "FAILED TO RESOLVE FUTURE", e) + e.printStackTrace() + } + } + } + + override suspend fun preparePlayer(audio: AudioFileModel): Result { + if (_controller == null) { + Log.d(TAG, "CONTROLLER IS NOT SET") + return Result.failure(Exception("Controller is not set")) + } + Log.d(TAG, "PREPARING PLAYER") + return player?.preparePlayer(audio) ?: Result.failure(Exception("Player is not set")) + } + + override fun cleanUp() { + Log.d(TAG, "CLEARING UP CONTROLLER") + // release the controller if not released + _controller?.release() + _controller = null + // perform player cleanup + val oldInstance = _playerFlow.getAndUpdate { null } + oldInstance?.cleanUp() + } + + override fun onMuteDevice() { + player?.onMuteDevice() + } + + override fun onSeekDuration(duration: Duration) { + player?.onSeekDuration(duration) + } + + override fun seekPlayerByNDuration(duration: Duration, rewind: Boolean) { + player?.seekPlayerByNDuration(duration, rewind) + } + + override fun setPlayBackSpeed(playBackSpeed: PlayerPlayBackSpeed) { + player?.setPlayBackSpeed(playBackSpeed) + } + + override fun setPlayLooping(loop: Boolean) { + player?.setPlayLooping(loop) + } + + override suspend fun pausePlayer() { + player?.pausePlayer() + } + + override suspend fun startOrResumePlayer() { + player?.startOrResumePlayer() + } + + override suspend fun stopPlayer() { + player?.stopPlayer() + } +} \ No newline at end of file diff --git a/data/player/src/main/java/com/eva/player/data/util/PlayerIsPlayingFlow.kt b/data/player/src/main/java/com/eva/player/data/util/PlayerIsPlayingFlow.kt index 6e0f18d0..62bafc4c 100644 --- a/data/player/src/main/java/com/eva/player/data/util/PlayerIsPlayingFlow.kt +++ b/data/player/src/main/java/com/eva/player/data/util/PlayerIsPlayingFlow.kt @@ -3,29 +3,61 @@ package com.eva.player.data.util import android.util.Log import androidx.media3.common.MediaItem import androidx.media3.common.Player +import com.eva.player.domain.model.PlayerPlayState import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.map private const val TAG = "PLAYER_PLAYING_FLOW" -fun Player.computeIsPlayerPlaying(): Flow = callbackFlow { +fun Player.computePlayerPlayState(): Flow = callbackFlow { + var isSeeking = false // initially send a false - trySend(false) + trySend(PlayerPlayState.PAUSED) val listener = object : Player.Listener { + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + if (reason != Player.DISCONTINUITY_REASON_SEEK) return + isSeeking = true + } + override fun onIsPlayingChanged(isPlaying: Boolean) { - Log.d(TAG, "PLAYER IS PLAYING CHANGED") - launch { send(isPlaying) } + // skip is playing condition if the player is seeking + if (isSeeking) return + val state = if (isPlaying) PlayerPlayState.PLAYING else PlayerPlayState.PAUSED + Log.d(TAG, "PLAYER IS PLAYING CHANGED :$state") + trySend(state) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + Player.STATE_BUFFERING + val newState = when (playbackState) { + Player.STATE_BUFFERING -> PlayerPlayState.BUFFERING + Player.STATE_READY -> { + // after seek player is again ready + isSeeking = false + if (playWhenReady) PlayerPlayState.PLAYING else PlayerPlayState.PAUSED + } + + else -> PlayerPlayState.PAUSED + } + Log.d(TAG, "PLAY BACK STATE CHANGED :$newState") + trySend(newState) } override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { - Log.d(TAG, "PLAYER WHEN READY CHANGED $reason $playWhenReady") - launch { send(playWhenReady) } + if (reason != Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) return + val state = if (playWhenReady) PlayerPlayState.PLAYING else PlayerPlayState.PAUSED + Log.d(TAG, "PLAY WHEN READY CHANGED :$state") + trySend(state) } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { @@ -35,7 +67,13 @@ fun Player.computeIsPlayerPlaying(): Flow = callbackFlow { Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED ) // if its any of the reason set it to play when ready - if (reason in reasons) launch { send(playWhenReady) } + if (reason !in reasons) return + val newState = when (playbackState) { + Player.STATE_READY -> PlayerPlayState.PLAYING + else -> PlayerPlayState.PAUSED + } + Log.d(TAG, "MEDIA ITEM TRANSITIONS") + trySend(newState) } } // adding the listener @@ -43,3 +81,7 @@ fun Player.computeIsPlayerPlaying(): Flow = callbackFlow { // removing the listener awaitClose { removeListener(listener) } }.distinctUntilChanged() + +fun Player.computeIsPlayerPlaying(): Flow = + computePlayerPlayState().map { it == PlayerPlayState.PLAYING || it == PlayerPlayState.BUFFERING } + .distinctUntilChanged() diff --git a/data/player/src/main/java/com/eva/player/data/util/PlayerTrackDataFlow.kt b/data/player/src/main/java/com/eva/player/data/util/PlayerTrackDataFlow.kt index 8a032850..735eaa15 100644 --- a/data/player/src/main/java/com/eva/player/data/util/PlayerTrackDataFlow.kt +++ b/data/player/src/main/java/com/eva/player/data/util/PlayerTrackDataFlow.kt @@ -1,9 +1,11 @@ package com.eva.player.data.util import android.util.Log +import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.Player +import androidx.media3.common.Timeline import com.eva.player.domain.model.PlayerTrackData import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -22,103 +24,119 @@ import kotlin.time.Duration.Companion.seconds private const val TAG = "PLAYER_TRACK_DATA_FLOW" -fun Player.computePlayerTrackData(delayDuration: Duration = 100.milliseconds): Flow = - callbackFlow { +fun Player.computePlayerTrackData( + delayDuration: Duration = 100.milliseconds +): Flow = callbackFlow { - // first emission - launch { - val trackData = this@computePlayerTrackData.toTrackData() - if (trackData.allPositiveAndFinite) trySend(trackData) - } + // first emission + launch { + val trackData = this@computePlayerTrackData.toTrackData() + send(trackData) + } + + var job: Job? = null - var job: Job? = null + val listener = object : Player.Listener { - val listener = object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_READY || playbackState == Player.STATE_BUFFERING) { + launch { + val trackData = this@computePlayerTrackData.toTrackData() + send(trackData) + } + } + } - override fun onPlaybackStateChanged(playbackState: Int) { - if (playbackState == Player.STATE_READY) { - launch { + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + if (reason != Player.DISCONTINUITY_REASON_SEEK) return + // on seek update the current position to the final seek position + launch { + val newPosDuration = newPosition.positionMs.milliseconds + val trackData = this@computePlayerTrackData.toTrackData() + .copy(current = newPosDuration) + send(trackData) + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + // cancel the old coroutine + job?.cancel() + // if playing launch a new job to observe + job = launch(Dispatchers.Main) { + try { + // advertise data if its active and canLoop + while (isPlaying && isActive) { val trackData = this@computePlayerTrackData.toTrackData() - if (trackData.allPositiveAndFinite) trySend(trackData) + if (!trackData.allPositiveAndFinite) continue + send(trackData) + // create a delay + delay(delayDuration) } + } catch (_: CancellationException) { + Log.d(TAG, "CANNOT ADVERTISE DATA ANY MORE COROUTINE CANCELLED") + } catch (e: Exception) { + e.printStackTrace() } } + } - override fun onPositionDiscontinuity( - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + if (reason in arrayOf( + Player.MEDIA_ITEM_TRANSITION_REASON_AUTO, + Player.MEDIA_ITEM_TRANSITION_REASON_SEEK + ) ) { - if (reason != Player.DISCONTINUITY_REASON_SEEK) return - // on seek update the current position to the final seek position launch { - val newPosDuration = newPosition.positionMs.milliseconds val trackData = this@computePlayerTrackData.toTrackData() - .copy(current = newPosDuration) - if (trackData.allPositiveAndFinite) trySend(trackData) + send(trackData) } } - - override fun onIsPlayingChanged(isPlaying: Boolean) { - // cancel the old coroutine - job?.cancel() - // if playing launch a new job to observe - job = launch(Dispatchers.Default) { - try { - // advertise data if its active and canLoop - while (isPlaying && isActive) { - val trackData = this@computePlayerTrackData.toTrackData() - if (!trackData.allPositiveAndFinite) continue - trySend(trackData) - // create a delay - delay(delayDuration) - } - } catch (_: CancellationException) { - Log.d(TAG, "CANNOT ADVERTISE DATA ANY MORE COROUTINE CANCELLED") - } catch (e: Exception) { - e.printStackTrace() - } + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) { + val itemDuration = mediaItem?.mediaMetadata?.durationMs?.milliseconds ?: return + Log.d(TAG, "MEDIA ITEM TRANSITION NEW DURATION :$itemDuration") + launch { + val trackData = PlayerTrackData(0.seconds, itemDuration) + send(trackData) } } + } - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - if (reason in arrayOf( - Player.MEDIA_ITEM_TRANSITION_REASON_AUTO, - Player.MEDIA_ITEM_TRANSITION_REASON_SEEK - ) - ) { - launch { - val trackData = this@computePlayerTrackData.toTrackData() - if (trackData.allPositiveAndFinite) trySend(trackData) - } - } - if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) { - val itemDuration = mediaItem?.mediaMetadata?.durationMs?.milliseconds ?: return - Log.d(TAG, "MEDIA ITEM TRANSITION NEW DURATION :$itemDuration") - launch { - val trackData = PlayerTrackData(0.seconds, itemDuration) - if (trackData.allPositiveAndFinite) trySend(trackData) - } - } - } + override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { + super.onMediaMetadataChanged(mediaMetadata) + val itemDuration = mediaMetadata.durationMs?.milliseconds ?: return + Log.d(TAG, "MEDIA ITEM METADATA CHANGED NEW DURATION :$itemDuration") + } - override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { - val itemDuration = mediaMetadata.durationMs?.milliseconds ?: return - Log.d(TAG, "MEDIA ITEM METADATA CHANGED NEW DURATION :$itemDuration") + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) { + Log.d(TAG, "TIMELINE OF THE MEDIA CHANGED") + launch(Dispatchers.Main) { + val window = Timeline.Window() + timeline.getWindow(0, window) + val duration = window.durationMs.takeIf { it != C.TIME_UNSET } ?: return@launch + val trackData = PlayerTrackData(0.seconds, duration.milliseconds) + send(trackData) + } } } + } - // add the listener - Log.d(TAG, "LISTENER ADDED") - addListener(listener) + // add the listener + Log.d(TAG, "LISTENER ADDED") + addListener(listener) - //remove the listener - awaitClose { - job?.cancel() - Log.d(TAG, "REMOVING LISTENER") - removeListener(listener) - } - }.filter { it.allPositiveAndFinite } - .distinctUntilChanged() + //remove the listener + awaitClose { + job?.cancel() + Log.d(TAG, "REMOVING LISTENER") + removeListener(listener) + } +} + .filter { it.allPositiveAndFinite } + .distinctUntilChanged { old, new -> old.current == new.current } diff --git a/data/player/src/main/java/com/eva/player/domain/AudioFilePlayer.kt b/data/player/src/main/java/com/eva/player/domain/AudioFilePlayer.kt index a7b180a0..81a0102c 100644 --- a/data/player/src/main/java/com/eva/player/domain/AudioFilePlayer.kt +++ b/data/player/src/main/java/com/eva/player/domain/AudioFilePlayer.kt @@ -4,17 +4,40 @@ import com.eva.player.domain.model.PlayerMetaData import com.eva.player.domain.model.PlayerPlayBackSpeed import com.eva.player.domain.model.PlayerTrackData import com.eva.recordings.domain.models.AudioFileModel -import com.eva.utils.Resource import kotlinx.coroutines.flow.Flow import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds interface AudioFilePlayer { + /** + * Emits the current track's playback data, including the current position and total duration. + * This flow provides real-time updates as the media plays. + * + * @see PlayerTrackData + */ val trackInfoAsFlow: Flow + + /** + * Emits the current playing state (`true` if playing, `false` if paused or stopped). + * This provides a simple boolean flag for consumers who only need to know if playback is active. + */ + val isPlaying: Flow + + /** + * Emits a comprehensive snapshot of the player's metadata, including its state, + * playback speed, and repeat/mute settings. + * + * @see PlayerMetaData + */ val playerMetaDataFlow: Flow - fun onMuteDevice() + /** + * A flow that emits `true` when the underlying media controller is ready for use. + * For implementations that do not require an media controller, this should return an [Flow] false + * or a flow that immediately emits `true`. + */ + val isControllerReady: Flow /** * Controls the playback speed of the player @@ -35,12 +58,18 @@ interface AudioFilePlayer { */ fun onSeekDuration(duration: Duration) + /** + * Prepares the controller for the player + * @param audioId ID used by the player session + */ + suspend fun prepareController(audioId: Long) {} + /** * Prepares the player from a [AudioFileModel] * @param audio The audio model which will be used to play the recording - * @return [Resource.Success] indicating everything went well otherwise [Resource.Error] + * @return [Result] indicating player was prepared correctly */ - suspend fun preparePlayer(audio: AudioFileModel): Resource + suspend fun preparePlayer(audio: AudioFileModel): Result /** * Seeks the player by n [duration] forward or backward @@ -49,6 +78,10 @@ interface AudioFilePlayer { */ fun seekPlayerByNDuration(duration: Duration = 2.seconds, rewind: Boolean = false) + /** + * Mutes the player although the player work the player volume is set to zero + */ + fun onMuteDevice() /** * Pauses the ongoing play diff --git a/data/player/src/main/java/com/eva/player/domain/AudioMetadataRetriever.kt b/data/player/src/main/java/com/eva/player/domain/AudioMetadataRetriever.kt new file mode 100644 index 00000000..b77dfb9f --- /dev/null +++ b/data/player/src/main/java/com/eva/player/domain/AudioMetadataRetriever.kt @@ -0,0 +1,9 @@ +package com.eva.player.domain + +import com.eva.recordings.domain.models.AudioFileModel +import kotlin.time.Duration + +interface AudioMetadataRetriever { + + suspend fun retrieveAudioDuration(model: AudioFileModel): Duration? +} \ No newline at end of file diff --git a/data/player/src/main/java/com/eva/player/domain/model/PlayerMetaData.kt b/data/player/src/main/java/com/eva/player/domain/model/PlayerMetaData.kt index 7c6b6221..f26ae0b1 100644 --- a/data/player/src/main/java/com/eva/player/domain/model/PlayerMetaData.kt +++ b/data/player/src/main/java/com/eva/player/domain/model/PlayerMetaData.kt @@ -1,7 +1,16 @@ package com.eva.player.domain.model +/** + * Represents a snapshot of the current player configuration + * @property playerState The current operational state of the player. Defaults to [PlayerState.IDLE] + * @property playBackSpeed The current playback speed of the media. Defaults to [PlayerPlayBackSpeed.Normal]. + * @property isRepeating A flag indicating whether the media will loop after it finishes. + * @property isMuted A flag indicating whether the player's audio is muted. + * + * @see PlayerState + * @see PlayerPlayBackSpeed + */ data class PlayerMetaData( - val isPlaying: Boolean = false, val playerState: PlayerState = PlayerState.IDLE, val playBackSpeed: PlayerPlayBackSpeed = PlayerPlayBackSpeed.Normal, val isRepeating: Boolean = false, diff --git a/data/player/src/main/java/com/eva/player/domain/model/PlayerPlayState.kt b/data/player/src/main/java/com/eva/player/domain/model/PlayerPlayState.kt new file mode 100644 index 00000000..4e7e65e0 --- /dev/null +++ b/data/player/src/main/java/com/eva/player/domain/model/PlayerPlayState.kt @@ -0,0 +1,31 @@ +package com.eva.player.domain.model + +/** + * Represents the various playback states of a media player. + */ +enum class PlayerPlayState { + /** + * The player is currently paused. + * + * In this state, playback is not progressing, and the player is waiting for a + * command to resume playing. + */ + PAUSED, + + /** + * The player is actively playing media content. + * + * In this state, the playback position is advancing, and the user can see or + * hear the content. + */ + PLAYING, + + /** + * The player is temporarily stopped to buffer content. + * This state occurs when the player does not have enough data available for playback. + * It will automatically transition back to [PLAYING] once a sufficient amount of content + * is buffered. + */ + BUFFERING + +} diff --git a/feature/player/src/main/java/com/eva/feature_player/AudioPlayerRoute.kt b/feature/player/src/main/java/com/eva/feature_player/AudioPlayerRoute.kt index d2549ba8..7dcaf14f 100644 --- a/feature/player/src/main/java/com/eva/feature_player/AudioPlayerRoute.kt +++ b/feature/player/src/main/java/com/eva/feature_player/AudioPlayerRoute.kt @@ -62,6 +62,7 @@ fun NavGraphBuilder.audioPlayerRoute(controller: NavHostController) = // player states val trackData by playerViewModel.trackData.collectAsStateWithLifecycle() + val isPlaying by playerViewModel.isPlayerPlaying.collectAsStateWithLifecycle() val playerMetadata by playerViewModel.playerMetaData.collectAsStateWithLifecycle() val isControllerReady by playerViewModel.isControllerReady.collectAsStateWithLifecycle() @@ -98,6 +99,7 @@ fun NavGraphBuilder.audioPlayerRoute(controller: NavHostController) = trackData = { trackData }, playerMetaData = playerMetadata, isControllerReady = isControllerReady, + isPlayerPlaying = isPlaying, bookMarkState = bookMarkState, onFileEvent = metaDataViewmodel::onFileEvent, onPlayerEvents = playerViewModel::onPlayerEvents, diff --git a/feature/player/src/main/java/com/eva/feature_player/AudioPlayerScreen.kt b/feature/player/src/main/java/com/eva/feature_player/AudioPlayerScreen.kt index 641dc50c..23310277 100644 --- a/feature/player/src/main/java/com/eva/feature_player/AudioPlayerScreen.kt +++ b/feature/player/src/main/java/com/eva/feature_player/AudioPlayerScreen.kt @@ -75,6 +75,7 @@ internal fun AudioPlayerScreen( onPlayerEvents: (PlayerEvents) -> Unit, modifier: Modifier = Modifier, isControllerReady: Boolean = false, + isPlayerPlaying: Boolean = true, onBookmarkEvent: (BookMarkEvents) -> Unit = {}, onFileEvent: (UserAudioAction) -> Unit = {}, navigation: @Composable () -> Unit = {}, @@ -134,6 +135,7 @@ internal fun AudioPlayerScreen( trackData = trackData, playerMetaData = playerMetaData, isControllerReady = isControllerReady, + isPlayerPlaying = isPlayerPlaying, bookMarkState = bookMarkState, onPlayerEvents = onPlayerEvents, onBookmarkEvent = onBookmarkEvent, diff --git a/feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerActions.kt b/feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerActions.kt index f005b66e..ca5dad03 100644 --- a/feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerActions.kt +++ b/feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerActions.kt @@ -49,6 +49,7 @@ internal fun AudioPlayerActions( onForward: () -> Unit = {}, onRewind: () -> Unit = {}, isControllerReady: Boolean = true, + isPlayerPlaying: Boolean = true, onSpeedSelected: (PlayerPlayBackSpeed) -> Unit = {}, shape: Shape = MaterialTheme.shapes.large, color: Color = MaterialTheme.colorScheme.surfaceContainerHighest, @@ -148,7 +149,7 @@ internal fun AudioPlayerActions( enabled = isControllerReady ) AnimatedPlayPauseButton( - isPlaying = playerMetaData.isPlaying, + isPlaying = isPlayerPlaying, enabled = isControllerReady, onPause = onPause, onPlay = onPlay, diff --git a/feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerScreenContent.kt b/feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerScreenContent.kt index eee07ea1..d1571c55 100644 --- a/feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerScreenContent.kt +++ b/feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerScreenContent.kt @@ -40,6 +40,7 @@ internal fun AudioPlayerScreenContent( modifier: Modifier = Modifier, onBookmarkEvent: (BookMarkEvents) -> Unit = {}, isControllerReady: Boolean = false, + isPlayerPlaying: Boolean = true, ) { val bookMarkTimeStamps by remember(bookmarks) { derivedStateOf { @@ -82,6 +83,7 @@ internal fun AudioPlayerScreenContent( PlayerActionsAndSlider( metaData = playerMetaData, trackData = trackData, + isPlayerPlaying = isPlayerPlaying, isControllerSet = isControllerReady, onPlayerAction = onPlayerEvents, modifier = Modifier diff --git a/feature/player/src/main/java/com/eva/feature_player/composable/PlayerActionsAndSlider.kt b/feature/player/src/main/java/com/eva/feature_player/composable/PlayerActionsAndSlider.kt index 5e43c1bf..8fa07142 100644 --- a/feature/player/src/main/java/com/eva/feature_player/composable/PlayerActionsAndSlider.kt +++ b/feature/player/src/main/java/com/eva/feature_player/composable/PlayerActionsAndSlider.kt @@ -26,6 +26,7 @@ internal fun PlayerActionsAndSlider( onPlayerAction: (PlayerEvents) -> Unit, modifier: Modifier = Modifier, isControllerSet: Boolean = true, + isPlayerPlaying: Boolean = true, containerShape: Shape = MaterialTheme.shapes.large, containerColor: Color = MaterialTheme.colorScheme.surfaceContainer, contentColor: Color = contentColorFor(backgroundColor = containerColor), @@ -42,6 +43,7 @@ internal fun PlayerActionsAndSlider( AudioPlayerActions( playerMetaData = metaData, isControllerReady = isControllerSet, + isPlayerPlaying = isPlayerPlaying, onPlay = { onPlayerAction(PlayerEvents.OnStartPlayer) }, onPause = { onPlayerAction(PlayerEvents.OnPausePlayer) }, onMuteStream = { onPlayerAction(PlayerEvents.OnMutePlayer) }, @@ -61,8 +63,9 @@ internal fun PlayerActionsAndSlider( private fun PlayerActionsAndSliderPreview() = RecorderAppTheme { Surface { PlayerActionsAndSlider( - metaData = PlayerMetaData(isPlaying = true), + metaData = PlayerMetaData(), trackData = { PlayerTrackData(total = 2.minutes) }, + isPlayerPlaying = true, onPlayerAction = {}, modifier = Modifier.padding(12.dp) ) diff --git a/feature/player/src/main/java/com/eva/feature_player/viewmodel/AudioPlayerViewModel.kt b/feature/player/src/main/java/com/eva/feature_player/viewmodel/AudioPlayerViewModel.kt index a3d4041f..d00df923 100644 --- a/feature/player/src/main/java/com/eva/feature_player/viewmodel/AudioPlayerViewModel.kt +++ b/feature/player/src/main/java/com/eva/feature_player/viewmodel/AudioPlayerViewModel.kt @@ -3,7 +3,6 @@ package com.eva.feature_player.viewmodel import androidx.lifecycle.viewModelScope import com.eva.feature_player.state.ControllerEvents import com.eva.feature_player.state.PlayerEvents -import com.eva.player.data.MediaControllerProvider import com.eva.player.domain.AudioFilePlayer import com.eva.player.domain.model.PlayerMetaData import com.eva.player.domain.model.PlayerTrackData @@ -33,32 +32,33 @@ import kotlinx.coroutines.launch internal class AudioPlayerViewModel @AssistedInject constructor( @Assisted private val audioId: Long, private val fileProvider: PlayerFileProvider, - private val controller: MediaControllerProvider, + private val player: AudioFilePlayer, ) : AppViewModel() { - // audio player instance for calling the underlying app's - private val audioPlayer: AudioFilePlayer? - get() = controller.player - - private val _currentFile = MutableStateFlow(null) private val _currentFileDistinctById = _currentFile .filterNotNull() .distinctUntilChangedBy { it.id } - val playerMetaData = controller.playerMetaDataFlow.stateIn( + val playerMetaData = player.playerMetaDataFlow.stateIn( scope = viewModelScope, started = SharingStarted.Lazily, initialValue = PlayerMetaData() ) - val trackData = controller.trackInfoAsFlow.stateIn( + val isPlayerPlaying = player.isPlaying.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = false + ) + + val trackData = player.trackInfoAsFlow.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = PlayerTrackData() ) - val isControllerReady = controller.isControllerConnected + val isControllerReady = player.isControllerReady .onStart { setAudioModel() setControllerIfReady() @@ -76,45 +76,38 @@ internal class AudioPlayerViewModel @AssistedInject constructor( fun onControllerEvents(event: ControllerEvents) { when (event) { is ControllerEvents.OnAddController -> viewModelScope.launch { - controller.prepareController(event.audioId) + player.prepareController(event.audioId) } - ControllerEvents.OnRemoveController -> controller.releaseController() + ControllerEvents.OnRemoveController -> player.cleanUp() } } fun onPlayerEvents(event: PlayerEvents) { when (event) { - PlayerEvents.OnPausePlayer -> viewModelScope.launch { audioPlayer?.pausePlayer() } - PlayerEvents.OnStartPlayer -> viewModelScope.launch { audioPlayer?.startOrResumePlayer() } + PlayerEvents.OnPausePlayer -> viewModelScope.launch { player.pausePlayer() } + PlayerEvents.OnStartPlayer -> viewModelScope.launch { player.startOrResumePlayer() } is PlayerEvents.OnForwardByNDuration -> - audioPlayer?.seekPlayerByNDuration(duration = event.duration) + player.seekPlayerByNDuration(duration = event.duration) is PlayerEvents.OnRewindByNDuration -> - audioPlayer?.seekPlayerByNDuration(duration = event.duration, rewind = true) + player.seekPlayerByNDuration(duration = event.duration, rewind = true) - is PlayerEvents.OnPlayerSpeedChange -> audioPlayer?.setPlayBackSpeed(event.speed) - is PlayerEvents.OnRepeatModeChange -> audioPlayer?.setPlayLooping(event.canRepeat) - PlayerEvents.OnMutePlayer -> audioPlayer?.onMuteDevice() - is PlayerEvents.OnSeekPlayer -> audioPlayer?.onSeekDuration(event.amount) + is PlayerEvents.OnPlayerSpeedChange -> player.setPlayBackSpeed(event.speed) + is PlayerEvents.OnRepeatModeChange -> player.setPlayLooping(event.canRepeat) + PlayerEvents.OnMutePlayer -> player.onMuteDevice() + is PlayerEvents.OnSeekPlayer -> player.onSeekDuration(event.amount) } } private fun setControllerIfReady() { - combine( - controller.isControllerConnected, - _currentFileDistinctById - ) { connected, fileModel -> + combine(player.isControllerReady, _currentFileDistinctById) { connected, fileModel -> if (!connected) return@combine - val result = controller.preparePlayer(fileModel) ?: return@combine - when (result) { - is Resource.Error -> { - val message = result.message ?: result.error.message ?: "" - _uiEvents.emit(UIEvents.ShowSnackBar(message)) - } - - else -> {} + val result = player.preparePlayer(fileModel) + result.onFailure { error -> + val message = error.message ?: "" + _uiEvents.emit(UIEvents.ShowSnackBar(message)) } }.launchIn(viewModelScope) } @@ -131,7 +124,7 @@ internal class AudioPlayerViewModel @AssistedInject constructor( override fun onCleared() { // cleanup for controller - controller.releaseController() + player.cleanUp() super.onCleared() } } \ No newline at end of file From 3bc571eca201e5b7e9a5f833cb2896e83e76db38 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Wed, 29 Oct 2025 18:05:32 +0530 Subject: [PATCH 09/25] DI changes As two of the players uses different exoplayer instance the config with audio attributes and media source factory is kept in PlayerCommonModule.kt --- .../eva/editor/di/EditorViewmodelModule.kt | 24 +------- .../com/eva/player/di/PlayerCommonModule.kt | 58 +++++++++++++++++++ .../eva/player/di/PlayerControllerModule.kt | 5 +- .../com/eva/player/di/PlayerServiceModule.kt | 25 ++------ 4 files changed, 68 insertions(+), 44 deletions(-) create mode 100644 data/player/src/main/java/com/eva/player/di/PlayerCommonModule.kt diff --git a/data/editor/src/main/java/com/eva/editor/di/EditorViewmodelModule.kt b/data/editor/src/main/java/com/eva/editor/di/EditorViewmodelModule.kt index 7f358a77..9a5b98ee 100644 --- a/data/editor/src/main/java/com/eva/editor/di/EditorViewmodelModule.kt +++ b/data/editor/src/main/java/com/eva/editor/di/EditorViewmodelModule.kt @@ -3,16 +3,11 @@ package com.eva.editor.di import android.content.Context import androidx.annotation.OptIn import androidx.media3.common.AudioAttributes -import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.trackselection.DefaultTrackSelector -import androidx.media3.extractor.DefaultExtractorsFactory -import androidx.media3.extractor.amr.AmrExtractor -import androidx.media3.extractor.mp3.Mp3Extractor import com.eva.editor.data.EditableAudioPlayerImpl import com.eva.editor.data.transformer.AudioTransformerImpl import com.eva.editor.domain.AudioTransformer @@ -30,34 +25,19 @@ import javax.inject.Named @OptIn(UnstableApi::class) object EditorViewmodelModule { - @Provides - @ViewModelScoped - fun providesMediaSourceFactory(@ApplicationContext context: Context): MediaSource.Factory { - val extractor = DefaultExtractorsFactory().apply { - //set extractor flags later if there is some problem - setAmrExtractorFlags(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING) - setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING) - } - return DefaultMediaSourceFactory(context, extractor) - } - @Provides @ViewModelScoped @Named("EDITOR_PLAYER") fun providesExoPlayer( @ApplicationContext context: Context, + attributes: AudioAttributes, mediaSourceFactory: MediaSource.Factory ): Player { - val attributes = AudioAttributes.Builder() - .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) - .setUsage(C.USAGE_MEDIA) - .setSpatializationBehavior(C.SPATIALIZATION_BEHAVIOR_AUTO) - .build() - return ExoPlayer.Builder(context) .setMediaSourceFactory(mediaSourceFactory) .setAudioAttributes(attributes, true) .setTrackSelector(DefaultTrackSelector(context)) + .setName("EDITOR_PLAYER") .build() } diff --git a/data/player/src/main/java/com/eva/player/di/PlayerCommonModule.kt b/data/player/src/main/java/com/eva/player/di/PlayerCommonModule.kt new file mode 100644 index 00000000..58c054a9 --- /dev/null +++ b/data/player/src/main/java/com/eva/player/di/PlayerCommonModule.kt @@ -0,0 +1,58 @@ +package com.eva.player.di + +import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.extractor.amr.AmrExtractor +import androidx.media3.extractor.mp3.Mp3Extractor +import com.eva.player.data.AudioMetadataRetrieverImpl +import com.eva.player.domain.AudioMetadataRetriever +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +typealias MediaSourceFactory = MediaSource.Factory + +@Module +@InstallIn(SingletonComponent::class) +@OptIn(UnstableApi::class) +object PlayerCommonModule { + + @Provides + @Singleton + fun providesPlayerAudioAttributes(): AudioAttributes { + return AudioAttributes.Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .setUsage(C.USAGE_MEDIA) + .setSpatializationBehavior(C.SPATIALIZATION_BEHAVIOR_AUTO) + .build() + } + + @Provides + @Singleton + fun providesMediaExtractorFactory(@ApplicationContext context: Context): MediaSource.Factory { + + val extractor = DefaultExtractorsFactory().apply { + //set extractor flags later if there is some problem + setAmrExtractorFlags(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING) + setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING) + } + + return DefaultMediaSourceFactory(context, extractor) + } + + @Provides + @Singleton + fun providesAudioMetadataRetriever( + @ApplicationContext context: Context, + mediaSource: MediaSourceFactory + ): AudioMetadataRetriever = AudioMetadataRetrieverImpl(context, mediaSource) +} \ No newline at end of file diff --git a/data/player/src/main/java/com/eva/player/di/PlayerControllerModule.kt b/data/player/src/main/java/com/eva/player/di/PlayerControllerModule.kt index 548b74b3..1f3ba5a7 100644 --- a/data/player/src/main/java/com/eva/player/di/PlayerControllerModule.kt +++ b/data/player/src/main/java/com/eva/player/di/PlayerControllerModule.kt @@ -1,8 +1,9 @@ package com.eva.player.di import android.content.Context -import com.eva.player.data.MediaControllerProvider +import com.eva.player.data.player.MediaControllerProvider import com.eva.player.data.reader.AudioVisualizerImpl +import com.eva.player.domain.AudioFilePlayer import com.eva.player.domain.AudioVisualizer import dagger.Module import dagger.Provides @@ -18,7 +19,7 @@ object PlayerControllerModule { @Provides @ViewModelScoped fun providesMediaControllerProvider(@ApplicationContext context: Context) - : MediaControllerProvider = MediaControllerProvider(context) + : AudioFilePlayer = MediaControllerProvider(context) @Provides @ViewModelScoped diff --git a/data/player/src/main/java/com/eva/player/di/PlayerServiceModule.kt b/data/player/src/main/java/com/eva/player/di/PlayerServiceModule.kt index 4aeea277..da536229 100644 --- a/data/player/src/main/java/com/eva/player/di/PlayerServiceModule.kt +++ b/data/player/src/main/java/com/eva/player/di/PlayerServiceModule.kt @@ -3,15 +3,11 @@ package com.eva.player.di import android.content.Context import androidx.core.os.bundleOf import androidx.media3.common.AudioAttributes -import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.trackselection.DefaultTrackSelector -import androidx.media3.extractor.DefaultExtractorsFactory -import androidx.media3.extractor.amr.AmrExtractor -import androidx.media3.extractor.mp3.Mp3Extractor import androidx.media3.session.MediaConstants import androidx.media3.session.MediaNotification import androidx.media3.session.MediaSession @@ -39,29 +35,18 @@ object PlayerServiceModule { fun providesExoPlayer( @ApplicationContext context: Context, settings: RecorderAudioSettingsRepo, + mediaSource: MediaSource.Factory, + attributes: AudioAttributes, ): Player { - val attributes = AudioAttributes.Builder() - .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) - .setUsage(C.USAGE_MEDIA) - .setSpatializationBehavior(C.SPATIALIZATION_BEHAVIOR_AUTO) - .build() - - val extractor = DefaultExtractorsFactory().apply { - //set extractor flags later if there is some problem - setAmrExtractorFlags(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING) - setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING) - } - - val mediaSourceFactory = DefaultMediaSourceFactory(context, extractor) - val audioSettings = runBlocking { settings.audioSettings() } return ExoPlayer.Builder(context) - .setMediaSourceFactory(mediaSourceFactory) + .setMediaSourceFactory(mediaSource) .setSkipSilenceEnabled(audioSettings.skipSilences) .setAudioAttributes(attributes, true) .setTrackSelector(DefaultTrackSelector(context)) + .setName("SERVICE_PLAYER") .build() } From 850bb5c0b09c171e8e1f5c69d4134e3986560eb0 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Thu, 30 Oct 2025 19:34:34 +0530 Subject: [PATCH 10/25] Corrections in location module TaskExts.kt provides await function on Task object for location provider and Included location enabled method Included accuracy in BaseLocationModel.kt CoarseLocationProviderImpl.kt returning correct exception for result for reading last location LocationAddressProvider.kt now returns a result, in its implementation if geo coder is unable to decode location it returns an exception rather than a null,alongside removed the settings repo thus keeping single responsibility for the provider MediaMetaDataInfo.kt made location string as nullable PlayerFileProviderImpl.kt using the settings repo to check if location string need to be fetched then using that string --- .../com/eva/ui/composables/DurationText.kt | 8 +- .../com/eva/location/di/LocationModule.kt | 4 +- .../eva/location/domain/BaseLocationModel.kt | 1 + .../exceptions/GeoCoderMissingException.kt | 4 + .../exceptions/InvalidLocationException.kt | 3 + .../repository/LocationAddressProvider.kt | 2 +- .../domain/repository/LocationProvider.kt | 2 + .../com/eva/location/domain/utils/TaskExts.kt | 18 +++ .../provider/CoarseLocationProviderImpl.kt | 109 ++++++++---------- .../provider/LocationAddressProviderImpl.kt | 60 ++++------ .../data/provider/PlayerFileProviderImpl.kt | 8 +- .../data/utils/MediaMetaDataInfo.kt | 2 +- .../recordings/di/RecordingsProviderModule.kt | 4 +- 13 files changed, 121 insertions(+), 104 deletions(-) create mode 100644 data/location/src/main/java/com/eva/location/domain/exceptions/GeoCoderMissingException.kt create mode 100644 data/location/src/main/java/com/eva/location/domain/exceptions/InvalidLocationException.kt create mode 100644 data/location/src/main/java/com/eva/location/domain/utils/TaskExts.kt diff --git a/core/ui/src/main/java/com/eva/ui/composables/DurationText.kt b/core/ui/src/main/java/com/eva/ui/composables/DurationText.kt index cbc5a381..04074594 100644 --- a/core/ui/src/main/java/com/eva/ui/composables/DurationText.kt +++ b/core/ui/src/main/java/com/eva/ui/composables/DurationText.kt @@ -1,10 +1,12 @@ package com.eva.ui.composables -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -19,7 +21,8 @@ import kotlin.time.toJavaDuration fun DurationText( duration: Duration, modifier: Modifier = Modifier, - style: TextStyle = MaterialTheme.typography.bodyMedium, + style: TextStyle = LocalTextStyle.current, + color: Color = LocalContentColor.current, formatToLocale: Boolean = true, fontFamily: FontFamily? = null, fontWeight: FontWeight? = null, @@ -38,6 +41,7 @@ fun DurationText( Text( text = formattedDuration, modifier = modifier, + color = color, style = style, fontFamily = fontFamily, fontWeight = fontWeight diff --git a/data/location/src/main/java/com/eva/location/di/LocationModule.kt b/data/location/src/main/java/com/eva/location/di/LocationModule.kt index 3723a372..781d8360 100644 --- a/data/location/src/main/java/com/eva/location/di/LocationModule.kt +++ b/data/location/src/main/java/com/eva/location/di/LocationModule.kt @@ -1,7 +1,6 @@ package com.eva.location.di import android.content.Context -import com.eva.datastore.domain.repository.RecorderAudioSettingsRepo import com.eva.location.domain.repository.LocationAddressProvider import com.eva.location.domain.repository.LocationProvider import com.eva.location.provider.CoarseLocationProviderImpl @@ -27,6 +26,5 @@ object LocationModule { @Singleton fun providesLocationAddressProvider( @ApplicationContext context: Context, - settingsRepo: RecorderAudioSettingsRepo, - ): LocationAddressProvider = LocationAddressProviderImpl(context, settingsRepo) + ): LocationAddressProvider = LocationAddressProviderImpl(context) } \ No newline at end of file diff --git a/data/location/src/main/java/com/eva/location/domain/BaseLocationModel.kt b/data/location/src/main/java/com/eva/location/domain/BaseLocationModel.kt index cbaa710d..edaa8945 100644 --- a/data/location/src/main/java/com/eva/location/domain/BaseLocationModel.kt +++ b/data/location/src/main/java/com/eva/location/domain/BaseLocationModel.kt @@ -3,4 +3,5 @@ package com.eva.location.domain data class BaseLocationModel( val latitude: Double, val longitude: Double, + val accuracy: Float? = null, ) \ No newline at end of file diff --git a/data/location/src/main/java/com/eva/location/domain/exceptions/GeoCoderMissingException.kt b/data/location/src/main/java/com/eva/location/domain/exceptions/GeoCoderMissingException.kt new file mode 100644 index 00000000..407269bf --- /dev/null +++ b/data/location/src/main/java/com/eva/location/domain/exceptions/GeoCoderMissingException.kt @@ -0,0 +1,4 @@ +package com.eva.location.domain.exceptions + +class GeoCoderMissingException : + Exception("Geo coder is not present in the device cannot decode location") \ No newline at end of file diff --git a/data/location/src/main/java/com/eva/location/domain/exceptions/InvalidLocationException.kt b/data/location/src/main/java/com/eva/location/domain/exceptions/InvalidLocationException.kt new file mode 100644 index 00000000..bce8ba94 --- /dev/null +++ b/data/location/src/main/java/com/eva/location/domain/exceptions/InvalidLocationException.kt @@ -0,0 +1,3 @@ +package com.eva.location.domain.exceptions + +class InvalidLocationException : Exception("Provided location to geocoder is wrong, not in range") \ No newline at end of file diff --git a/data/location/src/main/java/com/eva/location/domain/repository/LocationAddressProvider.kt b/data/location/src/main/java/com/eva/location/domain/repository/LocationAddressProvider.kt index 9b42777b..34559722 100644 --- a/data/location/src/main/java/com/eva/location/domain/repository/LocationAddressProvider.kt +++ b/data/location/src/main/java/com/eva/location/domain/repository/LocationAddressProvider.kt @@ -4,5 +4,5 @@ import com.eva.location.domain.BaseLocationModel fun interface LocationAddressProvider { - suspend operator fun invoke(locationModel: BaseLocationModel): String? + suspend operator fun invoke(locationModel: BaseLocationModel): Result } \ No newline at end of file diff --git a/data/location/src/main/java/com/eva/location/domain/repository/LocationProvider.kt b/data/location/src/main/java/com/eva/location/domain/repository/LocationProvider.kt index 2efd01ae..15d52c5a 100644 --- a/data/location/src/main/java/com/eva/location/domain/repository/LocationProvider.kt +++ b/data/location/src/main/java/com/eva/location/domain/repository/LocationProvider.kt @@ -4,5 +4,7 @@ import com.eva.location.domain.BaseLocationModel interface LocationProvider { + val isLocationEnabled: Boolean + suspend operator fun invoke(fetchCurrentIfNotFound: Boolean = true): Result } \ No newline at end of file diff --git a/data/location/src/main/java/com/eva/location/domain/utils/TaskExts.kt b/data/location/src/main/java/com/eva/location/domain/utils/TaskExts.kt new file mode 100644 index 00000000..7ff7afd3 --- /dev/null +++ b/data/location/src/main/java/com/eva/location/domain/utils/TaskExts.kt @@ -0,0 +1,18 @@ +package com.eva.location.domain.utils + +import com.google.android.gms.tasks.Task +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +internal suspend fun Task.await(): T = suspendCancellableCoroutine { cont -> + addOnSuccessListener { result -> + if (cont.isActive) cont.resume(result) + } + addOnFailureListener { exception -> + if (cont.isActive) cont.resumeWithException(exception) + } + addOnCanceledListener { + if (cont.isActive) cont.cancel() + } +} \ No newline at end of file diff --git a/data/location/src/main/java/com/eva/location/provider/CoarseLocationProviderImpl.kt b/data/location/src/main/java/com/eva/location/provider/CoarseLocationProviderImpl.kt index 3fbe09f6..39d604c2 100644 --- a/data/location/src/main/java/com/eva/location/provider/CoarseLocationProviderImpl.kt +++ b/data/location/src/main/java/com/eva/location/provider/CoarseLocationProviderImpl.kt @@ -3,23 +3,26 @@ package com.eva.location.provider import android.Manifest import android.annotation.SuppressLint import android.content.Context +import android.location.Location import android.location.LocationManager import androidx.core.content.ContextCompat import androidx.core.content.PermissionChecker import androidx.core.content.getSystemService import com.eva.location.domain.BaseLocationModel +import com.eva.location.domain.exceptions.CannotFoundLastLocationException import com.eva.location.domain.exceptions.CurrentLocationTimeoutException import com.eva.location.domain.exceptions.LocationNotEnabledException import com.eva.location.domain.exceptions.LocationPermissionNotFoundException import com.eva.location.domain.exceptions.LocationProviderNotFoundException import com.eva.location.domain.repository.LocationProvider +import com.eva.location.domain.utils.await import com.google.android.gms.location.CurrentLocationRequest import com.google.android.gms.location.Granularity import com.google.android.gms.location.LastLocationRequest import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume +import com.google.android.gms.tasks.CancellationTokenSource +import kotlinx.coroutines.CancellationException internal class CoarseLocationProviderImpl(private val context: Context) : LocationProvider { @@ -43,81 +46,71 @@ internal class CoarseLocationProviderImpl(private val context: Context) : Locati get() = CurrentLocationRequest.Builder() .setGranularity(Granularity.GRANULARITY_COARSE) .setPriority(Priority.PRIORITY_BALANCED_POWER_ACCURACY) - .setDurationMillis(1_000) + .setDurationMillis(2_000) .build() private val isGpsEnabled: Boolean get() = locationManager?.isProviderEnabled(LocationManager.GPS_PROVIDER) == true - private val isLocationEnabled: Boolean - get() = locationManager?.isLocationEnabled == true - private val isNetworkProviderEnabled: Boolean get() = locationManager?.isProviderEnabled(LocationManager.NETWORK_PROVIDER) == true + override val isLocationEnabled: Boolean + get() = locationManager?.isLocationEnabled == true + @SuppressLint("MissingPermission") private suspend fun getCurrentLocation(): Result { - return suspendCancellableCoroutine { cont -> - locationProvider.getCurrentLocation(currentLocationRequest, null).apply { - addOnCompleteListener { - addOnSuccessListener { location -> - if (location == null) { - cont.resume(Result.failure(CurrentLocationTimeoutException())) - return@addOnSuccessListener - } - val evaluatedLocation = BaseLocationModel( - latitude = location.latitude, - longitude = location.longitude - ) - cont.resume(value = Result.success(evaluatedLocation)) - - } - addOnFailureListener { exp -> cont.resume(value = Result.failure(exp)) } - } - addOnCanceledListener { - cont.cancel() - } - } + val tokenSource = CancellationTokenSource() + return try { + val location = locationProvider + .getCurrentLocation(currentLocationRequest, tokenSource.token) + .await() + ?: return Result.failure(CurrentLocationTimeoutException()) + Result.success(location.toDomainModel()) + } catch (e: CancellationException) { + tokenSource.cancel() + throw e + } catch (e: Exception) { + Result.failure(e) } } @SuppressLint("MissingPermission") private suspend fun getLastKnownLocation(): Result { - return suspendCancellableCoroutine { cont -> - locationProvider.getLastLocation(lastLocationRequest).apply { - addOnCompleteListener { - addOnSuccessListener { location -> - if (location == null) { - cont.resume(Result.failure(CurrentLocationTimeoutException())) - return@addOnSuccessListener - } - val evaluatedLocation = BaseLocationModel( - latitude = location.latitude, - longitude = location.longitude - ) - cont.resume(value = Result.success(evaluatedLocation)) - - } - addOnFailureListener { exp -> cont.resume(value = Result.failure(exp)) } - } - addOnCanceledListener { - cont.cancel() - } - } + return try { + val location = locationProvider.getLastLocation(lastLocationRequest).await() + ?: return Result.failure(CannotFoundLastLocationException()) + Result.success(location.toDomainModel()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) } } override suspend fun invoke(fetchCurrentIfNotFound: Boolean): Result { - if (!_hasLocationPermission) return Result.failure(LocationPermissionNotFoundException()) - if (!isLocationEnabled) return Result.failure(LocationNotEnabledException()) - if (!isNetworkProviderEnabled || !isGpsEnabled) - return Result.failure(LocationProviderNotFoundException()) - - val lastLocation = getLastKnownLocation() - lastLocation.onFailure { exp -> - if (exp is CurrentLocationTimeoutException && fetchCurrentIfNotFound) - getCurrentLocation() + return when { + !_hasLocationPermission -> Result.failure(LocationPermissionNotFoundException()) + !isLocationEnabled -> Result.failure(LocationNotEnabledException()) + !isNetworkProviderEnabled || !isGpsEnabled -> + Result.failure(LocationProviderNotFoundException()) + + else -> { + val lastLocation = getLastKnownLocation() + if (lastLocation.isSuccess) return lastLocation + else { + val isFetchCurrentAllowed = + lastLocation.exceptionOrNull() is CannotFoundLastLocationException && fetchCurrentIfNotFound + if (!isFetchCurrentAllowed) return lastLocation + getCurrentLocation() + } + } } - return lastLocation } + + private fun Location.toDomainModel() = BaseLocationModel( + latitude = latitude, + longitude = longitude, + accuracy = if (hasAccuracy()) accuracy else .0f + ) } \ No newline at end of file diff --git a/data/location/src/main/java/com/eva/location/provider/LocationAddressProviderImpl.kt b/data/location/src/main/java/com/eva/location/provider/LocationAddressProviderImpl.kt index 4695f7cc..6c9563ee 100644 --- a/data/location/src/main/java/com/eva/location/provider/LocationAddressProviderImpl.kt +++ b/data/location/src/main/java/com/eva/location/provider/LocationAddressProviderImpl.kt @@ -6,64 +6,45 @@ import android.location.Geocoder import android.os.Build import android.util.Log import androidx.annotation.RequiresApi -import com.eva.datastore.domain.repository.RecorderAudioSettingsRepo import com.eva.location.domain.BaseLocationModel +import com.eva.location.domain.exceptions.GeoCoderMissingException +import com.eva.location.domain.exceptions.InvalidLocationException import com.eva.location.domain.repository.LocationAddressProvider +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.io.IOException import java.util.Locale import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine private const val TAG = "GEO_CODER" -internal class LocationAddressProviderImpl( - private val context: Context, - private val settings: RecorderAudioSettingsRepo, -) : LocationAddressProvider { +internal class LocationAddressProviderImpl(private val context: Context) : + LocationAddressProvider { private val geoCoder by lazy { Geocoder(context, Locale.getDefault()) } private val hasGeoCoder: Boolean get() = Geocoder.isPresent() - private fun buildAddressFromGeoCoderAddress(address: Address) = buildString { - val pattern = with(address) { - listOfNotNull(locality, adminArea, countryName, postalCode) - } - - pattern.forEachIndexed { idx, block -> - append(block) - if (idx + 1 != pattern.size) - append(",") - } - } - + override suspend operator fun invoke(locationModel: BaseLocationModel): Result { + if (!hasGeoCoder) return Result.failure(GeoCoderMissingException()) - override suspend operator fun invoke(locationModel: BaseLocationModel): String? { - if (!hasGeoCoder) { - Log.i(TAG, "GEO CODER API IS NOT FOUND") - return null - } - val audioSettings = settings.audioSettings() - if (!audioSettings.addLocationInfoInRecording) { - Log.d(TAG, "NO NEED TO SHOW LOCATION ") - return null - } return withContext(Dispatchers.IO) { try { val addressPattern = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) evalAddressAPI33(locationModel) else evalAddressNormal(locationModel) Log.d(TAG, "ADDRESS DETERMINED FROM LOCATION :$addressPattern") - return@withContext addressPattern - } catch (e: IOException) { + Result.success(addressPattern ?: "") + } catch (e: IllegalArgumentException) { Log.e(TAG, "LAT LONG IS INVALID", e) - null + Result.failure(InvalidLocationException()) } catch (e: Exception) { + if (e is CancellationException) throw e e.printStackTrace() - null + Result.failure(e) } } } @@ -73,14 +54,13 @@ internal class LocationAddressProviderImpl( // geocoder observer val observer = object : Geocoder.GeocodeListener { override fun onGeocode(addresses: MutableList
) { - val result = addresses.firstOrNull() - ?.let(::buildAddressFromGeoCoderAddress) + val result = addresses.firstOrNull()?.buildAddressString() cont.resume(result) } override fun onError(errorMessage: String?) { Log.e(TAG, "ERROR IN USING GEOCODER :$errorMessage") - cont.resume(null) + cont.resumeWithException(Exception(errorMessage)) } } // add the observer @@ -92,5 +72,13 @@ internal class LocationAddressProviderImpl( geoCoder.getFromLocation(location.latitude, location.longitude, 2) ?.filterNotNull() ?.firstOrNull() - ?.let(::buildAddressFromGeoCoderAddress) + ?.buildAddressString() + + private fun Address.buildAddressString() = buildString { + val pattern = listOfNotNull(locality, adminArea, countryName, postalCode) + pattern.forEachIndexed { idx, block -> + append(block) + if (idx + 1 != pattern.size) append(", ") + } + } } \ No newline at end of file diff --git a/data/recordings/src/main/java/com/eva/recordings/data/provider/PlayerFileProviderImpl.kt b/data/recordings/src/main/java/com/eva/recordings/data/provider/PlayerFileProviderImpl.kt index 402edd81..2e1751e0 100644 --- a/data/recordings/src/main/java/com/eva/recordings/data/provider/PlayerFileProviderImpl.kt +++ b/data/recordings/src/main/java/com/eva/recordings/data/provider/PlayerFileProviderImpl.kt @@ -14,6 +14,7 @@ import android.os.Build import android.provider.MediaStore import android.util.Log import androidx.core.os.bundleOf +import com.eva.datastore.domain.repository.RecorderAudioSettingsRepo import com.eva.location.domain.repository.LocationAddressProvider import com.eva.location.domain.utils.parseLocationFromString import com.eva.recordings.data.utils.MediaMetaDataInfo @@ -40,6 +41,7 @@ private const val TAG = "PLAYER_FILE_PROVIDER" internal class PlayerFileProviderImpl( private val context: Context, + private val settings: RecorderAudioSettingsRepo, private val addressProvider: LocationAddressProvider, ) : RecordingsContentResolverWrapper(context), PlayerFileProvider { @@ -133,7 +135,7 @@ internal class PlayerFileProviderImpl( val extractor = MediaExtractor() val retriever = MediaMetadataRetriever() try { - return withContext(Dispatchers.Default) {// set source + return withContext(Dispatchers.IO) {// set source extractor.setDataSource(context, uri, null) retriever.setDataSource(context, uri) // its accountable that there is a single track @@ -146,8 +148,10 @@ internal class PlayerFileProviderImpl( } else mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) val locationAsString = async { + val audioSettings = settings.audioSettings() + if (!audioSettings.addLocationInfoInRecording) return@async null parseLocationFromString(retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)) - ?.let { addressProvider.invoke(it) } ?: "" + ?.let { addressProvider.invoke(it).getOrNull() } } val bitRate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE) diff --git a/data/recordings/src/main/java/com/eva/recordings/data/utils/MediaMetaDataInfo.kt b/data/recordings/src/main/java/com/eva/recordings/data/utils/MediaMetaDataInfo.kt index b91fac62..6e08d05c 100644 --- a/data/recordings/src/main/java/com/eva/recordings/data/utils/MediaMetaDataInfo.kt +++ b/data/recordings/src/main/java/com/eva/recordings/data/utils/MediaMetaDataInfo.kt @@ -4,5 +4,5 @@ data class MediaMetaDataInfo( val channelCount: Int = 0, val sampleRate: Int = 0, val bitRate: Float = 0f, - val locationString: String = "", + val locationString: String? = null, ) \ No newline at end of file diff --git a/data/recordings/src/main/java/com/eva/recordings/di/RecordingsProviderModule.kt b/data/recordings/src/main/java/com/eva/recordings/di/RecordingsProviderModule.kt index 9f3f5405..f246fbbe 100644 --- a/data/recordings/src/main/java/com/eva/recordings/di/RecordingsProviderModule.kt +++ b/data/recordings/src/main/java/com/eva/recordings/di/RecordingsProviderModule.kt @@ -4,6 +4,7 @@ import android.content.Context import android.os.Build import com.eva.database.dao.RecordingsMetadataDao import com.eva.database.dao.TrashFileDao +import com.eva.datastore.domain.repository.RecorderAudioSettingsRepo import com.eva.datastore.domain.repository.RecorderFileSettingsRepo import com.eva.location.domain.repository.LocationAddressProvider import com.eva.recordings.data.RecordingWidgetInteractorImpl @@ -66,7 +67,8 @@ object RecordingsProviderModule { fun providesPlayerFileProvider( @ApplicationContext context: Context, locationProvider: LocationAddressProvider, - ): PlayerFileProvider = PlayerFileProviderImpl(context, locationProvider) + settings: RecorderAudioSettingsRepo, + ): PlayerFileProvider = PlayerFileProviderImpl(context, settings, locationProvider) @Provides From 90e91e6f72168760a0572574a7244287a1ec4535 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Fri, 31 Oct 2025 04:20:09 +0530 Subject: [PATCH 11/25] Included test cases interaction module and changes PhoneStateObserverImpl.kt always emit a PhoneState.IDLE at starts If bluetooth sco is absent beginScoConnection returns BluetoothSCONotAvailableException.kt Included tests for ShareRecordingsUtil as bluetooth and phone state cannot be tested only attached share test, as content uri are required AndroidManifest.xml and test_file_paths.xml for android test are added --- data/interactions/build.gradle.kts | 5 + .../src/androidTest/AndroidManifest.xml | 14 ++ .../interactions/ShareRecordingsIntentTest.kt | 189 ++++++++++++++++++ .../androidTest/res/xml/test_file_paths.xml | 6 + .../data/bluetooth/BluetoothScoConnectImpl.kt | 7 +- .../bluetooth/BluetoothScoConnectImplApi31.kt | 6 +- .../data/phone/PhoneStateObserverImpl.kt | 3 + .../data/phone/PhoneStateObserverImplApi31.kt | 2 + .../BluetoothSCONotAvailableException.kt | 3 + 9 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 data/interactions/src/androidTest/AndroidManifest.xml create mode 100644 data/interactions/src/androidTest/java/com/eva/interactions/ShareRecordingsIntentTest.kt create mode 100644 data/interactions/src/androidTest/res/xml/test_file_paths.xml create mode 100644 data/interactions/src/main/java/com/eva/interactions/domain/exception/BluetoothSCONotAvailableException.kt diff --git a/data/interactions/build.gradle.kts b/data/interactions/build.gradle.kts index a6ccc0ff..187f8d56 100644 --- a/data/interactions/build.gradle.kts +++ b/data/interactions/build.gradle.kts @@ -11,4 +11,9 @@ dependencies { implementation(project(":core:utils")) implementation(project(":data:bookmarks")) implementation(project(":data:recordings")) + + // testing + androidTestImplementation(libs.androidx.espresso.intents) + androidTestImplementation(project(":testing:runtime")) + } \ No newline at end of file diff --git a/data/interactions/src/androidTest/AndroidManifest.xml b/data/interactions/src/androidTest/AndroidManifest.xml new file mode 100644 index 00000000..2e8d58da --- /dev/null +++ b/data/interactions/src/androidTest/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + \ No newline at end of file diff --git a/data/interactions/src/androidTest/java/com/eva/interactions/ShareRecordingsIntentTest.kt b/data/interactions/src/androidTest/java/com/eva/interactions/ShareRecordingsIntentTest.kt new file mode 100644 index 00000000..2ca11392 --- /dev/null +++ b/data/interactions/src/androidTest/java/com/eva/interactions/ShareRecordingsIntentTest.kt @@ -0,0 +1,189 @@ +package com.eva.interactions + +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction +import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra +import androidx.test.espresso.intent.matcher.IntentMatchers.hasType +import androidx.test.espresso.intent.rule.IntentsRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.eva.bookmarks.domain.AudioBookmarkModel +import com.eva.interactions.domain.ShareRecordingsUtil +import com.eva.recordings.domain.models.AudioFileModel +import com.eva.recordings.domain.models.RecordedVoiceModel +import com.eva.utils.Resource +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.datetime.LocalTime +import kotlinx.datetime.toKotlinLocalDateTime +import org.hamcrest.CoreMatchers.allOf +import org.hamcrest.CoreMatchers.`is` +import org.junit.Rule +import org.junit.runner.RunWith +import java.io.File +import java.time.LocalDateTime +import javax.inject.Inject +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class ShareRecordingsIntentTest { + + lateinit var context: Context + + @get:Rule + val intentRule = IntentsRule() + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var shareUtil: ShareRecordingsUtil + + @BeforeTest + fun setup() { + context = ApplicationProvider.getApplicationContext() + hiltRule.inject() + } + + private fun File.toContentURI() = + FileProvider.getUriForFile(context, "${context.packageName}.provider", this) + + @Test + fun check_if_sharing_intent_is_shown_for_single_audio_file() = runTest { + + val someFile = withContext(Dispatchers.IO) { + File(context.cacheDir, "some.mp3").apply { createNewFile() } + } + + val contentURI = someFile.toContentURI() + + val audioFile = AudioFileModel( + id = 0L, + title = "Voice_001", + displayName = "Voice_001.abc", + duration = 5.minutes, + size = 1024 * 20, + lastModified = LocalDateTime.now().toKotlinLocalDateTime(), + fileUri = contentURI.toString(), + mimeType = "audio/mp3", path = "", + channel = 1, + bitRateInKbps = 0f, + samplingRateKHz = 0f + ) + + try { + val result = shareUtil.shareAudioFile(audioFile) + assertTrue(result is Resource.Success, message = "Intent was successfully launched") + + Intents.intended( + allOf( + hasAction(Intent.ACTION_CHOOSER), + hasExtra( + `is`(Intent.EXTRA_INTENT), + allOf( + hasAction(Intent.ACTION_SEND), + hasType("audio/*") + ) + ) + ) + ) + + } finally { + // clear the uri when done + withContext(Dispatchers.IO) { + someFile.delete() + } + } + } + + @Test + fun check_if_sharing_intent_is_shown_for_list_of_recordings() = runTest { + + val someFile = withContext(Dispatchers.IO) { + File(context.cacheDir, "some.mp3").apply { createNewFile() } + } + + val contentURI = FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + someFile + ) + + val fakeModel = RecordedVoiceModel( + id = 0L, + title = "Voice_001", + displayName = "Voice_001.abc", + duration = 5.minutes, + sizeInBytes = 1024 * 20, + modifiedAt = LocalDateTime.now().toKotlinLocalDateTime(), + recordedAt = LocalDateTime.now().toKotlinLocalDateTime(), + fileUri = contentURI.toString(), + mimeType = "audio/mp3" + ) + + val models = List(10) { fakeModel.copy(id = it.toLong()) } + + try { + val result = shareUtil.shareAudioFiles(models) + assertTrue(result is Resource.Success, message = "Intent was successfully launched") + + Intents.intended( + allOf( + hasAction(Intent.ACTION_CHOOSER), + hasExtra( + `is`(Intent.EXTRA_INTENT), + allOf( + hasAction(Intent.ACTION_SEND_MULTIPLE), + hasType("audio/*") + ) + ) + ) + ) + + } finally { + // clear the uri when done + withContext(Dispatchers.IO) { + someFile.delete() + } + } + } + + @Test + fun check_sharing_intent_for_bookmarks_shown() = runTest { + + val bookMarks = List(10) { + AudioBookmarkModel( + bookMarkId = it.toLong(), + text = "Some test", + recordingId = 0, + timeStamp = LocalTime(0, it) + ) + } + val result = shareUtil.shareBookmarksCsv(bookMarks) + + assertTrue(result is Resource.Success, message = "Bookmark intent is launched") + + Intents.intended( + allOf( + hasAction(Intent.ACTION_CHOOSER), + hasExtra( + `is`(Intent.EXTRA_INTENT), + allOf( + hasAction(Intent.ACTION_SEND), + hasType("text/csv") + ) + ) + ) + ) + } +} \ No newline at end of file diff --git a/data/interactions/src/androidTest/res/xml/test_file_paths.xml b/data/interactions/src/androidTest/res/xml/test_file_paths.xml new file mode 100644 index 00000000..03e3b380 --- /dev/null +++ b/data/interactions/src/androidTest/res/xml/test_file_paths.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/data/interactions/src/main/java/com/eva/interactions/data/bluetooth/BluetoothScoConnectImpl.kt b/data/interactions/src/main/java/com/eva/interactions/data/bluetooth/BluetoothScoConnectImpl.kt index 7a253d1d..39a9c81b 100644 --- a/data/interactions/src/main/java/com/eva/interactions/data/bluetooth/BluetoothScoConnectImpl.kt +++ b/data/interactions/src/main/java/com/eva/interactions/data/bluetooth/BluetoothScoConnectImpl.kt @@ -9,8 +9,8 @@ import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import com.eva.interactions.domain.BluetoothScoConnect import com.eva.interactions.domain.enums.BtSCOChannelState +import com.eva.interactions.domain.exception.BluetoothSCONotAvailableException import com.eva.interactions.domain.exception.BluetoothScoAlreadyConnected -import com.eva.interactions.domain.exception.TelephonyFeatureNotException import com.eva.interactions.domain.models.AudioDevice import com.eva.utils.Resource import kotlinx.coroutines.channels.awaitClose @@ -27,13 +27,12 @@ internal class BluetoothScoConnectImpl(private val context: Context) : Bluetooth override val hasTelephonyFeature: Boolean get() = context.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) - /** * In lower api we cannot observe the connection for [AudioDevice] * @see BluetoothScoConnectImplApi31 */ override val observeConnection: Flow - get() = emptyFlow() + get() = emptyFlow() override val observeScoState: Flow get() = callbackFlow { @@ -64,7 +63,7 @@ internal class BluetoothScoConnectImpl(private val context: Context) : Bluetooth Log.i(TAG, "STARTING CONNECTION...") if (audioManager?.isBluetoothScoAvailableOffCall == false) { Log.i(TAG, "SCO NOT AVAILABLE") - return Resource.Error(TelephonyFeatureNotException()) + return Resource.Error(BluetoothSCONotAvailableException()) } if (audioManager?.isBluetoothScoOn == true) { Log.i(TAG, "BLUETOOTH SCO IS ALREADY ON") diff --git a/data/interactions/src/main/java/com/eva/interactions/data/bluetooth/BluetoothScoConnectImplApi31.kt b/data/interactions/src/main/java/com/eva/interactions/data/bluetooth/BluetoothScoConnectImplApi31.kt index 493bf0bc..fccbf67b 100644 --- a/data/interactions/src/main/java/com/eva/interactions/data/bluetooth/BluetoothScoConnectImplApi31.kt +++ b/data/interactions/src/main/java/com/eva/interactions/data/bluetooth/BluetoothScoConnectImplApi31.kt @@ -12,9 +12,9 @@ import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import com.eva.interactions.domain.BluetoothScoConnect import com.eva.interactions.domain.enums.BtSCOChannelState +import com.eva.interactions.domain.exception.BluetoothSCONotAvailableException import com.eva.interactions.domain.exception.BluetoothScoAlreadyConnected import com.eva.interactions.domain.exception.BluetoothScoDeviceNotFound -import com.eva.interactions.domain.exception.TelephonyFeatureNotException import com.eva.interactions.domain.models.AudioDevice import com.eva.utils.Resource import kotlinx.coroutines.channels.awaitClose @@ -81,11 +81,11 @@ internal class BluetoothScoConnectImplApi31(private val context: Context) : Blue Log.i(TAG, "STARTING CONNECTION...") if (audioManager?.isBluetoothScoAvailableOffCall == false) { Log.i(TAG, "SCO NOT AVAILABLE") - return Resource.Error(TelephonyFeatureNotException()) + return Resource.Error(BluetoothSCONotAvailableException()) } val connected = audioManager?.communicationDevice - ?: return Resource.Error(TelephonyFeatureNotException()) + ?: return Resource.Error(BluetoothScoDeviceNotFound()) Log.i(TAG, "DEVICE FOUND OF TYPE :${connected.type}") diff --git a/data/interactions/src/main/java/com/eva/interactions/data/phone/PhoneStateObserverImpl.kt b/data/interactions/src/main/java/com/eva/interactions/data/phone/PhoneStateObserverImpl.kt index 8281b9a3..49432785 100644 --- a/data/interactions/src/main/java/com/eva/interactions/data/phone/PhoneStateObserverImpl.kt +++ b/data/interactions/src/main/java/com/eva/interactions/data/phone/PhoneStateObserverImpl.kt @@ -28,6 +28,8 @@ internal class PhoneStateObserverImpl(private val context: Context) : PhoneState override fun invoke(): Flow { return callbackFlow { + // initial send + trySend(PhoneState.IDLE) if (!hasPhoneStatePermission) { Log.i(TAG, "PERMISSION WAS NOT GRANTED") @@ -36,6 +38,7 @@ internal class PhoneStateObserverImpl(private val context: Context) : PhoneState val listener = object : PhoneStateListener() { + @Deprecated("Deprecated in Java") override fun onCallStateChanged(state: Int, phoneNumber: String?) { super.onCallStateChanged(state, phoneNumber) val phoneState = when (state) { diff --git a/data/interactions/src/main/java/com/eva/interactions/data/phone/PhoneStateObserverImplApi31.kt b/data/interactions/src/main/java/com/eva/interactions/data/phone/PhoneStateObserverImplApi31.kt index f3d0d974..e040ef21 100644 --- a/data/interactions/src/main/java/com/eva/interactions/data/phone/PhoneStateObserverImplApi31.kt +++ b/data/interactions/src/main/java/com/eva/interactions/data/phone/PhoneStateObserverImplApi31.kt @@ -29,6 +29,8 @@ internal class PhoneStateObserverImplApi31(private val context: Context) : Phone override fun invoke(): Flow { return callbackFlow { + // initial send + trySend(PhoneState.IDLE) if (!hasPhoneStatePermission) { Log.i(TAG, "PERMISSION WAS NOT GRANTED") diff --git a/data/interactions/src/main/java/com/eva/interactions/domain/exception/BluetoothSCONotAvailableException.kt b/data/interactions/src/main/java/com/eva/interactions/domain/exception/BluetoothSCONotAvailableException.kt new file mode 100644 index 00000000..96a3671f --- /dev/null +++ b/data/interactions/src/main/java/com/eva/interactions/domain/exception/BluetoothSCONotAvailableException.kt @@ -0,0 +1,3 @@ +package com.eva.interactions.domain.exception + +class BluetoothSCONotAvailableException : Exception("Bluetooth SCO not available") \ No newline at end of file From 3bf70f70f70475d36bf208936576620393f8f4ce Mon Sep 17 00:00:00 2001 From: tuuhin Date: Fri, 31 Oct 2025 06:37:20 +0530 Subject: [PATCH 12/25] Included test for datastore As we are applying test we need different datastore instances for test and actual thus DataStoreProvider.kt provides the DefaultDataStoreProvider.kt and TestDatastoreProvider.kt which are injected into repositories TestDatastoreProvider.kt creates datastore instances via factory and files which are deleted at the end of each test Moved the serializers into other file --- data/datastore/build.gradle.kts | 2 + .../java/com/eva/datastore/RepositoryTests.kt | 147 ++++++++++++++++++ .../datastore/data/TestDatastoreProvider.kt | 47 ++++++ .../eva/datastore/di/DatastoreTestModule.kt | 25 +++ .../data/DefaultDataStoreProvider.kt | 41 +++++ .../repository/PreferencesSettingsRepoImpl.kt | 16 +- .../RecorderAudioSettingsRepoImpl.kt | 52 ++----- .../RecorderFileSettingsRepoImpl.kt | 51 ++---- .../serializers/FileSettingsSerializer.kt | 31 ++++ .../serializers/RecorderSettingsSerializer.kt | 25 +++ .../com/eva/datastore/di/DatastoreModule.kt | 21 +++ ...DataStoreModule.kt => RepositoryModule.kt} | 17 +- .../eva/datastore/domain/DataStoreProvider.kt | 23 +++ 13 files changed, 400 insertions(+), 98 deletions(-) create mode 100644 data/datastore/src/androidTest/java/com/eva/datastore/RepositoryTests.kt create mode 100644 data/datastore/src/androidTest/java/com/eva/datastore/data/TestDatastoreProvider.kt create mode 100644 data/datastore/src/androidTest/java/com/eva/datastore/di/DatastoreTestModule.kt create mode 100644 data/datastore/src/main/java/com/eva/datastore/data/DefaultDataStoreProvider.kt create mode 100644 data/datastore/src/main/java/com/eva/datastore/data/serializers/FileSettingsSerializer.kt create mode 100644 data/datastore/src/main/java/com/eva/datastore/data/serializers/RecorderSettingsSerializer.kt create mode 100644 data/datastore/src/main/java/com/eva/datastore/di/DatastoreModule.kt rename data/datastore/src/main/java/com/eva/datastore/di/{DataStoreModule.kt => RepositoryModule.kt} (57%) create mode 100644 data/datastore/src/main/java/com/eva/datastore/domain/DataStoreProvider.kt diff --git a/data/datastore/build.gradle.kts b/data/datastore/build.gradle.kts index 98169855..0be513d9 100644 --- a/data/datastore/build.gradle.kts +++ b/data/datastore/build.gradle.kts @@ -14,4 +14,6 @@ dependencies { implementation(libs.androidx.datastore.preferences) //local implementation(project(":core:utils")) + + androidTestImplementation(project(":testing:runtime")) } \ No newline at end of file diff --git a/data/datastore/src/androidTest/java/com/eva/datastore/RepositoryTests.kt b/data/datastore/src/androidTest/java/com/eva/datastore/RepositoryTests.kt new file mode 100644 index 00000000..e5910b4d --- /dev/null +++ b/data/datastore/src/androidTest/java/com/eva/datastore/RepositoryTests.kt @@ -0,0 +1,147 @@ +package com.eva.datastore + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.eva.datastore.domain.DataStoreProvider +import com.eva.datastore.domain.enums.AudioFileNamingFormat +import com.eva.datastore.domain.enums.RecordQuality +import com.eva.datastore.domain.enums.RecordingEncoders +import com.eva.datastore.domain.repository.PreferencesSettingsRepo +import com.eva.datastore.domain.repository.RecorderAudioSettingsRepo +import com.eva.datastore.domain.repository.RecorderFileSettingsRepo +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.runner.RunWith +import javax.inject.Inject +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +class RepositoryTests { + + @Inject + lateinit var recorderRepo: RecorderAudioSettingsRepo + + @Inject + lateinit var filesRepo: RecorderFileSettingsRepo + + @Inject + lateinit var preferencesRepo: PreferencesSettingsRepo + + @Inject + lateinit var provider: DataStoreProvider + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @BeforeTest + fun setUp() = hiltRule.inject() + + @AfterTest + fun tearDown() = runBlocking { + provider.cleanUp() + } + + @Test + fun run_basic_updates_in_audio_settings() = runTest { + recorderRepo.audioSettingsFlow.test(timeout = 5.seconds) { + + // initial update + awaitItem() + + recorderRepo.onEncoderChange(RecordingEncoders.AMR_NB) + assertEquals( + expected = RecordingEncoders.AMR_NB, + actual = awaitItem().encoders, + message = "Encoder should be amr nb" + ) + + recorderRepo.onEncoderChange(RecordingEncoders.ACC) + assertEquals( + expected = RecordingEncoders.ACC, + actual = awaitItem().encoders, + message = "Encoder should be aac" + ) + + recorderRepo.onQualityChange(RecordQuality.HIGH) + assertEquals( + expected = RecordQuality.HIGH, + actual = awaitItem().quality, + message = "Quality is set to high" + ) + + recorderRepo.onQualityChange(RecordQuality.LOW) + assertEquals( + expected = RecordQuality.LOW, + awaitItem().quality, + message = "Quality is set to low" + ) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun run_basic_updates_in_file_settings() = runTest { + filesRepo.fileSettingsFlow.test(timeout = 5.seconds) { + + // skip the first + awaitItem() + + filesRepo.onAllowExternalFileRead(true) + assertEquals( + true, + awaitItem().allowExternalRead, + "External files read is enabled" + ) + + filesRepo.onAllowExternalFileRead(false) + assertEquals( + false, + awaitItem().allowExternalRead, + "External files read is disabled" + ) + + filesRepo.onFileNameFormatChange(AudioFileNamingFormat.COUNT) + assertEquals( + expected = AudioFileNamingFormat.COUNT, + actual = awaitItem().format, + message = "Format should be count" + ) + + filesRepo.onFileNameFormatChange(AudioFileNamingFormat.DATE_TIME) + assertEquals( + expected = AudioFileNamingFormat.DATE_TIME, + actual = awaitItem().format, + message = "Format should be date-time" + ) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun check_if_basic_preferences_with_onboarding_working() = runTest { + preferencesRepo.canShowOnBoardingScreenFlow.test(timeout = 5.seconds) { + + awaitItem() + + preferencesRepo.updateCanShowOnBoarding(false) + assertEquals(expected = false, actual = awaitItem(), "Should be false") + + preferencesRepo.updateCanShowOnBoarding(true) + assertEquals(expected = true, actual = awaitItem(), "Should be true") + + cancelAndIgnoreRemainingEvents() + } + } +} \ No newline at end of file diff --git a/data/datastore/src/androidTest/java/com/eva/datastore/data/TestDatastoreProvider.kt b/data/datastore/src/androidTest/java/com/eva/datastore/data/TestDatastoreProvider.kt new file mode 100644 index 00000000..93794685 --- /dev/null +++ b/data/datastore/src/androidTest/java/com/eva/datastore/data/TestDatastoreProvider.kt @@ -0,0 +1,47 @@ +package com.eva.datastore.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.eva.datastore.data.serializers.FileSettingsSerializer +import com.eva.datastore.data.serializers.RecorderSettingsSerializer +import com.eva.datastore.domain.DataStoreProvider +import com.eva.datastore.proto.FileSettingsProto +import com.eva.datastore.proto.RecorderSettingsProto +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +class TestDatastoreProvider(private val context: Context) : DataStoreProvider { + + private val _tempDir by lazy { + File(context.cacheDir, "fake_datastore") + .apply(File::mkdirs) + } + + override val preferencesDataStore: DataStore + get() = PreferenceDataStoreFactory.create { File(_tempDir, "prefs_test.preferences_pb") } + + override val audioSettingsDataStore: DataStore + get() = DataStoreFactory.create( + serializer = RecorderSettingsSerializer, + produceFile = { File(_tempDir, "audio_test.pb") }, + ) + + override val fileSettingsDataStore: DataStore + get() = DataStoreFactory.create( + serializer = FileSettingsSerializer, + produceFile = { File(_tempDir, "file_test.pb") } + ) + + override suspend fun cleanUp() { + withContext(Dispatchers.IO) { + try { + _tempDir.deleteRecursively() + } catch (_: Exception) { + } + } + } +} \ No newline at end of file diff --git a/data/datastore/src/androidTest/java/com/eva/datastore/di/DatastoreTestModule.kt b/data/datastore/src/androidTest/java/com/eva/datastore/di/DatastoreTestModule.kt new file mode 100644 index 00000000..aac8c0fb --- /dev/null +++ b/data/datastore/src/androidTest/java/com/eva/datastore/di/DatastoreTestModule.kt @@ -0,0 +1,25 @@ +package com.eva.datastore.di + +import android.content.Context +import com.eva.datastore.data.TestDatastoreProvider +import com.eva.datastore.domain.DataStoreProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DatastoreModule::class] +) +object DatastoreTestModule { + + @Singleton + @Provides + fun providesTestProvider( + @ApplicationContext context: Context + ): DataStoreProvider = TestDatastoreProvider(context) +} \ No newline at end of file diff --git a/data/datastore/src/main/java/com/eva/datastore/data/DefaultDataStoreProvider.kt b/data/datastore/src/main/java/com/eva/datastore/data/DefaultDataStoreProvider.kt new file mode 100644 index 00000000..ea21641f --- /dev/null +++ b/data/datastore/src/main/java/com/eva/datastore/data/DefaultDataStoreProvider.kt @@ -0,0 +1,41 @@ +package com.eva.datastore.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.dataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import com.eva.datastore.data.serializers.FileSettingsSerializer +import com.eva.datastore.data.serializers.RecorderSettingsSerializer +import com.eva.datastore.domain.DataStoreProvider +import com.eva.datastore.proto.FileSettingsProto +import com.eva.datastore.proto.RecorderSettingsProto + +private val Context.preferences by preferencesDataStore( + name = DataStoreConstants.PREFERENCES_DATASTORE_FILE +) + +private val Context.recorderSettings: DataStore by dataStore( + fileName = DataStoreConstants.RECORDER_SETTINGS_FILE_NAME, + serializer = RecorderSettingsSerializer +) + +private val Context.recorderFileSettings: DataStore by dataStore( + fileName = DataStoreConstants.RECORDER_FILE_SETTINGS_FILE_NAME, + serializer = FileSettingsSerializer +) + +internal class DefaultDataStoreProvider(private val context: Context) : DataStoreProvider { + + override val preferencesDataStore: DataStore + get() = context.preferences + + override val audioSettingsDataStore: DataStore + get() = context.recorderSettings + + override val fileSettingsDataStore: DataStore + get() = context.recorderFileSettings + + override suspend fun cleanUp() = Unit + +} \ No newline at end of file diff --git a/data/datastore/src/main/java/com/eva/datastore/data/repository/PreferencesSettingsRepoImpl.kt b/data/datastore/src/main/java/com/eva/datastore/data/repository/PreferencesSettingsRepoImpl.kt index 6f28038f..ab126edf 100644 --- a/data/datastore/src/main/java/com/eva/datastore/data/repository/PreferencesSettingsRepoImpl.kt +++ b/data/datastore/src/main/java/com/eva/datastore/data/repository/PreferencesSettingsRepoImpl.kt @@ -1,9 +1,9 @@ package com.eva.datastore.data.repository -import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.preferencesDataStore import com.eva.datastore.data.DataStoreConstants import com.eva.datastore.domain.repository.PreferencesSettingsRepo import kotlinx.coroutines.Dispatchers @@ -12,9 +12,9 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext -internal val Context.preferences by preferencesDataStore(DataStoreConstants.PREFERENCES_DATASTORE_FILE) - -internal class PreferencesSettingsRepoImpl(private val context: Context) : PreferencesSettingsRepo { +internal class PreferencesSettingsRepoImpl( + private val preferences: DataStore +) : PreferencesSettingsRepo { private val _preferences = booleanPreferencesKey(DataStoreConstants.SHOW_ON_BOARDING_SCREEN) @@ -23,11 +23,9 @@ internal class PreferencesSettingsRepoImpl(private val context: Context) : Prefe } override val canShowOnBoardingScreenFlow: Flow - get() = context.preferences.data.map { prefs -> prefs[_preferences] ?: true } + get() = preferences.data.map { prefs -> prefs[_preferences] ?: true } override suspend fun updateCanShowOnBoarding(canShow: Boolean) { - context.preferences.edit { prefs -> - prefs[_preferences] = canShow - } + preferences.edit { prefs -> prefs[_preferences] = canShow } } } \ No newline at end of file diff --git a/data/datastore/src/main/java/com/eva/datastore/data/repository/RecorderAudioSettingsRepoImpl.kt b/data/datastore/src/main/java/com/eva/datastore/data/repository/RecorderAudioSettingsRepoImpl.kt index 6875317c..370ad9f9 100644 --- a/data/datastore/src/main/java/com/eva/datastore/data/repository/RecorderAudioSettingsRepoImpl.kt +++ b/data/datastore/src/main/java/com/eva/datastore/data/repository/RecorderAudioSettingsRepoImpl.kt @@ -1,11 +1,6 @@ package com.eva.datastore.data.repository -import android.content.Context -import androidx.datastore.core.CorruptionException import androidx.datastore.core.DataStore -import androidx.datastore.core.Serializer -import androidx.datastore.dataStore -import com.eva.datastore.data.DataStoreConstants import com.eva.datastore.data.mappers.toDomain import com.eva.datastore.data.mappers.toProto import com.eva.datastore.domain.enums.RecordQuality @@ -13,28 +8,26 @@ import com.eva.datastore.domain.enums.RecordingEncoders import com.eva.datastore.domain.models.RecorderAudioSettings import com.eva.datastore.domain.repository.RecorderAudioSettingsRepo import com.eva.datastore.proto.RecorderSettingsProto -import com.eva.datastore.proto.recorderSettingsProto -import com.google.protobuf.InvalidProtocolBufferException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext -import java.io.InputStream -import java.io.OutputStream -internal class RecorderAudioSettingsRepoImpl(private val context: Context) : - RecorderAudioSettingsRepo { +internal class RecorderAudioSettingsRepoImpl( + private val datastore: DataStore +) : RecorderAudioSettingsRepo { override val audioSettingsFlow: Flow - get() = context.recorderSettings.data.map(RecorderSettingsProto::toDomain) + // its will only emit values if the values are distinct + get() = datastore.data.map(RecorderSettingsProto::toDomain) override suspend fun audioSettings(): RecorderAudioSettings { return withContext(Dispatchers.IO) { audioSettingsFlow.first() } } override suspend fun onEncoderChange(encoder: RecordingEncoders) { - context.recorderSettings.updateData { settings -> + datastore.updateData { settings -> settings.toBuilder() .setEncoder(encoder.toProto) .build() @@ -42,7 +35,7 @@ internal class RecorderAudioSettingsRepoImpl(private val context: Context) : } override suspend fun onQualityChange(quality: RecordQuality) { - context.recorderSettings.updateData { settings -> + datastore.updateData { settings -> settings.toBuilder() .setQuality(quality.toProto) .build() @@ -50,7 +43,7 @@ internal class RecorderAudioSettingsRepoImpl(private val context: Context) : } override suspend fun onStereoModeChange(mode: Boolean) { - context.recorderSettings.updateData { settings -> + datastore.updateData { settings -> settings.toBuilder() .setIsStereoMode(mode) .build() @@ -58,7 +51,7 @@ internal class RecorderAudioSettingsRepoImpl(private val context: Context) : } override suspend fun onSkipSilencesChange(skipAllowed: Boolean) { - context.recorderSettings.updateData { settings -> + datastore.updateData { settings -> settings.toBuilder() .setSkipSilences(skipAllowed) .build() @@ -66,7 +59,7 @@ internal class RecorderAudioSettingsRepoImpl(private val context: Context) : } override suspend fun onUseBluetoothMicEnabled(isAllowed: Boolean) { - context.recorderSettings.updateData { settings -> + datastore.updateData { settings -> settings.toBuilder() .setUseBluetoothMic(isAllowed) .build() @@ -74,7 +67,7 @@ internal class RecorderAudioSettingsRepoImpl(private val context: Context) : } override suspend fun onPauseRecorderOnCallEnabled(isEnabled: Boolean) { - context.recorderSettings.updateData { settings -> + datastore.updateData { settings -> settings.toBuilder() .setPauseDuringCalls(isEnabled) .build() @@ -82,29 +75,10 @@ internal class RecorderAudioSettingsRepoImpl(private val context: Context) : } override suspend fun onAddLocationEnabled(isEnabled: Boolean) { - context.recorderSettings.updateData { settings -> + datastore.updateData { settings -> settings.toBuilder() .setAllowLocationInfoIfAvailable(isEnabled) .build() } } -} - -private val Context.recorderSettings: DataStore by dataStore( - fileName = DataStoreConstants.RECORDER_SETTINGS_FILE_NAME, - serializer = object : Serializer { - - override val defaultValue: RecorderSettingsProto = recorderSettingsProto {} - - override suspend fun readFrom(input: InputStream): RecorderSettingsProto { - try { - return RecorderSettingsProto.parseFrom(input) - } catch (exception: InvalidProtocolBufferException) { - throw CorruptionException("Cannot read .proto file", exception) - } - } - - override suspend fun writeTo(t: RecorderSettingsProto, output: OutputStream) = - t.writeTo(output) - } -) \ No newline at end of file +} \ No newline at end of file diff --git a/data/datastore/src/main/java/com/eva/datastore/data/repository/RecorderFileSettingsRepoImpl.kt b/data/datastore/src/main/java/com/eva/datastore/data/repository/RecorderFileSettingsRepoImpl.kt index c77e9f1b..5f7b9ce2 100644 --- a/data/datastore/src/main/java/com/eva/datastore/data/repository/RecorderFileSettingsRepoImpl.kt +++ b/data/datastore/src/main/java/com/eva/datastore/data/repository/RecorderFileSettingsRepoImpl.kt @@ -1,40 +1,32 @@ package com.eva.datastore.data.repository -import android.content.Context -import androidx.datastore.core.CorruptionException import androidx.datastore.core.DataStore -import androidx.datastore.core.Serializer -import androidx.datastore.dataStore -import com.eva.datastore.data.DataStoreConstants import com.eva.datastore.data.mappers.toDomain import com.eva.datastore.data.mappers.toProto import com.eva.datastore.domain.enums.AudioFileNamingFormat import com.eva.datastore.domain.models.RecorderFileSettings import com.eva.datastore.domain.repository.RecorderFileSettingsRepo import com.eva.datastore.proto.FileSettingsProto -import com.eva.datastore.proto.NamingFormatProto -import com.eva.datastore.proto.fileSettingsProto -import com.google.protobuf.InvalidProtocolBufferException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext -import java.io.InputStream -import java.io.OutputStream -internal class RecorderFileSettingsRepoImpl(private val context: Context) : - RecorderFileSettingsRepo { +internal class RecorderFileSettingsRepoImpl( + private val dataStore: DataStore +) : RecorderFileSettingsRepo { override val fileSettingsFlow: Flow - get() = context.recorderFileSettings.data.map(FileSettingsProto::toDomain) + // its will only emit values if the values are distinct + get() = dataStore.data.map(FileSettingsProto::toDomain) override suspend fun fileSettings(): RecorderFileSettings { return withContext(Dispatchers.IO) { fileSettingsFlow.first() } } override suspend fun onFileNameFormatChange(format: AudioFileNamingFormat) { - context.recorderFileSettings.updateData { settings -> + dataStore.updateData { settings -> settings.toBuilder() .setFormat(format.toProto) .build() @@ -42,7 +34,7 @@ internal class RecorderFileSettingsRepoImpl(private val context: Context) : } override suspend fun onFilePrefixChange(prefix: String) { - context.recorderFileSettings.updateData { settings -> + dataStore.updateData { settings -> settings.toBuilder() .setPrefix(prefix) .build() @@ -50,7 +42,7 @@ internal class RecorderFileSettingsRepoImpl(private val context: Context) : } override suspend fun onAllowExternalFileRead(isAllowed: Boolean) { - context.recorderFileSettings.updateData { settings -> + dataStore.updateData { settings -> settings.toBuilder() .setAllowExternalRead(isAllowed) .build() @@ -58,33 +50,10 @@ internal class RecorderFileSettingsRepoImpl(private val context: Context) : } override suspend fun onExportItemPrefixChange(prefix: String) { - context.recorderFileSettings.updateData { settings -> + dataStore.updateData { settings -> settings.toBuilder() .setExportedItemPrefix(prefix) .build() } } -} - -private val Context.recorderFileSettings: DataStore by dataStore( - fileName = DataStoreConstants.RECORDER_FILE_SETTINGS_FILE_NAME, - serializer = object : Serializer { - - override val defaultValue: FileSettingsProto = fileSettingsProto { - prefix = RecorderFileSettings.NORMAL_FILE_PREFIX - exportedItemPrefix = RecorderFileSettings.EXPORT_FILE_PREFIX - format = NamingFormatProto.FORMAT_VIA_DATE - } - - override suspend fun readFrom(input: InputStream): FileSettingsProto { - try { - return FileSettingsProto.parseFrom(input) - } catch (exception: InvalidProtocolBufferException) { - throw CorruptionException("Cannot read .proto file", exception) - } - } - - override suspend fun writeTo(t: FileSettingsProto, output: OutputStream) = - t.writeTo(output) - } -) \ No newline at end of file +} \ No newline at end of file diff --git a/data/datastore/src/main/java/com/eva/datastore/data/serializers/FileSettingsSerializer.kt b/data/datastore/src/main/java/com/eva/datastore/data/serializers/FileSettingsSerializer.kt new file mode 100644 index 00000000..6953d780 --- /dev/null +++ b/data/datastore/src/main/java/com/eva/datastore/data/serializers/FileSettingsSerializer.kt @@ -0,0 +1,31 @@ +package com.eva.datastore.data.serializers + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.eva.datastore.domain.models.RecorderFileSettings +import com.eva.datastore.proto.FileSettingsProto +import com.eva.datastore.proto.NamingFormatProto +import com.eva.datastore.proto.fileSettingsProto +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream + +internal object FileSettingsSerializer : Serializer { + + override val defaultValue: FileSettingsProto = fileSettingsProto { + prefix = RecorderFileSettings.NORMAL_FILE_PREFIX + exportedItemPrefix = RecorderFileSettings.EXPORT_FILE_PREFIX + format = NamingFormatProto.FORMAT_VIA_DATE + } + + override suspend fun readFrom(input: InputStream): FileSettingsProto { + try { + return FileSettingsProto.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read .proto file", exception) + } + } + + override suspend fun writeTo(t: FileSettingsProto, output: OutputStream) = + t.writeTo(output) +} \ No newline at end of file diff --git a/data/datastore/src/main/java/com/eva/datastore/data/serializers/RecorderSettingsSerializer.kt b/data/datastore/src/main/java/com/eva/datastore/data/serializers/RecorderSettingsSerializer.kt new file mode 100644 index 00000000..5432104d --- /dev/null +++ b/data/datastore/src/main/java/com/eva/datastore/data/serializers/RecorderSettingsSerializer.kt @@ -0,0 +1,25 @@ +package com.eva.datastore.data.serializers + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.eva.datastore.proto.RecorderSettingsProto +import com.eva.datastore.proto.recorderSettingsProto +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream + +internal object RecorderSettingsSerializer : Serializer { + + override val defaultValue: RecorderSettingsProto = recorderSettingsProto {} + + override suspend fun readFrom(input: InputStream): RecorderSettingsProto { + try { + return RecorderSettingsProto.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read .proto file", exception) + } + } + + override suspend fun writeTo(t: RecorderSettingsProto, output: OutputStream) = + t.writeTo(output) +} \ No newline at end of file diff --git a/data/datastore/src/main/java/com/eva/datastore/di/DatastoreModule.kt b/data/datastore/src/main/java/com/eva/datastore/di/DatastoreModule.kt new file mode 100644 index 00000000..3a660347 --- /dev/null +++ b/data/datastore/src/main/java/com/eva/datastore/di/DatastoreModule.kt @@ -0,0 +1,21 @@ +package com.eva.datastore.di + +import android.content.Context +import com.eva.datastore.data.DefaultDataStoreProvider +import com.eva.datastore.domain.DataStoreProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object DatastoreModule { + + @Provides + @Singleton + fun providesDatastoreInstance(@ApplicationContext context: Context): DataStoreProvider = + DefaultDataStoreProvider(context) +} \ No newline at end of file diff --git a/data/datastore/src/main/java/com/eva/datastore/di/DataStoreModule.kt b/data/datastore/src/main/java/com/eva/datastore/di/RepositoryModule.kt similarity index 57% rename from data/datastore/src/main/java/com/eva/datastore/di/DataStoreModule.kt rename to data/datastore/src/main/java/com/eva/datastore/di/RepositoryModule.kt index 43f8c912..33e44f47 100644 --- a/data/datastore/src/main/java/com/eva/datastore/di/DataStoreModule.kt +++ b/data/datastore/src/main/java/com/eva/datastore/di/RepositoryModule.kt @@ -1,36 +1,35 @@ package com.eva.datastore.di -import android.content.Context import com.eva.datastore.data.repository.PreferencesSettingsRepoImpl import com.eva.datastore.data.repository.RecorderAudioSettingsRepoImpl import com.eva.datastore.data.repository.RecorderFileSettingsRepoImpl +import com.eva.datastore.domain.DataStoreProvider import com.eva.datastore.domain.repository.PreferencesSettingsRepo import com.eva.datastore.domain.repository.RecorderAudioSettingsRepo import com.eva.datastore.domain.repository.RecorderFileSettingsRepo import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -object DataStoreModule { +object RepositoryModule { @Provides @Singleton - fun providesRecorderSettings(@ApplicationContext context: Context): RecorderAudioSettingsRepo = - RecorderAudioSettingsRepoImpl(context) + fun providesRecorderSettings(provider: DataStoreProvider): RecorderAudioSettingsRepo = + RecorderAudioSettingsRepoImpl(provider.audioSettingsDataStore) @Provides @Singleton - fun providesFileSettings(@ApplicationContext context: Context): RecorderFileSettingsRepo = - RecorderFileSettingsRepoImpl(context) + fun providesFileSettings(provider: DataStoreProvider): RecorderFileSettingsRepo = + RecorderFileSettingsRepoImpl(provider.fileSettingsDataStore) @Provides @Singleton - fun providesOnBoardingSettings(@ApplicationContext context: Context): PreferencesSettingsRepo = - PreferencesSettingsRepoImpl(context) + fun providesOnBoardingSettings(provider: DataStoreProvider): PreferencesSettingsRepo = + PreferencesSettingsRepoImpl(provider.preferencesDataStore) } \ No newline at end of file diff --git a/data/datastore/src/main/java/com/eva/datastore/domain/DataStoreProvider.kt b/data/datastore/src/main/java/com/eva/datastore/domain/DataStoreProvider.kt new file mode 100644 index 00000000..93cbd215 --- /dev/null +++ b/data/datastore/src/main/java/com/eva/datastore/domain/DataStoreProvider.kt @@ -0,0 +1,23 @@ +package com.eva.datastore.domain + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import com.eva.datastore.proto.FileSettingsProto +import com.eva.datastore.proto.RecorderSettingsProto +import org.jetbrains.annotations.VisibleForTesting + +interface DataStoreProvider { + + val preferencesDataStore: DataStore + + val audioSettingsDataStore: DataStore + + val fileSettingsDataStore: DataStore + + /** + * Cleans up the generated file in testing phase no need to consider this function for + * non test scope + */ + @VisibleForTesting + suspend fun cleanUp() +} \ No newline at end of file From cc502db0b1f30ba2d3152327844a2144a7cb4aa5 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Fri, 31 Oct 2025 21:06:54 +0530 Subject: [PATCH 13/25] Correction in recorder module VoiceRecorder.kt stop recording returns a result, in VoiceRecorderImpl.kt using locks extension, not querying location if the audio format doesn't allow option adding try finally block to ensure recorder is clear and pcm reader is reset RecordFormats.kt is made into an enum class with specific recorder init helpers and RecordEncoderAndFormat.kt is removed VoiceRecorderService.kt notification for recording time emit on each second using distinctUntilChanged In AudioRecordAmplitudeReader.kt corrected the buffer size if stereo mode is enabled, moved rms function inside the class RecordedPointExt.kt now using sequences rather than list to reduce creation of intermediate lists in between operations Corrected Aliases.kt, RecorderFileProvider.kt provides a result if file transfer from temp to shared storage is done, if transfer failed for any reason the file is deleted --- .../com/eva/recorder/data/RecordFormats.kt | 49 ++- .../eva/recorder/data/VoiceRecorderImpl.kt | 293 ++++++++---------- .../com/eva/recorder/data/ext/RMSValueExt.kt | 14 - .../eva/recorder/data/ext/RecordedPointExt.kt | 65 ---- .../recorder/data/reader/AmplitudeRange.kt | 9 - .../data/reader/AudioRecordAmplitudeReader.kt | 134 ++++---- .../recorder/data/reader/RecordedPointExt.kt | 78 +++++ .../data/service/VoiceRecorderService.kt | 25 +- .../data/service/startForegroundTypeMic.kt | 31 +- .../com/eva/recorder/domain/VoiceRecorder.kt | 3 +- .../domain/models/RecordEncoderAndFormat.kt | 7 - .../java/com/eva/recorder/utils/Aliases.kt | 2 +- .../data/provider/RecorderFileProviderImpl.kt | 171 +++++----- .../MediastoreOperationException.kt | 3 + .../domain/provider/RecorderFileProvider.kt | 4 +- .../composable/RecorderAmplitudeGraph.kt | 2 +- .../composable/RecorderContent.kt | 2 +- 17 files changed, 417 insertions(+), 475 deletions(-) delete mode 100644 data/recorder/src/main/java/com/eva/recorder/data/ext/RMSValueExt.kt delete mode 100644 data/recorder/src/main/java/com/eva/recorder/data/ext/RecordedPointExt.kt delete mode 100644 data/recorder/src/main/java/com/eva/recorder/data/reader/AmplitudeRange.kt create mode 100644 data/recorder/src/main/java/com/eva/recorder/data/reader/RecordedPointExt.kt delete mode 100644 data/recorder/src/main/java/com/eva/recorder/domain/models/RecordEncoderAndFormat.kt create mode 100644 data/recordings/src/main/java/com/eva/recordings/domain/exceptions/MediastoreOperationException.kt diff --git a/data/recorder/src/main/java/com/eva/recorder/data/RecordFormats.kt b/data/recorder/src/main/java/com/eva/recorder/data/RecordFormats.kt index 92d45e71..42059593 100644 --- a/data/recorder/src/main/java/com/eva/recorder/data/RecordFormats.kt +++ b/data/recorder/src/main/java/com/eva/recorder/data/RecordFormats.kt @@ -4,50 +4,49 @@ import android.media.MediaRecorder import android.webkit.MimeTypeMap import androidx.media3.common.MimeTypes import com.eva.datastore.domain.enums.RecordingEncoders -import com.eva.recorder.domain.models.RecordEncoderAndFormat private val mimeTypesMap = MimeTypeMap.getSingleton() -internal object RecordFormats { +enum class RecordFormats(val encoder: Int, val outputFormat: Int, val mimeType: String) { - val THREE_GPP = RecordEncoderAndFormat( + THREE_GPP( encoder = MediaRecorder.AudioEncoder.AMR_NB, outputFormat = MediaRecorder.OutputFormat.AMR_NB, mimeType = MimeTypes.AUDIO_AMR, - ) + ), - val AMR_WB = RecordEncoderAndFormat( + AMR_WB( encoder = MediaRecorder.AudioEncoder.AMR_WB, outputFormat = MediaRecorder.OutputFormat.AMR_WB, mimeType = MimeTypes.AUDIO_AMR_WB, - ) + ), - - val M4A = RecordEncoderAndFormat( + M4A( encoder = MediaRecorder.AudioEncoder.AAC, outputFormat = MediaRecorder.OutputFormat.MPEG_4, mimeType = MimeTypes.AUDIO_MP4, - ) + ), - val OGG = RecordEncoderAndFormat( + OGG( encoder = MediaRecorder.AudioEncoder.OPUS, outputFormat = MediaRecorder.OutputFormat.OGG, mimeType = MimeTypes.AUDIO_OGG, - ) - -} - -internal val RecordEncoderAndFormat.fileExtension: String? - get() = mimeTypesMap.getExtensionFromMimeType(mimeType) - ?.let { ext -> ".$ext" } - -internal val RecordingEncoders.recordFormat: RecordEncoderAndFormat - get() = when (this) { - RecordingEncoders.AMR_NB -> RecordFormats.THREE_GPP - RecordingEncoders.AMR_WB -> RecordFormats.AMR_WB - RecordingEncoders.ACC -> RecordFormats.M4A - RecordingEncoders.OPTUS -> RecordFormats.OGG + ); + + internal val fileExtension: String? + get() = mimeTypesMap.getExtensionFromMimeType(mimeType) + ?.let { ext -> ".$ext" } + + companion object { + fun fromEncoder(encoder: RecordingEncoders): RecordFormats { + return when (encoder) { + RecordingEncoders.AMR_NB -> THREE_GPP + RecordingEncoders.AMR_WB -> AMR_WB + RecordingEncoders.ACC -> M4A + RecordingEncoders.OPTUS -> OGG + } + } } - +} diff --git a/data/recorder/src/main/java/com/eva/recorder/data/VoiceRecorderImpl.kt b/data/recorder/src/main/java/com/eva/recorder/data/VoiceRecorderImpl.kt index 6e91eac5..24328cf0 100644 --- a/data/recorder/src/main/java/com/eva/recorder/data/VoiceRecorderImpl.kt +++ b/data/recorder/src/main/java/com/eva/recorder/data/VoiceRecorderImpl.kt @@ -17,8 +17,7 @@ import com.eva.recorder.domain.models.RecorderState import com.eva.recorder.domain.stopwatch.RecorderStopWatch import com.eva.recordings.domain.provider.RecorderFileProvider import com.eva.utils.RecorderConstants -import com.eva.utils.Resource -import kotlinx.coroutines.Dispatchers +import com.eva.utils.tryWithLock import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async @@ -29,6 +28,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.datetime.LocalTime import java.io.File @@ -44,18 +44,21 @@ internal class VoiceRecorderImpl( private val locationProvider: LocationProvider, ) : VoiceRecorder { - private val _stopWatch = RecorderStopWatch(delayTime = RecorderConstants.AMPS_READ_DELAY_RATE) + private val sampleTime = RecorderConstants.AMPS_READ_DELAY_RATE - private val _reader by lazy { + private val _stopWatch = RecorderStopWatch(delayTime = sampleTime) + + private val _pcmReader by lazy { AudioRecordAmplitudeReader( context = context, stopWatch = _stopWatch, - settings = settings, - delayRate = RecorderConstants.AMPS_READ_DELAY_RATE + delayRate = sampleTime ) } private var _recorder: MediaRecorder? = null + + @Volatile private var _recordingFile: File? = null // locks ensures an operation complete before another operation can start @@ -73,7 +76,12 @@ internal class VoiceRecorderImpl( override val dataPoints: Flow> get() = _stopWatch.recorderState - .flatMapLatest(_reader::readAmplitudeBuffered) + .flatMapLatest(_pcmReader::readAmplitudeBuffered) + + private val errorListener = MediaRecorder.OnErrorListener { _, what, extra -> + if (what == MediaRecorder.MEDIA_ERROR_SERVER_DIED) releaseResources() + Log.w(TAG, "SOME ERROR OCCURRED :$what CODE: $extra") + } @Suppress("DEPRECATION") private fun createRecorder(): Boolean { @@ -92,10 +100,7 @@ internal class VoiceRecorderImpl( MediaRecorder(context) else MediaRecorder() - _recorder?.setOnErrorListener { mr, what, extra -> - Log.w(TAG, "SOME ERROR OCCURRED :$what") - } - + _recorder?.setOnErrorListener(errorListener) Log.d(TAG, "CREATED RECORDER AND AMPLITUDE SUCCESSFULLY") return true } @@ -104,7 +109,6 @@ internal class VoiceRecorderImpl( * Creates the file uri in which the audio to be recorded and initiate * the recorder parameters */ - @OptIn(ExperimentalCoroutinesApi::class) private suspend fun initiateRecorderParams() = coroutineScope { if (_recorder == null) { @@ -113,32 +117,43 @@ internal class VoiceRecorderImpl( } val audioSettings = settings.audioSettings() - val format = audioSettings.encoders.recordFormat + val format = RecordFormats.fromEncoder(audioSettings.encoders) val quality = audioSettings.quality val channelCount = if (audioSettings.enableStereo) 2 else 1 // recorder should be ready by now val recorder = _recorder ?: return@coroutineScope // initiate the amplitude reader - _reader.initiateRecorder() + _pcmReader.initiateRecorder( + sampleRate = quality.sampleRate, + isStereo = audioSettings.enableStereo + ) // ensures the file is being created in a different coroutine - val fileDeferred = async(Dispatchers.IO) { + val fileDeferred = async { fileProvider.createFileForRecording(format.fileExtension) } // location deferred for current location if available - val locationDeferred = async(Dispatchers.IO) { + val locationDeferred = async { + // if settings is enabled if (!audioSettings.addLocationInfoInRecording) return@async null + // if format supports it + if (format.outputFormat != MediaRecorder.OutputFormat.THREE_GPP && format.outputFormat != MediaRecorder.OutputFormat.MPEG_4) return@async null locationProvider.invoke() } Log.d(TAG, "CONCURRENTLY FETCHING LOCATION AND PREPARING FILE") awaitAll(locationDeferred, fileDeferred) + Log.d(TAG, "DEFERRED CALL ARE READY") - _recordingFile = fileDeferred.getCompleted() + _recordingFile = fileDeferred.await() - val locationResult = locationDeferred.getCompleted() + val locationResult = locationDeferred.await() + // log if any location error + if (locationResult?.isFailure == true) { + Log.w(TAG, "Location Cannot be fetched", locationResult.exceptionOrNull()) + } recorder.apply { setOutputFile(_recordingFile) @@ -152,18 +167,14 @@ internal class VoiceRecorderImpl( setAudioSamplingRate(quality.sampleRate) setAudioEncodingBitRate(quality.bitRate) //set location can only add location to mp4 and 3gp files - locationResult?.fold( - onSuccess = { data -> - Log.d(TAG, "LOCATION ADDED ") - setLocation( - data.latitude.toFloat(), - data.longitude.toFloat() - ) - }, - onFailure = { error -> Log.w(TAG, "${error.message}") }, - ) + if (locationResult != null && locationResult.isSuccess) { + val location = locationResult.getOrNull() ?: return@apply + setLocation( + location.latitude.toFloat(), + location.longitude.toFloat() + ) + } } - // metrics logs recorder.logMetrics() } @@ -171,58 +182,53 @@ internal class VoiceRecorderImpl( * Method to be called when recording has been finished, and you update the file * metadata */ - private suspend fun updateRecordingToExternalStorage(): Resource { + private suspend fun updateRecordingToExternalStorage(recordingFile: File): Result { // update the file - val recordingId = _recordingFile?.let { file -> - + try { val audioSettings = settings.audioSettings() - val format = audioSettings.encoders.recordFormat + val format = RecordFormats.fromEncoder(audioSettings.encoders) Log.d(TAG, "RECORDER FILE UPDATED") - fileProvider.transferFileDataToStorage(file = file, mimeType = format.mimeType) - - } ?: return Resource.Error(RecorderNotConfiguredException()) - // set recording uri to null and close the socket - _recordingFile = null - // resets the recorder for next recording - Log.d(TAG, "RESTING THE RECORDER") - _recorder?.reset() - _reader.releaseRecorder() - return Resource.Success(recordingId) + return fileProvider.transferFileDataToStorage( + file = recordingFile, + mimeType = format.mimeType + ) + } finally { + // set recording uri to null and close the socket + _recordingFile = null + // resets the recorder for next recording + Log.d(TAG, "RESTING THE RECORDER") + _recorder?.reset() + _pcmReader.releaseRecorder() + } } private suspend fun stopAndDeleteFileMetaData() { // update the file - _recordingFile?.let { file -> + try { + val file = _recordingFile ?: return // non-cancellable as file should be deleted withContext(NonCancellable) { fileProvider.deleteCreatedFile(file) Log.d(TAG, "RECORDER FILE DELETED") } + } finally { + // set recording uri to null and close the socket + _recordingFile = null + // resets the recorder for next recording + Log.d(TAG, "RESTING THE RECORDER") + _recorder?.reset() + _pcmReader.releaseRecorder() } - // set recording uri to null and close the socket - _recordingFile = null - // resets the recorder for next recording - Log.d(TAG, "RESTING THE RECORDER") - _recorder?.reset() - _reader.releaseRecorder() } override suspend fun startRecording() { - // if it's holding the lock don't do anything - if (_lock.holdsLock(this)) { - Log.d(TAG, "CANNOT START RECORDING ITS LOCKED") - return - } - // current uri is already set cannot set it again - if (_recordingFile != null) { - Log.d(TAG, "CURRENT URI IS ALREADY SET") - return - } - // staring an operation lock it - _lock.lock(this) - try { - // prepare the recording params + _lock.tryWithLock(this) { + // current uri is already set cannot set it again + if (_recordingFile != null) { + Log.d(TAG, "CURRENT URI IS ALREADY SET") + return@tryWithLock + } _stopWatch.prepare() Log.i(TAG, "PREPARING FILE FOR RECORDING") initiateRecorderParams() @@ -231,140 +237,101 @@ internal class VoiceRecorderImpl( Log.d(TAG, "RECORDER PREPARED") //start the recorder _stopWatch.startOrResume() - _reader.startRecorder() + _pcmReader.startRecorder() _recorder?.start() Log.d(TAG, "RECORDER STARTED") - } catch (e: IOException) { - e.printStackTrace() - } finally { - // unlocks the current lock - _lock.unlock(this) - Log.d(TAG, "CLEARING LOCK IN START") } } - override suspend fun stopRecording(): Resource { - // if it's holding the lock don't do anything - if (_lock.holdsLock(this)) { - Log.d(TAG, "CANNOT STOP RECORDING ITS LOCKED") - // returning null as there was no error but a lock - return Resource.Success(null) - } - if (_recordingFile == null) { - Log.d(TAG, "FILE URI IS NOT SET SO RECORDER IS NOT READY") - } + override suspend fun stopRecording(): Result { // staring an operation lock it - _lock.lock(this) - return try { + return _lock.withLock(this) { + val file = _recordingFile ?: return Result.failure(RecorderNotConfiguredException()) // reset the timer Log.d(TAG, "STOPWATCH STOPPED") _stopWatch.stop() //stop the ongoing recording _recorder?.stop() Log.d(TAG, "RECORDER STOPPED") + Result.success(0L) // update the file - updateRecordingToExternalStorage() - } catch (e: IllegalStateException) { - e.printStackTrace() - Resource.Error(e, message = "Cannot stop as start wasn't called") - } finally { - // unlocks the current lock - _lock.unlock(this) - Log.d(TAG, "CLEARING LOCK IN STOP") + updateRecordingToExternalStorage(file) } } override suspend fun pauseRecording() { - if (_lock.holdsLock(this)) { - Log.d(TAG, "CANNOT PAUSE RECORDING") - // returning null as there was no error but a lock - return - } - // staring an operation lock it - _lock.lock(this) - try { - //pause recorder - Log.d(TAG, "STOPWATCH PAUSED") - _stopWatch.pause() - //pause recorder - _recorder?.pause() - Log.d(TAG, "RECORDER PAUSED") - } catch (e: IOException) { - e.printStackTrace() - } finally { - _lock.unlock() + _lock.tryWithLock(this) { + try { + //pause recorder + Log.d(TAG, "STOPWATCH PAUSED") + _stopWatch.pause() + //pause recorder + _recorder?.pause() + Log.d(TAG, "RECORDER PAUSED") + } catch (e: IOException) { + e.printStackTrace() + } } } override suspend fun resumeRecording() { - if (_lock.holdsLock(this)) { - Log.d(TAG, "CANNOT RESUME RECORDING") - // returning null as there was no error but a lock - return - } - // staring an operation lock it - _lock.lock(this) - try { - //resume stopwatch - Log.d(TAG, "STOPWATCH RESUMED") - _stopWatch.startOrResume() - //resume recorder - _recorder?.resume() - Log.d(TAG, "RECORDER RESUMED") - } catch (e: IOException) { - e.printStackTrace() - } finally { - _lock.unlock() + _lock.tryWithLock(this) { + try { + //resume stopwatch + Log.d(TAG, "STOPWATCH RESUMED") + _stopWatch.startOrResume() + //resume recorder + _recorder?.resume() + Log.d(TAG, "RECORDER RESUMED") + } catch (e: IOException) { + e.printStackTrace() + } } } override suspend fun cancelRecording() { // if it's holding the lock don't do anything - if (_lock.holdsLock(this)) { - Log.d(TAG, "CANNOT CANCEL RECORDING ITS LOCKED") - return - } - // staring an operation lock it - _lock.lock(this) - try { - // cancel the timer watch - Log.d(TAG, "STOPWATCH STOPPED") - _stopWatch.cancel() - //stop the ongoing recording - Log.d(TAG, "RECORDER STOPPED") - _recorder?.stop() - // delete the current recording - stopAndDeleteFileMetaData() - Log.d(TAG, "RECORDER STOPPED") - } catch (e: Exception) { - e.printStackTrace() - } finally { - // unlocks the current lock - _lock.unlock(this) - Log.d(TAG, "CLEARING LOCK IN CANCEL") + _lock.tryWithLock(this) { + try { + // cancel the timer watch + Log.d(TAG, "STOPWATCH STOPPED") + _stopWatch.cancel() + //stop the ongoing recording + Log.d(TAG, "RECORDER STOPPED") + _recorder?.stop() + // delete the current recording + stopAndDeleteFileMetaData() + Log.d(TAG, "RECORDER STOPPED") + } catch (e: Exception) { + e.printStackTrace() + } } } override fun releaseResources() { // delete the recording file if its exits - _recordingFile?.let { file -> + try { + val file = _recordingFile ?: return // run blocking as we want to run this blocking code in the IO thread. runBlocking { - Log.d(TAG, "CLEARING THE FILE AS RECORDER CLEAR METHOD IS CALLED") - fileProvider.deleteCreatedFile(file) + withContext(NonCancellable) { + Log.d(TAG, "CLEARING THE FILE AS RECORDER CLEAR METHOD IS CALLED") + fileProvider.deleteCreatedFile(file) + } } + } finally { + //set recording file to null + _recordingFile = null + //set buffer reader to null + _pcmReader.releaseRecorder() + // clear the recorder resources + Log.d(TAG, "RELEASE RECORDER") + _recorder?.release() + _recorder = null + // resetting the stopwatch + Log.d(TAG, "RESETTING STOPWATCH") + _stopWatch.reset() } - //set recording file to null - _recordingFile = null - //set buffer reader to null - _reader.releaseRecorder() - // clear the recorder resources - Log.d(TAG, "RELEASE RECORDER") - _recorder?.release() - _recorder = null - // resetting the stopwatch - Log.d(TAG, "RESETTING STOPWATCH") - _stopWatch.reset() } } diff --git a/data/recorder/src/main/java/com/eva/recorder/data/ext/RMSValueExt.kt b/data/recorder/src/main/java/com/eva/recorder/data/ext/RMSValueExt.kt deleted file mode 100644 index 95565874..00000000 --- a/data/recorder/src/main/java/com/eva/recorder/data/ext/RMSValueExt.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.eva.recorder.data.ext - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlin.math.pow -import kotlin.math.sqrt - -internal suspend fun rms(array: ShortArray): Float { - return withContext(Dispatchers.Default) { - val squaredSum = array.sumOf { it.toDouble().pow(2) } - val avg = squaredSum / array.size - sqrt(avg).toFloat() - } -} \ No newline at end of file diff --git a/data/recorder/src/main/java/com/eva/recorder/data/ext/RecordedPointExt.kt b/data/recorder/src/main/java/com/eva/recorder/data/ext/RecordedPointExt.kt deleted file mode 100644 index 53ba7ffa..00000000 --- a/data/recorder/src/main/java/com/eva/recorder/data/ext/RecordedPointExt.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.eva.recorder.data.ext - -import com.eva.recorder.domain.models.RecordedPoint -import kotlin.math.abs - -internal fun List.normalize(max: Int, min: Int): List { - val range = (max - min).let { if (it <= 0) 1 else it } - return map { point -> - point.copy( - rmsValue = (abs(point.rmsValue - min) / range) - .coerceIn(0f..1f) - ) - } -} - -internal fun List.smoothen(factor: Float = 0.3f): List { - var prev = 0f - return map { point -> - val smooth = lerp(prev, point.rmsValue, factor) - prev = smooth - point.copy(rmsValue = smooth) - } -} - -internal fun List.padListWithExtra( - bufferSize: Int, - extra: Int = 10 -): List { - val differences = bufferSize - size - val lastValue = lastOrNull()?.timeInMillis ?: 0L - // extra will create the translation effect properly - val amount = if (differences > 0) differences + extra else +extra - val result = buildList { - addAll(this@padListWithExtra) - repeat(amount) { - val timeInMillis = lastValue + (it * bufferSize) - add(RecordedPoint(timeInMillis, 0f, true)) - } - }.distinctBy { it.timeInMillis } - return result -} - -internal fun List.toProperSequence(eachBlockSize: Int): List { - if (isEmpty()) return emptyList() - - val resultList = mutableListOf() - var expected = first() - var start = expected.timeInMillis - - for (actualPoint in this) { - while (start < actualPoint.timeInMillis) { - resultList.add(expected.copy(timeInMillis = start)) - start += eachBlockSize - } - resultList.add(actualPoint) - expected = actualPoint - start += eachBlockSize - } - - return resultList -} - -private fun lerp(v0: Float, v1: Float, t: Float): Float { - return (1 - t) * v1 + t * v0 -} \ No newline at end of file diff --git a/data/recorder/src/main/java/com/eva/recorder/data/reader/AmplitudeRange.kt b/data/recorder/src/main/java/com/eva/recorder/data/reader/AmplitudeRange.kt deleted file mode 100644 index 6432122b..00000000 --- a/data/recorder/src/main/java/com/eva/recorder/data/reader/AmplitudeRange.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.eva.recorder.data.reader - -internal data class AmplitudeRange( - val max: Int = 0, - val min: Int = 0, -) { - val range: Int - get() = (max - min).let { range -> if (range <= 0) 1 else range } -} diff --git a/data/recorder/src/main/java/com/eva/recorder/data/reader/AudioRecordAmplitudeReader.kt b/data/recorder/src/main/java/com/eva/recorder/data/reader/AudioRecordAmplitudeReader.kt index 1458c82f..a7a86438 100644 --- a/data/recorder/src/main/java/com/eva/recorder/data/reader/AudioRecordAmplitudeReader.kt +++ b/data/recorder/src/main/java/com/eva/recorder/data/reader/AudioRecordAmplitudeReader.kt @@ -9,19 +9,13 @@ import android.media.MediaRecorder import android.util.Log import androidx.core.content.ContextCompat import androidx.core.content.PermissionChecker -import com.eva.datastore.domain.repository.RecorderAudioSettingsRepo -import com.eva.recorder.data.ext.normalize -import com.eva.recorder.data.ext.padListWithExtra -import com.eva.recorder.data.ext.rms -import com.eva.recorder.data.ext.smoothen -import com.eva.recorder.data.ext.toProperSequence import com.eva.recorder.domain.models.RecordedPoint import com.eva.recorder.domain.models.RecorderState import com.eva.recorder.domain.stopwatch.RecorderStopWatch import com.eva.utils.RecorderConstants -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive @@ -30,11 +24,12 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.isActive import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.util.concurrent.ConcurrentLinkedQueue import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.math.sqrt import kotlin.time.Duration private const val TAG = "AmplitudeVisualizer" @@ -47,7 +42,6 @@ private const val TAG = "AmplitudeVisualizer" class AudioRecordAmplitudeReader( private val context: Context, private val stopWatch: RecorderStopWatch, - private val settings: RecorderAudioSettingsRepo, private val delayRate: Duration = RecorderConstants.AMPS_READ_DELAY_RATE, private val bufferSize: Int = RecorderConstants.RECORDER_AMPLITUDES_BUFFER_SIZE, ) { @@ -57,16 +51,19 @@ class AudioRecordAmplitudeReader( PermissionChecker.PERMISSION_GRANTED private val _buffer = ConcurrentLinkedQueue() - private val _lock = Mutex() + + private val _lock = Any() + private val _mutex = Mutex() private val _rangeMin = AtomicInt(0) private val _rangeMax = AtomicInt(100) private var _recorder: AudioRecord? = null - private var _minBufferSize: Int = 0 - suspend fun initiateRecorder() { + @Volatile + private var _pcmBufferSize: Int = 0 + fun initiateRecorder(sampleRate: Int, isStereo: Boolean) { if (!_hasRecordPermission) { Log.d(TAG, "MISSING PERMISSION") return @@ -76,25 +73,31 @@ class AudioRecordAmplitudeReader( Log.d(TAG, "RECORDER ALREADY INITIATED") return } - - val audioSettings = settings.audioSettings() - val sampleRate = audioSettings.quality.sampleRate + // encoding is based to 16 bits val audioFormat = AudioFormat.ENCODING_PCM_16BIT - val channelConfig = if (audioSettings.enableStereo) AudioFormat.CHANNEL_IN_STEREO + val channelConfig = if (isStereo) AudioFormat.CHANNEL_IN_STEREO else AudioFormat.CHANNEL_IN_MONO + val channelCount = if (isStereo) 2 else 1 + val bytesPerSample = 2 + try { - _minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat) - if (_minBufferSize == AudioRecord.ERROR || _minBufferSize == AudioRecord.ERROR_BAD_VALUE) { - Log.e(TAG, "AudioRecord.getMinBufferSize error: $_minBufferSize") + val bufferSize = AudioRecord + .getMinBufferSize(sampleRate, channelConfig, audioFormat) + if (bufferSize == AudioRecord.ERROR || bufferSize == AudioRecord.ERROR_BAD_VALUE) { + Log.e(TAG, "AudioRecord.getMinBufferSize error: $_pcmBufferSize") return } + + _pcmBufferSize = bufferSize / (bytesPerSample * channelCount) + Log.d(TAG, "PCM BUFFER SIZE :$_pcmBufferSize") + _recorder = AudioRecord( MediaRecorder.AudioSource.MIC, sampleRate, channelConfig, audioFormat, - _minBufferSize * 2 + bufferSize * 2 ) if (_recorder?.state != AudioRecord.STATE_INITIALIZED) { @@ -107,14 +110,14 @@ class AudioRecordAmplitudeReader( } } - suspend fun startRecorder() { + fun startRecorder() { if (_recorder?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { Log.d(TAG, "RECORDER STATE RECORDING CANNOT START AGAIN") return } if (_recorder == null) { Log.d(TAG, "RECORDER WAS NOT INITIATED ") - initiateRecorder() + throw Exception("Audio Record instance not initiated") } try { _recorder?.startRecording() @@ -143,7 +146,7 @@ class AudioRecordAmplitudeReader( } _recorder?.release() _recorder = null - _minBufferSize = 0 + _pcmBufferSize = 0 } catch (e: Exception) { Log.d(TAG, "FAILED TO RELEASE RECORDER", e) } finally { @@ -155,11 +158,13 @@ class AudioRecordAmplitudeReader( return readRecorderRawBytes(recorderState) .flatMapLatest(::toFixedSizeCollection) .mapLatest { points -> - points.smoothen(factor = .25f) + points.asSequence() + .smoothen(factor = .3f) .normalize(max = _rangeMax.load(), min = _rangeMin.load()) .padListWithExtra(bufferSize * 2) .toProperSequence(bufferSize) .distinctBy { it.timeInMillis } + .toList() }.flowOn(Dispatchers.Default) } @@ -175,28 +180,23 @@ class AudioRecordAmplitudeReader( val invalids = arrayOf(AudioRecord.ERROR_INVALID_OPERATION, AudioRecord.ERROR_BAD_VALUE) if (_recorder == null) return@flow - val pcmBuffer = ShortArray(_minBufferSize / 2) + val pcmBuffer = ShortArray(_pcmBufferSize) var shortsRead: Int - while (state == RecorderState.RECORDING && currentCoroutineContext().isActive) { + while (state == RecorderState.RECORDING) { // ensure the current coroutine is active otherwise currentCoroutineContext().ensureActive() - shortsRead = _recorder?.read(pcmBuffer, 0, pcmBuffer.size) ?: break - if (shortsRead in invalids) { - Log.e(TAG, "RECORDER CANNOT READ BYTES!") - break // Exit loop on critical error - } + if (shortsRead in invalids) break + if (shortsRead == 0) break // these are raw bytes - val rmsValue = rms(pcmBuffer) - emit(maxOf(rmsValue, .0f)) + val rmsValue = pcmBuffer.rms(shortsRead) + emit(rmsValue) // check if audio source set otherwise amp is zero // read the values here delay(delayRate) } - } catch (e: CancellationException) { - throw e } catch (err: IllegalStateException) { Log.e(TAG, "ILLEGAL STATE", err) } catch (e: Exception) { @@ -208,12 +208,11 @@ class AudioRecordAmplitudeReader( * Clears the buffer if it contains any value and emit an end zero */ private fun clearBuffer() { - if (_buffer.isNotEmpty()) { - Log.d(TAG, "CLEARING VALUES") - _buffer.clear() - _rangeMin.store(0) - _rangeMax.store(100) - } + if (_buffer.isEmpty()) return + Log.d(TAG, "CLEARING VALUES") + _buffer.clear() + _rangeMin.store(0) + _rangeMax.store(100) } @@ -233,7 +232,7 @@ class AudioRecordAmplitudeReader( Log.d(TAG, "NEW MIN VALUE SET $newValue") _rangeMin.store(newValue.toInt()) } - val distinctBuffer = _buffer.distinctBy { it.timeInMillis }.toList() + val distinctBuffer = _buffer.distinctBy { it.timeInMillis } emit(distinctBuffer) } catch (e: Exception) { e.printStackTrace() @@ -242,32 +241,35 @@ class AudioRecordAmplitudeReader( } private suspend fun updateItemsInList(newValue: Float) { - - if (_lock.holdsLock(this)) { - Log.d(TAG, "ITS LOCKED REMOVING ITEMS IS ALREADY PROCESSING") - return + _mutex.withLock(_lock) { + try { + val stopWatchTime = stopWatch.elapsedTime.value.toMillisecondOfDay().toLong() + val entry = (stopWatchTime / bufferSize) * bufferSize + val point = RecordedPoint(entry, newValue) + // adds the element to the end of queue + _buffer.offer(point) + if (_buffer.size >= bufferSize * 3) { + // remove the first pair + Log.d(TAG, "REMOVING SOME ITEMS FROM FRONT") + // removes the elements via polling them out + repeat(bufferSize) { + // if polling failed return from the block + _buffer.poll() ?: return@repeat + } + // items removed + Log.d(TAG, "ITEMS REMOVED") + } + } catch (e: Exception) { + e.printStackTrace() + } } - _lock.lock(this) + } - try { - val stopWatchTime = stopWatch.elapsedTime.value.toMillisecondOfDay().toLong() - val entry = (stopWatchTime / bufferSize) * bufferSize - val point = RecordedPoint(entry, newValue) - // adds the element to the end of queue - _buffer.add(point) - if (_buffer.size >= bufferSize * 3) { - // remove the first pair - Log.d(TAG, "REMOVING SOME ITEMS FROM FRONT") - // removes the elements - val itemsToRemove = _buffer.take(bufferSize) - _buffer.removeAll(itemsToRemove) - // items removed - Log.d(TAG, "ITEMS REMOVED") - } - } catch (e: Exception) { - e.printStackTrace() - } finally { - _lock.unlock() + internal suspend fun ShortArray.rms(readSize: Int): Float { + // lets not context switch + return coroutineScope { + val squaredAvg = take(readSize).map { it * it }.average().toFloat() + sqrt(squaredAvg) } } } \ No newline at end of file diff --git a/data/recorder/src/main/java/com/eva/recorder/data/reader/RecordedPointExt.kt b/data/recorder/src/main/java/com/eva/recorder/data/reader/RecordedPointExt.kt new file mode 100644 index 00000000..413b46d5 --- /dev/null +++ b/data/recorder/src/main/java/com/eva/recorder/data/reader/RecordedPointExt.kt @@ -0,0 +1,78 @@ +package com.eva.recorder.data.reader + +import com.eva.recorder.domain.models.RecordedPoint +import kotlin.math.abs + +internal fun Sequence.normalize(max: Int, min: Int): Sequence { + val range = (max - min).let { diff -> if (diff <= 0) 1 else diff } + return map { point -> + point.copy( + rmsValue = (abs(point.rmsValue - min) / range) + .coerceIn(0f..1f) + ) + } +} + +internal fun Sequence.smoothen(factor: Float = 0.3f): Sequence { + var prev = 0f + return map { point -> + prev = lerp(prev, point.rmsValue, factor) + point.copy(rmsValue = prev) + } +} + +internal fun Sequence.padListWithExtra( + bufferSize: Int, + extra: Int = 10 +): Sequence = sequence { + + val seen = mutableSetOf() + var size = 0 + var lastTime = 0L + + // Yield all incoming elements first, tracking size and last value + for (point in this@padListWithExtra.iterator()) { + if (seen.add(point.timeInMillis)) { + yield(point) + } + size++ + lastTime = point.timeInMillis + } + + // Compute how many extras we need *after* finishing + val differences = bufferSize - size + val amount = if (differences > 0) differences + extra else extra + + // Yield the padded extra points lazily + for (i in 0 until amount) { + val timeInMillis = lastTime + (i * bufferSize) + if (seen.add(timeInMillis)) { + yield(RecordedPoint(timeInMillis, 0f, true)) + } + } +} + +internal fun Sequence.toProperSequence(eachBlockSize: Int): Sequence { + return sequence { + + val iterator = this@toProperSequence.iterator() + if (!iterator.hasNext()) return@sequence + + var expected = iterator.next() + var start = expected.timeInMillis + + for (actualPoint in iterator) { + while (start < actualPoint.timeInMillis) { + yield(expected.copy(timeInMillis = start)) + start += eachBlockSize + } + yield(actualPoint) + expected = actualPoint + start += eachBlockSize + } + } +} + +private fun lerp(v0: Float, v1: Float, t: Float): Float { + return (1 - t) * v1 + t * v0 +} \ No newline at end of file diff --git a/data/recorder/src/main/java/com/eva/recorder/data/service/VoiceRecorderService.kt b/data/recorder/src/main/java/com/eva/recorder/data/service/VoiceRecorderService.kt index 2be17072..989e348e 100644 --- a/data/recorder/src/main/java/com/eva/recorder/data/service/VoiceRecorderService.kt +++ b/data/recorder/src/main/java/com/eva/recorder/data/service/VoiceRecorderService.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach @@ -79,7 +80,8 @@ internal class VoiceRecorderService : LifecycleService() { @OptIn(FlowPreview::class) private val notificationTimer: Flow - get() = voiceRecorder.recorderTimer.sample(1.seconds) + get() = voiceRecorder.recorderTimer + .distinctUntilChanged { old, new -> old.second == new.second } @OptIn(FlowPreview::class) val recorderTime: Flow @@ -227,22 +229,17 @@ internal class VoiceRecorderService : LifecycleService() { // stop the recording lifecycleScope.launch { val timeBeforeSave = voiceRecorder.recorderTimer.value - when (val result = voiceRecorder.stopRecording()) { - // show an error toast - is Resource.Error -> { - val message = result.message ?: result.error.message ?: "" - showSaveRecordingErrorMessage(message) - } - // save it to bookmarks - is Resource.Success -> { - val recordingId = result.data ?: return@launch + voiceRecorder.stopRecording().fold( + onSuccess = { recordingId -> clearAndSaveBookMarks(recordingId, timeBeforeSave) // again show the notification notificationHelper.showCompletedNotificationWithIntent(recordingId) - } - - else -> {} - } + }, + onFailure = { error -> + val message = error.message ?: "" + showSaveRecordingErrorMessage(message) + }, + ) //update widget widgetFacade.resetWidget() }.invokeOnCompletion { diff --git a/data/recorder/src/main/java/com/eva/recorder/data/service/startForegroundTypeMic.kt b/data/recorder/src/main/java/com/eva/recorder/data/service/startForegroundTypeMic.kt index e4f9d003..7904809b 100644 --- a/data/recorder/src/main/java/com/eva/recorder/data/service/startForegroundTypeMic.kt +++ b/data/recorder/src/main/java/com/eva/recorder/data/service/startForegroundTypeMic.kt @@ -11,31 +11,16 @@ import android.util.Log private const val LOGGER_TAG = "MIC_FOREGROUND_SERVICE" internal fun Service.startVoiceRecorderService(id: Int, notification: Notification) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - try { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) startForeground(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE) - } catch (e: ForegroundServiceTypeException) { - Log.e(LOGGER_TAG, "WRONG FG-SERVICE TYPE", e) - } catch (e: ForegroundServiceStartNotAllowedException) { + else startForeground(id, notification) + } catch (e: Exception) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && e is ForegroundServiceTypeException) + Log.e(LOGGER_TAG, "WRONG FG TYPE", e) + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && e is ForegroundServiceStartNotAllowedException) Log.e(LOGGER_TAG, "FG-SERVICE NOT ALLOWED TO START", e) - } - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - try { - startForeground(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE) - } catch (e: ForegroundServiceStartNotAllowedException) { - Log.e(LOGGER_TAG, "FG-SERVICE NOT ALLOWED TO START", e) - } - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - try { - startForeground(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE) - } catch (e: Exception) { - Log.e(LOGGER_TAG, "SOME EXCEPTION OCCURRED", e) - } - } else { - try { - startForeground(id, notification) - } catch (e: Exception) { + else Log.e(LOGGER_TAG, "SOME EXCEPTION OCCURRED", e) - } } } \ No newline at end of file diff --git a/data/recorder/src/main/java/com/eva/recorder/domain/VoiceRecorder.kt b/data/recorder/src/main/java/com/eva/recorder/domain/VoiceRecorder.kt index 8f6aedb1..37c3237e 100644 --- a/data/recorder/src/main/java/com/eva/recorder/domain/VoiceRecorder.kt +++ b/data/recorder/src/main/java/com/eva/recorder/domain/VoiceRecorder.kt @@ -2,7 +2,6 @@ package com.eva.recorder.domain import com.eva.recorder.domain.models.RecordedPoint import com.eva.recorder.domain.models.RecorderState -import com.eva.utils.Resource import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.datetime.LocalTime @@ -36,7 +35,7 @@ interface VoiceRecorder { /** * Stop the running recording */ - suspend fun stopRecording(): Resource + suspend fun stopRecording(): Result /** * Pause the ongoing recording diff --git a/data/recorder/src/main/java/com/eva/recorder/domain/models/RecordEncoderAndFormat.kt b/data/recorder/src/main/java/com/eva/recorder/domain/models/RecordEncoderAndFormat.kt deleted file mode 100644 index c1e3d88f..00000000 --- a/data/recorder/src/main/java/com/eva/recorder/domain/models/RecordEncoderAndFormat.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.eva.recorder.domain.models - -data class RecordEncoderAndFormat( - val encoder: Int, - val outputFormat: Int, - val mimeType: String, -) \ No newline at end of file diff --git a/data/recorder/src/main/java/com/eva/recorder/utils/Aliases.kt b/data/recorder/src/main/java/com/eva/recorder/utils/Aliases.kt index b9e2a098..79010022 100644 --- a/data/recorder/src/main/java/com/eva/recorder/utils/Aliases.kt +++ b/data/recorder/src/main/java/com/eva/recorder/utils/Aliases.kt @@ -4,4 +4,4 @@ import com.eva.recorder.domain.models.RecordedPoint import kotlin.time.Duration typealias DeferredRecordedPointList = () -> List -typealias DeferredDurationList = () -> Iterable \ No newline at end of file +typealias DeferredDurationList = () -> Set \ No newline at end of file diff --git a/data/recordings/src/main/java/com/eva/recordings/data/provider/RecorderFileProviderImpl.kt b/data/recordings/src/main/java/com/eva/recordings/data/provider/RecorderFileProviderImpl.kt index c2e278dc..3a146361 100644 --- a/data/recordings/src/main/java/com/eva/recordings/data/provider/RecorderFileProviderImpl.kt +++ b/data/recordings/src/main/java/com/eva/recordings/data/provider/RecorderFileProviderImpl.kt @@ -14,14 +14,15 @@ import com.eva.database.entity.RecordingsMetaDataEntity import com.eva.datastore.domain.enums.AudioFileNamingFormat import com.eva.datastore.domain.repository.RecorderFileSettingsRepo import com.eva.recordings.data.wrapper.RecordingsConstants -import com.eva.recordings.data.wrapper.RecordingsContentResolverWrapper +import com.eva.recordings.domain.exceptions.MediastoreOperationException import com.eva.recordings.domain.provider.RecorderFileProvider import com.eva.utils.LocalTimeFormats +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext import kotlinx.datetime.format import kotlinx.datetime.toKotlinLocalDateTime @@ -35,93 +36,110 @@ internal class RecorderFileProviderImpl( private val context: Context, private val recordingDao: RecordingsMetadataDao, private val settings: RecorderFileSettingsRepo, -) : RecordingsContentResolverWrapper(context), RecorderFileProvider { +) : RecorderFileProvider { - private val tempPrefix = "RECORDING" + private val _tempRecordingDir by lazy { + File(context.cacheDir, "temp_recordings") + .apply(File::mkdirs) + } override suspend fun createFileForRecording(extension: String?): File { return withContext(Dispatchers.IO) { - val file = File.createTempFile(tempPrefix, extension, context.cacheDir) + val ext = extension.takeIf { it?.startsWith(".") ?: false } ?: ".tmp" + val file = File.createTempFile("some_recordings", ext, _tempRecordingDir) Log.d(LOGGER_TAG, "FILE CREATED FOR RECORDING NAME: ${file.name}") file } } - override suspend fun transferFileDataToStorage(file: File, mimeType: String): Long? { + override suspend fun transferFileDataToStorage(file: File, mimeType: String): Result { return withContext(Dispatchers.IO) { try { - // file don't exits + // file don't exist if (!file.exists()) { - Log.d(LOGGER_TAG, "FILE DON'T EXIT'S") - return@withContext null + Log.d(LOGGER_TAG, "FILE DON'T EXISTS") + return@withContext Result.failure(Exception("File missing exception")) } // content uri cannot be created - val contentUri = createUriForRecording(mimeType) ?: run { - Log.d(LOGGER_TAG, "CANNOT CREATE URI FOR RECORDING") - return@withContext null - } + val contentUri = createContentUriAndCopy(file, mimeType) + ?: return@withContext Result.failure(MediastoreOperationException()) - Log.d(LOGGER_TAG, "UPDATING THE FILE CONTENT..") - val job = launch(Dispatchers.IO) { - contentResolver.openOutputStream(contentUri, "w")?.use { stream -> - stream.write(file.readBytes()) - } - } - // wait for the file data to be completely read - job.join() - Log.d(LOGGER_TAG, "CONTENT COPIED DONE") val uriId = ContentUris.parseId(contentUri) - // update the metadata for the file - val mediaStoreUpdate = async(Dispatchers.IO) { - updateUriAfterRecording(contentUri) - } - val delTempFile = async(Dispatchers.IO) { - deleteCreatedFile(file) - } - // save the secondary metadata - val otherMetadataDataUpdate = async(Dispatchers.IO) { - val entity = RecordingsMetaDataEntity(recordingId = uriId) - recordingDao.updateOrInsertRecordingMetadata(entity) + // launching a supervisor scope to ensure one error doesn't effect the other + supervisorScope { + val delTempFile = async { deleteCreatedFile(file) } + // save the secondary metadata + val otherMetadataDataUpdate = async { + val entity = RecordingsMetaDataEntity(recordingId = uriId) + recordingDao.updateOrInsertRecordingMetadata(entity) + } + awaitAll(otherMetadataDataUpdate, delTempFile) } - // execute them parallel - Log.d(LOGGER_TAG, "UPDATING METADATA CONCURRENTLY") - awaitAll(mediaStoreUpdate, otherMetadataDataUpdate, delTempFile) - Log.d(LOGGER_TAG, "UPDATE COMPLETED") - return@withContext uriId - } catch (e: IllegalArgumentException) { - Log.e(LOGGER_TAG, "EXTRAS PROVIDED WRONG", e) + return@withContext Result.success(uriId) + } catch (e: CancellationException) { + throw e } catch (e: IOException) { e.printStackTrace() + Result.failure(e) } - return@withContext null } } override suspend fun deleteCreatedFile(file: File): Boolean { return withContext(Dispatchers.IO) { - // ensure the file exits and starts with the tempPrefix - if (!file.exists() && !file.name.startsWith(tempPrefix, true)) return@withContext false - val result = file.delete() - Log.d(LOGGER_TAG, "TEMP FILE DELETED : $result") - result + try { + // ensure the file exits and starts with the tempPrefix + if (!file.exists()) return@withContext false + if (file.parentFile != _tempRecordingDir) return@withContext false + val result = file.delete() + Log.d(LOGGER_TAG, "TEMP FILE DELETED : $result") + result + } catch (_: SecurityException) { + false + } + } + } + + private suspend fun createContentUriAndCopy(file: File, mimeType: String): Uri? { + return withContext(Dispatchers.IO) { + val contentUri = createUriForRecording(mimeType) ?: return@withContext null + Log.d(LOGGER_TAG, "CONTENT URI CREATED") + try { + context.contentResolver.openOutputStream(contentUri, "w")?.use { outStream -> + file.inputStream().use { inStream -> inStream.copyTo(outStream) } + } + Log.d(LOGGER_TAG, "CONTENT COPIED") + val newMetaData = ContentValues().apply { + put(MediaStore.Audio.AudioColumns.IS_PENDING, 0) + } + val result = context.contentResolver.update(contentUri, newMetaData, null, null) + Log.d(LOGGER_TAG, "UPDATED URI AFTER COPY :${result == 1}") + contentUri + } catch (_: Exception) { + withContext(NonCancellable) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + context.contentResolver.delete(contentUri, null) + else context.contentResolver.delete(contentUri, null, null) + Log.d(LOGGER_TAG, "CONTENT URI DELETED") + } + null + } } } - private suspend fun createUriForRecording(mimeType: String): Uri? = coroutineScope { + private suspend fun createUriForRecording(mimeType: String): Uri? { val fileSettings = settings.fileSettings() - val namingStrategyDeferred = async(Dispatchers.IO) { - when (fileSettings.format) { - AudioFileNamingFormat.DATE_TIME -> { - LocalDateTime.now().toKotlinLocalDateTime() - .format(LocalTimeFormats.RECORDING_RECORD_TIME_FORMAT) - .trim() - } + val namingStrategy = when (fileSettings.format) { + AudioFileNamingFormat.DATE_TIME -> { + LocalDateTime.now().toKotlinLocalDateTime() + .format(LocalTimeFormats.RECORDING_RECORD_TIME_FORMAT) + .trim() + } - AudioFileNamingFormat.COUNT -> { - val currentCount = getItemNumber() - "${currentCount + 1}".padStart(3, '0').trim() - } + AudioFileNamingFormat.COUNT -> { + val currentCount = getItemNumber() + "${currentCount + 1}".padStart(3, '0').trim() } } @@ -129,47 +147,31 @@ internal class RecorderFileProviderImpl( val fileName = buildString { append(fileSettings.name) append("-") - append(namingStrategyDeferred.await()) + append(namingStrategy) } // metadata val metaData = ContentValues().apply { - put(MediaStore.Audio.AudioColumns.RELATIVE_PATH, + put( + MediaStore.Audio.AudioColumns.RELATIVE_PATH, RecordingsConstants.RECORDINGS_MUSIC_PATH ) put(MediaStore.Audio.AudioColumns.DISPLAY_NAME, fileName) put(MediaStore.Audio.AudioColumns.MIME_TYPE, mimeType) - put(MediaStore.Audio.AudioColumns.DATE_ADDED, epochSeconds) put(MediaStore.Audio.AudioColumns.IS_PENDING, 1) } // insert the metadata on IO thread - withContext(Dispatchers.IO) { + return withContext(Dispatchers.IO) { Log.d(LOGGER_TAG, "CREATING FILE WITH METADATA :$metaData") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - contentResolver.insert(RecordingsConstants.AUDIO_VOLUME_URI, metaData, null) + context.contentResolver.insert(RecordingsConstants.AUDIO_VOLUME_URI, metaData, null) } else { - contentResolver.insert(RecordingsConstants.AUDIO_VOLUME_URI, metaData) + context.contentResolver.insert(RecordingsConstants.AUDIO_VOLUME_URI, metaData) } } } - private suspend fun updateUriAfterRecording(file: Uri): Boolean { - val updatedMetaData = ContentValues().apply { - put(MediaStore.Audio.AudioColumns.IS_PENDING, 0) - put(MediaStore.Audio.AudioColumns.DATE_MODIFIED, epochSeconds) - } - Log.d(LOGGER_TAG, "UPDATING FILE METADATA :$updatedMetaData") - - val result = withContext(Dispatchers.IO) { - contentResolver.update(file, updatedMetaData, null, null) - } - Log.d(LOGGER_TAG, "UPDATED URI AFTER RECORDING") - - return result == 1 - } - - private suspend fun getItemNumber(): Int { val projection = arrayOf(MediaStore.Audio.AudioColumns._ID) val selection = "${MediaStore.Audio.AudioColumns.OWNER_PACKAGE_NAME} = ?" @@ -181,7 +183,12 @@ internal class RecorderFileProviderImpl( ) return withContext(Dispatchers.IO) { - contentResolver.query(RecordingsConstants.AUDIO_VOLUME_URI, projection, bundle, null) + context.contentResolver.query( + RecordingsConstants.AUDIO_VOLUME_URI, + projection, + bundle, + null + ) ?.use { cursor -> cursor.count } ?: 0 } diff --git a/data/recordings/src/main/java/com/eva/recordings/domain/exceptions/MediastoreOperationException.kt b/data/recordings/src/main/java/com/eva/recordings/domain/exceptions/MediastoreOperationException.kt new file mode 100644 index 00000000..61ead55f --- /dev/null +++ b/data/recordings/src/main/java/com/eva/recordings/domain/exceptions/MediastoreOperationException.kt @@ -0,0 +1,3 @@ +package com.eva.recordings.domain.exceptions + +class MediastoreOperationException : Exception("Issue eith media store operations") \ No newline at end of file diff --git a/data/recordings/src/main/java/com/eva/recordings/domain/provider/RecorderFileProvider.kt b/data/recordings/src/main/java/com/eva/recordings/domain/provider/RecorderFileProvider.kt index 4cea374c..9dad5cf1 100644 --- a/data/recordings/src/main/java/com/eva/recordings/domain/provider/RecorderFileProvider.kt +++ b/data/recordings/src/main/java/com/eva/recordings/domain/provider/RecorderFileProvider.kt @@ -16,9 +16,9 @@ interface RecorderFileProvider { * database if entry successfully entered * @param file The File whose data to be copied or metadata need to be evaluated * @param mimeType MimeType of the file to be submitted - * @return The recordingId assigned for the current recording + * @return The result with recording id if it's a success */ - suspend fun transferFileDataToStorage(file: File, mimeType: String): Long? + suspend fun transferFileDataToStorage(file: File, mimeType: String): Result /** diff --git a/feature/recorder/src/main/java/com/eva/feature_recorder/composable/RecorderAmplitudeGraph.kt b/feature/recorder/src/main/java/com/eva/feature_recorder/composable/RecorderAmplitudeGraph.kt index 2409a0de..84e0a5d5 100644 --- a/feature/recorder/src/main/java/com/eva/feature_recorder/composable/RecorderAmplitudeGraph.kt +++ b/feature/recorder/src/main/java/com/eva/feature_recorder/composable/RecorderAmplitudeGraph.kt @@ -135,7 +135,7 @@ internal fun RecorderAmplitudeGraph( private fun RecorderAmplitudeGraphPreview() = RecorderAppTheme { RecorderAmplitudeGraph( recoderPointsCallback = { RecorderPreviewFakes.PREVIEW_RECORDER_AMPLITUDE_FLOAT_ARRAY_LARGE }, - bookMarksDeferred = { listOf(2.seconds, 5.seconds) }, + bookMarksDeferred = { setOf(2.seconds, 5.seconds) }, modifier = Modifier.fillMaxWidth(), ) } diff --git a/feature/recorder/src/main/java/com/eva/feature_recorder/composable/RecorderContent.kt b/feature/recorder/src/main/java/com/eva/feature_recorder/composable/RecorderContent.kt index 4a460746..5e64c999 100644 --- a/feature/recorder/src/main/java/com/eva/feature_recorder/composable/RecorderContent.kt +++ b/feature/recorder/src/main/java/com/eva/feature_recorder/composable/RecorderContent.kt @@ -87,7 +87,7 @@ private fun RecorderContentPreview( RecorderContent( timer = LocalTime(0, 10, 56, 0), recorderState = recorderState, - bookMarksDeferred = { listOf() }, + bookMarksDeferred = { setOf() }, recordingPointsCallback = { RecorderPreviewFakes.PREVIEW_RECORDER_AMPLITUDE_FLOAT_ARRAY_LARGE }, onRecorderAction = {}, modifier = Modifier From 7fbbab452af8463b59fdd300fc201687aaa3e100 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sun, 2 Nov 2025 00:15:19 +0530 Subject: [PATCH 14/25] AudioFileModel.kt not all data is loaded In most cases MediaMetaDataInfo.kt is not required so it's wasteful to read these values as these required MediaExtractor PlayerFileProvider.kt method include optional argument readMetadata to allow reading extra metadata Using Result API rather in Resource in PlayerFileProvider.getAudioFileFromId --- .../interactions/ShareRecordingsIntentTest.kt | 6 +- .../data/provider/PlayerFileProviderImpl.kt | 75 ++++++++++--------- .../data/utils/MediaMetaDataInfo.kt | 8 -- .../domain/models/AudioFileModel.kt | 14 +--- .../domain/models/MediaMetaDataInfo.kt | 14 ++++ .../domain/provider/PlayerFileProvider.kt | 10 ++- .../player_shared/util/PlayerPreviewFakes.kt | 14 ++-- .../composable/FileMetaDataSheetContent.kt | 75 +++++++++++-------- .../viewmodel/AudioPlayerViewModel.kt | 12 ++- 9 files changed, 124 insertions(+), 104 deletions(-) delete mode 100644 data/recordings/src/main/java/com/eva/recordings/data/utils/MediaMetaDataInfo.kt create mode 100644 data/recordings/src/main/java/com/eva/recordings/domain/models/MediaMetaDataInfo.kt diff --git a/data/interactions/src/androidTest/java/com/eva/interactions/ShareRecordingsIntentTest.kt b/data/interactions/src/androidTest/java/com/eva/interactions/ShareRecordingsIntentTest.kt index 2ca11392..ab64c110 100644 --- a/data/interactions/src/androidTest/java/com/eva/interactions/ShareRecordingsIntentTest.kt +++ b/data/interactions/src/androidTest/java/com/eva/interactions/ShareRecordingsIntentTest.kt @@ -75,10 +75,8 @@ class ShareRecordingsIntentTest { size = 1024 * 20, lastModified = LocalDateTime.now().toKotlinLocalDateTime(), fileUri = contentURI.toString(), - mimeType = "audio/mp3", path = "", - channel = 1, - bitRateInKbps = 0f, - samplingRateKHz = 0f + mimeType = "audio/mp3", + path = "", ) try { diff --git a/data/recordings/src/main/java/com/eva/recordings/data/provider/PlayerFileProviderImpl.kt b/data/recordings/src/main/java/com/eva/recordings/data/provider/PlayerFileProviderImpl.kt index 2e1751e0..cae46a77 100644 --- a/data/recordings/src/main/java/com/eva/recordings/data/provider/PlayerFileProviderImpl.kt +++ b/data/recordings/src/main/java/com/eva/recordings/data/provider/PlayerFileProviderImpl.kt @@ -5,7 +5,6 @@ import android.content.ContentUris import android.content.Context import android.database.ContentObserver import android.database.Cursor -import android.database.SQLException import android.media.MediaExtractor import android.media.MediaFormat import android.media.MediaMetadataRetriever @@ -13,21 +12,21 @@ import android.net.Uri import android.os.Build import android.provider.MediaStore import android.util.Log +import androidx.core.net.toUri import androidx.core.os.bundleOf import com.eva.datastore.domain.repository.RecorderAudioSettingsRepo import com.eva.location.domain.repository.LocationAddressProvider import com.eva.location.domain.utils.parseLocationFromString -import com.eva.recordings.data.utils.MediaMetaDataInfo import com.eva.recordings.data.wrapper.RecordingsConstants import com.eva.recordings.data.wrapper.RecordingsContentResolverWrapper import com.eva.recordings.domain.exceptions.InvalidRecordingIdException import com.eva.recordings.domain.models.AudioFileModel +import com.eva.recordings.domain.models.MediaMetaDataInfo import com.eva.recordings.domain.provider.PlayerFileProvider import com.eva.recordings.domain.provider.ResourcedDetailedRecordingModel import com.eva.utils.Resource import com.eva.utils.toLocalDateTime import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow @@ -61,28 +60,36 @@ internal class PlayerFileProviderImpl( return ContentUris.withAppendedId(RecordingsConstants.AUDIO_VOLUME_URI, audioId).toString() } - override fun getAudioFileFromIdFlow(id: Long): Flow { + override fun getAudioFileFromIdFlow( + id: Long, + readMetaData: Boolean + ): Flow { return callbackFlow { - - var updateJob: Job? = null // send loading trySend(Resource.Loading) // send the data launch(Dispatchers.IO) { // evaluate it and send - val first = getAudioFileFromId(id) - send(first) + val first = getAudioFileFromId(id, readMetaData) + first.fold( + onSuccess = { send(Resource.Success(it)) }, + onFailure = { + send(Resource.Error(Exception(it))) + }, + ) } val contentObserver = object : ContentObserver(null) { override fun onChange(selfChange: Boolean) { - // observer has found some changes - // cancel the previous job and run new one - updateJob?.cancel() - updateJob = launch(Dispatchers.IO) { - val update = getAudioFileFromId(id) - send(update) + launch(Dispatchers.IO) { + val updated = getAudioFileFromId(id, readMetaData) + updated.fold( + onSuccess = { send(Resource.Success(it)) }, + onFailure = { + send(Resource.Error(Exception(it))) + }, + ) } } } @@ -99,7 +106,10 @@ internal class PlayerFileProviderImpl( } } - override suspend fun getAudioFileFromId(id: Long): ResourcedDetailedRecordingModel { + override suspend fun getAudioFileFromId( + id: Long, + readMetaData: Boolean + ): Result { val selection = "${MediaStore.Audio.AudioColumns._ID} = ?" val selectionArgs = arrayOf("$id") @@ -108,24 +118,21 @@ internal class PlayerFileProviderImpl( ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to selectionArgs ) return withContext(Dispatchers.IO) { - try { + runCatching { contentResolver.query( RecordingsConstants.AUDIO_VOLUME_URI, _projection, bundle, null - ) - ?.use { cur -> evaluateValuesFromCursor(cur) } - ?.let { Resource.Success(it) } - ?: Resource.Error(InvalidRecordingIdException()) - } catch (e: SecurityException) { - Resource.Error(e, "CANNOT ACCESS FILE PERMISSION WAS NOT GRANTED") - } catch (e: SQLException) { - e.printStackTrace() - Resource.Error(e, "SQL EXCEPTION") - } catch (e: Exception) { - e.printStackTrace() - Resource.Error(e) + )?.use { cur -> + val result = evaluateValuesFromCursor(cur) + ?: return@withContext Result.failure(InvalidRecordingIdException()) + val metadata = if (readMetaData) { + Log.d(TAG, "READING METADATA") + extractMediaInfo(result.fileUri.toUri()) + } else null + result.copy(metaData = metadata) + } ?: return@withContext Result.failure(InvalidRecordingIdException()) } } } @@ -150,7 +157,9 @@ internal class PlayerFileProviderImpl( val locationAsString = async { val audioSettings = settings.audioSettings() if (!audioSettings.addLocationInfoInRecording) return@async null - parseLocationFromString(retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)) + val locationString = + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION) + parseLocationFromString(locationString) ?.let { addressProvider.invoke(it).getOrNull() } } @@ -160,7 +169,7 @@ internal class PlayerFileProviderImpl( MediaMetaDataInfo( channelCount = channelCount, sampleRate = sampleRate, - bitRate = bitRate / 1_000f, + bitRate = bitRate, locationString = locationAsString.await() ) } @@ -200,8 +209,6 @@ internal class PlayerFileProviderImpl( val mimeType = cursor.getString(mimeTypeColumn) val contentUri = ContentUris.withAppendedId(RecordingsConstants.AUDIO_VOLUME_URI, id) - val extractor = extractMediaInfo(contentUri) - AudioFileModel( id = id, title = title, @@ -209,13 +216,9 @@ internal class PlayerFileProviderImpl( duration = duration.milliseconds, size = size, fileUri = contentUri.toString(), - bitRateInKbps = extractor?.bitRate ?: 0f, lastModified = lastModified.seconds.toLocalDateTime(), - channel = extractor?.channelCount ?: 0, path = relPath, mimeType = mimeType, - samplingRateKHz = (extractor?.sampleRate ?: 0) / 1000f, - metaDataLocation = extractor?.locationString ?: "" ) } } diff --git a/data/recordings/src/main/java/com/eva/recordings/data/utils/MediaMetaDataInfo.kt b/data/recordings/src/main/java/com/eva/recordings/data/utils/MediaMetaDataInfo.kt deleted file mode 100644 index 6e08d05c..00000000 --- a/data/recordings/src/main/java/com/eva/recordings/data/utils/MediaMetaDataInfo.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.eva.recordings.data.utils - -data class MediaMetaDataInfo( - val channelCount: Int = 0, - val sampleRate: Int = 0, - val bitRate: Float = 0f, - val locationString: String? = null, -) \ No newline at end of file diff --git a/data/recordings/src/main/java/com/eva/recordings/domain/models/AudioFileModel.kt b/data/recordings/src/main/java/com/eva/recordings/domain/models/AudioFileModel.kt index 2f61866d..be2e5771 100644 --- a/data/recordings/src/main/java/com/eva/recordings/domain/models/AudioFileModel.kt +++ b/data/recordings/src/main/java/com/eva/recordings/domain/models/AudioFileModel.kt @@ -7,24 +7,18 @@ import kotlin.time.DurationUnit data class AudioFileModel( val id: Long, - val title: String, val displayName: String, val size: Long, val duration: Duration, val lastModified: LocalDateTime, - val channel: Int, - val bitRateInKbps: Float, - val samplingRateKHz: Float, - val path: String, val fileUri: String, val mimeType: String, + val title: String = displayName, + val path: String? = null, val isFavourite: Boolean = false, - val metaDataLocation: String = "", + val metaData: MediaMetaDataInfo? = null, ) { val durationAsLocaltime: LocalTime - get() = LocalTime.Companion.fromSecondOfDay(duration.toInt(DurationUnit.SECONDS)) - - val hasLocation: Boolean - get() = metaDataLocation.isNotBlank() + get() = LocalTime.fromSecondOfDay(duration.toInt(DurationUnit.SECONDS)) } \ No newline at end of file diff --git a/data/recordings/src/main/java/com/eva/recordings/domain/models/MediaMetaDataInfo.kt b/data/recordings/src/main/java/com/eva/recordings/domain/models/MediaMetaDataInfo.kt new file mode 100644 index 00000000..c9c10401 --- /dev/null +++ b/data/recordings/src/main/java/com/eva/recordings/domain/models/MediaMetaDataInfo.kt @@ -0,0 +1,14 @@ +package com.eva.recordings.domain.models + +data class MediaMetaDataInfo( + val channelCount: Int = 0, + val sampleRate: Int = 0, + val bitRate: Int = 0, + val locationString: String? = null, +) { + val sampleRateInKHz: Float + get() = sampleRate / 1_000f + + val bitRateInKbps + get() = bitRate / 1_000f +} \ No newline at end of file diff --git a/data/recordings/src/main/java/com/eva/recordings/domain/provider/PlayerFileProvider.kt b/data/recordings/src/main/java/com/eva/recordings/domain/provider/PlayerFileProvider.kt index 0020dec1..5c6104b8 100644 --- a/data/recordings/src/main/java/com/eva/recordings/domain/provider/PlayerFileProvider.kt +++ b/data/recordings/src/main/java/com/eva/recordings/domain/provider/PlayerFileProvider.kt @@ -10,7 +10,13 @@ interface PlayerFileProvider { fun providesAudioFileUri(audioId: Long): String - fun getAudioFileFromIdFlow(id: Long): Flow + fun getAudioFileFromIdFlow( + id: Long, + readMetaData: Boolean = true + ): Flow - suspend fun getAudioFileFromId(id: Long): ResourcedDetailedRecordingModel + suspend fun getAudioFileFromId( + id: Long, + readMetaData: Boolean = false + ): Result } \ No newline at end of file diff --git a/feature/player-shared/src/main/java/com/eva/player_shared/util/PlayerPreviewFakes.kt b/feature/player-shared/src/main/java/com/eva/player_shared/util/PlayerPreviewFakes.kt index 4a8d7f69..1ddbf25a 100644 --- a/feature/player-shared/src/main/java/com/eva/player_shared/util/PlayerPreviewFakes.kt +++ b/feature/player-shared/src/main/java/com/eva/player_shared/util/PlayerPreviewFakes.kt @@ -2,6 +2,7 @@ package com.eva.player_shared.util import com.eva.player.domain.model.PlayerTrackData import com.eva.recordings.domain.models.AudioFileModel +import com.eva.recordings.domain.models.MediaMetaDataInfo import kotlinx.datetime.toKotlinLocalDateTime import java.time.LocalDateTime import kotlin.time.Duration @@ -126,20 +127,23 @@ object PlayerPreviewFakes { return data.toFloatArray() } + val FAKE_MEDIA_INFO = MediaMetaDataInfo( + bitRate = 128_000, + sampleRate = 44_100, + channelCount = 1, + locationString = null + ) + val FAKE_AUDIO_MODEL = AudioFileModel( id = 0L, title = "Voice_001", displayName = "Voice_001.abc", duration = 5.minutes, fileUri = "", - bitRateInKbps = 0f, lastModified = LocalDateTime.now().toKotlinLocalDateTime(), - samplingRateKHz = 0f, - path = "this_is_a_path/file", - channel = 1, size = 100L, mimeType = "This/that", - isFavourite = true + isFavourite = true, metaData = FAKE_MEDIA_INFO ) val FAKE_TRACK_DATA = PlayerTrackData(current = 4.seconds, total = 10.seconds) diff --git a/feature/player/src/main/java/com/eva/feature_player/composable/FileMetaDataSheetContent.kt b/feature/player/src/main/java/com/eva/feature_player/composable/FileMetaDataSheetContent.kt index f7a87195..c68ee3f0 100644 --- a/feature/player/src/main/java/com/eva/feature_player/composable/FileMetaDataSheetContent.kt +++ b/feature/player/src/main/java/com/eva/feature_player/composable/FileMetaDataSheetContent.kt @@ -1,7 +1,6 @@ package com.eva.feature_player.composable import android.text.format.Formatter -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -27,7 +26,6 @@ import com.eva.ui.theme.RecorderAppTheme import com.eva.utils.LocalTimeFormats import kotlinx.datetime.format -@OptIn(ExperimentalFoundationApi::class) @Composable fun FileMetaDataSheetContent( audio: AudioFileModel, @@ -93,43 +91,56 @@ fun FileMetaDataSheetContent( text = lastModified ) } - item { - FileMetaData( - title = stringResource(id = R.string.audio_metadata_channel_title), - text = stringResource( - id = if (audio.channel == 1) R.string.audio_metadata_channel_mono - else R.string.audio_metadata_channel_stereo + audio.metaData?.let { metaData -> + item { + FileMetaData( + title = stringResource(id = R.string.audio_metadata_channel_title), + text = stringResource( + id = if (metaData.channelCount == 1) R.string.audio_metadata_channel_mono + else R.string.audio_metadata_channel_stereo + ), + modifier = Modifier.animateItem(), ) - ) - } - item { - FileMetaData( - title = stringResource(id = R.string.audio_metadata_bitrate_title), - text = stringResource(id = R.string.audio_metadata_bitrate, audio.bitRateInKbps) - ) - } - item { - FileMetaData( - title = stringResource(id = R.string.audio_metadata_sample_rate_title), - text = stringResource( - id = R.string.audio_metadata_sample_rate, - audio.samplingRateKHz + } + item { + FileMetaData( + title = stringResource(id = R.string.audio_metadata_bitrate_title), + text = stringResource( + id = R.string.audio_metadata_bitrate, + metaData.bitRateInKbps + ), + modifier = Modifier.animateItem(), ) - ) - } - item { - FileMetaData( - title = stringResource(id = R.string.audio_metadata_path_title), - text = audio.path - ) + } + item { + FileMetaData( + title = stringResource(id = R.string.audio_metadata_sample_rate_title), + text = stringResource( + id = R.string.audio_metadata_sample_rate, + metaData.sampleRateInKHz + ), + modifier = Modifier.animateItem(), + ) + } + metaData.locationString?.let { location -> + item { + FileMetaData( + title = stringResource(R.string.audio_metadata_file_location), + text = location, + modifier = Modifier.animateItem() + ) + } + } } - if (audio.hasLocation) { + audio.path?.let { path -> item { FileMetaData( - title = stringResource(R.string.audio_metadata_file_location), - text = audio.metaDataLocation + title = stringResource(id = R.string.audio_metadata_path_title), + text = path, + modifier = Modifier.animateItem(), ) } + } } } diff --git a/feature/player/src/main/java/com/eva/feature_player/viewmodel/AudioPlayerViewModel.kt b/feature/player/src/main/java/com/eva/feature_player/viewmodel/AudioPlayerViewModel.kt index d00df923..c48fcd7e 100644 --- a/feature/player/src/main/java/com/eva/feature_player/viewmodel/AudioPlayerViewModel.kt +++ b/feature/player/src/main/java/com/eva/feature_player/viewmodel/AudioPlayerViewModel.kt @@ -10,7 +10,6 @@ import com.eva.recordings.domain.models.AudioFileModel import com.eva.recordings.domain.provider.PlayerFileProvider import com.eva.ui.viewmodel.AppViewModel import com.eva.ui.viewmodel.UIEvents -import com.eva.utils.Resource import dagger.assisted.Assisted import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel @@ -25,7 +24,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @HiltViewModel(assistedFactory = PlayerViewmodelFactory::class) @@ -115,11 +114,10 @@ internal class AudioPlayerViewModel @AssistedInject constructor( private fun setAudioModel() = viewModelScope.launch { val result = fileProvider.getAudioFileFromId(audioId) - when (result) { - is Resource.Success -> _currentFile.updateAndGet { result.data } - is Resource.Error -> _uiEvents.emit(UIEvents.ShowSnackBar(result.message ?: "")) - else -> {} - } + result.fold( + onSuccess = { data -> _currentFile.update { data } }, + onFailure = { error -> _uiEvents.emit(UIEvents.ShowSnackBar(error.message ?: "")) }, + ) } override fun onCleared() { From 1b0838faa8347611165a5257bd258a5d2fc04312 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sun, 2 Nov 2025 01:07:54 +0530 Subject: [PATCH 15/25] Corrections in recordings RecordingsContentResolverWrapper.kt in utils catching exceptions but only rethrowing if it's a securityexception TrashRecordingsProvider.kt included both flow and resource based methods for deleting RemoveTrashRecordingTaskImpl.kt the difference time is reduced to min 1 hr Use of supervisorScope while creating and restoring trash recording files in TrashRecordingsProviderApi29Impl.kt For trashing or deleting first the owner recording are moved then the non owner recordings emits the security exceptions which is handled in the Viewmodel each of the handleSecurityException methods in viewmodel added a flag to enable it, only rename is made enabled In Request handlers rememberUpdatedState used to get current lambda value --- .../TrashRecordingsProviderApi29Impl.kt | 285 ++++++++++-------- .../provider/TrashRecordingsProviderImpl.kt | 191 +++++++----- .../provider/VoiceRecordingsProviderImpl.kt | 47 +-- .../data/task/RemoveTrashRecordingTaskImpl.kt | 54 ++-- .../RecordingsContentResolverWrapper.kt | 107 ++++--- .../provider/TrashRecordingsProvider.kt | 14 +- .../bin/RecordingsBinViewmodel.kt | 54 ++-- .../handlers/DeleteItemRequestHandler.kt | 5 +- .../handlers/RenameItemRequestHandler.kt | 5 +- .../handlers/TrashItemRequestHandler.kt | 5 +- .../recordings/RecordingsViewmodel.kt | 10 +- .../rename/RenameDialogViewModel.kt | 34 ++- 12 files changed, 469 insertions(+), 342 deletions(-) diff --git a/data/recordings/src/main/java/com/eva/recordings/data/provider/TrashRecordingsProviderApi29Impl.kt b/data/recordings/src/main/java/com/eva/recordings/data/provider/TrashRecordingsProviderApi29Impl.kt index 96f52de0..d916f24a 100644 --- a/data/recordings/src/main/java/com/eva/recordings/data/provider/TrashRecordingsProviderApi29Impl.kt +++ b/data/recordings/src/main/java/com/eva/recordings/data/provider/TrashRecordingsProviderApi29Impl.kt @@ -33,6 +33,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone @@ -53,7 +55,7 @@ internal class TrashRecordingsProviderApi29Impl( private val folderName = "trash_recordings" - private val filesDir: File + private val trashFilesDirectory: File get() = File(context.filesDir, folderName).apply(File::mkdirs) @OptIn(ExperimentalTime::class) @@ -84,111 +86,126 @@ internal class TrashRecordingsProviderApi29Impl( } override suspend fun restoreRecordingsFromTrash(recordings: Collection): Resource { - // ensure that only this app files are restore. - return coroutineScope { - try { - // recordings are from here only so no need to check for owner + return try { + // recordings are from here only so no need to check for owner + supervisorScope { val operations = recordings.map { model -> val entity = model.toEntity() - Log.d(TAG, "WORKING FOR $entity") // async async(Dispatchers.IO) { // restore from internal storage restoreRecordingsDataFromTableAndFile(entity) // delete from internal storage - deleteRecordingInfoFromFileAndTable(entity) - Unit + removeBackupFileAndMetadata(entity) } } // run all the operations together operations.awaitAll() + } - val createSecondaryMetadata = recordings.map { model -> - RecordingsMetaDataEntity(model.id) - } - withContext(Dispatchers.IO) { - recordingsDao.updateOrInsertRecordingMetadataBulk(createSecondaryMetadata) - } - - Log.d(TAG, "TRASHED ITEMS RECOVERED") - - val message = context.getString(R.string.restore_recordings_success) - Resource.Success(Unit, message) - } catch (e: Exception) { - Resource.Error(e) + val createSecondaryMetadata = recordings.map { model -> + RecordingsMetaDataEntity(model.id) } + recordingsDao.updateOrInsertRecordingMetadataBulk(createSecondaryMetadata) + Log.d(TAG, "TRASHED ITEMS RECOVERED") + + val message = context.getString(R.string.restore_recordings_success) + Resource.Success(Unit, message) + } catch (e: Exception) { + Resource.Error(e) } } - override fun createTrashRecordings(recordings: Collection) : Flow, Exception>> { - val recordingsWithOwnerShip = recordings.filter { it.owner == context.packageName } - val recordingsWithoutOwnerShip = recordings.filterNot { it.owner == context.packageName } + val ownerRecordings = recordings.filter { it.owner == context.packageName } + val notOwnerRecordings = recordings.filterNot { it.owner == context.packageName } - return flow, Exception>> { - - if (recordingsWithOwnerShip.isNotEmpty()) { - try { - coroutineScope { - // perform operations with creating a backup file - val operations = recordingsWithOwnerShip.map { recording -> - async(Dispatchers.IO) { - createBackUpEntry(recording) - } - } - // run all the operations together - operations.awaitAll() - // now remove secondary data - val ids = recordings.map { it.id } - withContext(Dispatchers.IO) { - recordingsDao.deleteRecordingMetaDataFromIds(ids) - } + return flow { + try { + supervisorScope { + // perform operations with creating a backup file + val operations = ownerRecordings.map { recording -> + async(Dispatchers.IO) { createBackUpEntry(recording) } } - val successMessage = context.getString(R.string.recording_trash_request_success) - emit(Resource.Success(emptyList(), message = successMessage)) - } catch (e: Exception) { - emit(Resource.Error(e)) + // run all the operations together + operations.awaitAll() } + // now remove secondary data + val ids = recordings.map { it.id } + recordingsDao.deleteRecordingMetaDataFromIds(ids) + val successMessage = context.getString(R.string.recording_trash_request_success) + emit(Resource.Success(emptyList(), message = successMessage)) + } catch (e: Exception) { + Log.d(TAG, "SOME ERROR IN TRASHING FILES", e) + emit(Resource.Error(e)) } - if (recordingsWithoutOwnerShip.isNotEmpty()) { + if (notOwnerRecordings.isNotEmpty()) { emit(Resource.Error(CannotTrashFileDifferentOwnerException())) } - }.flowOn(Dispatchers.IO) + } } - override suspend fun permanentlyDeleteRecordingsInTrash(trashRecordings: Collection) - : Resource { - return coroutineScope { + override fun permanentlyDeleteRecordingsInTrash(trashRecordings: List) + : Flow, Exception>> { + return flow, Exception>> { try { // delete from internal storage - val operations = trashRecordings.map { model -> - async(Dispatchers.IO) { - // delete the file and the table - deleteRecordingInfoFromFileAndTable(model.toEntity()) + supervisorScope { + val operations = trashRecordings.map { model -> + async(Dispatchers.IO) { + // delete the file and the table + removeBackupFileAndMetadata(model.toEntity()) + } } + // run all the operations together + operations.awaitAll() } - // run all the operations together - operations.awaitAll() Log.d(TAG, "PERMANENT REMOVED FILES FROM TRASH:") - Resource.Success( - data = Unit, - message = context.getString(R.string.recording_delete_request_success) + emit( + Resource.Success( + data = emptyList(), + message = context.getString(R.string.recording_delete_request_success) + ) ) - } catch (e: CancellationException) { - throw e } catch (e: Exception) { e.printStackTrace() val errorMessage = context.getString(R.string.recording_delete_request_failed) - Resource.Error(e, errorMessage) + emit(Resource.Error(e, errorMessage)) } + }.flowOn(Dispatchers.IO) + } + override suspend fun permanentlyDeleteRecordings(trashRecordings: List): Resource { + return withContext(Dispatchers.IO) { + try { + // move items to trash + supervisorScope { + val operations = trashRecordings.map { model -> + async(Dispatchers.IO) { + // delete the file and the table + removeBackupFileAndMetadata(model.toEntity()) + } + } + // run all the operations together + operations.awaitAll() + } + // then clear the associated + Resource.Success( + Unit, + context.getString(R.string.recording_trash_request_success) + ) + } catch (e: Exception) { + e.printStackTrace() + val message = context.getString(R.string.recording_trash_request_failed) + Resource.Error(e, message) + } } } private suspend fun createBackUpEntry(recording: RecordedVoiceModel) { - val entry = createBackupFileForRecording(recording, thirtyDaysLater) + val entry = createBackupFileForRecording(recording, thirtyDaysLater) ?: return // add metadata to table trashMediaDao.addNewTrashFile(entry) val isDeleteSuccess = try { @@ -198,73 +215,86 @@ internal class TrashRecordingsProviderApi29Impl( // if delete was cancelled then delete the file and the table entry withContext(NonCancellable) { Log.d(TAG, "FAILED TO DELETE FILE FROM THE STORAGE") - deleteRecordingInfoFromFileAndTable(entry) + removeBackupFileAndMetadata(entry) } throw e } - if (!isDeleteSuccess) { - // if delete is unsuccessful so delete the file and the entry - deleteRecordingInfoFromFileAndTable(entry) - } + if (isDeleteSuccess) return + // if delete is unsuccessful so delete the file and the entry + removeBackupFileAndMetadata(entry) } private suspend fun restoreRecordingsDataFromTableAndFile(entity: TrashFileEntity) { - return coroutineScope { - val file = entity.file.toUri().toFile() - if (!file.exists()) { - Log.d(TAG, "FILE DO-NOT EXISTS") - return@coroutineScope - } + val file = entity.file.toUri().toFile() - val metaData = ContentValues().apply { - put(MediaStore.Audio.AudioColumns.RELATIVE_PATH, - RecordingsConstants.RECORDINGS_MUSIC_PATH - ) - put(MediaStore.Audio.AudioColumns.DISPLAY_NAME, entity.displayName) - put(MediaStore.Audio.AudioColumns.MIME_TYPE, entity.mimeType) - put(MediaStore.Audio.AudioColumns.DATE_ADDED, epochSeconds) - put(MediaStore.Audio.AudioColumns.IS_PENDING, 1) - } + if (!file.exists()) return + + val metaData = ContentValues().apply { + put( + MediaStore.Audio.AudioColumns.RELATIVE_PATH, + RecordingsConstants.RECORDINGS_MUSIC_PATH + ) + put(MediaStore.Audio.AudioColumns.DISPLAY_NAME, entity.displayName) + put(MediaStore.Audio.AudioColumns.MIME_TYPE, entity.mimeType) + put(MediaStore.Audio.AudioColumns.DATE_ADDED, epochSeconds) + put(MediaStore.Audio.AudioColumns.IS_PENDING, 1) + } + + val updateMetaData = ContentValues().apply { + put(MediaStore.Audio.AudioColumns.IS_PENDING, 0) + put(MediaStore.Audio.AudioColumns.DATE_MODIFIED, System.currentTimeMillis()) + } + try { val newUri = withContext(Dispatchers.IO) { contentResolver.insert(RecordingsConstants.AUDIO_VOLUME_URI, metaData) - } ?: return@coroutineScope + } ?: return - withContext(Dispatchers.IO) { - contentResolver.openOutputStream(newUri, "w")?.use { stream -> - // read the bytes and submit to the new uri - val data = file.readBytes() - stream.write(data) - Log.d(TAG, "WRITTEN DATA FOR DATA : ${entity.id}") + try { + withContext(Dispatchers.IO) { + contentResolver.openOutputStream(newUri, "w")?.use { stream -> + // read the bytes and submit to the new uri + file.inputStream().use { inStream -> inStream.copyTo(stream) } + Log.d(TAG, "WRITTEN DATA FOR DATA : ${entity.id}") + } + contentResolver.update(newUri, updateMetaData, null, null) } + } catch (e: CancellationException) { + withContext(NonCancellable) { + contentResolver.delete(newUri, null, null) + Log.d(TAG, "DELETING NEW URI") + } + throw e } - - val updateMetaData = ContentValues().apply { - put(MediaStore.Audio.AudioColumns.IS_PENDING, 0) - put(MediaStore.Audio.AudioColumns.DATE_MODIFIED, System.currentTimeMillis()) - } - - withContext(Dispatchers.IO) { - contentResolver.update(newUri, updateMetaData, null, null) - } - + } catch (e: Exception) { + Log.e(TAG, "ISSUE IN CREATING NEW URI", e) } + } - private suspend fun deleteRecordingInfoFromFileAndTable(entity: TrashFileEntity): Boolean { - return withContext(Dispatchers.IO) { + private suspend fun removeBackupFileAndMetadata(entity: TrashFileEntity): Boolean { + return coroutineScope { try { - val file = entity.file.toUri().toFile() - if (file.exists()) { - val result = file.delete() - Log.d(TAG, "FILE REMOVE RESULT $result") + val fileDeleteJob = async(Dispatchers.IO) { + try { + val file = entity.file.toUri().toFile() + if (!file.exists()) return@async + val result = file.delete() + Log.d(TAG, "FILE REMOVE RESULT $result") + result + } catch (e: IOException) { + Log.d(TAG, "TRASH FILE FAILED TO CREATE", e) + } } - //delete the entry - trashMediaDao.deleteTrashEntity(entity) - Log.d(TAG, "REMOVED ENTRY ") + val deleteEntity = async { + //delete the entry + trashMediaDao.deleteTrashEntity(entity) + Log.d(TAG, "REMOVED ENTRY ") + } + awaitAll(fileDeleteJob, deleteEntity) true } catch (e: CancellationException) { throw e @@ -279,39 +309,38 @@ internal class TrashRecordingsProviderApi29Impl( private suspend fun createBackupFileForRecording( recording: RecordedVoiceModel, expiry: LocalDateTime, - ): TrashFileEntity { - return coroutineScope { - - val recordingUri = recording.fileUri.toUri() - val trashFile = File(filesDir, "file_${recording.id}") - + ): TrashFileEntity? = coroutineScope { + val recordingUri = recording.fileUri.toUri() + try { + val trashFile = withContext(Dispatchers.IO) { + File(trashFilesDirectory, "file_${recording.id}") + .apply(File::createNewFile) + } try { - val operation = async(Dispatchers.IO) { - // adding the bytes info to a separate file - context.contentResolver.openInputStream(recordingUri)?.use { stream -> - val bytes = stream.readBytes() + val copyJob = launch(Dispatchers.IO) { + context.contentResolver.openInputStream(recordingUri)?.use { inStream -> // upload the contents to a new file - trashFile.writeBytes(bytes) + trashFile.outputStream().use { outStream -> inStream.copyTo(outStream) } } - trashFile } - - return@coroutineScope recording.toEntity( + copyJob.join() + recording.toEntity( expires = expiry, - fileUri = operation.await().toUri().toString() + fileUri = trashFile.toUri().toString() ) } catch (e: CancellationException) { - // if cancel delete the file then throw the cancellation exception withContext(NonCancellable) { try { - if (trashFile.exists()) trashFile.delete() - else Unit + trashFile.delete() } catch (e: IOException) { Log.e(TAG, "FAILED TO REMOVE THE FILE", e) } } throw e } + } catch (e: IOException) { + Log.e(TAG, "CANNOT CREATE BACKUP FILE", e) + null } } } diff --git a/data/recordings/src/main/java/com/eva/recordings/data/provider/TrashRecordingsProviderImpl.kt b/data/recordings/src/main/java/com/eva/recordings/data/provider/TrashRecordingsProviderImpl.kt index ad1e8c9c..592acf2e 100644 --- a/data/recordings/src/main/java/com/eva/recordings/data/provider/TrashRecordingsProviderImpl.kt +++ b/data/recordings/src/main/java/com/eva/recordings/data/provider/TrashRecordingsProviderImpl.kt @@ -15,17 +15,13 @@ import com.eva.database.entity.RecordingsMetaDataEntity import com.eva.recordings.R import com.eva.recordings.data.wrapper.RecordingsConstants import com.eva.recordings.data.wrapper.RecordingsContentResolverWrapper -import com.eva.recordings.domain.exceptions.CannotTrashFileDifferentOwnerException import com.eva.recordings.domain.models.RecordedVoiceModel import com.eva.recordings.domain.models.TrashRecordingModel import com.eva.recordings.domain.provider.ResourcedTrashRecordingModels import com.eva.recordings.domain.provider.TrashRecordingsProvider import com.eva.utils.Resource import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.flow @@ -62,7 +58,11 @@ internal class TrashRecordingsProviderImpl( } Log.d(LOGGER_TAG, "ADDED OBSERVER FOR TRASHED ITEMS") - contentResolver.registerContentObserver(RecordingsConstants.AUDIO_VOLUME_URI, true, observer) + contentResolver.registerContentObserver( + RecordingsConstants.AUDIO_VOLUME_URI, + true, + observer + ) awaitClose { Log.d(LOGGER_TAG, "CANCELED OBSERVER FOR TRASH ITEMS") @@ -86,8 +86,12 @@ internal class TrashRecordingsProviderImpl( return withContext(Dispatchers.IO) { try { - val models = contentResolver - .query(RecordingsConstants.AUDIO_VOLUME_URI, trashRecordingsProjection, queryArgs, null) + val models = contentResolver.query( + RecordingsConstants.AUDIO_VOLUME_URI, + trashRecordingsProjection, + queryArgs, + null + ) ?.use(::readTrashedRecordingsFromCursor) ?: emptyList() @@ -97,6 +101,7 @@ internal class TrashRecordingsProviderImpl( } catch (e: SecurityException) { Resource.Error(e, e.localizedMessage ?: "SECURITY EXCEPTION") } catch (e: Exception) { + if (e is CancellationException) throw e e.printStackTrace() Resource.Error(e, e.message) } @@ -105,98 +110,136 @@ internal class TrashRecordingsProviderImpl( override suspend fun restoreRecordingsFromTrash(recordings: Collection) : Resource { - return coroutineScope { - // ensure that only this app files are restore. - val recordingsWithOwnerShip = recordings.filter { it.owner == context.packageName } - - try { - val restoreUris = async(Dispatchers.IO) { - // restore uris from trash - val uriToRestore = recordingsWithOwnerShip - .map { model -> model.fileUri.toUri() } - - moveUrisToOrFromTrash(uriToRestore, fromTrash = true) - } - val createMetadata = async(Dispatchers.IO) { - // create secondary metadata - val entities = recordingsWithOwnerShip.map { RecordingsMetaDataEntity(it.id) } - recordingsDao.addRecordingMetaDataBulk(entities) - } - awaitAll(restoreUris, createMetadata) + // ensure that only this app files are restore. + val recordingsWithOwnerShip = recordings.filter { it.owner == context.packageName } - val message = context.getString(R.string.restore_recordings_success) - Resource.Success(Unit, message) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - // on other exceptions - e.printStackTrace() - val errorMessage = context.getString(R.string.recording_restore_request_failed) - Resource.Error(e, message = errorMessage) - } + return try { + // restore uris from trash + val uriToRestore = recordingsWithOwnerShip + .map { model -> model.fileUri.toUri() } + .toSet() + + moveToTrashOrRestoreFromTrash(uriToRestore, fromTrash = true) + // then create secondary metadata : kept it synchronous as after delete only this should be performed + val entities = recordingsWithOwnerShip.map { RecordingsMetaDataEntity(it.id) } + recordingsDao.addRecordingMetaDataBulk(entities) + + val message = context.getString(R.string.restore_recordings_success) + Resource.Success(Unit, message) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // on other exceptions + e.printStackTrace() + val errorMessage = context.getString(R.string.recording_restore_request_failed) + Resource.Error(e, message = errorMessage) } } override fun createTrashRecordings(recordings: Collection) : Flow, Exception>> { - val recordingsWithOwnerShip = recordings.filter { it.owner == context.packageName } - val recordingsWithoutOwnerShip = recordings.filterNot { it.owner == context.packageName } + val ownedRecordings = recordings.filter { it.owner == context.packageName }.toSet() + val notOwnedRecordings = recordings.filter { it.owner != context.packageName }.toSet() - return flow, Exception>> { - if (recordingsWithOwnerShip.isNotEmpty()) { + return flow { + // handle the ownership recordings + if (ownedRecordings.isNotEmpty()) { try { - // try to delete recordings with ownership - val urisToDelete = recordingsWithOwnerShip.map { it.fileUri.toUri() } - // perform action in dispatches IO - coroutineScope { - val moveTrashDeferred = async(Dispatchers.IO) { - moveUrisToOrFromTrash(urisToDelete, fromTrash = false) - } - // clear metadata of all the selected ones - val clearMetaDataDeferred = async(Dispatchers.IO) { - val ids = recordings.map { it.id } - // now remove secondary data from the table - recordingsDao.deleteRecordingMetaDataFromIds(ids) - } - awaitAll(moveTrashDeferred, clearMetaDataDeferred) - } - val successMessage = context.getString(R.string.recording_trash_request_success) - emit(Resource.Success(emptyList(), successMessage)) - } catch (e: SecurityException) { - emit(Resource.Error(e, "Security Issues")) + val urisToDelete = ownedRecordings.map { it.fileUri.toUri() }.toSet() + // move items to trash + moveToTrashOrRestoreFromTrash(urisToDelete, fromTrash = false) + // emit a success with empty list + val result: Resource.Success, Exception> = + Resource.Success( + emptyList(), + context.getString(R.string.recording_trash_request_success) + ) + emit(result) + + // now delete the associated entries + val ids = recordings.map { it.id } + // now remove secondary data from the table + recordingsDao.deleteRecordingMetaDataFromIds(ids) } catch (e: Exception) { e.printStackTrace() val message = context.getString(R.string.recording_trash_request_failed) emit(Resource.Error(e, message)) } } - // try to delete without ownership - if (recordingsWithoutOwnerShip.isNotEmpty()) { - emit(Resource.Error(error = CannotTrashFileDifferentOwnerException())) + if (notOwnedRecordings.isNotEmpty()) { + try { + // this is sure to throw a security exception + val recordingsURIs = notOwnedRecordings.map { it.fileUri.toUri() }.toSet() + moveToTrashOrRestoreFromTrash(recordingsURIs, fromTrash = false) + } catch (e: SecurityException) { + Log.e(LOGGER_TAG, "Trying to trash non owner recordings", e) + // now in send the exception that it has failed to remove + // this need to be handled via recoverable security exception + emit(Resource.Error(e, data = notOwnedRecordings)) + } } - }.flowOn(Dispatchers.IO) + } } - override suspend fun permanentlyDeleteRecordingsInTrash(trashRecordings: Collection): Resource { + override fun permanentlyDeleteRecordingsInTrash(trashRecordings: List) + : Flow, Exception>> { + + val ownedRecordings = trashRecordings.filter { it.owner == context.packageName }.toSet() + val notOwnedRecordings = trashRecordings.filter { it.owner != context.packageName }.toSet() - val currentAppRecordings = trashRecordings.filter { it.owner == context.packageName } + return flow { + if (ownedRecordings.isNotEmpty()) { + try { + val urisToDelete = ownedRecordings.map { it.fileUri.toUri() }.toSet() + // move items to trash + permanentlyDeleteURIFromScopedStorage(urisToDelete) + // then clear the associated + val result: Resource.Success, Exception> = + Resource.Success( + emptyList(), + context.getString(R.string.recording_trash_request_success) + ) + emit(result) + } catch (e: Exception) { + e.printStackTrace() + val message = context.getString(R.string.recording_trash_request_failed) + emit(Resource.Error(e, message)) + } + } + if (notOwnedRecordings.isNotEmpty()) { + try { + val urisToDelete = notOwnedRecordings.map { it.fileUri.toUri() } + .toSet() + // this is sure to expose it throw a security exception + permanentlyDeleteURIFromScopedStorage(urisToDelete) + } catch (e: SecurityException) { + Log.e(LOGGER_TAG, "Trying permanently delete non owner uris", e) + // now in send the exception that it has failed to remove + // this need to be handled via recoverable security exception + emit(Resource.Error(e, data = notOwnedRecordings.toList())) + } + } + }.flowOn(Dispatchers.IO) + } + override suspend fun permanentlyDeleteRecordings(trashRecordings: List): Resource { + val ownedRecordings = trashRecordings.filter { it.owner == context.packageName } return withContext(Dispatchers.IO) { try { - val uriToDeletePermanent = - currentAppRecordings.map { model -> model.fileUri.toUri() } - permanentDeleteUrisFromAudioMediaVolume(uriToDeletePermanent) - - val message = context.getString(R.string.recording_delete_request_success) - Resource.Success(Unit, message) - } catch (e: CancellationException) { - throw e + val urisToDelete = ownedRecordings.map { it.fileUri.toUri() }.toSet() + // move items to trash + permanentlyDeleteURIFromScopedStorage(urisToDelete) + // then clear the associated + Resource.Success( + Unit, + context.getString(R.string.recording_trash_request_success) + ) } catch (e: Exception) { e.printStackTrace() - val errorMessage = context.getString(R.string.recording_delete_request_failed) - Resource.Error(e, errorMessage) + val message = context.getString(R.string.recording_trash_request_failed) + Resource.Error(e, message) } } } diff --git a/data/recordings/src/main/java/com/eva/recordings/data/provider/VoiceRecordingsProviderImpl.kt b/data/recordings/src/main/java/com/eva/recordings/data/provider/VoiceRecordingsProviderImpl.kt index a185bf78..9fde9e59 100644 --- a/data/recordings/src/main/java/com/eva/recordings/data/provider/VoiceRecordingsProviderImpl.kt +++ b/data/recordings/src/main/java/com/eva/recordings/data/provider/VoiceRecordingsProviderImpl.kt @@ -65,7 +65,11 @@ internal class VoiceRecordingsProviderImpl( } Log.d(LOGGER_TAG, "ADDED OBSERVER FOR VOICE RECORDINGS") - contentResolver.registerContentObserver(RecordingsConstants.AUDIO_VOLUME_URI, true, observer) + contentResolver.registerContentObserver( + RecordingsConstants.AUDIO_VOLUME_URI, + true, + observer + ) awaitClose { // the launch will get automatically cancelled when closed @@ -149,7 +153,8 @@ internal class VoiceRecordingsProviderImpl( override suspend fun getVoiceRecordingAsResourceFromId(recordingId: Long): Resource { - val recordingUri = ContentUris.withAppendedId(RecordingsConstants.AUDIO_VOLUME_URI, recordingId) + val recordingUri = + ContentUris.withAppendedId(RecordingsConstants.AUDIO_VOLUME_URI, recordingId) return try { val models = withContext(Dispatchers.IO) { contentResolver.query(recordingUri, recordingsProjection, null, null) @@ -198,17 +203,21 @@ internal class VoiceRecordingsProviderImpl( try { val deleteRow = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) contentResolver.delete(RecordingsConstants.AUDIO_VOLUME_URI, bundle) - else contentResolver.delete(RecordingsConstants.AUDIO_VOLUME_URI, selection, selectionArgs) + else contentResolver.delete( + RecordingsConstants.AUDIO_VOLUME_URI, + selection, + selectionArgs + ) return@withContext if (deleteRow == 1) - Resource.Success( + Resource.Success( data = Unit, message = context.getString(R.string.rename_recording_success) ) else Resource.Error(NoRecordingsModifiedOrDeletedException()) } catch (e: SecurityException) { - Resource.Error(e, "SECURITY EXCEPTION") + Resource.Error(e, "Cannot delete file don't have access") } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -225,8 +234,8 @@ internal class VoiceRecordingsProviderImpl( return withContext(Dispatchers.IO) { try { - val urisToDelete = filesFromThisApp.map { model -> model.fileUri.toUri() } - permanentDeleteUrisFromAudioMediaVolume(urisToDelete) + val urisToDelete = filesFromThisApp.map { model -> model.fileUri.toUri() }.toSet() + permanentlyDeleteURIFromScopedStorage(urisToDelete) Resource.Success( data = Unit, message = context.getString(R.string.recording_delete_request_success) @@ -241,31 +250,33 @@ internal class VoiceRecordingsProviderImpl( } } - override fun renameRecording(recording: RecordedVoiceModel, newName: String) - : Flow> { + override fun renameRecording( + recording: RecordedVoiceModel, + newName: String + ): Flow> { return flow { try { + emit(Resource.Loading) val uri = recording.fileUri.toUri() - val contentValues = ContentValues().apply { put(MediaStore.Audio.AudioColumns.DISPLAY_NAME, newName) } - emit(Resource.Loading) - - val transaction = withContext(Dispatchers.IO) { + val isSuccess = withContext(Dispatchers.IO) { contentResolver.update(uri, contentValues, null, null) - } - val result = if (transaction == 1) - Resource.Success( + } == 1 + + val res: Resource = if (isSuccess) + Resource.Success( data = Unit, message = context.getString(R.string.rename_recording_success) ) else Resource.Error(NoRecordingsModifiedOrDeletedException()) - emit(result) + emit(res) } catch (e: SecurityException) { - emit(Resource.Error(e, message = "Access not found")) + Log.e(LOGGER_TAG, "DON'T HAVE OWNER ACCESS", e) + emit(Resource.Error(e, message = "Don't have access to modify the file metadata")) } catch (e: SQLException) { emit(Resource.Error(e, "SQL EXCEPTION")) } catch (e: Exception) { diff --git a/data/recordings/src/main/java/com/eva/recordings/data/task/RemoveTrashRecordingTaskImpl.kt b/data/recordings/src/main/java/com/eva/recordings/data/task/RemoveTrashRecordingTaskImpl.kt index 42398b47..cebaee08 100644 --- a/data/recordings/src/main/java/com/eva/recordings/data/task/RemoveTrashRecordingTaskImpl.kt +++ b/data/recordings/src/main/java/com/eva/recordings/data/task/RemoveTrashRecordingTaskImpl.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.withContext import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant import kotlin.time.Clock -import kotlin.time.DurationUnit +import kotlin.time.Duration.Companion.hours import kotlin.time.ExperimentalTime private const val TAG = "REMOVE_TRASH_RECORDING_TASK" @@ -25,40 +25,31 @@ class RemoveTrashRecordingTaskImpl(val provider: TrashRecordingsProvider) : // no need to do anything for Api 30 as removing trash files is handled by the system if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) return@withContext Result.success(Unit) // if its api 29 then handle removing items via this class - when (val resource = provider.getTrashedVoiceRecordings()) { - is Resource.Success -> { - val trashModels = evaluateItemsToRemove(resource.data) - Log.d(TAG, "READY TO REMOVE :${trashModels.size} MODELS") - when (val res = provider.permanentlyDeleteRecordingsInTrash(trashModels)) { - is Resource.Error -> { - val message = "DELETE FAILED :${res.error.message ?: "UNKNOWN"}" - Log.e(TAG, message) - return@withContext Result.failure(res.error) - } - - is Resource.Success -> { - val message = "FILES TO BE DELETED TODAY ${trashModels.size} " - Log.d(TAG, message) - Result.success(Unit) - } + val trashedFiles = provider.getTrashedVoiceRecordings() as? Resource.Success + ?: return@withContext Result.failure(Exception("Cannot fetch trash recordings")) - else -> {} - } - Result.failure(Exception()) + val trashModels = evaluateItemsToRemove(trashedFiles.data).toList() + Log.d(TAG, "READY TO REMOVE :${trashModels.size} MODELS") + when (val res = provider.permanentlyDeleteRecordings(trashModels)) { + is Resource.Error -> { + val message = "DELETE FAILED :${res.error.message ?: "UNKNOWN"}" + Log.e(TAG, message) + return@withContext Result.failure(res.error) } - is Resource.Error -> { - val errorMessage = "DELETE FAILED :${resource.error.message ?: "UNKNOWN"}" - Log.d(TAG, "ERROR OCCCURED :$errorMessage ") - Result.failure(resource.error) + is Resource.Success -> { + val message = "FILES TO BE DELETED TODAY ${trashModels.size} " + Log.d(TAG, message) + Result.success(Unit) } - // it's an invalid state so no need to check or attach some message - Resource.Loading -> Result.failure(Exception("Unwanted state")) + + else -> {} } + Result.failure(Exception()) } @OptIn(ExperimentalTime::class) - private fun evaluateItemsToRemove(items: List): Set { + private fun evaluateItemsToRemove(items: List): List { val currentInstant = Clock.System.now() // delete-now delete is always at future so // delete-now is always positive except the case of @@ -66,19 +57,20 @@ class RemoveTrashRecordingTaskImpl(val provider: TrashRecordingsProvider) : val expiredByTime = items.filter { model -> val deleteInstant = model.expiresAt.toInstant(TimeZone.currentSystemDefault()) val diff = deleteInstant.minus(currentInstant) - diff.toLong(DurationUnit.MINUTES) < 0 + // the difference is less than an hour + diff < 1.hours } // if mistakenly some files are deleted but the metadata already exits then delete them too. val expiredByDelete = items.filter { model -> val fileUri = model.fileUri.toUri() - if (fileUri.scheme == "file") return@filter false val file = fileUri.toFile() + if (!file.isFile) return@filter false // take if file and file don't exit or file is empty - file.isFile && (!file.exists() || file.length() != 0L) + !file.exists() || file.length() != 0L } val trashModels = expiredByDelete union expiredByTime Log.d(TAG, "NO. OF TO BE FILES DELETED :${trashModels.size} ") - return trashModels + return trashModels.toList() } } diff --git a/data/recordings/src/main/java/com/eva/recordings/data/wrapper/RecordingsContentResolverWrapper.kt b/data/recordings/src/main/java/com/eva/recordings/data/wrapper/RecordingsContentResolverWrapper.kt index fd00c358..a075e482 100644 --- a/data/recordings/src/main/java/com/eva/recordings/data/wrapper/RecordingsContentResolverWrapper.kt +++ b/data/recordings/src/main/java/com/eva/recordings/data/wrapper/RecordingsContentResolverWrapper.kt @@ -7,7 +7,6 @@ import android.content.Context import android.database.Cursor import android.net.Uri import android.os.Build -import android.os.Bundle import android.provider.MediaStore import android.util.Log import androidx.core.database.getIntOrNull @@ -19,7 +18,6 @@ import com.eva.utils.toLocalDateTime import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext import java.io.File @@ -36,7 +34,7 @@ internal abstract class RecordingsContentResolverWrapper(private val context: Co val epochSeconds: Long get() = Clock.System.now().epochSeconds - val contentResolver: ContentResolver + protected val contentResolver: ContentResolver get() = context.contentResolver @@ -91,7 +89,8 @@ internal abstract class RecordingsContentResolverWrapper(private val context: Co val dateUpdated = cursor.getInt(updateColumn) val dateAdded = cursor.getInt(createdColumn) val mimeType = cursor.getString(mimeTypeColumn) - val uriString = ContentUris.withAppendedId(RecordingsConstants.AUDIO_VOLUME_URI, id).toString() + val uriString = ContentUris.withAppendedId(RecordingsConstants.AUDIO_VOLUME_URI, id) + .toString() val data = cursor.getString(dataColumn) val packageName = cursor.getStringOrNull(packageNameColumn) @@ -137,7 +136,8 @@ internal abstract class RecordingsContentResolverWrapper(private val context: Co val mimeType = cursor.getString(mimeTypeColumn) val expires = cursor.getInt(expiresColumn) val owner = cursor.getStringOrNull(ownerColumn) - val uriString = ContentUris.withAppendedId(RecordingsConstants.AUDIO_VOLUME_URI, id).toString() + val uriString = ContentUris.withAppendedId(RecordingsConstants.AUDIO_VOLUME_URI, id) + .toString() val model = TrashRecordingModel( @@ -157,69 +157,78 @@ internal abstract class RecordingsContentResolverWrapper(private val context: Co /** * Checks if the uri is trashed or not pending - * @param uri [Uri] to check for - * @return [Pair] of [Boolean] first indicating isTrashed and second one isPending + * @param uri The [Uri] of the audio file that to be checked + * @return [Boolean] Indicating the URI is really trashed, if the uri cannot be found then it's a null */ - private suspend fun checkIfUriTrashed(uri: Uri): Boolean { - val projection = arrayOf( - MediaStore.Audio.AudioColumns.IS_TRASHED, - ) + private suspend fun checkIfUriTrashed(uri: Uri): Boolean? { return withContext(Dispatchers.IO) { - contentResolver.query(uri, projection, Bundle(), null)?.use { cursor -> - val trashColumn = - cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.IS_TRASHED) + contentResolver.query( + uri, + arrayOf(MediaStore.Audio.AudioColumns.IS_TRASHED), + bundleOf(), + null + )?.use { cursor -> + val col = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.IS_TRASHED) if (!cursor.moveToFirst()) return@withContext false - cursor.getIntOrNull(trashColumn) == 1 - } == true + cursor.getIntOrNull(col) == 1 + } } } - suspend fun moveUrisToOrFromTrash(recordingsUris: Collection, fromTrash: Boolean = true) { - return coroutineScope { - val trashedRecordings = recordingsUris.filter { uri -> - val isTrashed = checkIfUriTrashed(uri) - // if both of then true or false then it's true - (isTrashed xor fromTrash).not() - } + suspend fun moveToTrashOrRestoreFromTrash(recordingsUris: Set, fromTrash: Boolean = true) { + val trashedRecordings = recordingsUris.filter { uri -> + val isTrashed = checkIfUriTrashed(uri) ?: return@filter false + // if both of then true or both false + (isTrashed xor fromTrash).not() + } - if (trashedRecordings.isEmpty()) { - Log.d(TAG, "THERE ARE NO RECORDINGS TO INTERFERE WITH") - return@coroutineScope - } + if (trashedRecordings.isEmpty()) { + Log.d(TAG, "THERE ARE NO RECORDINGS TO INTERFERE WITH") + return + } - val trashFlag = if (fromTrash) 0 else 1 + val metadata = ContentValues().apply { + put(MediaStore.Audio.AudioColumns.IS_TRASHED, if (fromTrash) 0 else 1) + } - val metadata = ContentValues().apply { - put(MediaStore.Audio.AudioColumns.IS_TRASHED, trashFlag) - } - // don't put uris with non owner from this app - withContext(Dispatchers.IO) { - supervisorScope { - val results = trashedRecordings.map { uri -> - async { + withContext(Dispatchers.IO) { + supervisorScope { + val results = trashedRecordings.map { uri -> + async { + try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) contentResolver.update(uri, metadata, null) else contentResolver.update(uri, metadata, null, null) + } catch (e: Exception) { + if (e is SecurityException) throw e + e.printStackTrace() + Log.e(TAG, "SOME GENERAL EXCEPTION", e) } } - results.awaitAll() } + results.awaitAll() } } } - - suspend fun permanentDeleteUrisFromAudioMediaVolume(recordingsUris: Collection) { - // don't put uris with non owner from this app - // this may lead to security exception and exceptions are not handled + /** + * Permanently removes the [recordingsUris] from shared storage, + * Don't provide uri other than the owner of this package + */ + suspend fun permanentlyDeleteURIFromScopedStorage(recordingsUris: Set) { withContext(Dispatchers.IO) { supervisorScope { val results = recordingsUris.map { uri -> async { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) - contentResolver.delete(uri, null) - else contentResolver.delete(uri, null, null) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + contentResolver.delete(uri, null) + else contentResolver.delete(uri, null, null) + } catch (e: Exception) { + if (e is SecurityException) throw e + Log.e(TAG, "SOME GENERAL EXCEPTION", e) + } } } results.awaitAll() @@ -228,15 +237,13 @@ internal abstract class RecordingsContentResolverWrapper(private val context: Co } /** - * Permanently delete the uri from the scoped storage + * Permanently delete the given uri from the scoped storage * @param uri The uri to be deleted - * @return [Boolean] indicating only a single row of changes made - * As uri's are unique there should be only a single change */ suspend fun permanentDeleteFromStorage(uri: Uri): Boolean { return withContext(Dispatchers.IO) { - val rowsChanged = contentResolver.delete(uri, null, null) - rowsChanged == 1 + val rows = contentResolver.delete(uri, null, null) + rows == 1 } } @@ -245,7 +252,7 @@ internal abstract class RecordingsContentResolverWrapper(private val context: Co * @param uri [Uri] to check * @return [Boolean] indicating if its pending */ - suspend fun checkIfUriIsPending(uri: Uri): Boolean { + suspend fun checkURIIsPending(uri: Uri): Boolean { val projection = arrayOf(MediaStore.Audio.AudioColumns.IS_PENDING) return withContext(Dispatchers.IO) { diff --git a/data/recordings/src/main/java/com/eva/recordings/domain/provider/TrashRecordingsProvider.kt b/data/recordings/src/main/java/com/eva/recordings/domain/provider/TrashRecordingsProvider.kt index 705fedb2..4ad7e0ff 100644 --- a/data/recordings/src/main/java/com/eva/recordings/domain/provider/TrashRecordingsProvider.kt +++ b/data/recordings/src/main/java/com/eva/recordings/domain/provider/TrashRecordingsProvider.kt @@ -57,8 +57,18 @@ interface TrashRecordingsProvider { * Permanently deletes recordings from the trash. This action is irreversible. * * @param trashRecordings A [Collection] of [TrashRecordingModel] objects to permanently delete. - * @return A [Resource] indicating success (Unit) or failure (an [Exception]). + * @return [Flow] of [ResourcedTrashRecordingModels] As there can be security exceptions for certain files it's a flow resources to indicate + * few have succeeded and few caught exceptions */ - suspend fun permanentlyDeleteRecordingsInTrash(trashRecordings: Collection): Resource + fun permanentlyDeleteRecordingsInTrash(trashRecordings: List): Flow, Exception>> + /** + * Permanently deletes recordings from the trash. This action is irreversible. + * + * @param trashRecordings A [Collection] of [TrashRecordingModel] objects to permanently delete, + * If the owner is now this app then the [trashRecordings] are ignored + * @return [Resource] Indicating the recordings has been deleted + * + */ + suspend fun permanentlyDeleteRecordings(trashRecordings: List): Resource } \ No newline at end of file diff --git a/feature/recordings/src/main/java/com/eva/feature_recordings/bin/RecordingsBinViewmodel.kt b/feature/recordings/src/main/java/com/eva/feature_recordings/bin/RecordingsBinViewmodel.kt index 932d1d1e..6d2e6027 100644 --- a/feature/recordings/src/main/java/com/eva/feature_recordings/bin/RecordingsBinViewmodel.kt +++ b/feature/recordings/src/main/java/com/eva/feature_recordings/bin/RecordingsBinViewmodel.kt @@ -44,7 +44,7 @@ internal class RecordingsBinViewmodel @Inject constructor( initialValue = persistentListOf() ) - private val selectedRecordings: Collection + private val selectedRecordings: List get() = _trashedRecordings.value .filter(SelectableTrashRecordings::isSelected) .map(SelectableTrashRecordings::trashRecording) @@ -121,32 +121,46 @@ internal class RecordingsBinViewmodel @Inject constructor( } } - private fun onPermanentDelete() = viewModelScope.launch { - when (val result = provider.permanentlyDeleteRecordingsInTrash(selectedRecordings)) { - is Resource.Error -> { - (result.error as? SecurityException)?.let { - handleSecurityExceptionToDelete(it) - return@launch - } - val message = result.error.message ?: result.message ?: "Cannot delete items" - _uiEvents.emit(UIEvents.ShowSnackBar(message)) - } + private fun onPermanentDelete() { + provider.permanentlyDeleteRecordingsInTrash(selectedRecordings) + .onEach { result -> + when (result) { + is Resource.Error -> { + val error = result.error + if (error is SecurityException) { + val trashList = result.data ?: emptyList() + // on security exception handle the case + handleSecurityExceptionToDelete(error, trashList) + return@onEach + } + val message = + result.error.message ?: result.message ?: "Cannot delete items" + _uiEvents.emit(UIEvents.ShowSnackBar(message)) + } - is Resource.Success -> { - val message = result.message ?: "Items deleted successfully" - _uiEvents.emit(UIEvents.ShowToast(message)) - } + is Resource.Success -> { + val message = result.message ?: "Items deleted successfully" + _uiEvents.emit(UIEvents.ShowToast(message)) + } + + else -> {} + } + }.launchIn(viewModelScope) - else -> {} - } } - private fun handleSecurityExceptionToDelete(error: SecurityException) { + private fun handleSecurityExceptionToDelete( + error: SecurityException, + recordingsToDelete: Collection, + isEnabled: Boolean = false, + ) { + if (recordingsToDelete.isEmpty() || !isEnabled) return + if (error !is RecoverableSecurityException) return + viewModelScope.launch { val request = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - DeleteOrTrashRequestEvent.OnDeleteRequest(selectedRecordings) + DeleteOrTrashRequestEvent.OnDeleteRequest(recordingsToDelete) } else { - if (error !is RecoverableSecurityException) return@launch // TODO: Check the workflow for android 10 or below val pendingIntent = error.userAction.actionIntent val request = IntentSenderRequest.Builder(pendingIntent).build() diff --git a/feature/recordings/src/main/java/com/eva/feature_recordings/handlers/DeleteItemRequestHandler.kt b/feature/recordings/src/main/java/com/eva/feature_recordings/handlers/DeleteItemRequestHandler.kt index 08ab876f..3bf229f0 100644 --- a/feature/recordings/src/main/java/com/eva/feature_recordings/handlers/DeleteItemRequestHandler.kt +++ b/feature/recordings/src/main/java/com/eva/feature_recordings/handlers/DeleteItemRequestHandler.kt @@ -6,7 +6,9 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner @@ -25,6 +27,7 @@ fun DeleteItemRequestHandler( ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current + val currentOnEvent by rememberUpdatedState(onResult) val deleteRequestLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartIntentSenderForResult(), @@ -35,7 +38,7 @@ fun DeleteItemRequestHandler( else context.getString(R.string.recording_delete_request_failed) val event = TrashRecordingScreenEvent.OnPostDeleteRequest(message) - onResult(event) + currentOnEvent(event) }, ) diff --git a/feature/recordings/src/main/java/com/eva/feature_recordings/handlers/RenameItemRequestHandler.kt b/feature/recordings/src/main/java/com/eva/feature_recordings/handlers/RenameItemRequestHandler.kt index 54960a02..0fceb3ef 100644 --- a/feature/recordings/src/main/java/com/eva/feature_recordings/handlers/RenameItemRequestHandler.kt +++ b/feature/recordings/src/main/java/com/eva/feature_recordings/handlers/RenameItemRequestHandler.kt @@ -6,6 +6,8 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner @@ -23,6 +25,7 @@ internal fun RenameItemRequestHandler( ) { val lifeCycleOwner = LocalLifecycleOwner.current val context = LocalContext.current + val currentOnWriteAccessChange by rememberUpdatedState(onWriteAccessChange) val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartIntentSenderForResult(), @@ -33,7 +36,7 @@ internal fun RenameItemRequestHandler( else context.getString(R.string.write_request_rejected) val event = RenameRecordingEvent.OnWriteAccessChanged(isAccepted, message) - onWriteAccessChange(event) + currentOnWriteAccessChange(event) } ) diff --git a/feature/recordings/src/main/java/com/eva/feature_recordings/handlers/TrashItemRequestHandler.kt b/feature/recordings/src/main/java/com/eva/feature_recordings/handlers/TrashItemRequestHandler.kt index 0d1b1cc5..4ef0dd71 100644 --- a/feature/recordings/src/main/java/com/eva/feature_recordings/handlers/TrashItemRequestHandler.kt +++ b/feature/recordings/src/main/java/com/eva/feature_recordings/handlers/TrashItemRequestHandler.kt @@ -6,7 +6,9 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner @@ -26,6 +28,7 @@ internal fun TrashItemRequestHandler( val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current + val currentOnResults by rememberUpdatedState(onResult) val trashRequestLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartIntentSenderForResult(), @@ -36,7 +39,7 @@ internal fun TrashItemRequestHandler( else context.getString(R.string.recording_delete_request_failed) val event = RecordingScreenEvent.OnPostTrashRequest(message) - onResult(event) + currentOnResults(event) } ) diff --git a/feature/recordings/src/main/java/com/eva/feature_recordings/recordings/RecordingsViewmodel.kt b/feature/recordings/src/main/java/com/eva/feature_recordings/recordings/RecordingsViewmodel.kt index 4641913c..5708bed9 100644 --- a/feature/recordings/src/main/java/com/eva/feature_recordings/recordings/RecordingsViewmodel.kt +++ b/feature/recordings/src/main/java/com/eva/feature_recordings/recordings/RecordingsViewmodel.kt @@ -177,8 +177,9 @@ internal class RecordingsViewmodel @Inject constructor( is Resource.Error -> { val error = result.error if (error is SecurityException) { + val trashList = result.data ?: emptyList() // on security exception handle the case - handleSecurityExceptionToTrash(error, result.data) + handleSecurityExceptionToTrash(error, trashList) return@onEach } val message = result.error.message ?: result.message @@ -236,15 +237,16 @@ internal class RecordingsViewmodel @Inject constructor( private fun handleSecurityExceptionToTrash( error: SecurityException, - recordingsToTrash: Collection? = null, + recordingsToTrash: Collection, + isEnabled: Boolean = false, ) { - if (recordingsToTrash == null) return + if (recordingsToTrash.isEmpty() || !isEnabled) return + if (error !is RecoverableSecurityException) return viewModelScope.launch { val request = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { DeleteOrTrashRequestEvent.OnTrashRequest(recordingsToTrash) } else { - if (error !is RecoverableSecurityException) return@launch val pendingIntent = error.userAction.actionIntent val senderRequest = IntentSenderRequest.Builder(pendingIntent).build() diff --git a/feature/recordings/src/main/java/com/eva/feature_recordings/rename/RenameDialogViewModel.kt b/feature/recordings/src/main/java/com/eva/feature_recordings/rename/RenameDialogViewModel.kt index 84fc509e..59e08f4e 100644 --- a/feature/recordings/src/main/java/com/eva/feature_recordings/rename/RenameDialogViewModel.kt +++ b/feature/recordings/src/main/java/com/eva/feature_recordings/rename/RenameDialogViewModel.kt @@ -2,6 +2,7 @@ package com.eva.feature_recordings.rename import android.app.RecoverableSecurityException import android.os.Build +import android.util.Log import androidx.activity.result.IntentSenderRequest import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.SavedStateHandle @@ -21,10 +22,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch @@ -44,7 +47,12 @@ internal class RenameDialogViewModel @Inject constructor( get() = route.recordingId private val _state = MutableStateFlow(RenameRecordingState()) - val renameState = _state.asStateFlow() + val renameState = _state.onStart { loadEntry() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = RenameRecordingState() + ) private val _uiEvents = MutableSharedFlow() override val uiEvent: SharedFlow @@ -66,10 +74,10 @@ internal class RenameDialogViewModel @Inject constructor( } init { - viewModelScope.launch { loadEntry() } + viewModelScope.launch { } } - private suspend fun loadEntry() { + private fun loadEntry() = viewModelScope.launch { when (val result = recordingsProvider.getVoiceRecordingAsResourceFromId(recordingId)) { is Resource.Error -> { val message = result.error.message ?: result.message ?: "Recording not found" @@ -82,7 +90,7 @@ internal class RenameDialogViewModel @Inject constructor( is Resource.Success -> _state.update { state -> state.copy( recording = result.data, - textFieldState = TextFieldValue(text = result.data.title), + textFieldState = TextFieldValue(text = result.data.displayName), isRenameAllowed = true ) } @@ -104,6 +112,7 @@ internal class RenameDialogViewModel @Inject constructor( when (result) { Resource.Loading -> _state.update { it.copy(isRenameAllowed = true) } is Resource.Error -> { + Log.d("SOME_TAG", "${result.error}") val error = result.error if (error is SecurityException) { // show the toast @@ -114,7 +123,7 @@ internal class RenameDialogViewModel @Inject constructor( val recordingState = _state.updateAndGet { state -> state.copy(isRenameAllowed = false) } - handleSecurityException(error, recordingState.recording) + handleSecurityException(error, recordingState.recording, true) return@onEach } val message = result.message ?: result.error.message ?: "" @@ -139,13 +148,16 @@ internal class RenameDialogViewModel @Inject constructor( private fun handleSecurityException( error: SecurityException, recording: RecordedVoiceModel? = null, + isEnabled: Boolean = false, ) { - if (recording == null) return + + if (recording == null || !isEnabled) return + if (error !is RecoverableSecurityException) return val request = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { // event with recording RenamePermissionEvent.OnAskAccessRequest(recording) - } else if (true && error is RecoverableSecurityException) { + } else { val pendingIntent = error.userAction.actionIntent val request = IntentSenderRequest.Builder(pendingIntent).build() // event with the intent sender @@ -153,9 +165,7 @@ internal class RenameDialogViewModel @Inject constructor( recordings = recording, intentSenderRequest = request ) - - } else null - - request?.let { viewModelScope.launch { _permissionEvent.emit(it) } } + } + viewModelScope.launch { _permissionEvent.emit(request) } } } \ No newline at end of file From 4aed8c7e5441a6f3e666bea7fe80b81a568d3e3a Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sun, 2 Nov 2025 01:17:44 +0530 Subject: [PATCH 16/25] Using handler thread in MediaCodecPCMDataDecoder.kt and other changes Using a handler thread ensures the media extraction is not made in the main thread thus reducing the pressure of the main thread Included atomic and volatile fields to ensure values can be updated from multiple threads, The callback in the class is changed to a listener. FloatArrayExt.kt using array functions Correction in ByteBufferExt.kt with 16 pcm encoding In PlayerIsPlayingFlow.kt we need to filter the buffering state then check for playing or not playing --- .../player/data/reader/AudioVisualizerImpl.kt | 30 ++- .../eva/player/data/reader/ByteBufferExt.kt | 70 +++--- .../eva/player/data/reader/FloatArrayExt.kt | 34 ++- .../data/reader/MediaCodecPCMDataDecoder.kt | 215 +++++++++++------- .../player/data/util/PlayerIsPlayingFlow.kt | 8 +- 5 files changed, 205 insertions(+), 152 deletions(-) diff --git a/data/player/src/main/java/com/eva/player/data/reader/AudioVisualizerImpl.kt b/data/player/src/main/java/com/eva/player/data/reader/AudioVisualizerImpl.kt index 56899f29..441c55d6 100644 --- a/data/player/src/main/java/com/eva/player/data/reader/AudioVisualizerImpl.kt +++ b/data/player/src/main/java/com/eva/player/data/reader/AudioVisualizerImpl.kt @@ -10,12 +10,11 @@ import com.eva.player.domain.AudioVisualizer import com.eva.player.domain.exceptions.DecoderExistsException import com.eva.player.domain.exceptions.InvalidMimeTypeException import com.eva.recordings.domain.models.AudioFileModel -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update @@ -23,9 +22,7 @@ import kotlinx.coroutines.withContext private const val TAG = "PLAIN_VISUALIZER" -class AudioVisualizerImpl(private val context: Context) : AudioVisualizer { - - private val _scope = CoroutineScope(Dispatchers.Default) +internal class AudioVisualizerImpl(private val context: Context) : AudioVisualizer { private var _extractor: MediaExtractor? = null private var _decoder: MediaCodecPCMDataDecoder? = null @@ -37,6 +34,7 @@ class AudioVisualizerImpl(private val context: Context) : AudioVisualizer { private val _visualization = MutableStateFlow(floatArrayOf()) override val normalizedVisualization: Flow get() = _visualization.map { array -> array.normalize().smoothen(.4f) } + .catch { err -> Log.d(TAG, "SOME ERROR", err) } .flowOn(Dispatchers.Default) override suspend fun prepareVisualization(model: AudioFileModel, timePerPointInMs: Int) @@ -52,12 +50,13 @@ class AudioVisualizerImpl(private val context: Context) : AudioVisualizer { override suspend fun prepareVisualization(fileUri: String, timePerPointInMs: Int) : Result { - return withContext(Dispatchers.IO) { - if (_decoder != null) { - Log.d(TAG, "CLEAN DECODER TO PREPARE IT AGAIN") - return@withContext Result.failure(DecoderExistsException()) - } + if (_decoder != null) { + Log.d(TAG, "CLEAN DECODER TO PREPARE IT AGAIN") + return Result.failure(DecoderExistsException()) + } + + return withContext(Dispatchers.IO) { try { _extractor = MediaExtractor().apply { setDataSource(context, fileUri.toUri(), null) @@ -72,14 +71,14 @@ class AudioVisualizerImpl(private val context: Context) : AudioVisualizer { _decoder = MediaCodecPCMDataDecoder( extractor = _extractor, - scope = _scope, totalTime = format.duration, - seekDuration = timePerPointInMs, - onBufferDecoded = { array -> + seekDurationMillis = timePerPointInMs, + ).apply { + setOnBufferDecode { array -> _isReady.update { true } _visualization.update { it + array } - }, - ) + } + } Log.d(TAG, "MEDIA CODEC SET FOR MIME TYPE:$mimetype") _decoder?.initiateCodec(format, mimetype) Result.success(Unit) @@ -91,7 +90,6 @@ class AudioVisualizerImpl(private val context: Context) : AudioVisualizer { } override fun cleanUp() { - _scope.cancel() _isReady.update { false } Log.d(TAG, "MEDIA CODEC IS RELEASED") diff --git a/data/player/src/main/java/com/eva/player/data/reader/ByteBufferExt.kt b/data/player/src/main/java/com/eva/player/data/reader/ByteBufferExt.kt index c6971929..7ca9f931 100644 --- a/data/player/src/main/java/com/eva/player/data/reader/ByteBufferExt.kt +++ b/data/player/src/main/java/com/eva/player/data/reader/ByteBufferExt.kt @@ -9,48 +9,52 @@ fun ByteBuffer.asFloatArray(size: Int, pcmEncoding: Int, channelCount: Int): Flo val totalSamples = size / (pcmEncoding / 8) val samplesPerChannel = totalSamples / channelCount val floatArray = FloatArray(samplesPerChannel) - when (pcmEncoding) { - 16 -> { - val shortBuffer = asShortBuffer() - val shortArray = ShortArray(size / 2) - shortBuffer.get(shortArray) - - for (i in 0 until samplesPerChannel) { - var sum = 0f - for (channel in 0 until channelCount) { - sum += shortArray[i * channelCount + channel] + try { + when (pcmEncoding) { + 16 -> { + val shortBuffer = asShortBuffer() + val shortArray = ShortArray(totalSamples) + shortBuffer.get(shortArray) + + for (i in 0 until samplesPerChannel) { + var sum = 0f + for (channel in 0 until channelCount) { + sum += shortArray[i * channelCount + channel] / TWO_POWER_15 + } + floatArray[i] = sum / channelCount } - floatArray[i] = sum / channelCount // Average the channels } - } - 8 -> { - val byteArray = ByteArray(size) - get(byteArray) - for (i in 0 until samplesPerChannel) { - var sum = 0f - for (channel in 0 until channelCount) { - sum += (byteArray[i * channelCount + channel].toInt() - TWO_POWER_7) / TWO_POWER_7 + 8 -> { + val byteArray = ByteArray(size) + get(byteArray) + for (i in 0 until samplesPerChannel) { + var sum = 0f + for (channel in 0 until channelCount) { + sum += byteArray[i * channelCount + channel] / TWO_POWER_7 + } + floatArray[i] = sum / channelCount } - floatArray[i] = sum / channelCount } - } - - 32 -> { - val floatBuffer = asFloatBuffer() - val tempFloatArray = FloatArray(totalSamples) - floatBuffer.get(tempFloatArray) - for (i in 0 until samplesPerChannel) { - var sum = 0f - for (channel in 0 until channelCount) { - sum += tempFloatArray[i * channelCount + channel] + 32 -> { + val floatBuffer = asFloatBuffer() + val tempFloatArray = FloatArray(totalSamples) + floatBuffer.get(tempFloatArray) + + for (i in 0 until samplesPerChannel) { + var sum = 0f + for (channel in 0 until channelCount) { + sum += tempFloatArray[i * channelCount + channel] + } + floatArray[i] = sum / channelCount } - floatArray[i] = sum / channelCount } - } - else -> throw IllegalArgumentException("Unsupported PCM encoding: $pcmEncoding") + else -> throw IllegalArgumentException("Unsupported PCM encoding: $pcmEncoding") + } + } catch (e: Exception) { + e.printStackTrace() } return floatArray } diff --git a/data/player/src/main/java/com/eva/player/data/reader/FloatArrayExt.kt b/data/player/src/main/java/com/eva/player/data/reader/FloatArrayExt.kt index a6ccbbb3..d78eeb4e 100644 --- a/data/player/src/main/java/com/eva/player/data/reader/FloatArrayExt.kt +++ b/data/player/src/main/java/com/eva/player/data/reader/FloatArrayExt.kt @@ -71,35 +71,29 @@ fun FloatArray.compressFloatArray(m: Int): FloatArray { } - fun FloatArray.normalize(): FloatArray { +fun FloatArray.normalize(): FloatArray { - if (isEmpty()) return FloatArray(0) val maxValue = maxOrNull() ?: .0f val minValue = minOrNull() ?: .0f - if (maxValue == minValue) return FloatArray(size) + if (maxValue == minValue || isEmpty()) return this - val finalArray = FloatArray(size) - val diff = (maxValue - minValue) - for (i in indices) { - finalArray[i] = (get(i) - minValue) / diff + val diff = (maxValue - minValue).let { value -> if (value == 0f) 1f else value } + val copy = copyOf() + for (i in 1.. Unit ) : MediaCodec.Callback() { - private val _operations = mutableScatterSetOf>() - private var _codecState = MediaCodecState.EXEC - + private val threadName = "MediaCodecComputeThread" + private var _handlerThread: HandlerThread? = null + private var _handler: Handler? = null private var _mediaCodec: MediaCodec? = null - private val lock = Any() + private val _scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val _operations = ConcurrentLinkedQueue>() + + @Volatile + private var _codecState = MediaCodecState.EXEC + private val _mutex = Mutex() + // callbacks + private var _onBufferDecoded: ((FloatArray) -> Unit)? = null + private var _onDecodeComplete: (() -> Unit)? = null + // need to play with the size to get the optimal results private val batchSize = 50 - private var currentTimeInMs = 0L + private val currentTimeInMs = AtomicLong(0L) + private val _isBatchProcessing = AtomicBoolean(false) override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { @@ -50,9 +68,9 @@ class MediaCodecPCMDataDecoder( codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) return } - + val currentTime = currentTimeInMs.load() // if timeInMs is greater than totalTime+extra return END_OF_STREAM - if (currentTimeInMs >= totalTime.inWholeMilliseconds + seekDuration) { + if (currentTime >= totalTime.inWholeMilliseconds + seekDurationMillis) { Log.d(TAG, "TOTAL TIME IS ALREADY REACHED") codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) return @@ -66,7 +84,7 @@ class MediaCodecPCMDataDecoder( val extractor = extractor ?: return // seek the extractor as we don't need extra data - extractor.seekTo(currentTimeInMs * 1000, MediaExtractor.SEEK_TO_CLOSEST_SYNC) + extractor.seekTo(currentTimeInMs.load() * 1000, MediaExtractor.SEEK_TO_CLOSEST_SYNC) val sampleSize = extractor.readSampleData(inputBuffer, 0) // sample size is zero thus processing done END_OF_STREAM @@ -84,7 +102,7 @@ class MediaCodecPCMDataDecoder( codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) } else { // update the current time - currentTimeInMs += seekDuration + currentTimeInMs.plusAssign(seekDurationMillis.toLong()) codec.queueInputBuffer(index, 0, sampleSize, extractor.sampleTime, 0) } @@ -97,80 +115,97 @@ class MediaCodecPCMDataDecoder( override fun onOutputBufferAvailable( - codec: MediaCodec, index: Int, + codec: MediaCodec, + index: Int, info: MediaCodec.BufferInfo ) { - try { - // there is some data - if (info.size > 0) { - val outputBuffer = codec.getOutputBuffer(index) - outputBuffer?.position(info.offset) - outputBuffer?.rewind() - outputBuffer?.order(ByteOrder.LITTLE_ENDIAN) + if (info.isEndOfStream) { + Log.i(TAG, "EVERYTHING RAN ON ${Thread.currentThread().name}") + codec.stop() + _codecState = MediaCodecState.STOP + handleEndOfStream() + return + } + if (info.size > 0) { + try { + val outputBuffer = codec.getOutputBuffer(index) ?: return + outputBuffer.position(info.offset) + outputBuffer.rewind() + outputBuffer.order(ByteOrder.LITTLE_ENDIAN) val format = codec.outputFormat val pcmEncoding = format.pcmEncoding val channelCount = format.channels + val floatArray = outputBuffer.asFloatArray(info.size, pcmEncoding, channelCount) + handleFloatArray(floatArray) + } catch (e: Exception) { + e.printStackTrace() + } finally { + codec.releaseOutputBuffer(index, false) + } + } - val pcm = outputBuffer?.asFloatArray(info.size, pcmEncoding, channelCount) - ?: floatArrayOf() + } - if (!scope.isActive) { - Log.i(TAG, "SCOPE IS NOT ACTIVE ANY MORE STOPING CODEC") - _codecState = MediaCodecState.STOP - // release the buffer - codec.releaseOutputBuffer(index, false) - return - } - // if there is some pcm data available - if (pcm.isNotEmpty()) { - val action = scope.async(Dispatchers.Default) { - performOperation(pcm) - } - _operations.plusAssign(action) - } - // batched operation - if (_operations.size >= batchSize && scope.isActive) { - scope.launch(Dispatchers.Default) { - if (_mutex.holdsLock(lock)) { - Log.d(TAG, "A BATCH BEING SEND CANNOT SEND IN THIS BATCH") - return@launch - } - _mutex.lock(lock) - try { - Log.i(TAG, "EVALUATING INFORMATION") - val results = _operations.asSet().awaitAll() - val resultAsFloatArray = results.toFloatArray() - onBufferDecoded(resultAsFloatArray) - _operations.clear() - } finally { - _mutex.unlock() + private fun handleFloatArray(pcm: FloatArray) { + // if there is some pcm data available + if (pcm.isNotEmpty()) { + val action = _scope.async { pcm.performRMS() } + _operations.offer(action) + } + + // Try to acquire the lock atomically BEFORE launching + if (!_isBatchProcessing.compareAndSet(expectedValue = false, newValue = true)) return + + _scope.launch { + try { + if (_operations.size >= batchSize && isActive) { + // batched operation + _isBatchProcessing.compareAndSet(expectedValue = false, newValue = true) + val operations = buildSet { + repeat(batchSize) { + val item = _operations.poll() ?: return@repeat + add(item) } } + Log.i(TAG, "EVALUATING INFORMATION BATCH :${operations.size}") + val resultAsFloatArray = operations.awaitAll().toFloatArray() + _onBufferDecoded?.invoke(resultAsFloatArray) } - // release the buffer - codec.releaseOutputBuffer(index, false) - return + } catch (_: CancellationException) { + Log.d(TAG, "CANCELLATION IN BATCH PROCESSING") + } catch (e: Exception) { + Log.d(TAG, "Exception at the end of stream", e) + } finally { + _isBatchProcessing.store(false) } + } + } - if (info.isEndOfStream) { + private fun handleEndOfStream() { + _scope.launch { + try { Log.d(TAG, "END OF BUFFER REACHED, AWAITING OPERATIONS") - codec.stop() - _codecState = MediaCodecState.STOP - scope.launch(Dispatchers.Default) { - if (!isActive) return@launch - Log.i(TAG, "EVALUATING INFORMATION END") - - - val results = _operations.asSet().awaitAll() + val leftItems = buildSet { + while (isActive) { + val item = _operations.poll() ?: break + add(item) + } + } + Log.i(TAG, "EVALUATING INFORMATION END :${leftItems.size}") + _mutex.withLock { + if (leftItems.isEmpty()) return@withLock + val results = leftItems.awaitAll() val resultAsFloatArray = results.toFloatArray() - onBufferDecoded(resultAsFloatArray) - + _onBufferDecoded?.invoke(resultAsFloatArray) + _onDecodeComplete?.invoke() } + } catch (_: CancellationException) { + Log.d(TAG, "CANCELLATION IN HANDLING END OF STREAM") + } catch (e: Exception) { + Log.d(TAG, "Exception at the end of stream", e) } - } catch (e: IllegalStateException) { - Log.e(TAG, "MEDIA CODEC IS NOT IN EXECUTION STATE", e) } } @@ -178,36 +213,56 @@ class MediaCodecPCMDataDecoder( Log.e(TAG, "ERROR HAPPENED", e) } - override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { Log.d(TAG, "MEDIA FORMAT CHANGED: $format") } + fun setOnBufferDecode(listener: (FloatArray) -> Unit) { + _onBufferDecoded = listener + } + + fun setOnComplete(listener: () -> Unit) { + _onDecodeComplete = listener + } + fun initiateCodec(format: MediaFormat, mimeType: String) { + if (_handlerThread == null || _handlerThread?.isAlive == false) { + _handlerThread = HandlerThread(threadName).apply { start() } + _handler = Handler(_handlerThread!!.looper) + } _mediaCodec?.reset() _mediaCodec = MediaCodec.createDecoderByType(mimeType).apply { configure(format, null, null, 0) - setCallback(this@MediaCodecPCMDataDecoder) + setCallback(this@MediaCodecPCMDataDecoder, _handler!!) } _mediaCodec?.start() Log.d(TAG, "MEDIA CODEC STARTED") } fun cleanUp() { - Log.d(TAG, "CLEANING UP OPERATIONS") - _operations.forEach { it.cancel() } - _operations.clear() - Log.d(TAG, "MEDIA CODEC RELEASING") _mediaCodec?.stop() _mediaCodec?.release() _mediaCodec = null + + // shut down the thread + val quit = _handlerThread?.quitSafely() ?: false + _handlerThread = null + _handler = null + Log.d(TAG, "HANDLER THREAD STOPPED:$quit") + + Log.d(TAG, "CLEANING UP OPERATIONS") + _operations.forEach { it.cancel() } + _operations.clear() + + Log.d(TAG, "CLEARING UP SCOPE") + _scope.cancel() } - private suspend fun performOperation(samples: FloatArray): Float { + private suspend fun FloatArray.performRMS(): Float { return withContext(Dispatchers.Default) { - val squaredSum = samples.sumOf { it.toDouble().pow(2) } - sqrt(squaredSum / samples.size).toFloat() + val squaredAvg = map { it * it }.average().toFloat() + sqrt(squaredAvg) } } } \ No newline at end of file diff --git a/data/player/src/main/java/com/eva/player/data/util/PlayerIsPlayingFlow.kt b/data/player/src/main/java/com/eva/player/data/util/PlayerIsPlayingFlow.kt index 62bafc4c..c8369d1e 100644 --- a/data/player/src/main/java/com/eva/player/data/util/PlayerIsPlayingFlow.kt +++ b/data/player/src/main/java/com/eva/player/data/util/PlayerIsPlayingFlow.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.map private const val TAG = "PLAYER_PLAYING_FLOW" @@ -82,6 +83,7 @@ fun Player.computePlayerPlayState(): Flow = callbackFlow { awaitClose { removeListener(listener) } }.distinctUntilChanged() -fun Player.computeIsPlayerPlaying(): Flow = - computePlayerPlayState().map { it == PlayerPlayState.PLAYING || it == PlayerPlayState.BUFFERING } - .distinctUntilChanged() +fun Player.computeIsPlayerPlaying(): Flow = computePlayerPlayState() + .filterNot { it == PlayerPlayState.BUFFERING } + .map { it == PlayerPlayState.PLAYING } + .distinctUntilChanged() From 6c32ac1c30ea65293ad35b0b4e24c098d1d15309 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sun, 2 Nov 2025 05:15:59 +0530 Subject: [PATCH 17/25] Lazy read in RecorderRoute.kt and Service Binder As graph data is lazy read inside canvas the only thing that's constantly changing in the screen is the timer text making the timer text lazy reduced recomposition count by a lot RecorderServiceBinderImpl.kt using MutableStateflow rather than null using flow operators giving it a observable Service class In AudioRecordAmplitudeReader.kt in audio read ensure the current coroutine context is active For cancellation added a basic message --- .../data/reader/AudioRecordAmplitudeReader.kt | 30 +++++++++++-------- .../data/service/RecorderServiceBinderImpl.kt | 29 +++++++++--------- .../com/eva/feature_recorder/RecorderRoute.kt | 2 +- .../composable/RecorderContent.kt | 4 +-- .../composable/RecorderTimerText.kt | 10 ++++--- .../feature_recorder/screen/RecorderScreen.kt | 2 +- 6 files changed, 43 insertions(+), 34 deletions(-) diff --git a/data/recorder/src/main/java/com/eva/recorder/data/reader/AudioRecordAmplitudeReader.kt b/data/recorder/src/main/java/com/eva/recorder/data/reader/AudioRecordAmplitudeReader.kt index a7a86438..9bb799de 100644 --- a/data/recorder/src/main/java/com/eva/recorder/data/reader/AudioRecordAmplitudeReader.kt +++ b/data/recorder/src/main/java/com/eva/recorder/data/reader/AudioRecordAmplitudeReader.kt @@ -18,14 +18,15 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay -import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.isActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import java.util.concurrent.CancellationException import java.util.concurrent.ConcurrentLinkedQueue import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi @@ -177,29 +178,33 @@ class AudioRecordAmplitudeReader( return@flow } - val invalids = arrayOf(AudioRecord.ERROR_INVALID_OPERATION, AudioRecord.ERROR_BAD_VALUE) + val invalids = arrayOf( + AudioRecord.ERROR_INVALID_OPERATION, + AudioRecord.ERROR_BAD_VALUE, + AudioRecord.ERROR + ) if (_recorder == null) return@flow val pcmBuffer = ShortArray(_pcmBufferSize) var shortsRead: Int - while (state == RecorderState.RECORDING) { + while (state == RecorderState.RECORDING && currentCoroutineContext().isActive) { // ensure the current coroutine is active otherwise - currentCoroutineContext().ensureActive() shortsRead = _recorder?.read(pcmBuffer, 0, pcmBuffer.size) ?: break if (shortsRead in invalids) break if (shortsRead == 0) break - // these are raw bytes - val rmsValue = pcmBuffer.rms(shortsRead) - emit(rmsValue) - // check if audio source set otherwise amp is zero - // read the values here - delay(delayRate) + if (currentCoroutineContext().isActive) { + // these are raw bytes + val rmsValue = pcmBuffer.rms(shortsRead) + emit(rmsValue) + // check if audio source set otherwise amp is zero + // read the values here + delay(delayRate) + } } - } catch (err: IllegalStateException) { - Log.e(TAG, "ILLEGAL STATE", err) } catch (e: Exception) { + if (e is CancellationException) Log.d(TAG, "NO MORE PROCESSING VALUES") e.printStackTrace() } }.flowOn(Dispatchers.IO) @@ -235,6 +240,7 @@ class AudioRecordAmplitudeReader( val distinctBuffer = _buffer.distinctBy { it.timeInMillis } emit(distinctBuffer) } catch (e: Exception) { + if (e is CancellationException) Log.d(TAG, "UPDATE BUFFER CANCELLED") e.printStackTrace() } }.flowOn(Dispatchers.Default) diff --git a/data/recorder/src/main/java/com/eva/recorder/data/service/RecorderServiceBinderImpl.kt b/data/recorder/src/main/java/com/eva/recorder/data/service/RecorderServiceBinderImpl.kt index c98d107e..46cc499e 100644 --- a/data/recorder/src/main/java/com/eva/recorder/data/service/RecorderServiceBinderImpl.kt +++ b/data/recorder/src/main/java/com/eva/recorder/data/service/RecorderServiceBinderImpl.kt @@ -11,8 +11,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet @@ -24,20 +24,21 @@ private const val TAG = "RECORDER_SERVICE_BINDER" internal class RecorderServiceBinderImpl(private val context: Context) : RecorderServiceBinder { private val _isBounded = MutableStateFlow(false) - private var _service: VoiceRecorderService? = null + private var _service = MutableStateFlow(null) - override val recorderTimer = _isBounded.filter { it } - .flatMapLatest { _service?.recorderTime ?: emptyFlow() } + private val _serviceInstanceFlow = combine(_isBounded, _service) { bounded, service -> + if (bounded && service != null) service + else null + }.filterNotNull() - override val recorderState = _isBounded.filter { it } - .flatMapLatest { _service?.recorderState ?: emptyFlow() } + override val recorderTimer = _serviceInstanceFlow.flatMapLatest { it.recorderTime } + + override val recorderState = _serviceInstanceFlow.flatMapLatest { it.recorderState } override val bookMarkTimes: Flow> - get() = _isBounded.filter { it } - .flatMapLatest { _service?.bookMarks ?: emptyFlow() } + get() = _serviceInstanceFlow.flatMapLatest { it.bookMarks } - override val amplitudes = _isBounded.filter { it } - .flatMapLatest { _service?.amplitudes ?: emptyFlow() } + override val amplitudes = _serviceInstanceFlow.flatMapLatest { it.amplitudes } override val isConnectionReady: StateFlow get() = _isBounded @@ -45,14 +46,14 @@ internal class RecorderServiceBinderImpl(private val context: Context) : Recorde private val serviceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { val binder = (service as? VoiceRecorderService.LocalBinder) - _service = binder?.getService() + _service.value = binder?.getService() val isBounded = _isBounded.updateAndGet { true } Log.d(TAG, "SERVICE CONNECTED :BOUNDED :$isBounded") } override fun onServiceDisconnected(name: ComponentName?) { val bounded = _isBounded.updateAndGet { false } - _service = null + _service.value = null Log.d(TAG, "SERVICE DISCONNECTED :BOUNDED:$bounded") } } @@ -80,7 +81,7 @@ internal class RecorderServiceBinderImpl(private val context: Context) : Recorde override fun cleanUp() { Log.d(TAG, "SERVICE BINDER CLEANUP") - _service = null + _service.value = null _isBounded.update { false } } } \ No newline at end of file diff --git a/feature/recorder/src/main/java/com/eva/feature_recorder/RecorderRoute.kt b/feature/recorder/src/main/java/com/eva/feature_recorder/RecorderRoute.kt index 9963c012..1ecb58ba 100644 --- a/feature/recorder/src/main/java/com/eva/feature_recorder/RecorderRoute.kt +++ b/feature/recorder/src/main/java/com/eva/feature_recorder/RecorderRoute.kt @@ -43,7 +43,7 @@ fun NavGraphBuilder.recorderRoute(navController: NavHostController) = VoiceRecorderScreen( isRecorderReady = isRecorderReady, recorderState = recorderState, - recorderTimer = recorderTimer, + recorderTimer = { recorderTimer }, bookMarksSetDeferred = { bookMarksSet }, deferredRecordingPoints = { recordingPoints }, onRecorderAction = viewModel::onAction, diff --git a/feature/recorder/src/main/java/com/eva/feature_recorder/composable/RecorderContent.kt b/feature/recorder/src/main/java/com/eva/feature_recorder/composable/RecorderContent.kt index 5e64c999..e5cab93e 100644 --- a/feature/recorder/src/main/java/com/eva/feature_recorder/composable/RecorderContent.kt +++ b/feature/recorder/src/main/java/com/eva/feature_recorder/composable/RecorderContent.kt @@ -28,7 +28,7 @@ import kotlinx.datetime.LocalTime @Composable internal fun RecorderContent( - timer: LocalTime, + timer: () -> LocalTime, recordingPointsCallback: DeferredRecordedPointList, bookMarksDeferred: DeferredDurationList, recorderState: RecorderState, @@ -85,7 +85,7 @@ private fun RecorderContentPreview( ) = RecorderAppTheme { Surface { RecorderContent( - timer = LocalTime(0, 10, 56, 0), + timer = { LocalTime(0, 10, 56, 0) }, recorderState = recorderState, bookMarksDeferred = { setOf() }, recordingPointsCallback = { RecorderPreviewFakes.PREVIEW_RECORDER_AMPLITUDE_FLOAT_ARRAY_LARGE }, diff --git a/feature/recorder/src/main/java/com/eva/feature_recorder/composable/RecorderTimerText.kt b/feature/recorder/src/main/java/com/eva/feature_recorder/composable/RecorderTimerText.kt index b06bd8ea..d8f402a8 100644 --- a/feature/recorder/src/main/java/com/eva/feature_recorder/composable/RecorderTimerText.kt +++ b/feature/recorder/src/main/java/com/eva/feature_recorder/composable/RecorderTimerText.kt @@ -20,16 +20,18 @@ import kotlinx.datetime.format @Composable internal fun RecorderTimerText( - time: LocalTime, + time: () -> LocalTime, modifier: Modifier = Modifier, style: TextStyle = MaterialTheme.typography.displayMedium, color: Color = MaterialTheme.colorScheme.primary, fontFamily: FontFamily = DownloadableFonts.PLUS_CODE_LATIN_FONT_FAMILY, ) { - val timeText by remember(time) { + val timeText by remember { derivedStateOf { - if (time.hour > 0) time.format(LocalTimeFormats.LOCALTIME_FORMAT_HH_MM_SS_SF2) - else time.format(LocalTimeFormats.LOCALTIME_FORMAT_MM_SS_SF2) + val readTime = time() + + if (readTime.hour > 0) readTime.format(LocalTimeFormats.LOCALTIME_FORMAT_HH_MM_SS_SF2) + else readTime.format(LocalTimeFormats.LOCALTIME_FORMAT_MM_SS_SF2) } } diff --git a/feature/recorder/src/main/java/com/eva/feature_recorder/screen/RecorderScreen.kt b/feature/recorder/src/main/java/com/eva/feature_recorder/screen/RecorderScreen.kt index 2738408f..c2b21755 100644 --- a/feature/recorder/src/main/java/com/eva/feature_recorder/screen/RecorderScreen.kt +++ b/feature/recorder/src/main/java/com/eva/feature_recorder/screen/RecorderScreen.kt @@ -29,7 +29,7 @@ import kotlinx.datetime.LocalTime internal fun VoiceRecorderScreen( isRecorderReady: Boolean, recorderState: RecorderState, - recorderTimer: LocalTime, + recorderTimer:()-> LocalTime, deferredRecordingPoints: DeferredRecordedPointList, bookMarksSetDeferred: DeferredDurationList, onRecorderAction: (RecorderAction) -> Unit, From be86fe01bb87035b68a71139c679b887a5109b86 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sun, 2 Nov 2025 06:18:16 +0530 Subject: [PATCH 18/25] Fix Issues in SplashExitAnimationExt.kt and OnBoardingActivity.kt Splash screen icon may not be ready when animation is prepared so its causing a null point exception if icon cannot be found then go with basic view animation otherwise both IN OnBoardingActivity.kt move setTransition in onCreate and keep on condition if boarding state is content In RecorderApp.kt included Composer diagnostic stack trace option Removed unwanted test cases from app module --- .../recorderapp/ExampleInstrumentedTest.kt | 24 ---- .../java/com/eva/recorderapp/RecorderApp.kt | 28 +++-- .../com/eva/recorderapp/ExampleUnitTest.kt | 17 --- .../eva/ui/activity/SplashExitAnimationExt.kt | 111 +++++++++--------- .../feature_onboarding/OnBoardingActivity.kt | 15 ++- 5 files changed, 85 insertions(+), 110 deletions(-) delete mode 100644 app/src/androidTest/java/com/eva/recorderapp/ExampleInstrumentedTest.kt delete mode 100644 app/src/test/java/com/eva/recorderapp/ExampleUnitTest.kt diff --git a/app/src/androidTest/java/com/eva/recorderapp/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/eva/recorderapp/ExampleInstrumentedTest.kt deleted file mode 100644 index fda6dfb0..00000000 --- a/app/src/androidTest/java/com/eva/recorderapp/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.eva.recorderapp - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.eva.recorderapp", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/eva/recorderapp/RecorderApp.kt b/app/src/main/java/com/eva/recorderapp/RecorderApp.kt index bec3e0eb..44610c00 100644 --- a/app/src/main/java/com/eva/recorderapp/RecorderApp.kt +++ b/app/src/main/java/com/eva/recorderapp/RecorderApp.kt @@ -3,6 +3,8 @@ package com.eva.recorderapp import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager +import androidx.compose.runtime.Composer +import androidx.compose.runtime.ExperimentalComposeRuntimeApi import androidx.core.app.NotificationCompat import androidx.core.content.getSystemService import androidx.hilt.work.HiltWorkerFactory @@ -14,7 +16,7 @@ import com.eva.worker.UpdateRecordingPathWorker import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject - +@OptIn(ExperimentalComposeRuntimeApi::class) @HiltAndroidApp class RecorderApp : Application(), Configuration.Provider { @@ -35,6 +37,22 @@ class RecorderApp : Application(), Configuration.Provider { override fun onCreate() { super.onCreate() + // enabled compose stack-trace + Composer.setDiagnosticStackTraceEnabled(enabled = BuildConfig.DEBUG) + + createNotificationChannels() + + //shortcuts + shortcutFacade.createRecordingsShortCut() + + //start workers + RemoveTrashRecordingWorker.startRepeatWorker(applicationContext) + // update path worker + UpdateRecordingPathWorker.startWorker(applicationContext) + } + + + private fun createNotificationChannels() { val channel1 = NotificationChannel( NotificationConstants.RECORDER_CHANNEL_ID, NotificationConstants.RECORDER_CHANNEL_NAME, @@ -67,13 +85,5 @@ class RecorderApp : Application(), Configuration.Provider { val channels = listOf(channel1, channel2, channel3) notificationManager?.createNotificationChannels(channels) - - //shortcuts - shortcutFacade.createRecordingsShortCut() - - //start workers - RemoveTrashRecordingWorker.startRepeatWorker(applicationContext) - // update path worker - UpdateRecordingPathWorker.startWorker(applicationContext) } } \ No newline at end of file diff --git a/app/src/test/java/com/eva/recorderapp/ExampleUnitTest.kt b/app/src/test/java/com/eva/recorderapp/ExampleUnitTest.kt deleted file mode 100644 index 02193f4f..00000000 --- a/app/src/test/java/com/eva/recorderapp/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.eva.recorderapp - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/eva/ui/activity/SplashExitAnimationExt.kt b/core/ui/src/main/java/com/eva/ui/activity/SplashExitAnimationExt.kt index cf34098a..dcb56350 100644 --- a/core/ui/src/main/java/com/eva/ui/activity/SplashExitAnimationExt.kt +++ b/core/ui/src/main/java/com/eva/ui/activity/SplashExitAnimationExt.kt @@ -2,80 +2,81 @@ package com.eva.ui.activity import android.animation.AnimatorSet import android.animation.ObjectAnimator +import android.util.Log import android.view.View -import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AccelerateInterpolator import android.view.animation.DecelerateInterpolator import androidx.core.animation.doOnEnd import androidx.core.animation.doOnStart import androidx.core.splashscreen.SplashScreen +private const val TAG = "SplashExitAnimation" + fun SplashScreen.animateOnExit( - screenViewDuration: Long = 200L, + screenViewDuration: Long = 300L, onAnimationStart: () -> Unit = {}, onAnimationEnd: () -> Unit = {} -) { +) = setOnExitAnimationListener { screenView -> - setOnExitAnimationListener { screenView -> - // do all the animation is a reverse way - val interpolator = AccelerateDecelerateInterpolator() + // TODO: Cannot reproduce but sometime icon view is null + val icon = try { + screenView.iconView + } catch (_: NullPointerException) { + null + } - val iconScaleXAnimation = ObjectAnimator - .ofFloat(screenView.iconView, View.SCALE_X, 1f, 0.5f) - .apply { - this.interpolator = interpolator - this.duration = screenView.iconAnimationDurationMillis - } + val viewTranslateAnimation = ObjectAnimator + .ofFloat(screenView.view, View.TRANSLATION_Y, 0f, screenView.view.height.toFloat()) + .setDuration(screenViewDuration) + .apply { this.interpolator = AccelerateInterpolator() } - val iconScaleYAnimation = ObjectAnimator - .ofFloat(screenView.iconView, View.SCALE_Y, 1f, 0.5f) - .apply { - this.interpolator = interpolator - this.duration = screenView.iconAnimationDurationMillis - } + val viewFadeAnimation = ObjectAnimator + .ofFloat(screenView.view, View.ALPHA, 1.0f, .2f) + .setDuration(screenViewDuration) + .apply { this.interpolator = DecelerateInterpolator() } - val iconTranslateYAnimation = ObjectAnimator - .ofFloat(screenView.iconView, View.TRANSLATION_Y, 0.0f, 20.0f) - .apply { - this.interpolator = interpolator - this.duration = screenView.iconAnimationDurationMillis - } + val viewAnimatorSet = AnimatorSet().apply { + playTogether(viewFadeAnimation, viewTranslateAnimation) + doOnEnd { + screenView.remove() + onAnimationEnd() + } + } - val viewFadeAnimation = ObjectAnimator - .ofFloat(screenView.view, View.ALPHA, 1.0f, .2f) - .apply { - this.interpolator = DecelerateInterpolator() - this.duration = screenViewDuration - } + if (icon == null) { + Log.d(TAG, "ICON VIEW NOT FOUND FALLBACK TO VIEW ANIMATION") + // fallback: no icon animation + onAnimationStart() + viewAnimatorSet.start() + return@setOnExitAnimationListener + } - val viewTranslateAnimation = ObjectAnimator.ofFloat( - screenView.view, - View.TRANSLATION_Y, - 0f, - screenView.view.height.toFloat() - ).apply { - this.interpolator = AccelerateInterpolator() - this.duration = screenViewDuration - } + val iconScaleXAnimation = ObjectAnimator + .ofFloat(icon, View.SCALE_X, 1f, 0.5f) + .setDuration(screenView.iconAnimationDurationMillis) + .apply { this.interpolator = interpolator } - val viewAnimatorSet = AnimatorSet().apply { - playTogether(viewFadeAnimation, viewTranslateAnimation) - doOnEnd { - screenView.remove() - onAnimationEnd() - } - } + val iconScaleYAnimation = ObjectAnimator + .ofFloat(icon, View.SCALE_Y, 1f, 0.5f) + .setDuration(screenView.iconAnimationDurationMillis) + .apply { this.interpolator = interpolator } - val iconAnimatorSet = AnimatorSet().apply { - playTogether( - iconScaleXAnimation, - iconScaleYAnimation, - iconTranslateYAnimation - ) - doOnEnd { viewAnimatorSet.start() } - doOnStart { onAnimationStart() } + val iconTranslateYAnimation = ObjectAnimator + .ofFloat(icon, View.TRANSLATION_Y, 0.0f, 20.0f) + .setDuration(screenView.iconAnimationDurationMillis) + .apply { this.interpolator = interpolator } + val iconAnimatorSet = AnimatorSet().apply { + playTogether( + iconScaleXAnimation, + iconScaleYAnimation, + iconTranslateYAnimation + ) + doOnEnd { + viewAnimatorSet.startDelay = 50L + viewAnimatorSet.start() } - iconAnimatorSet.start() + doOnStart { onAnimationStart() } } + iconAnimatorSet.start() } \ No newline at end of file diff --git a/feature/onboarding/src/main/java/com/eva/feature_onboarding/OnBoardingActivity.kt b/feature/onboarding/src/main/java/com/eva/feature_onboarding/OnBoardingActivity.kt index 6de95728..54eb74c5 100644 --- a/feature/onboarding/src/main/java/com/eva/feature_onboarding/OnBoardingActivity.kt +++ b/feature/onboarding/src/main/java/com/eva/feature_onboarding/OnBoardingActivity.kt @@ -22,6 +22,7 @@ import com.eva.ui.activity.animateOnExit import com.eva.ui.theme.RecorderAppTheme import com.eva.utils.IntentConstants import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint @@ -32,19 +33,25 @@ class OnBoardingActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { val splash = installSplashScreen() - splash.setKeepOnScreenCondition { viewmodel.boardingState.value == OnBoardingState.UNKNOWN } - super.onCreate(savedInstanceState) // set enable edge to edge normally enableEdgeToEdge() + // set activity transitions + setTransitions() + + splash.setKeepOnScreenCondition { + val boardingState = viewmodel.boardingState.value + boardingState == OnBoardingState.UNKNOWN || + boardingState == OnBoardingState.SHOW_CONTENT + } // on splash complete again enable edge to edge splash.animateOnExit(onAnimationEnd = { enableEdgeToEdge() }) lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - viewmodel.boardingState.collect { state -> + viewmodel.boardingState.collectLatest { state -> if (state == OnBoardingState.SHOW_CONTENT) startMainActivityAndFinishCurrent() } @@ -67,8 +74,6 @@ class OnBoardingActivity : ComponentActivity() { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } startActivity(intent) - // set activity transitions - setTransitions() // finish onboarding activity finish() } catch (e: Exception) { From 479adb8505ea488e2f1c06709b81f8cfe4b2eb54 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sun, 2 Nov 2025 19:01:49 +0530 Subject: [PATCH 19/25] Removed resources corrected dependency and others Moved testing runtime dependency to custom android lib plugin Some of the unwanted resources are removed and few translation were marked non-translatable Updated the project dependencies to latest version Updated the module graph --- .idea/kotlinc.xml | 2 +- .../kotlin/ConfigureAndroidLibraryPlugin.kt | 4 + core/ui/src/main/res/drawable/ic_crop.xml | 15 ++ core/ui/src/main/res/values-bn/strings.xml | 2 - core/ui/src/main/res/values-hi/strings.xml | 2 - core/ui/src/main/res/values/colors.xml | 1 - core/ui/src/main/res/values/strings.xml | 1 - data/bookmarks/build.gradle.kts | 2 - data/categories/build.gradle.kts | 2 - data/database/build.gradle.kts | 1 - data/datastore/build.gradle.kts | 2 - data/editor/build.gradle.kts | 1 - data/interactions/build.gradle.kts | 1 - .../src/main/res/values/colors.xml | 4 - .../src/main/res/values-bn/strings.xml | 6 - .../src/main/res/values-hi/strings.xml | 6 - data/recorder/src/main/res/values/strings.xml | 10 +- gradle/libs.versions.toml | 71 ++++---- module_graph.md | 151 +++++++++++------- 19 files changed, 154 insertions(+), 130 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_crop.xml delete mode 100644 data/interactions/src/main/res/values/colors.xml diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 3efb2d8d..8ad8c861 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/build-logic/src/main/kotlin/ConfigureAndroidLibraryPlugin.kt b/build-logic/src/main/kotlin/ConfigureAndroidLibraryPlugin.kt index 65653bcb..1ff17ede 100644 --- a/build-logic/src/main/kotlin/ConfigureAndroidLibraryPlugin.kt +++ b/build-logic/src/main/kotlin/ConfigureAndroidLibraryPlugin.kt @@ -66,6 +66,10 @@ class ConfigureAndroidLibraryPlugin : Plugin { add("androidTestImplementation", dependency.get()) } } + // include the testing runtime module + if (findProject(":testing:runtime") != null) { + add("androidTestImplementation", project(":testing:runtime")) + } } private fun Project.configureLibrary() = extensions.configure { diff --git a/core/ui/src/main/res/drawable/ic_crop.xml b/core/ui/src/main/res/drawable/ic_crop.xml new file mode 100644 index 00000000..72c006fc --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_crop.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/core/ui/src/main/res/values-bn/strings.xml b/core/ui/src/main/res/values-bn/strings.xml index 402a048d..df2f6441 100644 --- a/core/ui/src/main/res/values-bn/strings.xml +++ b/core/ui/src/main/res/values-bn/strings.xml @@ -5,7 +5,6 @@ বন্ধ করুন বাতিল করুন বিরতি - বন্ধ করা অনুমতি দিন রেকর্ডার অনুমতি পাওয়া যায়নি এটি একটি রেকর্ডার অ্যাপ্লিকেশন যা আপনাকে কাজ করার জন্য অডিও রেকর্ডিংয়ের অনুমতি দিতে হবে @@ -75,7 +74,6 @@ মেয়াদ সাইজ নাম - প্লেয়ার চ্যানেল লিস্ট স্পীড পুনরাবৃত্তি করুন diff --git a/core/ui/src/main/res/values-hi/strings.xml b/core/ui/src/main/res/values-hi/strings.xml index b9d22b47..92b60e43 100644 --- a/core/ui/src/main/res/values-hi/strings.xml +++ b/core/ui/src/main/res/values-hi/strings.xml @@ -5,7 +5,6 @@ बंद करें रद्द करें रोकें - बंद करें अनुमति रिकॉर्डिंग की अनुमति नहीं है रिकॉर्डिंग की अनुमति आवश्यक है यह महत्वपूर्ण और आवश्यक है @@ -75,7 +74,6 @@ आकार नाम बिटरेट - प्लेयर_चैनल सूची गति दोहराएँ diff --git a/core/ui/src/main/res/values/colors.xml b/core/ui/src/main/res/values/colors.xml index a4891fb9..f3615d38 100644 --- a/core/ui/src/main/res/values/colors.xml +++ b/core/ui/src/main/res/values/colors.xml @@ -14,7 +14,6 @@ #000000 #FFF1FFE8 - #E0EBD0 #F87171 #FB923C diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index a7c39b9e..2930ba3e 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -6,7 +6,6 @@ Cancel Back Arrow Pause - Stop Allow permission Recorder Permission not found Its a recorder app you need to allow audio recording in order to work diff --git a/data/bookmarks/build.gradle.kts b/data/bookmarks/build.gradle.kts index d1954c74..8ecbe8a5 100644 --- a/data/bookmarks/build.gradle.kts +++ b/data/bookmarks/build.gradle.kts @@ -11,6 +11,4 @@ dependencies { implementation(project(":core:utils")) implementation(project(":data:recordings")) implementation(project(":data:database")) - - androidTestImplementation(project(":testing:runtime")) } \ No newline at end of file diff --git a/data/categories/build.gradle.kts b/data/categories/build.gradle.kts index 27728b52..aaf2e4c4 100644 --- a/data/categories/build.gradle.kts +++ b/data/categories/build.gradle.kts @@ -11,6 +11,4 @@ dependencies { implementation(project(":core:ui")) implementation(project(":core:utils")) implementation(project(":data:database")) - - androidTestImplementation(project(":testing:runtime")) } \ No newline at end of file diff --git a/data/database/build.gradle.kts b/data/database/build.gradle.kts index 581a4557..60fdbd5a 100644 --- a/data/database/build.gradle.kts +++ b/data/database/build.gradle.kts @@ -9,5 +9,4 @@ android { dependencies { implementation(project(":core:utils")) - androidTestImplementation(project(":testing:runtime")) } \ No newline at end of file diff --git a/data/datastore/build.gradle.kts b/data/datastore/build.gradle.kts index 0be513d9..98169855 100644 --- a/data/datastore/build.gradle.kts +++ b/data/datastore/build.gradle.kts @@ -14,6 +14,4 @@ dependencies { implementation(libs.androidx.datastore.preferences) //local implementation(project(":core:utils")) - - androidTestImplementation(project(":testing:runtime")) } \ No newline at end of file diff --git a/data/editor/build.gradle.kts b/data/editor/build.gradle.kts index ee208b6e..3e4c2a23 100644 --- a/data/editor/build.gradle.kts +++ b/data/editor/build.gradle.kts @@ -26,5 +26,4 @@ dependencies { implementation(project(":data:datastore")) androidTestImplementation(libs.androidx.media3.test.utils) - androidTestImplementation(project(":testing:runtime")) } \ No newline at end of file diff --git a/data/interactions/build.gradle.kts b/data/interactions/build.gradle.kts index 187f8d56..467e5ea1 100644 --- a/data/interactions/build.gradle.kts +++ b/data/interactions/build.gradle.kts @@ -14,6 +14,5 @@ dependencies { // testing androidTestImplementation(libs.androidx.espresso.intents) - androidTestImplementation(project(":testing:runtime")) } \ No newline at end of file diff --git a/data/interactions/src/main/res/values/colors.xml b/data/interactions/src/main/res/values/colors.xml deleted file mode 100644 index 32b5e6e7..00000000 --- a/data/interactions/src/main/res/values/colors.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #E0EBD0 - \ No newline at end of file diff --git a/data/recorder/src/main/res/values-bn/strings.xml b/data/recorder/src/main/res/values-bn/strings.xml index d070ab11..b8766697 100644 --- a/data/recorder/src/main/res/values-bn/strings.xml +++ b/data/recorder/src/main/res/values-bn/strings.xml @@ -1,16 +1,10 @@ - বিরতি - পুনরায় শুরু করুন - থামুন - বাতিল করুন রেকর্ডার চলছে রেকর্ডার পজ করা হয়েছে রেকর্ডিং সম্পন্ন হয়েছে বাহ্যিক সঞ্চয়স্থানে সংরক্ষণ করা হয়েছে৷ রেকর্ডিং প্লে করতে ক্লিক করুন - রেকর্ডিং বাতিল - ব্যবহারকারী দ্বারা রেকর্ডিং বাতিল করা হয়েছে ব্লুটুথ মাইক ব্যবহার করা ইনকামিং কল বুকমার্ক যোগ করা হয়েছে diff --git a/data/recorder/src/main/res/values-hi/strings.xml b/data/recorder/src/main/res/values-hi/strings.xml index 813949e0..3c34fd99 100644 --- a/data/recorder/src/main/res/values-hi/strings.xml +++ b/data/recorder/src/main/res/values-hi/strings.xml @@ -1,16 +1,10 @@ - रोकें - फिर से शुरू करें - बंद करें - रद्द करें रिकॉर्डर चल रहा है रिकॉर्डर रुका हुआ है रिकॉर्डिंग पूरी हुई बाहरी स्टोरेज में सहेजा गया चलाने के लिए क्लिक करें - रिकॉर्डिंग रद्द की गई - रिकॉर्डिंग उपयोगकर्ता द्वारा रद्द की गई ब्लूटूथ माइक्रोफोन का उपयोग हो रहा है आने वाली कॉल बुकमार्क जोड़े गए diff --git a/data/recorder/src/main/res/values/strings.xml b/data/recorder/src/main/res/values/strings.xml index 53e42265..87885cbc 100644 --- a/data/recorder/src/main/res/values/strings.xml +++ b/data/recorder/src/main/res/values/strings.xml @@ -1,16 +1,14 @@ - Pause - Resume - Stop - Cancel + Pause + Resume + Stop + Cancel Recorder is running Recorder Paused Recording Completed Saved to external storage Click to play - Recording Cancelled - Recording canceled by the user Using bluetooth mic Incoming call Bookmarks Added diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 539436b1..54d1c927 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ coreSplashscreen = "1.0.1" glance = "1.1.1" graphicsShapes = "1.1.0" hiltNavigation = "1.3.0" -kotlin = "2.2.20" +kotlin = "2.2.21" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" @@ -18,7 +18,7 @@ kotlinxSerializationJson = "1.9.0" lifecycleRuntimeKtx = "2.9.4" activityCompose = "1.11.0" composeBom = "2025.10.01" -ksp = "2.2.20-2.0.2" +ksp = "2.3.0" hilt = "2.57.2" media3Common = "1.8.0" navigationCompose = "2.9.5" @@ -33,45 +33,59 @@ protobuf_gen_java_lite = "3.0.0" materialIconExtended = "1.7.8" moduleGrapher = "0.13.0" mockk = "1.14.6" -coroutines-test = "1.10.2" +coroutines = "1.10.2" turbine_version = "1.2.1" compileSdk = "36" minSdk = "29" [libraries] -androidx-concurrent-futures-ktx = { module = "androidx.concurrent:concurrent-futures-ktx", version.ref = "concurrentFuturesKtx" } +#core androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } +androidx-concurrent-futures-ktx = { module = "androidx.concurrent:concurrent-futures-ktx", version.ref = "concurrentFuturesKtx" } +#glance androidx-glance = { module = "androidx.glance:glance", version.ref = "glance" } androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" } androidx-glance-appwidget-preview = { module = "androidx.glance:glance-appwidget-preview", version.ref = "glance" } androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "glance" } androidx-glance-preview = { module = "androidx.glance:glance-preview", version.ref = "glance" } androidx-graphics-shapes = { module = "androidx.graphics:graphics-shapes", version.ref = "graphicsShapes" } -androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltNavigation" } +# dagger hilt +hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-test = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigation" } -androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } -androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycleRuntimeKtx" } -androidx-media3-common = { module = "androidx.media3:media3-common", version.ref = "media3Common" } +androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltNavigation" } +androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" } +#media3 +androidx-media3-common = { module = "androidx.media3:media3-common-ktx", version.ref = "media3Common" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Common" } androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3Common" } androidx-media3-transformer = { module = "androidx.media3:media3-transformer", version.ref = "media3Common" } androidx-media3-effects = { module = "androidx.media3:media3-effect", version.ref = "media3Common" } -androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Common" } -androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } - +androidx-media3-extractor = { module = "androidx.media3:media3-extractor", version.ref = "media3Common" } +androidx-media3-decoder = { module = "androidx.media3:media3-decoder", version.ref = "media3Common" } +androidx-media3-ui = { module = "androidx.media3:media3-ui-compose-material3", version.ref = "media3Common" } +androidx-media3-test-utils = { module = "androidx.media3:media3-test-utils", version.ref = "media3Common" } #room androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomCompiler" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomCompiler" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomCompiler" } androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "roomCompiler" } - -androidx-ui-text-google-fonts = { module = "androidx.compose.ui:ui-text-google-fonts", version.ref = "uiTextGoogleFonts" } +#play services gms-play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocationVersion" } +#testing and android testing junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "espressoCore" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } +mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } +androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } +androidx-rules = { group = "androidx.test", name = "rules", version.ref = "runner" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine_version" } +#compose androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } @@ -81,18 +95,23 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } - -hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } -hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } -hilt-test = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } +androidx-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconExtended" } +androidx-ui-text-google-fonts = { module = "androidx.compose.ui:ui-text-google-fonts", version.ref = "uiTextGoogleFonts" } +#lifecycle and navigation +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycleRuntimeKtx" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +#kotlinx kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } -kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines-test" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +#worker work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtxVersion" } -androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" } -androidx-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconExtended" } +work-runtime-testing = { module = "androidx.work:work-testing", version.ref = "workRuntimeKtxVersion" } # protobuf and datastore androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } @@ -100,21 +119,13 @@ protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version. protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobufJavalite" } protobuf-protoc = { module = "com.google.protobuf:protoc", version.ref = "protobufJavalite" } protobuf-gen-javalite = { module = "com.google.protobuf:protoc-gen-javalite", version.ref = "protobuf_gen_java_lite" } - -#testing -mockk = { module = "io.mockk:mockk", version.ref = "mockk" } -mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } -mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } -turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine_version" } - -#gradle +#gradle plugins android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } compose-compiler-gradlePlugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } room-gradlePlugin = { group = "androidx.room", name = "room-gradle-plugin", version.ref = "roomCompiler" } protobuf-gradlePlugin = { group = "com.google.protobuf", name = "protobuf-gradle-plugin", version.ref = "protobuf_version" } -androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/module_graph.md b/module_graph.md index aee2fb35..9efe53b6 100644 --- a/module_graph.md +++ b/module_graph.md @@ -9,44 +9,48 @@ graph TB :app["app"] + subgraph :core + :core:ui["ui"] + :core:ui["ui"] + :core:utils["utils"] + end + subgraph :testing + :testing:runtime["runtime"] + end subgraph :data - :data:bookmarks["bookmarks"] - :data:recordings["recordings"] + :data:categories["categories"] :data:database["database"] - :data:player["player"] - :data:editor["editor"] - :data:interactions["interactions"] - :data:use_case["use_case"] - :data:recorder["recorder"] - :data:location["location"] + :data:recordings["recordings"] :data:datastore["datastore"] - :data:bookmarks["bookmarks"] - :data:categories["categories"] :data:recorder["recorder"] + :data:use_case["use_case"] :data:location["location"] :data:editor["editor"] + :data:player["player"] :data:worker["worker"] + :data:worker["worker"] + :data:bookmarks["bookmarks"] + :data:interactions["interactions"] + :data:interactions["interactions"] + :data:player["player"] + :data:bookmarks["bookmarks"] + :data:editor["editor"] + :data:recorder["recorder"] + :data:location["location"] :data:use_case["use_case"] :data:categories["categories"] - :data:worker["worker"] :data:database["database"] :data:recordings["recordings"] :data:datastore["datastore"] - :data:interactions["interactions"] - :data:player["player"] - end - subgraph :core - :core:utils["utils"] - :core:ui["ui"] end subgraph :feature - :feature:player-shared["player-shared"] :feature:settings["settings"] :feature:widget["widget"] :feature:recorder["recorder"] - :feature:onboarding["onboarding"] :feature:player["player"] :feature:player-shared["player-shared"] + :feature:player-shared["player-shared"] + :feature:onboarding["onboarding"] :feature:categories["categories"] :feature:editor["editor"] :feature:recordings["recordings"] @@ -60,55 +64,40 @@ graph TB :feature:onboarding["onboarding"] end - :data:bookmarks --> :core:utils - :data:bookmarks --> :data:recordings - :data:bookmarks --> :data:database - :feature:player-shared --> :core:ui - :feature:player-shared --> :core:utils - :feature:player-shared --> :data:player - :feature:player-shared --> :data:editor - :feature:player-shared --> :data:recordings - :feature:player-shared --> :data:interactions - :feature:player-shared --> :data:use_case - :data:recorder --> :core:utils - :data:recorder --> :data:use_case - :data:recorder --> :data:location - :data:recorder --> :data:datastore - :data:recorder --> :data:recordings - :data:recorder --> :data:bookmarks + :core:ui --> :testing:runtime + :data:categories --> :testing:runtime :data:categories --> :core:ui :data:categories --> :core:utils :data:categories --> :data:database + :feature:settings --> :testing:runtime :feature:settings --> :core:ui :feature:settings --> :core:utils :feature:settings --> :data:recordings :feature:settings --> :data:datastore + :feature:widget --> :testing:runtime :feature:widget --> :core:utils :feature:widget --> :core:ui :feature:widget --> :data:recorder :feature:widget --> :data:recordings :feature:widget --> :data:use_case + :data:location --> :testing:runtime :data:location --> :core:utils :data:location --> :data:datastore + :data:editor --> :testing:runtime :data:editor --> :core:utils :data:editor --> :data:player :data:editor --> :data:recordings :data:editor --> :data:worker :data:editor --> :data:datastore + :feature:recorder --> :testing:runtime :feature:recorder --> :core:ui :feature:recorder --> :core:utils :feature:recorder --> :data:recorder - :data:use_case --> :core:utils - :data:use_case --> :data:interactions - :data:use_case --> :data:recordings - :data:use_case --> :data:datastore - :data:use_case --> :data:categories - :feature:onboarding --> :core:ui - :feature:onboarding --> :core:utils - :feature:onboarding --> :data:datastore + :data:worker --> :testing:runtime :data:worker --> :core:utils :data:worker --> :core:ui :data:worker --> :data:recordings + :feature:player --> :testing:runtime :feature:player --> :core:ui :feature:player --> :core:utils :feature:player --> :data:player @@ -116,17 +105,58 @@ graph TB :feature:player --> :data:recordings :feature:player --> :data:interactions :feature:player --> :feature:player-shared + :data:interactions --> :testing:runtime + :data:interactions --> :core:utils + :data:interactions --> :data:bookmarks + :data:interactions --> :data:recordings + :data:player --> :testing:runtime + :data:player --> :core:utils + :data:player --> :data:recordings + :data:player --> :data:datastore + :data:bookmarks --> :testing:runtime + :data:bookmarks --> :core:utils + :data:bookmarks --> :data:recordings + :data:bookmarks --> :data:database + :feature:player-shared --> :testing:runtime + :feature:player-shared --> :core:ui + :feature:player-shared --> :core:utils + :feature:player-shared --> :data:player + :feature:player-shared --> :data:editor + :feature:player-shared --> :data:recordings + :feature:player-shared --> :data:interactions + :feature:player-shared --> :data:use_case + :data:recorder --> :testing:runtime + :data:recorder --> :core:utils + :data:recorder --> :data:use_case + :data:recorder --> :data:location + :data:recorder --> :data:datastore + :data:recorder --> :data:recordings + :data:recorder --> :data:bookmarks + :data:use_case --> :testing:runtime + :data:use_case --> :core:utils + :data:use_case --> :data:interactions + :data:use_case --> :data:recordings + :data:use_case --> :data:datastore + :data:use_case --> :data:categories + :feature:onboarding --> :testing:runtime + :feature:onboarding --> :core:ui + :feature:onboarding --> :core:utils + :feature:onboarding --> :data:datastore + :feature:categories --> :testing:runtime :feature:categories --> :core:ui :feature:categories --> :core:utils :feature:categories --> :data:categories :feature:categories --> :data:recordings + :data:database --> :testing:runtime :data:database --> :core:utils + :feature:editor --> :testing:runtime :feature:editor --> :core:ui :feature:editor --> :core:utils :feature:editor --> :data:editor :feature:editor --> :data:player :feature:editor --> :data:recordings :feature:editor --> :feature:player-shared + :feature:recordings --> :testing:runtime :feature:recordings --> :core:ui :feature:recordings --> :core:utils :feature:recordings --> :data:categories @@ -134,6 +164,7 @@ graph TB :feature:recordings --> :data:use_case :feature:recordings --> :data:interactions :feature:recordings --> :feature:categories + :data:recordings --> :testing:runtime :data:recordings --> :core:utils :data:recordings --> :data:database :data:recordings --> :data:datastore @@ -151,37 +182,33 @@ graph TB :app --> :feature:settings :app --> :feature:widget :app --> :feature:onboarding + :data:datastore --> :testing:runtime :data:datastore --> :core:utils - :data:interactions --> :core:utils - :data:interactions --> :data:bookmarks - :data:interactions --> :data:recordings - :data:player --> :core:utils - :data:player --> :data:recordings - :data:player --> :data:datastore classDef android-library fill:#4169E1,stroke:#fff,stroke-width:2px,color:#fff; classDef kotlin-jvm fill:#720e9e,stroke:#fff,stroke-width:2px,color:#fff; classDef android-application fill:#98FB98,stroke:#fff,stroke-width:2px,color:#fff; -class :data:bookmarks android-library -class :core:utils kotlin-jvm -class :data:recordings android-library -class :data:database android-library -class :feature:player-shared android-library class :core:ui android-library -class :data:player android-library -class :data:editor android-library -class :data:interactions android-library -class :data:use_case android-library -class :data:recorder android-library -class :data:location android-library -class :data:datastore android-library +class :testing:runtime android-library class :data:categories android-library +class :core:utils kotlin-jvm +class :data:database android-library class :feature:settings android-library +class :data:recordings android-library +class :data:datastore android-library class :feature:widget android-library +class :data:recorder android-library +class :data:use_case android-library +class :data:location android-library +class :data:editor android-library +class :data:player android-library class :data:worker android-library class :feature:recorder android-library -class :feature:onboarding android-library class :feature:player android-library +class :data:bookmarks android-library +class :data:interactions android-library +class :feature:player-shared android-library +class :feature:onboarding android-library class :feature:categories android-library class :feature:editor android-library class :feature:recordings android-library From c496bf3c9623fe12b6bcffd33494c8aaa5ca4f5f Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sun, 2 Nov 2025 20:46:26 +0530 Subject: [PATCH 20/25] Included A instrumented test workflow The workflow shows how many test has passed --- .github/workflows/instrumented_tests.yml | 85 ++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 .github/workflows/instrumented_tests.yml diff --git a/.github/workflows/instrumented_tests.yml b/.github/workflows/instrumented_tests.yml new file mode 100644 index 00000000..ae3682c5 --- /dev/null +++ b/.github/workflows/instrumented_tests.yml @@ -0,0 +1,85 @@ +name: Android Instrumented Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + instrumented-tests: + runs-on: ubuntu-latest + timeout-minutes: 45 + + strategy: + matrix: + api-level: [ 36 ] + target: [ default ] + arch: [ x86_64 ] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Gradle cache + uses: gradle/actions/setup-gradle@v3 + + - name: AVD cache + uses: actions/cache@v4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }}-${{ matrix.target }}-${{ matrix.arch }} + + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + target: ${{ matrix.target }} + arch: ${{ matrix.arch }} + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + + - name: Run instrumented tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + target: ${{ matrix.target }} + arch: ${{ matrix.arch }} + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: | + adb devices + ./gradlew connectedAndroidTest --stacktrace + + - name: Publish test results + if: always() + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: | + **/build/test-results/**/*.xml + **/build/outputs/androidTest-results/**/*.xml + check_name: Instrumented Test Results (API ${{ matrix.api-level }}) \ No newline at end of file From d57cdafe722b487b85e48588f96fbacfdd0813d3 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sun, 2 Nov 2025 21:14:18 +0530 Subject: [PATCH 21/25] Changed the test api target to google apis; --- .github/workflows/instrumented_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/instrumented_tests.yml b/.github/workflows/instrumented_tests.yml index ae3682c5..e4327ba0 100644 --- a/.github/workflows/instrumented_tests.yml +++ b/.github/workflows/instrumented_tests.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: api-level: [ 36 ] - target: [ default ] + target: [ google_apis ] arch: [ x86_64 ] steps: From 9aee0ced74309faebea6bf773535a152aef11648 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sun, 2 Nov 2025 21:40:17 +0530 Subject: [PATCH 22/25] Updated app build.gradle.kts Nothing new was added so it's a patch fix Need to include android runner dependency for app:connectedAndroidTest to run --- app/build.gradle.kts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 04c8c3fd..653ea794 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,7 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) alias(libs.plugins.jetbrains.kotlin.android) + // custom plugins alias(libs.plugins.recorderapp.hilt) alias(libs.plugins.recorderapp.compose.compiler) } @@ -16,8 +17,8 @@ android { applicationId = "com.eva.recorderapp" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.compileSdk.get().toInt() - versionCode = 10 - versionName = "1.4.0" + versionCode = 11 + versionName = "1.4.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -68,6 +69,10 @@ android { "proguard-rules.pro" ) } + debug { + applicationIdSuffix = ".debug" + resValue("string", "app_name", "RecorderApp (DEBUG)") + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 @@ -111,4 +116,6 @@ dependencies { implementation(project(":feature:widget")) implementation(project(":feature:onboarding")) + // android testing + androidTestImplementation(libs.androidx.runner) } From b32d7ca54f9c13a37fc573c17be2a507486c16c6 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sun, 2 Nov 2025 22:04:07 +0530 Subject: [PATCH 23/25] Increased heap size in workflow Mentioned to increate the heap size and meta size Included other .idea files --- .github/workflows/instrumented_tests.yml | 6 ++++++ .idea/markdown.xml | 8 ++++++++ 2 files changed, 14 insertions(+) create mode 100644 .idea/markdown.xml diff --git a/.github/workflows/instrumented_tests.yml b/.github/workflows/instrumented_tests.yml index e4327ba0..ca0947b4 100644 --- a/.github/workflows/instrumented_tests.yml +++ b/.github/workflows/instrumented_tests.yml @@ -32,6 +32,12 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew + - name: Configure Gradle memory + run: | + mkdir -p ~/.gradle + echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties + echo "org.gradle.daemon=true" >> ~/.gradle/gradle.properties + - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 00000000..c61ea334 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file From 065f86c18bce7b9a3a56a33a8765ca6f90a38a89 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sun, 2 Nov 2025 23:07:06 +0530 Subject: [PATCH 24/25] correction in test and updated workflow for built BookMarksToCsvConvertorTest.kt @AfterTest method should return Unit corrected that Updated the action version in android_build_ci.yaml --- .github/workflows/android_build_ci.yaml | 18 +++++++++--------- .../data/BookMarksToCsvConvertorTest.kt | 9 +++------ .../data/RecordingsBookmarkProviderTest.kt | 2 -- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/.github/workflows/android_build_ci.yaml b/.github/workflows/android_build_ci.yaml index 5828a2a5..01f238db 100644 --- a/.github/workflows/android_build_ci.yaml +++ b/.github/workflows/android_build_ci.yaml @@ -13,24 +13,24 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + # Optional, but good security practice + - name: 📝 Validate Gradle wrapper + uses: gradle/wrapper-validation-action@v3 + - name: Set up JDK uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' - cache: 'gradle' - - - name: Set up Android SDK - uses: android-actions/setup-android@v3 - - name: Grant execute permission for gradlew + - name: 🛡️ Grant execute permission for gradlew run: chmod +x gradlew - - name: Validate Gradle wrapper - uses: gradle/wrapper-validation-action@v1 + - name: 🔧 Setup Gradle (Caching & Optimization) + uses: gradle/actions/setup-gradle@v4 - - name: Create a debug build - run: ./gradlew assembleDebug + - name: 🏗️ Build debug APK + run: ./gradlew assembleDebug --stacktrace - name: Check if there is any build errors run: | diff --git a/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/BookMarksToCsvConvertorTest.kt b/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/BookMarksToCsvConvertorTest.kt index 14a174e1..1d3e9359 100644 --- a/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/BookMarksToCsvConvertorTest.kt +++ b/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/BookMarksToCsvConvertorTest.kt @@ -6,9 +6,7 @@ import com.eva.bookmarks.domain.provider.BookMarksExportRepository import com.eva.bookmarks.domain.provider.ExportURIProvider import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.datetime.LocalTime import org.junit.Rule @@ -17,11 +15,11 @@ import javax.inject.Inject import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertTrue @HiltAndroidTest @RunWith(AndroidJUnit4::class) -@OptIn(ExperimentalCoroutinesApi::class) class BookMarksToCsvConvertorTest { @get:Rule @@ -39,6 +37,7 @@ class BookMarksToCsvConvertorTest { @AfterTest fun tearDown() = runBlocking { uriProvider.clearAll() + Unit } @Test @@ -54,7 +53,6 @@ class BookMarksToCsvConvertorTest { } val uriString = exporter.invoke(entries) - advanceUntilIdle() assertTrue(uriString != null, "A CSV File created") } @@ -64,9 +62,8 @@ class BookMarksToCsvConvertorTest { val entries = emptyList() val uriString = exporter.invoke(entries) - advanceUntilIdle() - assertTrue(uriString == null, "Doesn't create a file as there is no entries") + assertEquals(null, uriString, "Doesn't create a file as there is no entries") } } \ No newline at end of file diff --git a/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/RecordingsBookmarkProviderTest.kt b/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/RecordingsBookmarkProviderTest.kt index 82c68c10..4a401870 100644 --- a/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/RecordingsBookmarkProviderTest.kt +++ b/data/bookmarks/src/androidTest/java/com/eva/bookmarks/data/RecordingsBookmarkProviderTest.kt @@ -9,7 +9,6 @@ import com.eva.recordings.domain.exceptions.InvalidRecordingIdException import com.eva.utils.Resource import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import kotlinx.datetime.LocalTime @@ -25,7 +24,6 @@ import kotlin.test.assertTrue @HiltAndroidTest @RunWith(AndroidJUnit4::class) -@OptIn(ExperimentalCoroutinesApi::class) class RecordingsBookmarkProviderTest { @get:Rule From 3c9fe1b9294529e0c8a59fd14f41b7670c4b0ec3 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Mon, 3 Nov 2025 00:36:26 +0530 Subject: [PATCH 25/25] Instrumented test needed permissions For publish test results permission checks write permission is needed --- .github/workflows/instrumented_tests.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/instrumented_tests.yml b/.github/workflows/instrumented_tests.yml index ca0947b4..8bb28b93 100644 --- a/.github/workflows/instrumented_tests.yml +++ b/.github/workflows/instrumented_tests.yml @@ -7,6 +7,11 @@ on: branches: [ main ] workflow_dispatch: +permissions: + contents: read + checks: write + pull-requests: write + jobs: instrumented-tests: runs-on: ubuntu-latest