diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100755 index 4ffde15f2..000000000 --- a/app/build.gradle +++ /dev/null @@ -1,117 +0,0 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-parcelize' -apply plugin: 'kotlin-kapt' -//apply plugin: 'com.google.firebase.crashlytics' -//apply plugin: 'com.google.gms.google-services' - -android { - namespace 'com.dimowner.audiorecorder' - compileSdkVersion 34 - defaultConfig { - applicationId "com.dimowner.audiorecorder" - minSdkVersion 23 - targetSdkVersion 34 - versionCode 935 - versionName "0.9.99" - } - - buildFeatures { - viewBinding true - buildConfig true - } - - def keystorePropertiesFile = rootProject.file("keystore.properties") - def keystoreProperties = new Properties() - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) - - signingConfigs { - dev { - storeFile file('key/debug/debug.keystore') - storePassword 'android' - keyAlias 'androiddebugkey' - keyPassword 'android' - } - release { - storeFile file(keystoreProperties['prodStoreFile']) - storePassword keystoreProperties['prodStorePassword'] - keyAlias keystoreProperties['prodKeyAlias'] - keyPassword keystoreProperties['prodKeyPassword'] - } - } - - buildTypes { - release { - minifyEnabled true - shrinkResources true - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' -// firebaseCrashlytics { -// mappingFileUploadEnabled true -// } - } - debug { - minifyEnabled false - } - } - - flavorDimensions "default" - - productFlavors { - debugConfig { - dimension "default" - applicationId "com.dimowner.audiorecorder.debug" - signingConfig = signingConfigs.dev - } - releaseConfig { - dimension "default" - signingConfig = signingConfigs.dev - applicationId "com.dimowner.audiorecorder" - } - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - lintOptions { - abortOnError false - } -} - -// Remove not needed buildVariants. -android.variantFilter { variant -> - if (variant.buildType.name == 'release' - && variant.getFlavors().get(0).name == 'debugConfig') { - variant.setIgnore(true) - } - if (variant.buildType.name == 'debug' - && variant.getFlavors().get(0).name == 'releaseConfig') { - variant.setIgnore(true) - } -} - -dependencies { - def androidX = "1.3.2" - def coroutines = "1.8.0" - def timber = "5.0.1" - - //Kotlin - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines" - - //Timber - implementation "com.jakewharton.timber:timber:$timber" - implementation "androidx.recyclerview:recyclerview:$androidX" - - testImplementation("junit:junit:4.13.2") - testImplementation("io.mockk:mockk:1.13.10") - -// // Import the BoM for the Firebase platform -// implementation platform('com.google.firebase:firebase-bom:26.1.0') -// // Declare the dependencies for the Crashlytics and Analytics libraries -// // When using the BoM, you don't specify versions in Firebase library dependencies -// implementation 'com.google.firebase:firebase-crashlytics' -// implementation 'com.google.firebase:firebase-analytics' -} diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 000000000..c2a85ee81 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,146 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) + alias(libs.plugins.compose.compiler) + id("kotlin-parcelize") +} + +android { + namespace = "com.dimowner.audiorecorder" + compileSdk = 36 + + defaultConfig { + applicationId = "com.dimowner.audiorecorder" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables.useSupportLibrary = true + } + + buildFeatures { + viewBinding = true + } + + signingConfigs { + create("dev") { + storeFile = file("key/debug/debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } + + buildTypes { + getByName("release") { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android.txt"), + "proguard-rules.pro" + ) +// firebaseCrashlytics { +// mappingFileUploadEnabled true +// } + } + getByName("debug") { + isMinifyEnabled = false + } + } + + flavorDimensions += listOf("default") + productFlavors { + create("debugConfig") { + dimension = "default" + applicationId = "com.dimowner.audiorecorder.debug" + signingConfig = signingConfigs.getByName("dev") + } + create("releaseConfig") { + dimension = "default" + signingConfig = signingConfigs.getByName("dev") + applicationId = "com.dimowner.audiorecorder" + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { + viewBinding = true + buildConfig = true + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } + packaging { + resources.excludes.addAll( + listOf("META-INF/LICENSE.md", "META-INF/LICENSE-notice.md") + ) + } + testOptions { + unitTests { + // Required for Robolectric to access Android resources + isIncludeAndroidResources = true + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +composeCompiler { + reportsDestination = layout.buildDirectory.dir("compose_compiler") + stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf") +} + +dependencies { + ksp(libs.androidx.room.compiler) + ksp(libs.hilt.android.compiler) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.timber) + implementation(libs.androidx.viewpager2) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + implementation(libs.androidx.documentfile) + implementation(libs.hilt.android) + implementation(libs.exoplayer.core) + implementation(libs.exoplayer.ui) + implementation(libs.androidx.core.splashscreen) + implementation(libs.gson) + + // Compose + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.constraintlayout.compose) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.foundation.layout) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.viewbinding) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.runtime.livedata) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.navigation.compose) + implementation(libs.hilt.navigation.compose) + implementation(libs.androidx.compose.material.icons.extended) + + testImplementation(libs.junit) + testImplementation(libs.androidx.junit.ktx) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) + testImplementation(libs.robolectric) + androidTestImplementation(libs.mockk) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} diff --git a/app/src/androidTest/java/com/dimowner/audiorecorder/v2/app/AppExtensionsTest.kt b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/app/AppExtensionsTest.kt new file mode 100644 index 000000000..e693de939 --- /dev/null +++ b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/app/AppExtensionsTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app + +import android.content.Context +import android.content.res.Resources +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AppExtensionsTest { + + @Test + fun formatDuration_correctly_formats_duration() { + val resources: Resources = ApplicationProvider.getApplicationContext().resources + + val durationMillis = (365L * 24 * 60 * 60 + 24L * 60 * 60 + 60L * 60 + 60 + 1) * 1000 + Assert.assertEquals("1year 1day 01h:01m:01s", formatDuration(resources, durationMillis)) + + val durationMillis2 = (365L * 24 * 60 * 60) * 1000 + Assert.assertEquals("1year 00m:00s", formatDuration(resources, durationMillis2)) + + val durationMillis3 = (24L * 60 * 60 + 60L * 60 + 60 + 1) * 1000 + Assert.assertEquals("1day 01h:01m:01s", formatDuration(resources, durationMillis3)) + + val durationMillis4 = (23L * 60 * 60 + 59 * 60 + 59) * 1000 + Assert.assertEquals("23h:59m:59s", formatDuration(resources, durationMillis4)) + + val durationMillis5 = (10 * 365L * 24 * 60 * 60 + 125 * 24 * 60 * 60 + 23L * 60 * 60 + 59 * 60 + 59) * 1000 + Assert.assertEquals("10years 125days 23h:59m:59s", formatDuration(resources, durationMillis5)) + } + + @Test + fun formatDuration_handles_zero_duration() { + // Example input: 0 milliseconds + val durationMillis = 0L + + val resources: Resources = ApplicationProvider.getApplicationContext().resources + + val formattedDuration = formatDuration(resources, durationMillis) + + Assert.assertEquals("00m:00s", formattedDuration) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/dimowner/audiorecorder/v2/app/records/RecordsExtensionsTest.kt b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/app/records/RecordsExtensionsTest.kt new file mode 100644 index 000000000..4887f2f87 --- /dev/null +++ b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/app/records/RecordsExtensionsTest.kt @@ -0,0 +1,522 @@ +package com.dimowner.audiorecorder.v2.app.records + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.dimowner.audiorecorder.util.TimeUtils.formatDateSmartLocale +import com.dimowner.audiorecorder.v2.data.model.SortOrder +import io.mockk.impl.annotations.MockK +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import io.mockk.MockKAnnotations +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.fail +import java.util.Calendar +import kotlin.collections.find + +@RunWith(AndroidJUnit4::class) +class RecordsExtensionsTest { + + @MockK + lateinit var mockContext: Context + + private lateinit var records: List + private lateinit var initialState: RecordsScreenState + + @Before + fun setup() { + MockKAnnotations.init(this) + + val time = Calendar.getInstance() + time.set(2025, 5, 24) + val time1 = time.timeInMillis + time.set(2025, 5, 22) + val time2 = time.timeInMillis + 1000 + val time3 = time.timeInMillis + time.set(2024, 7, 20) + val time4 = time.timeInMillis + + records = listOf( + RecordListItem( + recordId = 101, + name = "Name1", + details = "Details1", + duration = "1:01", + added = time1, + isBookmarked = false + ), + RecordListItem( + recordId = 202, + name = "Name2", + details = "Details2", + duration = "2:02", + added = time2, + isBookmarked = false + ), + RecordListItem( + recordId = 303, + name = "Name3", + details = "Details3", + duration = "3:03", + added = time3, + isBookmarked = false + ), + RecordListItem( + recordId = 404, + name = "Name4", + details = "Details4", + duration = "4:04", + added = time4, + isBookmarked = false + ), + ) + + initialState = RecordsScreenState( + sortOrder = SortOrder.DateDesc, + recordsMap = records.groupRecordsByDate(mockContext, SortOrder.DateDesc), + ) + } + + @Test + fun sort_by_DateAsc_returns_oldest_first() { + // time4 (2024) is the oldest + val result = records.sort(SortOrder.DateAsc) + + assertEquals(404L, result.first().recordId) + assertEquals(303L, result[1].recordId) + assertEquals(101L, result.last().recordId) + } + + @Test + fun sort_by_DateDesc_returns_newest_first() { + // time1 and time2 are the newest + val result = records.sort(SortOrder.DateDesc) + + assertEquals(101L, result.first().recordId) + assertEquals(202L, result[1].recordId) + assertEquals(404L, result.last().recordId) + } + + @Test + fun sort_by_NameDesc_returns_Name4_first() { + val result = records.sort(SortOrder.NameDesc) + + assertEquals("Name4", result.first().name) + assertEquals("Name3", result[1].name) + assertEquals("Name1", result.last().name) + } + + @Test + fun sort_by_NameAsc_returns_Name1_first() { + val result = records.sort(SortOrder.NameAsc) + + assertEquals("Name1", result.first().name) + assertEquals("Name2", result[1].name) + assertEquals("Name4", result.last().name) + } + + @Test + fun sort_by_DurationShortest_returns_1_01_first() { + val result = records.sort(SortOrder.DurationShortest) + + assertEquals("1:01", result.first().duration) + assertEquals("2:02", result[1].duration) + assertEquals("4:04", result.last().duration) + } + + @Test + fun sort_by_DurationLongest_returns_1_01_first() { + val result = records.sort(SortOrder.DurationLongest) + + assertEquals("4:04", result.first().duration) + assertEquals("3:03", result[1].duration) + assertEquals("1:01", result.last().duration) + } + + @Test + fun addRecordToMap_adds_record_to_existing_group_and_maintains_sort() { + // 1. Setup: A new record for an existing date (June 20, 2025 - same as Name1) + // We'll use NameDesc sort to see it move to the top of its group + val sortOrder = SortOrder.NameDesc + val existingMap = records.groupRecordsByDate(mockContext, sortOrder) + + val newRecord = RecordListItem( + recordId = 999, + name = "Name33", // Should come second in NameDesc + details = "Details5", + added = records.first().added, // June 20, 2025 + duration = "0:30", + isBookmarked = false, + ) + + // 2. Execution + val resultMap = existingMap.addRecordToMap(mockContext, newRecord, sortOrder) + + // 3. Verification + //For sortOrder not by date group key is always empty string. + val groupList = resultMap[""] + if (groupList == null) { + fail("Group key not found") + } else { + assertEquals(404L, groupList.first().recordId) + assertEquals(999, groupList[1].recordId) + assertEquals(5, groupList.size) + } + } + + @Test + fun addRecordToMap_creates_new_group_when_date_does_not_exist() { + // 1. Setup: A record from a completely different year (1999) + val oldTime = Calendar.getInstance().apply { set(1999, 0, 1) }.timeInMillis + val oldRecord = RecordListItem( + recordId = 999, + name = "Vintage", + details = "Details", + added = oldTime, + duration = "9:99", + isBookmarked = false + ) + + // 2. Execution + val resultMap = initialState.recordsMap.addRecordToMap( + mockContext, + oldRecord, + SortOrder.DateDesc + ) + + // 3. Verification + val newKey = formatDateSmartLocale(oldTime, mockContext) + assertTrue(resultMap.containsKey(newKey)) + assertEquals(1, resultMap[newKey]?.size) + assertEquals(999L, resultMap[newKey]?.first()?.recordId) + } + + @Test + fun addRecordToMap_preserves_other_groups_during_insertion() { + // Setup + val newRecord = RecordListItem( + recordId = 777, + name = "UniqueDate", + added = 0L, // Different date + details = "Details", + duration = "1:00", + isBookmarked = false + ) + + // Execution + val resultMap = initialState.recordsMap.addRecordToMap( + mockContext, + newRecord, + SortOrder.DateDesc + ) + + // Verification + assertEquals(4, resultMap.size) + // Check that Name1 (the May 24th 2025 record) is still there + val key2025May24th = formatDateSmartLocale(records.first().added, mockContext) + assertNotNull(resultMap[key2025May24th]) + assertEquals(1, resultMap[key2025May24th]?.size) + + // Check that Name1 (the May 22th 2025 record) is still there + val key2025May22th = formatDateSmartLocale(records[1].added, mockContext) + assertNotNull(resultMap[key2025May22th]) + assertEquals(2, resultMap[key2025May22th]?.size) + + // Check that Name4 (the 2024 record) is still there + val key2024 = formatDateSmartLocale(records.last().added, mockContext) + assertNotNull(resultMap[key2024]) + assertEquals(1, resultMap[key2024]?.size) + } + + //================ Test mapRecordInMap ==================== + + @Test + fun mapRecordInMap_shouldSuccessfullyUpdateTheTargetRecord() { + val newName = "New Target Name" + val recordId = 303L + val originalMap = records.groupRecordsByDate( + mockContext, SortOrder.DateDesc + ) + + // The update operation: changing name and bookmark status + val updateOperation: (RecordListItem) -> RecordListItem = { oldRecord -> + oldRecord.copy(name = newName, isBookmarked = true) + } + + // Act + val newMap = originalMap.mapRecordInMap(recordId, updateOperation) + + // Retrieve the updated record from the new map + val updatedRecord = newMap["Jun 22"]?.find { it.recordId == recordId } + + // Assert + assertNotEquals(originalMap, newMap) + assertEquals(newName, updatedRecord?.name) + assertTrue(updatedRecord?.isBookmarked == true) + assertEquals(recordId, updatedRecord?.recordId) + } + + @Test + fun mapRecordInMap_shouldReturnLogicallyIdenticalMap_whenIdIsNotFound() { + val newName = "New Target Name" + val recordId = 999L + val originalMap = records.groupRecordsByDate( + mockContext, SortOrder.DateDesc + ) + + // The update operation: changing name and bookmark status + val updateOperation: (RecordListItem) -> RecordListItem = { oldRecord -> + oldRecord.copy(name = newName, isBookmarked = true) + } + + // Act + val newMap = originalMap.mapRecordInMap(recordId, updateOperation) + + // Assert + newMap.values.forEach { list -> + assertNull(list.find { it.recordId == recordId }) + } + + assertEquals(originalMap, newMap) + assertNotSame(originalMap, newMap) + } + + @Test + fun mapRecordInMap_shouldHandleEmptyMap() { + // Arrange + val emptyMap = emptyMap>() + val updateOperation: (RecordListItem) -> RecordListItem = { it.copy(isBookmarked = true) } + + // Act + val newMap = emptyMap.mapRecordInMap(101, updateOperation) + + // Assert + assert(newMap.isEmpty()) { "Processing an empty map should result in an empty map." } + } + + //=============== Test groupRecordsByDate ===================== + + @Test + fun updateRecordInMap_shouldCreateNewStateAndUpdateRecord() { + // Arrange + val newName = "State Update Target" + val recordId = 303L + val updateOperation: (RecordListItem) -> RecordListItem = { it.copy(name = newName, isBookmarked = true) } + + // Act + val newState = initialState.updateRecordInMap(recordId, updateOperation) + + // Retrieve the updated record from the new map + val updatedRecord = newState.recordsMap["Jun 22"]?.find { it.recordId == recordId } + + // Assert + assertNotEquals(initialState, newState) + assertEquals(newName, updatedRecord?.name) + assertTrue(updatedRecord?.isBookmarked == true) + assertEquals(recordId, updatedRecord?.recordId) + } + + @Test + fun updateRecordInMap_shouldPreserveUnrelatedStateFields() { + // Arrange + val newName = "State Update Target" + val recordId = 303L + val updateOperation: (RecordListItem) -> RecordListItem = { it.copy(name = newName, isBookmarked = true) } + + // Act + val newState = initialState.updateRecordInMap(recordId, updateOperation) + + // Assert: Check unrelated state fields are preserved + assertNotEquals(initialState.recordsMap, newState.recordsMap) + assertEquals(initialState.selectedRecords, newState.selectedRecords) + assertEquals(initialState.sortOrder, newState.sortOrder) + assertEquals(initialState.bookmarksSelected, newState.bookmarksSelected) + assertEquals(initialState.showDeletedRecordsButton, newState.showDeletedRecordsButton) + assertEquals(initialState.showRecordPlaybackPanel, newState.showRecordPlaybackPanel) + assertEquals(initialState.deletedRecordsCount, newState.deletedRecordsCount) + assertEquals(initialState.isShowLoadingProgress, newState.isShowLoadingProgress) + assertEquals(initialState.showRenameDialog, newState.showRenameDialog) + assertEquals(initialState.showMoveToRecycleDialog, newState.showMoveToRecycleDialog) + assertEquals(initialState.showMoveToRecycleMultipleDialog, newState.showMoveToRecycleMultipleDialog) + assertEquals(initialState.showSaveAsDialog, newState.showSaveAsDialog) + assertEquals(initialState.showSaveAsMultipleDialog, newState.showSaveAsMultipleDialog) + assertEquals(initialState.operationSelectedRecord, newState.operationSelectedRecord) + assertEquals(initialState.activeRecord, newState.activeRecord) + } + + @Test + fun updateRecordInMap_shouldReturnNewStateButIdenticalRecordContent_whenIdIsNotFound() { + // Arrange + val nonExistentId = 999L + val updateOperation: (RecordListItem) -> RecordListItem = { it.copy(name = "Should Not Be Seen") } + + // Act + val newState = initialState.updateRecordInMap(nonExistentId, updateOperation) + + // Assert 1: The state object must still be new (due to the outer copy) + assertNotSame(initialState, newState) + assertEquals(initialState, newState) + } + + //=============== Test groupRecordsByDate ====================== + + @Test + fun removeRecordFromMap_shouldRemoveRecordButPreserveGroup() { + val initialMap = records.groupRecordsByDate(mockContext, SortOrder.DateAsc) + val recordId = 202L + + // Act + val newMap = initialMap.removeRecordFromMap(recordId) + + // Assert 1: The key for Group A still exists + assert(newMap.containsKey("Jun 22")) { "Group key should still exist." } + + // Assert 2: Group A now has only 1 item + assertEquals(1, newMap["Jun 22"]?.size) + + // Assert 3: The removed record is gone + val removedRecord = newMap["Jun 22"]?.find { it.recordId == recordId } + assertNull(removedRecord) + } + + @Test + fun removeRecordFromMap_shouldRemoveRecordAndDeleteGroup_whenListBecomesEmpty() { + val initialMap = records.groupRecordsByDate(mockContext, SortOrder.DateAsc) + val recordId = 101L + + // Act + val newMap = initialMap.removeRecordFromMap(recordId) + + // Assert 1: The key for Group (Jun 20) should be gone + assert(!newMap.containsKey("Jun 20")) { "Group Jun 20 key should be deleted because its list is now empty." } + + // Assert 2: The map size should be reduced from 3 to 2 + assertEquals(2, newMap.size) + } + + @Test + fun removeRecordFromMap_shouldReturnLogicallyIdenticalMap_whenIdIsNotFound() { + // Arrange + val initialMap = records.groupRecordsByDate(mockContext, SortOrder.DateAsc) + val nonExistentId = 999L + + // Act + val newMap = initialMap.removeRecordFromMap(nonExistentId) + + // Assert: The maps are equal but not the same + assertEquals(3, newMap.size) + assertEquals(initialMap, newMap) + assertNotSame(initialMap, newMap) + } + + @Test + fun removeRecordFromMap_shouldHandleEmptyMap() { + // Arrange + val recordId = 202L + val emptyMap = emptyMap>() + + // Act + val newMap = emptyMap.removeRecordFromMap(recordId) + + // Assert + assert(newMap.isEmpty()) { "Processing an empty map should result in an empty map." } + } + + //================== Test groupRecordsByDate ========================= + + @Test + fun groupRecordsByDate_shouldGroupItemsByDate_whenSortOrderIsDateAsc() { + val result = records.groupRecordsByDate(mockContext, SortOrder.DateAsc) + assertEquals(3, result.size) + assertEquals(1, result["Jun 24"]?.size) + assertEquals(2, result["Jun 22"]?.size) + assertEquals(1, result["Aug 20, 2024"]?.size) + } + + @Test + fun groupRecordsByDate_shouldGroupItemsByDate_whenSortOrderIsDateDesc() { + val result = records.groupRecordsByDate(mockContext, SortOrder.DateDesc) + + assertEquals(3, result.size) + assertEquals(1, result["Jun 24"]?.size) + assertEquals(2, result["Jun 22"]?.size) + assertEquals(1, result["Aug 20, 2024"]?.size) + } + + @Test + fun groupRecordsByDate_shouldGroupAllItemsByEmptyString_whenSortOrderIs_NameAsc() { + val result = records.groupRecordsByDate(mockContext, SortOrder.NameAsc) + + // Assert: Expecting exactly one group with the key "" + assertEquals(1, result.size) + + // Assert: The single group key should be the empty string + val singleGroupKey = "" + assert(result.containsKey(singleGroupKey)) { "The single group key must be an empty string." } + assertEquals(records.size, result[singleGroupKey]?.size) + } + + @Test + fun groupRecordsByDate_shouldGroupAllItemsByEmptyString_whenSortOrderIs_NameDesc() { + val result = records.groupRecordsByDate(mockContext, SortOrder.NameDesc) + + // Assert: Expecting exactly one group with the key "" + assertEquals(1, result.size) + + // Assert: The single group key should be the empty string + val singleGroupKey = "" + assert(result.containsKey(singleGroupKey)) { "The single group key must be an empty string." } + assertEquals(records.size, result[singleGroupKey]?.size) + } + + @Test + fun groupRecordsByDate_shouldGroupAllItemsByEmptyString_whenSortOrderIs_DurationLongest() { + val result = records.groupRecordsByDate(mockContext, SortOrder.DurationLongest) + + // Assert: Expecting exactly one group with the key "" + assertEquals(1, result.size) + + // Assert: The single group key should be the empty string + val singleGroupKey = "" + assert(result.containsKey(singleGroupKey)) { "The single group key must be an empty string." } + assertEquals(records.size, result[singleGroupKey]?.size) + } + + @Test + fun groupRecordsByDate_shouldGroupAllItemsByEmptyString_whenSortOrderIs_DurationShortest() { + val result = records.groupRecordsByDate(mockContext, SortOrder.DurationShortest) + + // Assert: Expecting exactly one group with the key "" + assertEquals(1, result.size) + + // Assert: The single group key should be the empty string + val singleGroupKey = "" + assert(result.containsKey(singleGroupKey)) { "The single group key must be an empty string." } + assertEquals(records.size, result[singleGroupKey]?.size) + } + + @Test + fun groupRecordsByDate_shouldHandleEmptyList() { + val emptyRecords = emptyList() + + val result = emptyRecords.groupRecordsByDate(mockContext, SortOrder.DateAsc) + + assert(result.isEmpty()) { "Grouping an empty list should result in an empty map." } + } + + @Test + fun isSortOrderByDate_shouldReturnTrue_forDateAsc() { + assertTrue(SortOrder.DateAsc.isSortOrderByDate()) + assertTrue(SortOrder.DateDesc.isSortOrderByDate()) + assertFalse(SortOrder.NameAsc.isSortOrderByDate()) + assertFalse(SortOrder.NameDesc.isSortOrderByDate()) + assertFalse(SortOrder.DurationLongest.isSortOrderByDate()) + assertFalse(SortOrder.DurationShortest.isSortOrderByDate()) + } +} diff --git a/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/PrefsImplTest.kt b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/PrefsImplTest.kt new file mode 100644 index 000000000..62609fa2e --- /dev/null +++ b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/PrefsImplTest.kt @@ -0,0 +1,244 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.data + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import com.dimowner.audiorecorder.AppConstantsV2 +import com.dimowner.audiorecorder.v2.DefaultValues +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.NameFormat +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import com.dimowner.audiorecorder.v2.data.model.SortOrder +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@LargeTest +class PrefsImplTest { + + private lateinit var prefs: PrefsV2Impl + + @Before + fun createPrefs() { + val context: Context = ApplicationProvider.getApplicationContext() + prefs = PrefsV2Impl(context) + } + + @After + fun resetPrefs() { + prefs.fullPreferenceReset() + } + + @Test + fun test_fullPreferenceReset() { + val id = 101L + + prefs.confirmFirstRunExecuted() + prefs.activeRecordId = id + prefs.recordedRecordId = id + prefs.recordedRecordPartCounter = 10 + prefs.recordedRecordBaseName = "Record-1" + prefs.isDarkTheme = true + prefs.isAppV2 = true + prefs.settingSampleRate = SampleRate.SR16000 + prefs.settingNamingFormat = NameFormat.DateUs + + prefs.fullPreferenceReset() + + assertEquals(-1, prefs.activeRecordId) + assertEquals(-1, prefs.recordedRecordId) + assertEquals(0, prefs.recordedRecordPartCounter) + assertNull( prefs.recordedRecordBaseName) + assertEquals(DefaultValues.isDarkTheme, prefs.isDarkTheme) + assertEquals(DefaultValues.isAppV2, prefs.isAppV2) + assertEquals(DefaultValues.DefaultSampleRate, prefs.settingSampleRate) + assertEquals(DefaultValues.DefaultNameFormat, prefs.settingNamingFormat) + } + + @Test + fun test_firstRun() { + assertTrue(prefs.isFirstRun) + + prefs.confirmFirstRunExecuted() + assertFalse(prefs.isFirstRun) + } + + @Test + fun test_askToRenameAfterRecordingStopped() { + assertEquals(DefaultValues.isAskToRename, prefs.askToRenameAfterRecordingStopped) + + prefs.askToRenameAfterRecordingStopped = !DefaultValues.isAskToRename + assertEquals(!DefaultValues.isAskToRename, prefs.askToRenameAfterRecordingStopped) + } + + @Test + fun test_activeRecordId() { + assertEquals(-1, prefs.activeRecordId) + + prefs.activeRecordId = 303 + assertEquals(303L, prefs.activeRecordId) + } + + @Test + fun test_recordedRecordId() { + assertEquals(-1, prefs.recordedRecordId) + + prefs.recordedRecordId = 404 + assertEquals(404L, prefs.recordedRecordId) + } + + @Test + fun test_recordedRecordPartCounter() { + assertEquals(0, prefs.recordedRecordPartCounter) + + prefs.recordedRecordPartCounter = 10 + assertEquals(10, prefs.recordedRecordPartCounter) + } + + @Test + fun test_recordedRecordBaseName() { + assertNull(prefs.recordedRecordBaseName) + + val name = "Record-2" + prefs.recordedRecordBaseName = name + assertEquals(name, prefs.recordedRecordBaseName) + } + + @Test + fun test_recordCounter() { + assertEquals(1, prefs.recordCounter) + + prefs.incrementRecordCounter() + assertEquals(2, prefs.recordCounter) + } + + @Test + fun test_isKeepScreenOn() { + assertEquals(DefaultValues.isKeepScreenOn, prefs.isKeepScreenOn) + + prefs.isKeepScreenOn = !DefaultValues.isKeepScreenOn + assertEquals(!DefaultValues.isKeepScreenOn, prefs.isKeepScreenOn) + } + + @Test + fun test_recordsSortOrder() { + assertEquals(DefaultValues.DefaultSortOrder, prefs.recordsSortOrder) + + prefs.recordsSortOrder = SortOrder.NameDesc + assertEquals(SortOrder.NameDesc, prefs.recordsSortOrder) + } + + @Test + fun test_isDynamicTheme() { + assertEquals(DefaultValues.isDynamicTheme, prefs.isDynamicTheme) + + prefs.isDynamicTheme = !DefaultValues.isDynamicTheme + assertEquals(!DefaultValues.isDynamicTheme, prefs.isDynamicTheme) + } + + @Test + fun test_isDarkTheme() { + assertEquals(DefaultValues.isDarkTheme, prefs.isDarkTheme) + + prefs.isDarkTheme = !DefaultValues.isDarkTheme + assertEquals(!DefaultValues.isDarkTheme, prefs.isDarkTheme) + } + + @Test + fun test_isAppV2() { + assertEquals(DefaultValues.isAppV2, prefs.isAppV2) + + prefs.isAppV2 = !DefaultValues.isAppV2 + assertEquals(!DefaultValues.isAppV2, prefs.isAppV2) + } + + @Test + fun test_settingNamingFormat() { + assertEquals(DefaultValues.DefaultNameFormat, prefs.settingNamingFormat) + + prefs.settingNamingFormat = NameFormat.DateUs + assertEquals(NameFormat.DateUs, prefs.settingNamingFormat) + } + + @Test + fun test_settingRecordingFormat() { + assertEquals(DefaultValues.DefaultRecordingFormat, prefs.settingRecordingFormat) + + prefs.settingRecordingFormat = RecordingFormat.ThreeGp + assertEquals(RecordingFormat.ThreeGp, prefs.settingRecordingFormat) + } + + @Test + fun test_settingSampleRate() { + assertEquals(DefaultValues.DefaultSampleRate, prefs.settingSampleRate) + + prefs.settingSampleRate = SampleRate.SR32000 + assertEquals(SampleRate.SR32000, prefs.settingSampleRate) + } + + @Test + fun test_settingBitrate() { + assertEquals(DefaultValues.DefaultBitRate, prefs.settingBitrate) + + prefs.settingBitrate = BitRate.BR256 + assertEquals(BitRate.BR256, prefs.settingBitrate) + } + + @Test + fun test_settingChannelCount() { + assertEquals(DefaultValues.DefaultChannelCount, prefs.settingChannelCount) + + prefs.settingChannelCount = ChannelCount.Mono + assertEquals(ChannelCount.Mono, prefs.settingChannelCount) + } + + @Test + fun test_resetRecordingSettings() { + prefs.settingRecordingFormat = RecordingFormat.ThreeGp + prefs.settingSampleRate = SampleRate.SR32000 + prefs.settingBitrate = BitRate.BR256 + prefs.settingChannelCount = ChannelCount.Mono + + prefs.resetRecordingSettings() + + assertEquals(DefaultValues.DefaultRecordingFormat, prefs.settingRecordingFormat) + assertEquals(DefaultValues.DefaultSampleRate, prefs.settingSampleRate) + assertEquals(DefaultValues.DefaultBitRate, prefs.settingBitrate) + assertEquals(DefaultValues.DefaultChannelCount, prefs.settingChannelCount) + } + + @Test + fun test_maxRecordingDurationMills() { + val defaultValue = AppConstantsV2.DEFAULT_MAX_RECORDING_DURATION_MS + assertEquals(defaultValue, prefs.maxRecordingDurationMills) + + val customDuration = 180 * 60 * 1000 // 180 minutes in ms + prefs.maxRecordingDurationMills = customDuration + assertEquals(customDuration, prefs.maxRecordingDurationMills) + } +} diff --git a/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensionsTest.kt b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensionsTest.kt new file mode 100644 index 000000000..a17fa279e --- /dev/null +++ b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensionsTest.kt @@ -0,0 +1,288 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data.extensions + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import io.mockk.every +import io.mockk.mockk +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +@LargeTest +class FileExtensionsTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun test_createFile_Existing_Directory() { + // Create a temporary directory for testing + val directory = tempFolder.newFolder("testDir") + + // Create a file within the directory + val fileName = "testFile.txt" + val createdFile = createFile(directory, fileName) + + // Verify that the file was created + assertTrue(createdFile.exists()) + assertEquals(fileName, createdFile.name) + } + + @Test + fun test_createFile_Existing_Directory_and_Existing_File() { + // Create a temporary directory for testing + val directory = tempFolder.newFolder("testDir") + + // Create a file within the directory + val fileName = "testFile.txt" + val createdFile = createFile(directory, fileName) + + // Verify that the file 1 was created and has correct name + assertTrue(createdFile.exists()) + assertEquals(fileName, createdFile.name) + + val expectedFileName2 = "testFile-1.txt" + val createdFile2 = createFile(directory, fileName) + // Verify that the file 2 was created and has correct name + assertTrue(createdFile2.exists()) + assertEquals(expectedFileName2, createdFile2.name) + + val expectedFileName3 = "testFile-2.txt" + val createdFile3 = createFile(directory, fileName) + // Verify that the file 3 was created and has correct name + assertTrue(createdFile3.exists()) + assertEquals(expectedFileName3, createdFile3.name) + } + + @Test + fun test_createFile_Non_Existent_Directory() { + // Create a non-existent directory + val nonExistentDirectory = File("/path/to/non_existent_directory") + val fileName = "testFile.txt" + + // Attempt to create a file in the non-existent directory + assertThrows(IOException::class.java) { + createFile(nonExistentDirectory, fileName) + } + } + + @Throws(IOException::class) + fun File.verifyCanReadWrite() { + if (!this.canRead()) { + throw IOException("Can't read file") + } else if (!this.canWrite()) { + throw IOException("Can't write file") + } + } + + @SuppressWarnings("SwallowedException") + @Test + fun test_verifyCanReadWrite() { + val file = mockk() + every { file.canRead() } returns false + every { file.canWrite() } returns true + + assertThrows(IOException::class.java) { + file.verifyCanReadWrite() + } + + every { file.canRead() } returns true + every { file.canWrite() } returns false + + assertThrows(IOException::class.java) { + file.verifyCanReadWrite() + } + + every { file.canRead() } returns false + every { file.canWrite() } returns false + + assertThrows(IOException::class.java) { + file.verifyCanReadWrite() + } + + every { file.canRead() } returns true + every { file.canWrite() } returns true + + try { + file.verifyCanReadWrite() + } catch (e: IOException) { + fail("Should not have thrown any exception") + } + } + + @Test + fun test_renameExistingFile() { + val tempDir = createTempDir() + val existingFile = File(tempDir, "existing_file.txt") + existingFile.createNewFile() + + val renamed = "renamed_file" + val expectedFile = "$renamed.txt" + val renamedFile = File(tempDir, expectedFile) + + assertTrue(existingFile.exists()) + val result = renameFileWithExtension(existingFile, renamed) + assertEquals(expectedFile, result?.name) + assertFalse(existingFile.exists()) + assertTrue(renamedFile.exists()) + } + + @Test + fun test_renameNonExistentFile() { + val tempDir = createTempDir() + val nonexistentFile = File(tempDir, "nonexistent_file.txt") + + val renamedFile = "renamed_file" + + assertFalse(nonexistentFile.exists()) + assertNull(renameFileWithExtension(nonexistentFile, renamedFile)) + } + + @Test + fun test_renameWithSameName() { + val tempDir = createTempDir() + val existingFile = File(tempDir, "existing_file.txt") + existingFile.createNewFile() + + val renamedFile = "existing_file" + + assertTrue(existingFile.exists()) + assertNull(renameFileWithExtension(existingFile, renamedFile)) + assertTrue(existingFile.exists()) + } + + @Test + fun test_deleteExistingFile() { + val tempDir = createTempDir() + val existingFile = File(tempDir, "existing_file.txt") + existingFile.createNewFile() + + assertTrue(existingFile.exists()) + assertTrue(deleteFileAndChildren(existingFile)) + assertFalse(existingFile.exists()) + } + + @Test + fun test_deleteExistingDirectory() { + val tempDir = createTempDir() + val existingDir = File(tempDir, "existing_directory") + val existingFile = File(existingDir, "existing_file.txt") + existingFile.mkdirs() + + assertTrue(existingDir.isDirectory) + assertTrue(existingFile.exists()) + assertTrue(deleteFileAndChildren(existingFile)) + assertFalse(existingFile.exists()) + } + + @Test + fun test_deleteNonexistentFile() { + val tempDir = createTempDir() + val nonexistentFile = File(tempDir, "nonexistent_file.txt") + + assertFalse(nonexistentFile.exists()) + assertFalse(deleteFileAndChildren(nonexistentFile)) + } + + @Test + fun test_markFileAsDeleted() { + // Create a temporary file for testing + val tempDir = createTempDir() + val tempFile = File(tempDir, "Record.m4a") + tempFile.createNewFile() + + val name = tempFile.name + + assertTrue(tempFile.exists()) + // Mark the file as deleted + val trashFile = markFileAsDeleted(tempFile) + + // Verify that the file was renamed + assertEquals("Record.m4a.deleted", trashFile?.name) + } + + @Test + fun test_markFileAsDeleted_with_non_existent_file() { + // Create a non-existent file + val nonExistentFile = File("/path/to/non_existent_file.m4a") + + assertFalse(nonExistentFile.exists()) + // Mark the file as deleted + val restoredFile = markFileAsDeleted(nonExistentFile) + + // Verify that the result is null (file doesn't exist) + assertNull(restoredFile) + } + + @Test + fun test_unmarkFileAsDeleted() { + // Create a temporary trash file for testing + val tempDir = createTempDir() + val tempTrashFile = File(tempDir, "Record.m4a.deleted") + tempTrashFile.createNewFile() + + val name = tempTrashFile.nameWithoutExtension + + assertTrue(tempTrashFile.exists()) + // Unmark the file (restore it) + val restoredFile = unmarkFileAsDeleted(tempTrashFile) + + // Verify that the file was renamed back to its original name + assertEquals(name, restoredFile?.name) + assertEquals("Record.m4a", restoredFile?.name) + } + + @Test + fun test_unmarkFileAsDeleted_with_non_existent_file() { + // Create a non-existent trash file + val nonExistentTrashFile = File("/path/to/non_existent_file.deleted") + + assertFalse(nonExistentTrashFile.exists()) + // Attempt to unmark the file + val restoredFile = unmarkFileAsDeleted(nonExistentTrashFile) + + // Verify that the result is null (file doesn't exist) + assertNull(restoredFile) + } + + @Test + fun test_getPrivateMusicStorageDir_ExternalStorageAvailable() { + val context: Context = ApplicationProvider.getApplicationContext() + val directoryName = "MyMusic" + + val result = getPrivateMusicStorageDir(context, directoryName) + + assertNotNull(result) + assertTrue(result!!.exists()) + assertEquals(directoryName, result.name) + } +} diff --git a/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/room/RecordDaoTest.kt b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/room/RecordDaoTest.kt new file mode 100644 index 000000000..e224f7a93 --- /dev/null +++ b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/room/RecordDaoTest.kt @@ -0,0 +1,401 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.data.room + +import android.content.Context +import androidx.room.Room +import androidx.sqlite.db.SimpleSQLiteQuery +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class RecordDaoTest { + + private lateinit var recordDao: RecordDao + private lateinit var db: AppDatabase + + @Before + fun createDb() { + val context: Context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).allowMainThreadQueries() + .build() + recordDao = db.recordDao() + //Set demo data + val records = Array(100) { + RecordEntity( + it + 1L, + "Record $it", + 1000L + it, + 123456789L + it, + 123456789L + it, + 0L, + "path/to/record$it", + "mp3", + 1024, + 44100, + 2, + 128, + false, + false, + false, + IntArray(10), + ) + } + + records.forEach { + recordDao.insertRecord(it) + } + } + + @After + @Throws(IOException::class) + fun closeDb() { + db.close() + } + + @Test + fun test_auto_generated_primary_key() { + // Create a sample RecordEntity + val record = RecordEntity( + name = "Sample Record", + duration = 120000L, + created = System.currentTimeMillis(), + added = System.currentTimeMillis(), + removed = System.currentTimeMillis(), + path = "/path/to/record", + format = "mp3", + size = 1024L, + sampleRate = 44100, + channelCount = 2, + bitrate = 128, + isBookmarked = false, + isWaveformProcessed = true, + isMovedToRecycle = false, + amps = intArrayOf(10, 20, 30) + ) + + // Verify that the initial ID is 0 + assertEquals(0, record.id) + + // Insert the record into your database + val insertedId = recordDao.insertRecord(record) + val insertedId2 = recordDao.insertRecord(record) + val insertedId3 = recordDao.insertRecord(record) + + // Verify that the inserted ID is non-zero + assertNotEquals(0, insertedId) + assertNotEquals(0, insertedId2) + assertNotEquals(0, insertedId3) + + // Fetch the record by ID (use your actual DAO here) + val fetchedRecord1 = recordDao.getRecordById(insertedId) + val fetchedRecord2 = recordDao.getRecordById(insertedId2) + val fetchedRecord3 = recordDao.getRecordById(insertedId3) + + assertNotEquals(fetchedRecord1, fetchedRecord2) + assertNotEquals(fetchedRecord1, fetchedRecord3) + assertNotEquals(fetchedRecord2, fetchedRecord3) + + // Verify that the fetched record matches the original record + assertEquals(record.copy(id = fetchedRecord1?.id ?: 0), fetchedRecord1) + assertEquals(record.copy(id = fetchedRecord2?.id ?: 0), fetchedRecord2) + assertEquals(record.copy(id = fetchedRecord3?.id ?: 0), fetchedRecord3) + } + + @Test + fun testInsertAndGetRecordById() { + val record = RecordEntity( + 1001L, + "Test Record", + 1000, + 123456789L, + 123456789L, + 0L, + "path/to/record", + "mp3", + 1024, + 44100, + 2, + 128, + false, + false, + false, + IntArray(10), + ) + recordDao.insertRecord(record) + + val loaded = recordDao.getRecordById(1001L) + assertEquals(record, loaded) + } + + @Test + fun testGetRecordsByIds() { + //Test valid records request + val records = recordDao.getRecordsByIds(listOf(2, 45, 91, 28)) + + assertEquals(4, records.size) + assertEquals("Record 1", records[0].name) + assertEquals("Record 27", records[1].name) + assertEquals("Record 44", records[2].name) + assertEquals("Record 90", records[3].name) + assertEquals(2, records[0].id) + assertEquals(28, records[1].id) + assertEquals(45, records[2].id) + assertEquals(91, records[3].id) + + //Test invalid records request (all invalid ids) + val invalidRecords = recordDao.getRecordsByIds(listOf(-1, -1000, 10101, 200)) + assertEquals(0, invalidRecords.size) + + //Test mixed records request (3 valid ids and 2 invalid) + val mixedRecords = recordDao.getRecordsByIds(listOf(2, -1, 28, 200, 45)) + assertEquals(3, mixedRecords.size) + assertEquals("Record 1", records[0].name) + assertEquals("Record 27", records[1].name) + assertEquals("Record 44", records[2].name) + assertEquals(2, records[0].id) + assertEquals(28, records[1].id) + assertEquals(45, records[2].id) + } + + @Test + @Throws(Exception::class) + fun testUpdateRecord() = runBlocking { + val record = recordDao.getRecordById(1) + + record?.copy(name = "Updated Record")?.let { + val updated = recordDao.updateRecord(it) + assertEquals(1, updated) + } + + val updated = recordDao.getRecordById(1) + assertEquals("Updated Record", updated?.name) + } + + @Test + @Throws(Exception::class) + fun testUpdateRecords() = runBlocking { + val records = recordDao.getRecordsByIds(listOf(1, 2)) + + val toUpdate = records.mapIndexed { index, record -> record.copy(name = "Updated record $index") } + val updatedCount = recordDao.updateRecords(toUpdate) + assertEquals(2, updatedCount) + + val updated1 = recordDao.getRecordById(1) + assertEquals("Updated record 0", updated1?.name) + val updated2 = recordDao.getRecordById(2) + assertEquals("Updated record 1", updated2?.name) + } + + @Test + @Throws(Exception::class) + fun testDeleteRecord() = runBlocking { + + recordDao.getRecordById(1)?.let { + recordDao.deleteRecord(it) + } + + val loaded = recordDao.getRecordById(1) + assertNull(loaded) + + val record = RecordEntity( + 1001L, + "Test Record", + 1000, + 123456789L, + 123456789L, + 0L, + "path/to/record", + "mp3", + 1024, + 44100, + 2, + 128, + false, + false, + false, + IntArray(10), + ) + //Delete not existing record silently skipped + recordDao.deleteRecord(record) + } + + @Test + fun testDeleteRecordById() { + val recordBefore = recordDao.getRecordById(1) + assertNotNull(recordBefore) + + recordDao.deleteRecordById(1) + val recordAfter = recordDao.getRecordById(1) + assertNull(recordAfter) + } + + @Test + fun testGetRecordsCount() { + val count = recordDao.getRecordsCount() + assertEquals(100, count) + } + + @Test + fun testGetRecordTotalDuration() { + val duration = recordDao.getRecordTotalDuration() + assertEquals(1000L*100+4950, duration) + //4950 is sum of number sequence from 1 to 100 (1, 2, 3, 4, 5, 6...) + } + + @Test + fun testDeleteAllRecords() { + val countBefore = recordDao.getRecordsCount() + assertEquals(100, countBefore) + + recordDao.deleteAllRecords() + val countAfter = recordDao.getRecordsCount() + assertEquals(0, countAfter) + } + + @Test + fun testGetRecordsByPage() { + val pageSize = 20 + val offset = 40 + val records = recordDao.getAllRecords() + val recordsByPage = recordDao.getRecordsByPage(pageSize, offset) + assertEquals(pageSize, recordsByPage.size) + val expected = records.slice(offset until (offset + pageSize)) + assertEquals(expected, recordsByPage) + } + + @Test + fun test_getAllRecords() { + val recordsAsc = recordDao.getAllRecords() + + assertEquals(100, recordsAsc.size) + assertEquals("Record 99", recordsAsc[0].name) + assertEquals("Record 93", recordsAsc[6].name) + assertEquals("Record 50", recordsAsc[49].name) + assertEquals("Record 6", recordsAsc[93].name) + assertEquals("Record 0", recordsAsc[99].name) + } + + @Test + fun test_getMovedToRecycleRecords() { + val records = recordDao.getMovedToRecycleRecords() + assertEquals(0, records.size) + + val record1 = recordDao.getRecordById(1) + val record50 = recordDao.getRecordById(50) + val record93 = recordDao.getRecordById(93) + + record1?.copy(isMovedToRecycle = true)?.let { + val updated = recordDao.updateRecord(it) + assertEquals(1, updated) + } + record50?.copy(isMovedToRecycle = true)?.let { + val updated = recordDao.updateRecord(it) + assertEquals(1, updated) + } + record93?.copy(isMovedToRecycle = true)?.let { + val updated = recordDao.updateRecord(it) + assertEquals(1, updated) + } + val records2 = recordDao.getMovedToRecycleRecords() + assertEquals(3, records2.size) + assertEquals("Record 0", records2[0].name) + assertEquals("Record 49", records2[1].name) + assertEquals("Record 92", records2[2].name) + assertEquals(1, records2[0].id) + assertEquals(50, records2[1].id) + assertEquals(93, records2[2].id) + } + + @Test + fun test_getMovedToRecycleRecordsCount() { + val count = recordDao.getMovedToRecycleRecordsCount() + assertEquals(0, count) + + val record1 = recordDao.getRecordById(1) + val record50 = recordDao.getRecordById(50) + val record93 = recordDao.getRecordById(93) + + record1?.copy(isMovedToRecycle = true)?.let { + val updated = recordDao.updateRecord(it) + assertEquals(1, updated) + } + val count2 = recordDao.getMovedToRecycleRecordsCount() + assertEquals(1, count2) + record50?.copy(isMovedToRecycle = true)?.let { + val updated = recordDao.updateRecord(it) + assertEquals(1, updated) + } + val count3 = recordDao.getMovedToRecycleRecordsCount() + assertEquals(2, count3) + record93?.copy(isMovedToRecycle = true)?.let { + val updated = recordDao.updateRecord(it) + assertEquals(1, updated) + } + val count4 = recordDao.getMovedToRecycleRecordsCount() + assertEquals(3, count4) + } + + @Test + fun test_getRecordsRewQuery() { + val query1 = "SELECT * FROM records" + + val records1 = recordDao.getRecordsRewQuery(SimpleSQLiteQuery(query1)) + assertEquals(100, records1.size) + assertEquals(1, records1[0].id) + assertEquals(100, records1[99].id) + + val sortField = "added" + val page = 2 + val pageSize = 5 + + val query2 = "SELECT * FROM records" + + " WHERE isBookmarked = 0" + + " ORDER BY $sortField DESC" + + " LIMIT $pageSize OFFSET ${(page - 1) * pageSize}" + + val records2 = recordDao.getRecordsRewQuery(SimpleSQLiteQuery(query2)) + + assertEquals(5, records2.size) + assertEquals("Record 94", records2[0].name) + assertEquals("Record 93", records2[1].name) + assertEquals("Record 92", records2[2].name) + assertEquals("Record 91", records2[3].name) + assertEquals("Record 90", records2[4].name) + + val bookmarkedRecord = recordDao.getRecordById(10)?.copy(id = 0, isBookmarked = true) + if (bookmarkedRecord != null) { + recordDao.insertRecord(bookmarkedRecord) + } + val query3 = "SELECT * FROM records" + + " WHERE isBookmarked = 1" + val records3 = recordDao.getRecordsRewQuery(SimpleSQLiteQuery(query3)) + + assertEquals(1, records3.size) + assertEquals(bookmarkedRecord?.copy(id = 101L), records3[0]) + } +} diff --git a/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/room/RecordEditDaoTest.kt b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/room/RecordEditDaoTest.kt new file mode 100644 index 000000000..8efb67c24 --- /dev/null +++ b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/room/RecordEditDaoTest.kt @@ -0,0 +1,215 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.data.room + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.dimowner.audiorecorder.v2.data.model.RecordEditOperation +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class RecordEditDaoTest { + + private lateinit var recordEditDao: RecordEditDao + private lateinit var db: AppDatabase + + @Before + fun createDb() { + val context: Context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).allowMainThreadQueries() + .build() + recordEditDao = db.recordEditDao() + //Set demo data + val recordOperation1 = RecordEditEntity( + 1L, + 101L, + RecordEditOperation.Rename, + "renameName1", + 123456789L, + 0, + ) + val recordOperation2 = RecordEditEntity( + 2L, + 102L, + RecordEditOperation.MoveToRecycle, + null, + 223456789L, + 0, + ) + val recordOperation3 = RecordEditEntity( + 3L, + 103L, + RecordEditOperation.RestoreFromRecycle, + null, + 323456789L, + 0, + ) + val recordOperation4 = RecordEditEntity( + 4L, + 104L, + RecordEditOperation.DeleteForever, + null, + 423456789L, + 0, + ) + recordEditDao.insertRecordsEditOperation(recordOperation1) + recordEditDao.insertRecordsEditOperation(recordOperation2) + recordEditDao.insertRecordsEditOperation(recordOperation3) + recordEditDao.insertRecordsEditOperation(recordOperation4) + } + + @After + @Throws(IOException::class) + fun closeDb() { + db.close() + } + + @Test + fun test_auto_generated_primary_key() { + // Create a sample RecordEditEntity + val recordOperation = RecordEditEntity( + recordId = 110L, + editOperation = RecordEditOperation.Rename, + renameName = "renameName1", + created = 123456789L, + retryCount = 0, + ) + + // Verify that the initial ID is 0 + assertEquals(0, recordOperation.id) + + // Insert the record into your database (use your actual DAO here) + // For demonstration purposes, assume the DAO method is called insertRecord + val insertedId = recordEditDao.insertRecordsEditOperation(recordOperation) + val insertedId2 = recordEditDao.insertRecordsEditOperation(recordOperation) + val insertedId3 = recordEditDao.insertRecordsEditOperation(recordOperation) + + // Verify that the inserted ID is non-zero + assertNotEquals(0, insertedId) + assertNotEquals(0, insertedId2) + assertNotEquals(0, insertedId3) + + // Fetch the record by ID (use your actual DAO here) + val fetchedRecord1 = recordEditDao.getRecordsEditOperationById(insertedId) + val fetchedRecord2 = recordEditDao.getRecordsEditOperationById(insertedId2) + val fetchedRecord3 = recordEditDao.getRecordsEditOperationById(insertedId3) + + assertNotEquals(fetchedRecord1, fetchedRecord2) + assertNotEquals(fetchedRecord1, fetchedRecord3) + assertNotEquals(fetchedRecord2, fetchedRecord3) + + // Verify that the fetched record matches the original record + assertEquals(recordOperation.copy(id = fetchedRecord1?.id ?: 0), fetchedRecord1) + assertEquals(recordOperation.copy(id = fetchedRecord2?.id ?: 0), fetchedRecord2) + assertEquals(recordOperation.copy(id = fetchedRecord3?.id ?: 0), fetchedRecord3) + } + + @Test + fun testInsertAndGetRecordEditOperationById() { + val recordOperation = RecordEditEntity( + 10L, + 110L, + RecordEditOperation.Rename, + "renameName1", + 1023456789L, + 0, + ) + recordEditDao.insertRecordsEditOperation(recordOperation) + + val loaded = recordEditDao.getRecordsEditOperationById(10L) + assertEquals(recordOperation, loaded) + } + + @Test + @Throws(Exception::class) + fun testUpdateRecordEditOperation() = runBlocking { + val record = recordEditDao.getRecordsEditOperationById(1) + + record?.copy( + editOperation = RecordEditOperation.MoveToRecycle)?.let { + recordEditDao.updateRecordsEditOperation(it) + } + + val updated = recordEditDao.getRecordsEditOperationById(1) + assertEquals(RecordEditOperation.MoveToRecycle, updated?.editOperation) + } + + @Test + @Throws(Exception::class) + fun testDeleteRecordEditOperation() = runBlocking { + + recordEditDao.getRecordsEditOperationById(1)?.let { + recordEditDao.deleteRecordsEditOperation(it) + } + + val loaded = recordEditDao.getRecordsEditOperationById(1) + assertNull(loaded) + + val recordOperation = RecordEditEntity( + 10L, + 110L, + RecordEditOperation.Rename, + "renameName1", + 1234567890L, + 0, + ) + //Delete not existing record silently skipped + recordEditDao.deleteRecordsEditOperation(recordOperation) + } + + @Test + fun testDeleteRecordEditOperationById() { + val recordBefore = recordEditDao.getRecordsEditOperationById(1) + assertNotNull(recordBefore) + + recordEditDao.deleteRecordEditOperationById(1) + val recordAfter = recordEditDao.getRecordsEditOperationById(1) + assertNull(recordAfter) + } + + @Test + fun testDeleteAllRecords() { + val countBefore = recordEditDao.getAllRecordsEditOperations().size + assertEquals(4, countBefore) + + recordEditDao.deleteAllRecordsEditOperations() + val countAfter = recordEditDao.getAllRecordsEditOperations().size + assertEquals(0, countAfter) + } + + @Test + fun test_getAllRecords() { + val recordsAsc = recordEditDao.getAllRecordsEditOperations() + + assertEquals(4, recordsAsc.size) + assertEquals(RecordEditOperation.Rename, recordsAsc[3].editOperation) + assertEquals(RecordEditOperation.MoveToRecycle, recordsAsc[2].editOperation) + assertEquals(RecordEditOperation.RestoreFromRecycle, recordsAsc[1].editOperation) + assertEquals(RecordEditOperation.DeleteForever, recordsAsc[0].editOperation) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7de796d5f..03fea8fd2 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + + + + @@ -111,6 +115,10 @@ android:name=".app.moverecords.MoveRecordsService" android:exported="false" android:foregroundServiceType="dataSync" /> + @@ -130,4 +138,4 @@ - \ No newline at end of file + diff --git a/app/src/main/java/com/dimowner/audiorecorder/ARApplication.kt b/app/src/main/java/com/dimowner/audiorecorder/ARApplication.kt index f46696eae..3c9e83630 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/ARApplication.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/ARApplication.kt @@ -32,12 +32,15 @@ import android.telephony.TelephonyCallback import android.telephony.TelephonyManager import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat +import com.dimowner.audiorecorder.app.migration.DatabaseMigrationService import com.dimowner.audiorecorder.util.AndroidUtils +import dagger.hilt.android.HiltAndroidApp import timber.log.Timber import timber.log.Timber.DebugTree - //import com.google.firebase.FirebaseApp; + +@HiltAndroidApp class ARApplication : Application() { private var audioOutputChangeReceiver: AudioOutputChangeReceiver? = null @@ -60,6 +63,7 @@ class ARApplication : Application() { if (!prefs.isMigratedSettings) { prefs.migrateSettings() } + registerAudioOutputChangeReceiver() registerRebootReceiver() @@ -201,4 +205,4 @@ class ARApplication : Application() { val longWaveformSampleCount: Int get() = (AppConstants.WAVEFORM_WIDTH * screenWidthDp).toInt() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/AppConstants.java b/app/src/main/java/com/dimowner/audiorecorder/AppConstants.java index 6e90901e8..94d0b0815 100755 --- a/app/src/main/java/com/dimowner/audiorecorder/AppConstants.java +++ b/app/src/main/java/com/dimowner/audiorecorder/AppConstants.java @@ -35,6 +35,18 @@ private AppConstants() {} public static final int PENDING_INTENT_FLAGS; + public static final String PREF_NAME = "com.dimowner.audiorecorder.data.PrefsImpl"; + public static final String PREF_KEY_IS_APP_V2 = "pref_is_app_v2"; + public static final String PREF_KEY_IS_FIRST_RUN = "is_first_run"; + public static final String PREF_KEY_RECORD_COUNTER = "record_counter"; + public static final String PREF_KEY_KEEP_SCREEN_ON = "keep_screen_on"; + //Recording prefs. + public static final String PREF_KEY_SETTING_RECORDING_FORMAT = "setting_recording_format"; + public static final String PREF_KEY_SETTING_BITRATE = "setting_bitrate"; + public static final String PREF_KEY_SETTING_SAMPLE_RATE = "setting_sample_rate"; + public static final String PREF_KEY_SETTING_NAMING_FORMAT = "setting_naming_format"; + public static final String PREF_KEY_SETTING_CHANNEL_COUNT = "setting_channel_count"; + public static final String REQUESTS_RECEIVER = "dmitriy.ponomarenko.ua@gmail.com"; public static final String APPLICATION_NAME = "AudioRecorder"; @@ -55,6 +67,7 @@ private AppConstants() {} public static final String THEME_BLUE_GREY = "blue_gray"; public static final String[] SUPPORTED_EXT = new String[]{"mp3", "wav", "3gpp", "3gp", "amr", "aac", "m4a", "mp4", "ogg", "flac"}; + //.mkv, .aiff .aif .aifc ?? public static final String FORMAT_M4A = "m4a"; public static final String FORMAT_WAV = "wav"; diff --git a/app/src/main/java/com/dimowner/audiorecorder/AppConstantsV2.kt b/app/src/main/java/com/dimowner/audiorecorder/AppConstantsV2.kt new file mode 100644 index 000000000..5394823f0 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/AppConstantsV2.kt @@ -0,0 +1,9 @@ +package com.dimowner.audiorecorder + +object AppConstantsV2 { + const val WAVEFORM_AMPLITUDE_MAX_VALUE = 32767f + const val SHORT_RECORD = 18000L //Milliseconds + const val DEFAULT_WIDTH_SCALE = 1.5F //Const val describes how many screens a record will take. + + const val DEFAULT_MAX_RECORDING_DURATION_MS = 120 * 60 * 1000 //120 minutes +} \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/Injector.java b/app/src/main/java/com/dimowner/audiorecorder/Injector.java index 17e4c84e3..8cdf94cb8 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/Injector.java +++ b/app/src/main/java/com/dimowner/audiorecorder/Injector.java @@ -162,11 +162,11 @@ public RecorderContract.Recorder provideAudioRecorder(Context context) { switch (providePrefs(context).getSettingRecordingFormat()) { default: case AppConstants.FORMAT_M4A: - return AudioRecorder.getInstance(); + return AudioRecorder.getInstance(context); case AppConstants.FORMAT_WAV: return WavRecorder.getInstance(); case AppConstants.FORMAT_3GP: - return ThreeGpRecorder.getInstance(); + return ThreeGpRecorder.getInstance(context); } } @@ -203,7 +203,7 @@ public SettingsContract.UserActionsListener provideSettingsPresenter(Context con if (settingsPresenter == null) { settingsPresenter = new SettingsPresenter(provideLocalRepository(context), provideFileRepository(context), provideRecordingTasksQueue(), provideLoadingTasksQueue(), providePrefs(context), - provideSettingsMapper(context), provideAppRecorder(context)); + provideSettingsMapper(context), provideAppRecorder(context), provideAudioPlayer()); } return settingsPresenter; } diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderCallback.java b/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderCallback.java index 32ec16d77..9c038a2fa 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderCallback.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderCallback.java @@ -16,16 +16,16 @@ package com.dimowner.audiorecorder.app; +import androidx.annotation.NonNull; import com.dimowner.audiorecorder.data.database.Record; import com.dimowner.audiorecorder.exception.AppException; - import java.io.File; public interface AppRecorderCallback { - void onRecordingStarted(File file); + void onRecordingStarted(@NonNull File file); void onRecordingPaused(); void onRecordingResumed(); - void onRecordingStopped(File file, Record record); + void onRecordingStopped(@NonNull File file, @NonNull Record record); void onRecordingProgress(long mills, int amp); void onError(AppException throwable); } diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderImpl.java b/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderImpl.java index 3028898cd..d81be92a4 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderImpl.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderImpl.java @@ -35,11 +35,9 @@ import java.util.List; import java.util.Timer; import java.util.TimerTask; - import timber.log.Timber; - import static com.dimowner.audiorecorder.AppConstants.PLAYBACK_VISUALIZATION_INTERVAL; - +import androidx.annotation.NonNull; public class AppRecorderImpl implements AppRecorder { private RecorderContract.Recorder audioRecorder; @@ -89,7 +87,7 @@ private AppRecorderImpl( recorderCallback = new RecorderContract.RecorderCallback() { @Override - public void onStartRecord(File output) { + public void onStartRecord(@NonNull File output) { durationMills = 0; scheduleRecordingTimeUpdate(); onRecordingStarted(output); @@ -113,7 +111,7 @@ public void onRecordProgress(final long mills, final int amplitude) { } @Override - public void onStopRecord(final File output) { + public void onStopRecord(@NonNull final File output) { stopRecordingTimer(); recordingsTasks.postRunnable(() -> { RecordInfo info = AudioDecoder.readRecordInfo(output); @@ -164,7 +162,7 @@ public void onStopRecord(final File output) { } @Override - public void onError(AppException e) { + public void onError(@NonNull AppException e) { Timber.e(e); onRecordingError(e); } diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/DecodeService.kt b/app/src/main/java/com/dimowner/audiorecorder/app/DecodeService.kt index 125df6d11..6228701a3 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/DecodeService.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/app/DecodeService.kt @@ -64,6 +64,8 @@ class DecodeService : Service() { const val ACTION_STOP_DECODING_SERVICE = "ACTION_STOP_DECODING_SERVICE" const val ACTION_CANCEL_DECODE = "ACTION_CANCEL_DECODE" const val EXTRAS_KEY_DECODE_INFO = "key_decode_info" + const val EXTRAS_KEY_DECODE_RECORD_DURATION = "key_decode_decode_record_duration" + const val EXTRAS_KEY_DECODE_RECORD_PATH = "key_decode_decode_record_path" private const val NOTIF_ID = 104 fun startNotification(context: Context, recId: Int) { @@ -72,6 +74,14 @@ class DecodeService : Service() { intent.putExtra(EXTRAS_KEY_DECODE_INFO, recId) context.startService(intent) } + + fun startNotificationV2(context: Context, path: String, durationMills: Long) { + val intent = Intent(context, DecodeService::class.java) + intent.action = ACTION_START_DECODING_SERVICE + intent.putExtra(EXTRAS_KEY_DECODE_RECORD_PATH, path) + intent.putExtra(EXTRAS_KEY_DECODE_RECORD_DURATION, durationMills) + context.startService(intent) + } } private var decodeListener: DecodeServiceListener? = null @@ -106,10 +116,18 @@ class DecodeService : Service() { val action = intent.action if (action != null && action.isNotEmpty()) { when (action) { - ACTION_START_DECODING_SERVICE -> if (intent.hasExtra(EXTRAS_KEY_DECODE_INFO)) { - val id = intent.getIntExtra(EXTRAS_KEY_DECODE_INFO, -1) - if (id >= 0) { - startDecode(id) + ACTION_START_DECODING_SERVICE -> { + if (intent.hasExtra(EXTRAS_KEY_DECODE_RECORD_PATH)) { + val path = intent.getStringExtra(EXTRAS_KEY_DECODE_RECORD_PATH) + val duration = intent.getLongExtra(EXTRAS_KEY_DECODE_RECORD_DURATION, 0) + path?.let { + startDecodeV2(path, duration) + } + } else if (intent.hasExtra(EXTRAS_KEY_DECODE_INFO)) { + val id = intent.getIntExtra(EXTRAS_KEY_DECODE_INFO, -1) + if (id >= 0) { + startDecode(id) + } } } ACTION_STOP_DECODING_SERVICE -> stopService() @@ -149,7 +167,7 @@ class DecodeService : Service() { override fun onProcessingCancel() { Toast.makeText(applicationContext, R.string.processing_canceled, Toast.LENGTH_LONG).show() - decodeListener?.onFinishProcessing() + decodeListener?.onFinishProcessing(intArrayOf()) stopService() } @@ -175,14 +193,62 @@ class DecodeService : Service() { data) localRepository.updateRecord(decodedRecord) } - decodeListener?.onFinishProcessing() + decodeListener?.onFinishProcessing(data) + stopService() + } + } + + override fun onError(exception: Exception) { + Timber.e(exception) + decodeListener?.onFinishProcessing(intArrayOf()) + stopService() + } + }) + } else { + stopService() + } + } + } + + private fun startDecodeV2(path: String, durationMills: Long) { + isCancel = false + startNotification() + processingTasks.postRunnable { + var prevTime: Long = 0 + if (durationMills < DECODE_DURATION) { + waveformVisualization.decodeRecordWaveform(path, object : AudioDecodingListener { + override fun isCanceled(): Boolean { + return isCancel + } + + override fun onStartProcessing(duration: Long, channelsCount: Int, sampleRate: Int) { + decodeListener?.onStartProcessing() + } + + override fun onProcessingProgress(percent: Int) { + val curTime = System.currentTimeMillis() + if (percent == 100 || curTime > prevTime + 200) { + updateNotification(percent) + prevTime = curTime + } + } + + override fun onProcessingCancel() { + Toast.makeText(applicationContext, R.string.processing_canceled, Toast.LENGTH_LONG).show() + decodeListener?.onFinishProcessing(intArrayOf()) + stopService() + } + + override fun onFinishProcessing(data: IntArray, duration: Long) { + recordingsTasks.postRunnable { + decodeListener?.onFinishProcessing(data) stopService() } } override fun onError(exception: Exception) { Timber.e(exception) - decodeListener?.onFinishProcessing() + decodeListener?.onFinishProcessing(intArrayOf()) stopService() } }) @@ -347,5 +413,5 @@ class DecodeService : Service() { interface DecodeServiceListener { fun onStartProcessing() - fun onFinishProcessing() + fun onFinishProcessing(decodedData: IntArray) } diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/RecordingService.java b/app/src/main/java/com/dimowner/audiorecorder/app/RecordingService.java index da989bb13..e9095721e 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/RecordingService.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/RecordingService.java @@ -31,6 +31,7 @@ import android.os.Build; import android.os.IBinder; +import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; @@ -122,7 +123,7 @@ public void onCreate() { appRecorderCallback = new AppRecorderCallback() { boolean checkHasSpace = true; - @Override public void onRecordingStarted(File file) { + @Override public void onRecordingStarted(@NonNull File file) { updateNotificationResume(); } @Override public void onRecordingPaused() { @@ -131,7 +132,7 @@ public void onCreate() { @Override public void onRecordingResumed() { updateNotificationResume(); } - @Override public void onRecordingStopped(File file, Record rec) { + @Override public void onRecordingStopped(@NonNull File file, @NonNull Record rec) { if (rec != null && rec.getDuration()/1000 < AppConstants.DECODE_DURATION && !rec.isWaveformProcessed()) { DecodeService.Companion.startNotification(getApplicationContext(), rec.getId()); } @@ -414,6 +415,12 @@ private void updateNotification(long mills) { } } + // - Has available space + // - Is already recoding + // - If is playing, stop playback + // - Create empty record in the database + // - Set it as active record + // - Start recording private void startRecording(String path) { appRecorder.setRecorder(recorder); try { diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserActivity.java b/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserActivity.java index ef1caec98..e19c4206f 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserActivity.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserActivity.java @@ -36,6 +36,7 @@ import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; +import android.window.OnBackInvokedCallback; import com.dimowner.audiorecorder.ARApplication; import com.dimowner.audiorecorder.R; @@ -67,6 +68,8 @@ public class FileBrowserActivity extends Activity implements FileBrowserContract private FileBrowserAdapter adapter; + private OnBackInvokedCallback backInvokedCallback; + public static Intent getStartIntent(Context context) { return new Intent(context, FileBrowserActivity.class); } @@ -77,6 +80,15 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_file_browser); + AndroidUtils.applyWindowInsets(this); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + backInvokedCallback = () -> { + ARApplication.getInjector().releaseFileBrowserPresenter(); + finish(); + }; + getOnBackInvokedDispatcher().registerOnBackInvokedCallback(0, backInvokedCallback); + } ImageButton btnBack = findViewById(R.id.btn_back); btnBack.setOnClickListener(v -> { ARApplication.getInjector().releaseFileBrowserPresenter(); @@ -150,6 +162,14 @@ protected void onStop() { } } + @Override + protected void onDestroy() { + super.onDestroy(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(backInvokedCallback); + } + } + @Override public void onBackPressed() { super.onBackPressed(); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserAdapter.java b/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserAdapter.java index 6d9ab0a2e..1370310a4 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserAdapter.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserAdapter.java @@ -124,7 +124,7 @@ public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { @Override public void onBindViewHolder(@NonNull ItemViewHolder holder, final int position) { - final int pos = holder.getAbsoluteAdapterPosition(); + final int pos = holder.getAdapterPosition(); if (pos != RecyclerView.NO_POSITION) { RecordInfo rec = data.get(pos); holder.name.setText(rec.getName()); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserPresenter.java b/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserPresenter.java index a446ae8fe..b50441f34 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserPresenter.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserPresenter.java @@ -18,7 +18,7 @@ import android.content.Context; import android.os.Build; - +import androidx.annotation.NonNull; import com.dimowner.audiorecorder.ARApplication; import com.dimowner.audiorecorder.AppConstants; import com.dimowner.audiorecorder.BackgroundQueue; @@ -86,7 +86,7 @@ public void bindView(FileBrowserContract.View v) { appRecorderCallback = new AppRecorderCallback() { @Override - public void onRecordingStarted(final File file) { + public void onRecordingStarted(@NonNull final File file) { } @Override @@ -98,7 +98,7 @@ public void onRecordingResumed() { } @Override - public void onRecordingStopped(final File file, final Record rec) { + public void onRecordingStopped(@NonNull final File file, @NonNull final Record rec) { } @Override diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/info/ActivityInformation.java b/app/src/main/java/com/dimowner/audiorecorder/app/info/ActivityInformation.java index 492a8063b..27de09a96 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/info/ActivityInformation.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/info/ActivityInformation.java @@ -27,6 +27,7 @@ import com.dimowner.audiorecorder.AppConstants; import com.dimowner.audiorecorder.ColorMap; import com.dimowner.audiorecorder.R; +import com.dimowner.audiorecorder.util.AndroidUtils; import com.dimowner.audiorecorder.util.TimeUtils; public class ActivityInformation extends Activity { @@ -46,6 +47,8 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_info); + AndroidUtils.applyWindowInsets(this); + Bundle extras = getIntent().getExtras(); TextView txtName = findViewById(R.id.txt_name); TextView txtFormat = findViewById(R.id.txt_format); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsActivity.java b/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsActivity.java index a89763c94..a025e8922 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsActivity.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsActivity.java @@ -19,6 +19,7 @@ import android.app.Activity; import android.content.Context; import android.content.Intent; +import android.os.Build; import android.os.Bundle; import androidx.annotation.Nullable; import androidx.recyclerview.widget.LinearLayoutManager; @@ -27,6 +28,7 @@ import android.widget.ImageButton; import android.widget.TextView; import android.widget.Toast; +import android.window.OnBackInvokedCallback; import com.dimowner.audiorecorder.ARApplication; import com.dimowner.audiorecorder.Mapper; @@ -52,6 +54,8 @@ public class LostRecordsActivity extends Activity implements LostRecordsContract private LostRecordsAdapter adapter; + private OnBackInvokedCallback backInvokedCallback; + public static Intent getStartIntent(Context context, ArrayList data) { Intent intent = new Intent(context, LostRecordsActivity.class); intent.putParcelableArrayListExtra(EXTRAS_RECORDS_LIST, data); @@ -64,6 +68,16 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_lost_records); + AndroidUtils.applyWindowInsets(this); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + backInvokedCallback = () -> { + ARApplication.getInjector().releaseLostRecordsPresenter(); + finish(); + }; + getOnBackInvokedDispatcher().registerOnBackInvokedCallback(0, backInvokedCallback); + } + ImageButton btnBack = findViewById(R.id.btn_back); btnBack.setOnClickListener(v -> { ARApplication.getInjector().releaseLostRecordsPresenter(); @@ -125,6 +139,14 @@ protected void onStop() { } } + @Override + protected void onDestroy() { + super.onDestroy(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(backInvokedCallback); + } + } + @Override public void onBackPressed() { super.onBackPressed(); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsAdapter.java b/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsAdapter.java index f3da3c87c..dc681ca7e 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsAdapter.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsAdapter.java @@ -86,7 +86,7 @@ public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { @Override public void onBindViewHolder(@NonNull ItemViewHolder holder, final int position) { - final int pos = holder.getAbsoluteAdapterPosition(); + final int pos = holder.getAdapterPosition(); if (pos != RecyclerView.NO_POSITION) { holder.name.setText(data.get(pos).getName()); holder.location.setText(data.get(pos).getPath()); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsPresenter.java b/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsPresenter.java index f95650e1d..a05618670 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsPresenter.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsPresenter.java @@ -78,7 +78,7 @@ public void clear() { public void deleteRecords(final List list) { recordingsTasks.postRunnable(() -> { for (RecordItem rec : list) { - localRepository.deleteRecord(rec.getId()); + localRepository.deleteRecordForever(rec.getId()); // fileRepository.deleteRecordFile(rec.getPath()); if (prefs.getActiveRecord() == rec.getId()) { prefs.setActiveRecord(-1); @@ -102,7 +102,7 @@ public void onRecordInfo(RecordInfo info) { @Override public void deleteRecord(final RecordItem rec) { recordingsTasks.postRunnable(() -> { - localRepository.deleteRecord(rec.getId()); + localRepository.deleteRecordForever(rec.getId()); // fileRepository.deleteRecordFile(rec.getPath()); if (prefs.getActiveRecord() == rec.getId()) { prefs.setActiveRecord(-1); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/main/MainActivity.java b/app/src/main/java/com/dimowner/audiorecorder/app/main/MainActivity.java index 7aad701db..dfa3b2543 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/main/MainActivity.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/main/MainActivity.java @@ -30,6 +30,7 @@ import android.os.IBinder; import android.view.MenuInflater; import android.view.View; +import android.view.Window; import android.view.WindowManager; import android.widget.Button; import android.widget.ImageButton; @@ -67,11 +68,14 @@ import com.dimowner.audiorecorder.util.AnimationUtil; import com.dimowner.audiorecorder.util.FileUtil; import com.dimowner.audiorecorder.util.TimeUtils; +import com.dimowner.audiorecorder.v2.app.HomeActivity; import java.io.File; import java.util.List; import androidx.annotation.NonNull; +import androidx.core.view.WindowCompat; + import timber.log.Timber; public class MainActivity extends Activity implements MainContract.View, View.OnClickListener { @@ -128,7 +132,7 @@ public void onStartProcessing() { } @Override - public void onFinishProcessing() { + public void onFinishProcessing(@NonNull int[] decodedData) { runOnUiThread(() -> { hideRecordProcessing(); presenter.loadActiveRecord(); @@ -161,6 +165,11 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); + AndroidUtils.applyWindowInsets(this); + + Window window = getWindow(); + WindowCompat.getInsetsController(window, window.getDecorView()).setAppearanceLightStatusBars(false); + waveformView = findViewById(R.id.record); recordingWaveformView = findViewById(R.id.recording_view); txtProgress = findViewById(R.id.txt_progress); @@ -309,8 +318,11 @@ public void onClick(View view) { if (checkRecordPermission2()) { if (checkStoragePermission2()) { //Start or stop recording - startRecordingService(); - presenter.pauseUnpauseRecording(getApplicationContext()); + if (presenter.isRecording()) { + presenter.pauseUnpauseRecording(getApplicationContext()); + } else { + startRecordingService(); + } } } } else if (id == R.id.btn_record_stop) { @@ -625,7 +637,7 @@ public void askDeleteRecord(String name) { MainActivity.this, R.drawable.ic_delete_forever_dark, getString(R.string.warning), - getString(R.string.delete_record, name), + getString(R.string.move_record_to_trash, name), v -> presenter.deleteActiveRecord() ); } @@ -694,6 +706,13 @@ public void showRecordFileNotAvailable(String path) { AndroidUtils.showRecordFileNotAvailable(this, path); } + @Override + public void showAppV2() { + Intent intent = new Intent(this, HomeActivity.class); + startActivity(intent); + finish(); + } + @Override public void onPlayProgress(final long mills, int percent) { playProgress.setProgress(percent); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/main/MainContract.java b/app/src/main/java/com/dimowner/audiorecorder/app/main/MainContract.java index 2ad36d012..54b66b7d9 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/main/MainContract.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/main/MainContract.java @@ -84,6 +84,8 @@ interface View extends Contract.View { void showMigratePublicStorageWarning(); void showRecordFileNotAvailable(String path); + + void showAppV2(); } interface UserActionsListener extends Contract.UserActionsListener { @@ -128,8 +130,9 @@ interface UserActionsListener extends Contract.UserActionsListener + oldRecord.toRecordV2(isMovedToRecycle = false).toRecordEntity() + } + + recordDao.insertRecords(recordEntities) + totalMigrated += recordEntities.size + + Timber.d("Migrated page $page with ${recordEntities.size} records") + page++ + } + + // Migrate trash records + val trashRecords = localRepository.trashRecords + if (!trashRecords.isNullOrEmpty()) { + val trashEntities = trashRecords.map { oldRecord -> + oldRecord.toRecordV2(isMovedToRecycle = true).toRecordEntity() + } + + recordDao.insertRecords(trashEntities) + totalMigrated += trashEntities.size + + Timber.d("Migrated ${trashEntities.size} trash records") + } + + // Mark migration as complete + prefs.setDatabaseMigratedToRoom(true) + Timber.d("Database migration completed successfully. Total records migrated: $totalMigrated") + + stopService() + } + + @SuppressLint("WrongConstant") + private fun startNotification() { + notificationManager = NotificationManagerCompat.from(this) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel(CHANNEL_ID, CHANNEL_NAME) + } + + remoteViewsSmall = RemoteViews(packageName, R.layout.layout_migration_notification_small) + remoteViewsSmall.setTextViewText( + R.id.txt_migration_status, + resources.getString(R.string.database_update_in_progress) + ) + + val isNightMode = isUsingNightModeResources(applicationContext) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + remoteViewsSmall.setInt( + R.id.container, "setBackgroundColor", + this.resources.getColor(colorMap.primaryColorRes) + ) + } else { + remoteViewsSmall.setInt( + R.id.container, "setBackgroundColor", + this.resources.getColor(R.color.transparent) + ) + if (isNightMode) { + remoteViewsSmall.setInt( + R.id.txt_app_label, "setTextColor", + this.resources.getColor(R.color.text_primary_light) + ) + remoteViewsSmall.setInt( + R.id.txt_migration_status, "setTextColor", + this.resources.getColor(R.color.text_primary_light) + ) + } else { + remoteViewsSmall.setInt( + R.id.txt_app_label, "setTextColor", + this.resources.getColor(R.color.text_primary_dark) + ) + remoteViewsSmall.setInt( + R.id.txt_migration_status, "setTextColor", + this.resources.getColor(R.color.text_primary_dark) + ) + } + } + + remoteViewsBig = RemoteViews(packageName, R.layout.layout_migration_notification_big) + remoteViewsBig.setTextViewText( + R.id.txt_migration_status, + resources.getString(R.string.database_update_in_progress) + ) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + remoteViewsBig.setInt( + R.id.container, "setBackgroundColor", + this.resources.getColor(colorMap.primaryColorRes) + ) + } else { + remoteViewsBig.setInt( + R.id.container, "setBackgroundColor", + this.resources.getColor(R.color.transparent) + ) + if (isNightMode) { + remoteViewsBig.setInt( + R.id.txt_migration_status, "setTextColor", + this.resources.getColor(R.color.text_primary_light) + ) + } else { + remoteViewsBig.setInt( + R.id.txt_migration_status, "setTextColor", + this.resources.getColor(R.color.text_primary_dark) + ) + } + } + + // Create notification default intent + val intent = Intent(applicationContext, MainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP + val pendingIntent = PendingIntent.getActivity( + applicationContext, 0, intent, AppConstants.PENDING_INTENT_FLAGS + ) + + // Create notification builder + builder = NotificationCompat.Builder(this, CHANNEL_ID) + builder.setWhen(System.currentTimeMillis()) + builder.setContentTitle(resources.getString(R.string.app_name)) + builder.setSmallIcon(R.drawable.ic_save_alt) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + builder.priority = NotificationManagerCompat.IMPORTANCE_LOW + } else { + builder.priority = NotificationCompat.PRIORITY_LOW + } + builder.setContentIntent(pendingIntent) + builder.setCustomContentView(remoteViewsSmall) + builder.setCustomBigContentView(remoteViewsBig) + builder.setOngoing(true) + builder.setOnlyAlertOnce(true) + builder.setDefaults(0) + builder.setSound(null) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + startForeground(NOTIF_ID, builder.build()) + } else { + startForeground( + NOTIF_ID, + builder.build(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } + } + + private fun stopService() { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.S_V2) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + stopSelf() + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createNotificationChannel(channelId: String, channelName: String) { + val channel = notificationManager.getNotificationChannel(channelId) + if (channel == null) { + val channelNew = NotificationChannel( + channelId, channelName, NotificationManager.IMPORTANCE_LOW + ) + channelNew.lightColor = Color.BLUE + channelNew.lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC + channelNew.setSound(null, null) + channelNew.enableLights(false) + channelNew.enableVibration(false) + notificationManager.createNotificationChannel(channelNew) + } else { + Timber.v("Channel already exists: %s", CHANNEL_ID) + } + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/moverecords/MoveRecordsActivity.kt b/app/src/main/java/com/dimowner/audiorecorder/app/moverecords/MoveRecordsActivity.kt index 5dda25650..91252a4c5 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/moverecords/MoveRecordsActivity.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/app/moverecords/MoveRecordsActivity.kt @@ -20,7 +20,11 @@ import android.Manifest import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.app.Activity -import android.content.* +import android.content.ActivityNotFoundException +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection import android.content.pm.PackageManager import android.os.Build import android.os.Bundle @@ -42,7 +46,11 @@ import com.dimowner.audiorecorder.util.AndroidUtils import com.dimowner.audiorecorder.util.RippleUtils import com.dimowner.audiorecorder.util.TimeUtils import com.dimowner.audiorecorder.util.isVisible -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import timber.log.Timber import java.io.File @@ -92,6 +100,8 @@ class MoveRecordsActivity : Activity() { val view = binding.root setContentView(view) + AndroidUtils.applyWindowInsets(this) + viewModel = ARApplication.injector.provideMoveRecordsViewModel(applicationContext) binding.recyclerView.layoutManager = LinearLayoutManager(applicationContext) diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/moverecords/MoveRecordsAdapter.kt b/app/src/main/java/com/dimowner/audiorecorder/app/moverecords/MoveRecordsAdapter.kt index b6f7c5d0e..0a64259e8 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/moverecords/MoveRecordsAdapter.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/app/moverecords/MoveRecordsAdapter.kt @@ -62,7 +62,7 @@ class MoveRecordsAdapter : ListAdapter override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { if (holder is MoveRecordsItemViewHolder) { holder.bind(getItem(position)) - if (holder.absoluteAdapterPosition == activeItem) { + if (holder.adapterPosition == activeItem) { holder.binding.container.setBackgroundResource(R.color.selected_item_color) } else { holder.binding.container.setBackgroundResource(android.R.color.transparent) @@ -145,8 +145,8 @@ class MoveRecordsAdapter : ListAdapter ): RecyclerView.ViewHolder(binding.root) { init { - binding.container.setOnClickListener { itemClickListener?.invoke(bindingAdapterPosition) } - binding.btnMove.setOnClickListener { moveRecordsClickListener?.invoke(bindingAdapterPosition) } + binding.container.setOnClickListener { itemClickListener?.invoke(adapterPosition) } + binding.btnMove.setOnClickListener { moveRecordsClickListener?.invoke(adapterPosition) } binding.btnMove.background = RippleUtils.createRippleShape( ContextCompat.getColor(binding.btnMove.context, R.color.white_transparent_80), ContextCompat.getColor(binding.btnMove.context, R.color.white_transparent_50), diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsActivity.java b/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsActivity.java index 973ea84d9..a7f008508 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsActivity.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsActivity.java @@ -38,6 +38,7 @@ import android.widget.SeekBar; import android.widget.TextView; import android.widget.Toast; +import android.window.OnBackInvokedCallback; import com.dimowner.audiorecorder.ARApplication; import com.dimowner.audiorecorder.AppConstants; @@ -103,6 +104,8 @@ public class RecordsActivity extends Activity implements RecordsContract.View, V final private List downloadRecords = new ArrayList<>(); + private OnBackInvokedCallback backInvokedCallback; + public static Intent getStartIntent(Context context) { Intent intent = new Intent(context, RecordsActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); @@ -117,6 +120,16 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_records); + AndroidUtils.applyWindowInsets(this); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + backInvokedCallback = () -> { + ARApplication.getInjector().releaseRecordsPresenter(); + finish(); + }; + getOnBackInvokedDispatcher().registerOnBackInvokedCallback(0, backInvokedCallback); + } + toolbar = findViewById(R.id.toolbar); // AndroidUtils.setTranslucent(this, true); @@ -289,7 +302,7 @@ public void onScrolled(@NonNull RecyclerView rv, int dx, int dy) { RecordsActivity.this, R.drawable.ic_delete_forever_dark, getString(R.string.warning), - getString(R.string.delete_record, item.getName()), + getString(R.string.move_record_to_trash, item.getName()), v -> presenter.deleteRecord(item.getId(), item.getPath()) ); } @@ -483,7 +496,7 @@ public void onClick(View view) { RecordsActivity.this, R.drawable.ic_delete_forever_dark, getString(R.string.warning), - getString(R.string.delete_record, presenter.getRecordName()), + getString(R.string.move_record_to_trash, presenter.getRecordName()), v -> presenter.deleteActiveRecord() ); } else if (id == R.id.btn_check_bookmark) { @@ -626,6 +639,14 @@ protected void onStop() { } } + @Override + protected void onDestroy() { + super.onDestroy(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(backInvokedCallback); + } + } + @Override public void onBackPressed() { super.onBackPressed(); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsAdapter.java b/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsAdapter.java index 77e960757..be9b38d38 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsAdapter.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsAdapter.java @@ -155,7 +155,7 @@ public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder viewHolder, final int pos) { if (viewHolder.getItemViewType() == ListItem.ITEM_TYPE_NORMAL) { final ItemViewHolder holder = (ItemViewHolder) viewHolder; - final int p = holder.getAbsoluteAdapterPosition(); + final int p = holder.getAdapterPosition(); final ListItem item = data.get(p); holder.name.setText(item.getName()); holder.description.setText(item.getDurationStr()); @@ -196,7 +196,7 @@ public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder viewHolder, UniversalViewHolder holder = (UniversalViewHolder) viewHolder; ((TextView)holder.view).setText( TimeUtils.formatDateSmartLocale( - data.get(viewHolder.getAbsoluteAdapterPosition()).getAdded(), + data.get(viewHolder.getAdapterPosition()).getAdded(), holder.view.getContext() ) ); @@ -626,13 +626,13 @@ static class ItemViewHolder extends RecyclerView.ViewHolder { super(itemView); view = itemView; view.setOnClickListener(v -> { - int pos = getAbsoluteAdapterPosition(); + int pos = getAdapterPosition(); if (pos != RecyclerView.NO_POSITION && onItemClickListener != null) { onItemClickListener.onItemClick(pos); } }); view.setOnLongClickListener(v -> { - int pos = getAbsoluteAdapterPosition(); + int pos = getAdapterPosition(); if (pos != RecyclerView.NO_POSITION && longClickListener != null) { longClickListener.onItemLongClick(pos); } diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsPresenter.java b/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsPresenter.java index 32da6f067..6c5adb78f 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsPresenter.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsPresenter.java @@ -76,14 +76,14 @@ public void bindView(final RecordsContract.View v) { if (appRecorderCallback == null) { appRecorderCallback = new AppRecorderCallback() { - @Override public void onRecordingStarted(File file) {} + @Override public void onRecordingStarted(@NonNull File file) {} @Override public void onRecordingPaused() {} @Override public void onRecordingResumed() { } @Override public void onRecordingProgress(long mills, int amp) {} @Override - public void onRecordingStopped(File file, Record rec) { + public void onRecordingStopped(@NonNull File file, @NonNull Record rec) { loadRecords(); } diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsActivity.java b/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsActivity.java index c77fb1b85..76acaae44 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsActivity.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsActivity.java @@ -36,6 +36,7 @@ import android.widget.Switch; import android.widget.TextView; import android.widget.Toast; +import android.window.OnBackInvokedCallback; import com.dimowner.audiorecorder.ARApplication; import com.dimowner.audiorecorder.AppConstants; @@ -48,6 +49,7 @@ import com.dimowner.audiorecorder.util.AndroidUtils; import com.dimowner.audiorecorder.util.FileUtil; import com.dimowner.audiorecorder.util.RippleUtils; +import com.dimowner.audiorecorder.v2.app.HomeActivity; import java.io.File; import java.util.ArrayList; @@ -83,7 +85,7 @@ public class SettingsActivity extends Activity implements SettingsContract.View, private SettingView channelsSetting; private Button btnReset; - private SettingsContract.UserActionsListener presenter; + private SettingsContract.UserActionsListener presenter; private ColorMap colorMap; private ColorMap.OnThemeColorChangeListener onThemeColorChangeListener; private final CompoundButton.OnCheckedChangeListener publicDirListener = new CompoundButton.OnCheckedChangeListener() { @@ -107,6 +109,8 @@ public void onCheckedChanged(CompoundButton btn, boolean isChecked) { private String[] recChannels; private String[] recChannelsKeys; + private OnBackInvokedCallback backInvokedCallback; + public static Intent getStartIntent(Context context) { Intent intent = new Intent(context, SettingsActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); @@ -120,6 +124,16 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_settings); + AndroidUtils.applyWindowInsets(this); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + backInvokedCallback = () -> { + ARApplication.getInjector().releaseSettingsPresenter(); + finish(); + }; + getOnBackInvokedDispatcher().registerOnBackInvokedCallback(0, backInvokedCallback); + } + btnView = findViewById(R.id.btnView); btnView.setBackground(RippleUtils.createRippleShape( @@ -130,6 +144,8 @@ protected void onCreate(Bundle savedInstanceState) { btnView.setOnClickListener(this); btnReset = findViewById(R.id.btnReset); btnReset.setOnClickListener(this); + LinearLayout pnlTry = findViewById(R.id.tryPanel); + pnlTry.setOnClickListener(this); txtSizePerMin = findViewById(R.id.txt_size_per_min); txtInformation = findViewById(R.id.txt_information); txtLocation = findViewById(R.id.txt_records_location); @@ -222,6 +238,12 @@ protected void onCreate(Bundle savedInstanceState) { getResources().getDimension(R.dimen.spacing_normal) ) ); + pnlTry.setBackground( + RippleUtils.createShape( + ContextCompat.getColor(getApplicationContext(),R.color.white_transparent_88), + getResources().getDimension(R.dimen.spacing_normal) + ) + ); btnReset.setBackground( RippleUtils.createShape( @@ -323,6 +345,9 @@ protected void onStop() { protected void onDestroy() { super.onDestroy(); colorMap.removeOnThemeColorChangeListener(onThemeColorChangeListener); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(backInvokedCallback); + } } @SuppressLint("UnsafeOptInUsageWarning") @@ -345,11 +370,19 @@ public void onClick(View v) { } else if (id == R.id.btnReset) { presenter.resetSettings(); presenter.loadSettings(); + } else if (id == R.id.tryPanel) { + presenter.switchAppV2(); } else if (id == R.id.btnRequest) { requestFeature(); } } + public void showAppV2() { + Intent intent = new Intent(this, HomeActivity.class); + startActivity(intent); + finish(); + } + @Override public void onBackPressed() { super.onBackPressed(); @@ -590,6 +623,20 @@ public void disableAudioSettings() { channelsSetting.setEnabled(false); } + @Override + public void showAppV2Confirmation() { + AndroidUtils.showDialogConfirmation( + SettingsActivity.this, + R.drawable.ic_info, + getString(R.string.try_new_audio_recorder), + getString(R.string.audio_recorder_updated_with_improved_features_message), + view -> { + presenter.confirmSwitchAppV2(getApplicationContext()); + showAppV2(); + } + ); + } + @Override public void showProgress() { // TODO: showProgress diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsContract.java b/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsContract.java index 2ecd6ca6c..e0de13783 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsContract.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsContract.java @@ -72,6 +72,8 @@ interface View extends Contract.View { void enableAudioSettings(); void disableAudioSettings(); + + void showAppV2Confirmation(); } public interface UserActionsListener extends Contract.UserActionsListener { @@ -80,6 +82,10 @@ public interface UserActionsListener extends Contract.UserActionsListener= Build.VERSION_CODES.TIRAMISU) { + backInvokedCallback = () -> { + ARApplication.getInjector().releaseSetupPresenter(); + finish(); + }; + getOnBackInvokedDispatcher().registerOnBackInvokedCallback(0, backInvokedCallback); + } + // getWindow().setFlags( // WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, // WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS); @@ -238,6 +252,9 @@ protected void onStop() { protected void onDestroy() { super.onDestroy(); colorMap.removeOnThemeColorChangeListener(onThemeColorChangeListener); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(backInvokedCallback); + } } @Override diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/trash/TrashActivity.java b/app/src/main/java/com/dimowner/audiorecorder/app/trash/TrashActivity.java index 9a9ff6df9..fb2cf1949 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/trash/TrashActivity.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/trash/TrashActivity.java @@ -63,6 +63,8 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_trash); + AndroidUtils.applyWindowInsets(this); + ImageButton btnBack = findViewById(R.id.btn_back); btnBack.setOnClickListener(v -> { ARApplication.getInjector().releaseTrashPresenter(); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/trash/TrashAdapter.java b/app/src/main/java/com/dimowner/audiorecorder/app/trash/TrashAdapter.java index a72c7f939..17ae58b88 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/trash/TrashAdapter.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/trash/TrashAdapter.java @@ -88,7 +88,7 @@ public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { @Override public void onBindViewHolder(@NonNull ItemViewHolder holder, final int pos) { - final int position = holder.getAbsoluteAdapterPosition(); + final int position = holder.getAdapterPosition(); if (position != RecyclerView.NO_POSITION) { holder.name.setText(data.get(position).getName()); holder.duration.setText(TimeUtils.formatTimeIntervalHourMinSec2(data.get(position).getDuration()/1000)); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/welcome/WelcomeActivity.java b/app/src/main/java/com/dimowner/audiorecorder/app/welcome/WelcomeActivity.java index 176ee85d7..6cfbb596c 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/welcome/WelcomeActivity.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/welcome/WelcomeActivity.java @@ -50,6 +50,8 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_welcome); + AndroidUtils.applyWindowInsets(this); + actionButton = findViewById(R.id.btn_action); actionButton.setOnClickListener(v -> { startActivity(SetupActivity.getStartIntent(getApplicationContext())); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/widget/WaveformViewNew.kt b/app/src/main/java/com/dimowner/audiorecorder/app/widget/WaveformViewNew.kt index 880abc715..c812f5374 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/widget/WaveformViewNew.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/app/widget/WaveformViewNew.kt @@ -64,7 +64,7 @@ class WaveformViewNew @JvmOverloads constructor( private var viewHeightPx = 0 private var originalData: IntArray = IntArray(0) - private var waveformData: IntArray = IntArray(0) + private var waveformData: FloatArray = FloatArray(0) lateinit var drawLinesArray: FloatArray private var showTimeline: Boolean = true @@ -452,9 +452,9 @@ class WaveformViewNew @JvmOverloads constructor( heights[i] = value * value } val halfHeight = viewHeightPx / 2 - textIndent.toInt() - 1 - waveformData = IntArray(numFrames) + waveformData = FloatArray(numFrames) for (i in 0 until numFrames) { - waveformData[i] = (heights[i] * halfHeight).toInt() + waveformData[i] = (heights[i] * halfHeight) } } diff --git a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/AudioRecorder.java b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/AudioRecorder.java index 436ee6a42..f85c4de6a 100755 --- a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/AudioRecorder.java +++ b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/AudioRecorder.java @@ -16,9 +16,11 @@ package com.dimowner.audiorecorder.audio.recorder; +import android.content.Context; import android.media.MediaRecorder; import android.os.Build; import android.os.Handler; +import android.os.Looper; import com.dimowner.audiorecorder.exception.InvalidOutputFile; import com.dimowner.audiorecorder.exception.RecorderInitException; @@ -26,10 +28,9 @@ import java.io.File; import java.io.IOException; import java.util.concurrent.atomic.AtomicBoolean; - import timber.log.Timber; - import static com.dimowner.audiorecorder.AppConstants.RECORDING_VISUALIZATION_INTERVAL; +import androidx.annotation.NonNull; public class AudioRecorder implements RecorderContract.Recorder { @@ -40,34 +41,42 @@ public class AudioRecorder implements RecorderContract.Recorder { private final AtomicBoolean isRecording = new AtomicBoolean(false); private final AtomicBoolean isPaused = new AtomicBoolean(false); - private final Handler handler = new Handler(); + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Context applicationContext; private RecorderContract.RecorderCallback recorderCallback; - private static class RecorderSingletonHolder { - private static final AudioRecorder singleton = new AudioRecorder(); + private volatile static AudioRecorder instance; - public static AudioRecorder getSingleton() { - return RecorderSingletonHolder.singleton; + public static AudioRecorder getInstance(Context context) { + if (instance == null) { + synchronized (AudioRecorder.class) { + if (instance == null) { + instance = new AudioRecorder(context); + } + } } + return instance; } - public static AudioRecorder getInstance() { - return RecorderSingletonHolder.getSingleton(); + private AudioRecorder(Context context) { + this.applicationContext = context; } - private AudioRecorder() { } - @Override - public void setRecorderCallback(RecorderContract.RecorderCallback callback) { + public void setRecorderCallback(@NonNull RecorderContract.RecorderCallback callback) { this.recorderCallback = callback; } @Override - public void startRecording(String outputFile, int channelCount, int sampleRate, int bitrate) { + public void startRecording(@NonNull String outputFile, int channelCount, int sampleRate, int bitrate) { recordFile = new File(outputFile); if (recordFile.exists() && recordFile.isFile()) { - recorder = new MediaRecorder(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + recorder = new MediaRecorder(applicationContext); // Requires context on S+ + } else { + recorder = new MediaRecorder(); + } recorder.setAudioSource(MediaRecorder.AudioSource.MIC); recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); diff --git a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/RecorderContract.java b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/RecorderContract.java index 4f1e57638..bb9f5410f 100755 --- a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/RecorderContract.java +++ b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/RecorderContract.java @@ -16,24 +16,24 @@ package com.dimowner.audiorecorder.audio.recorder; +import androidx.annotation.NonNull; import com.dimowner.audiorecorder.exception.AppException; - import java.io.File; public interface RecorderContract { interface RecorderCallback { - void onStartRecord(File output); + void onStartRecord(@NonNull File output); void onPauseRecord(); void onResumeRecord(); void onRecordProgress(long mills, int amp); - void onStopRecord(File output); - void onError(AppException throwable); + void onStopRecord(@NonNull File output); + void onError(@NonNull AppException throwable); } interface Recorder { - void setRecorderCallback(RecorderCallback callback); - void startRecording(String outputFile, int channelCount, int sampleRate, int bitrate); + void setRecorderCallback(@NonNull RecorderCallback callback); + void startRecording(@NonNull String outputFile, int channelCount, int sampleRate, int bitrate); void resumeRecording(); void pauseRecording(); void stopRecording(); diff --git a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/ThreeGpRecorder.java b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/ThreeGpRecorder.java index b97ae9f8e..66d0a0475 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/ThreeGpRecorder.java +++ b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/ThreeGpRecorder.java @@ -16,9 +16,11 @@ package com.dimowner.audiorecorder.audio.recorder; +import android.content.Context; import android.media.MediaRecorder; import android.os.Build; import android.os.Handler; +import android.os.Looper; import com.dimowner.audiorecorder.exception.InvalidOutputFile; import com.dimowner.audiorecorder.exception.RecorderInitException; @@ -26,10 +28,9 @@ import java.io.File; import java.io.IOException; import java.util.concurrent.atomic.AtomicBoolean; - import timber.log.Timber; - import static com.dimowner.audiorecorder.AppConstants.RECORDING_VISUALIZATION_INTERVAL; +import androidx.annotation.NonNull; public class ThreeGpRecorder implements RecorderContract.Recorder { @@ -40,34 +41,42 @@ public class ThreeGpRecorder implements RecorderContract.Recorder { private final AtomicBoolean isRecording = new AtomicBoolean(false); private final AtomicBoolean isPaused = new AtomicBoolean(false); - private final Handler handler = new Handler(); + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Context applicationContext; private RecorderContract.RecorderCallback recorderCallback; - private static class RecorderSingletonHolder { - private static final ThreeGpRecorder singleton = new ThreeGpRecorder(); + private volatile static ThreeGpRecorder instance; - public static ThreeGpRecorder getSingleton() { - return RecorderSingletonHolder.singleton; + public static ThreeGpRecorder getInstance(Context context) { + if (instance == null) { + synchronized (ThreeGpRecorder.class) { + if (instance == null) { + instance = new ThreeGpRecorder(context); + } + } } + return instance; } - public static ThreeGpRecorder getInstance() { - return RecorderSingletonHolder.getSingleton(); + private ThreeGpRecorder(Context context) { + this.applicationContext = context; } - private ThreeGpRecorder() { } - @Override - public void setRecorderCallback(RecorderContract.RecorderCallback callback) { + public void setRecorderCallback(@NonNull RecorderContract.RecorderCallback callback) { this.recorderCallback = callback; } @Override - public void startRecording(String outputFile, int channelCount, int sampleRate, int bitrate) { + public void startRecording(@NonNull String outputFile, int channelCount, int sampleRate, int bitrate) { recordFile = new File(outputFile); if (recordFile.exists() && recordFile.isFile()) { - recorder = new MediaRecorder(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + recorder = new MediaRecorder(applicationContext); // Requires context on S+ + } else { + recorder = new MediaRecorder(); + } recorder.setAudioSource(MediaRecorder.AudioSource.MIC); recorder.setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP); if (sampleRate > 8000) { diff --git a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/WavRecorder.java b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/WavRecorder.java index 574a5cb84..9b3c7f74f 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/WavRecorder.java +++ b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/WavRecorder.java @@ -35,6 +35,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import timber.log.Timber; import static com.dimowner.audiorecorder.AppConstants.RECORDING_VISUALIZATION_INTERVAL; +import androidx.annotation.NonNull; import androidx.annotation.RequiresPermission; public class WavRecorder implements RecorderContract.Recorder { @@ -78,13 +79,13 @@ public static WavRecorder getInstance() { private WavRecorder() { } @Override - public void setRecorderCallback(RecorderContract.RecorderCallback callback) { + public void setRecorderCallback(@NonNull RecorderContract.RecorderCallback callback) { recorderCallback = callback; } @Override @RequiresPermission(value = "android.permission.RECORD_AUDIO") - public void startRecording(String outputFile, int channelCount, int sampleRate, int bitrate) { + public void startRecording(@NonNull String outputFile, int channelCount, int sampleRate, int bitrate) { this.sampleRate = sampleRate; // this.framesPerVisInterval = (int)((VISUALIZATION_INTERVAL/1000f)/(1f/sampleRate)); this.channelCount = channelCount; diff --git a/app/src/main/java/com/dimowner/audiorecorder/data/FileRepositoryImpl.java b/app/src/main/java/com/dimowner/audiorecorder/data/FileRepositoryImpl.java index ee0b259ba..e08560f0a 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/data/FileRepositoryImpl.java +++ b/app/src/main/java/com/dimowner/audiorecorder/data/FileRepositoryImpl.java @@ -51,6 +51,9 @@ public static FileRepositoryImpl getInstance(Context context, Prefs prefs) { return instance; } + // - Increment record counter + // - Generate new file name with new record counter value + // - Create a new record file with a new name. @Override public File provideRecordFile() throws CantCreateFileException { prefs.incrementRecordCounter(); diff --git a/app/src/main/java/com/dimowner/audiorecorder/data/Prefs.java b/app/src/main/java/com/dimowner/audiorecorder/data/Prefs.java index e3336bb01..02e2de4cf 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/data/Prefs.java +++ b/app/src/main/java/com/dimowner/audiorecorder/data/Prefs.java @@ -21,9 +21,17 @@ public interface Prefs { boolean isFirstRun(); void firstRunExecuted(); + @Deprecated //Public storage is not used anymore boolean isStoreDirPublic(); + @Deprecated //Public storage is not used anymore void setStoreDirPublic(boolean b); + /** + * Flag indicates if Local database helper migrated from SQLiteHelper to Room. + */ + boolean isDatabaseMigratedToRoom(); + void setDatabaseMigratedToRoom(boolean b); + //This is needed for scoped storage support boolean isShowDirectorySetting(); @@ -54,6 +62,9 @@ public interface Prefs { boolean isMigratedDb3(); void migrateDb3Finished(); + boolean isAppV2(); + void setAppV2(boolean value); + void setSettingThemeColor(String colorKey); String getSettingThemeColor(); diff --git a/app/src/main/java/com/dimowner/audiorecorder/data/PrefsImpl.java b/app/src/main/java/com/dimowner/audiorecorder/data/PrefsImpl.java index 652c6f71d..fc3841e19 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/data/PrefsImpl.java +++ b/app/src/main/java/com/dimowner/audiorecorder/data/PrefsImpl.java @@ -16,6 +16,16 @@ package com.dimowner.audiorecorder.data; +import static com.dimowner.audiorecorder.AppConstants.PREF_KEY_IS_APP_V2; +import static com.dimowner.audiorecorder.AppConstants.PREF_KEY_IS_FIRST_RUN; +import static com.dimowner.audiorecorder.AppConstants.PREF_KEY_KEEP_SCREEN_ON; +import static com.dimowner.audiorecorder.AppConstants.PREF_KEY_RECORD_COUNTER; +import static com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_BITRATE; +import static com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_CHANNEL_COUNT; +import static com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_NAMING_FORMAT; +import static com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_RECORDING_FORMAT; +import static com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_SAMPLE_RATE; +import static com.dimowner.audiorecorder.AppConstants.PREF_NAME; import android.content.Context; import android.content.SharedPreferences; import android.os.Build; @@ -27,18 +37,13 @@ */ public class PrefsImpl implements Prefs { - private static final String PREF_NAME = "com.dimowner.audiorecorder.data.PrefsImpl"; - - private static final String PREF_KEY_IS_FIRST_RUN = "is_first_run"; private static final String PREF_KEY_IS_MIGRATED = "is_migrated"; private static final String PREF_KEY_IS_MIGRATED_DB3 = "is_migrated_db3"; private static final String PREF_KEY_IS_STORE_DIR_PUBLIC = "is_store_dir_public"; private static final String PREF_KEY_IS_SHOW_DIRECTORY_SETTING = "is_show_directory_setting"; private static final String PREF_KEY_IS_ASK_TO_RENAME_AFTER_STOP_RECORDING = "is_ask_rename_after_stop_recording"; private static final String PREF_KEY_ACTIVE_RECORD = "active_record"; - private static final String PREF_KEY_RECORD_COUNTER = "record_counter"; private static final String PREF_KEY_THEME_COLORMAP_POSITION = "theme_color"; - private static final String PREF_KEY_KEEP_SCREEN_ON = "keep_screen_on"; private static final String PREF_KEY_FORMAT = "pref_format"; private static final String PREF_KEY_BITRATE = "pref_bitrate"; private static final String PREF_KEY_SAMPLE_RATE = "pref_sample_rate"; @@ -46,16 +51,11 @@ public class PrefsImpl implements Prefs { private static final String PREF_KEY_NAMING_FORMAT = "pref_naming_format"; private static final String PREF_KEY_LAST_PUBLIC_STORAGE_MIGRATION_ASKED = "pref_last_public_storage_migration_asked"; private static final String PREF_KEY_IS_PUBLIC_STORAGE_MIGRATED = "pref_is_public_storage_migrated"; + private static final String PREF_KEY_IS_DATABASE_MIGRATED_TO_ROOM = "pref_key_is_database_migrated_to_room"; //Recording prefs. private static final String PREF_KEY_RECORD_CHANNEL_COUNT = "record_channel_count"; - private static final String PREF_KEY_SETTING_THEME_COLOR = "setting_theme_color"; - private static final String PREF_KEY_SETTING_RECORDING_FORMAT = "setting_recording_format"; - private static final String PREF_KEY_SETTING_BITRATE = "setting_bitrate"; - private static final String PREF_KEY_SETTING_SAMPLE_RATE = "setting_sample_rate"; - private static final String PREF_KEY_SETTING_NAMING_FORMAT = "setting_naming_format"; - private static final String PREF_KEY_SETTING_CHANNEL_COUNT = "setting_channel_count"; private final SharedPreferences sharedPreferences; @@ -107,7 +107,20 @@ public void setStoreDirPublic(boolean b) { editor.apply(); } - @Override + @Override + public boolean isDatabaseMigratedToRoom() { + return sharedPreferences.contains(PREF_KEY_IS_DATABASE_MIGRATED_TO_ROOM) + && sharedPreferences.getBoolean(PREF_KEY_IS_DATABASE_MIGRATED_TO_ROOM, false); + } + + @Override + public void setDatabaseMigratedToRoom(boolean b) { + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putBoolean(PREF_KEY_IS_DATABASE_MIGRATED_TO_ROOM, b); + editor.apply(); + } + + @Override public boolean isShowDirectorySetting() { return sharedPreferences.getBoolean(PREF_KEY_IS_SHOW_DIRECTORY_SETTING, true); } @@ -323,6 +336,18 @@ public void migrateDb3Finished() { editor.apply(); } + @Override + public boolean isAppV2() { + return sharedPreferences.getBoolean(PREF_KEY_IS_APP_V2, true); + } + + @Override + public void setAppV2(boolean value) { + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putBoolean(PREF_KEY_IS_APP_V2, value); + editor.apply(); + } + @Override public void setSettingThemeColor(String colorKey) { SharedPreferences.Editor editor = sharedPreferences.edit(); diff --git a/app/src/main/java/com/dimowner/audiorecorder/data/database/LocalRepositoryImpl.java b/app/src/main/java/com/dimowner/audiorecorder/data/database/LocalRepositoryImpl.java index d29a93756..2661b16cc 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/data/database/LocalRepositoryImpl.java +++ b/app/src/main/java/com/dimowner/audiorecorder/data/database/LocalRepositoryImpl.java @@ -531,8 +531,8 @@ public void removeOutdatedTrashRecords() { List list = trashDataSource.getAll(); for (int i = 0; i < list.size(); i++) { if (list.get(i).getRemoved() + AppConstants.RECORD_IN_TRASH_MAX_DURATION < curTime) { - fileRepository.deleteRecordFile(list.get(i).getPath()); trashDataSource.deleteItem(list.get(i).getId()); + fileRepository.deleteRecordFile(list.get(i).getPath()); } } } diff --git a/app/src/main/java/com/dimowner/audiorecorder/data/database/Record.java b/app/src/main/java/com/dimowner/audiorecorder/data/database/Record.java index 9f3ecc6a4..92d6d8721 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/data/database/Record.java +++ b/app/src/main/java/com/dimowner/audiorecorder/data/database/Record.java @@ -32,6 +32,7 @@ public class Record { private long duration; private final long created; private final long added; + /** Date when record removed. Required to be able to remove the record automatically from Trash after it expired. */ private final long removed; private String path; private final String format; diff --git a/app/src/main/java/com/dimowner/audiorecorder/exception/AlreadyRecordingException.kt b/app/src/main/java/com/dimowner/audiorecorder/exception/AlreadyRecordingException.kt new file mode 100644 index 000000000..d153bfcfc --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/exception/AlreadyRecordingException.kt @@ -0,0 +1,7 @@ +package com.dimowner.audiorecorder.exception + +class AlreadyRecordingException: AppException() { + override fun getType(): Int { + return ALREADY_RECORDING + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/exception/AppException.java b/app/src/main/java/com/dimowner/audiorecorder/exception/AppException.java index 3748a1544..ffa39e1dd 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/exception/AppException.java +++ b/app/src/main/java/com/dimowner/audiorecorder/exception/AppException.java @@ -28,6 +28,7 @@ public abstract class AppException extends Exception { public static final int NO_SPACE_AVAILABLE = 8; public static final int RECORDING_ERROR = 9; public static final int FAILED_TO_RESTORE = 10; + public static final int ALREADY_RECORDING = 11; public abstract int getType(); } diff --git a/app/src/main/java/com/dimowner/audiorecorder/exception/ErrorParser.java b/app/src/main/java/com/dimowner/audiorecorder/exception/ErrorParser.java index 3d7008562..159bfffe5 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/exception/ErrorParser.java +++ b/app/src/main/java/com/dimowner/audiorecorder/exception/ErrorParser.java @@ -43,6 +43,8 @@ public static int parseException(AppException e) { return R.string.error_failed_to_restore; } else if (e.getType() == AppException.READ_PERMISSION_DENIED) { return R.string.error_permission_denied; + } else if (e.getType() == AppException.ALREADY_RECORDING) { + return R.string.error_recording_already_started; } return R.string.error_unknown; } diff --git a/app/src/main/java/com/dimowner/audiorecorder/util/AndroidUtils.java b/app/src/main/java/com/dimowner/audiorecorder/util/AndroidUtils.java index e48567689..6fe5f39c2 100755 --- a/app/src/main/java/com/dimowner/audiorecorder/util/AndroidUtils.java +++ b/app/src/main/java/com/dimowner/audiorecorder/util/AndroidUtils.java @@ -33,6 +33,9 @@ import android.media.MediaFormat; import android.net.Uri; import androidx.core.content.FileProvider; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; import android.text.Editable; import android.text.Spannable; import android.text.SpannableString; @@ -476,6 +479,23 @@ public static void showDialogYesNo(Activity activity, v -> {}); } + public static void showDialogConfirmation(Activity activity, + int drawableRes, + String titleStr, + String contentStr, + final View.OnClickListener positiveBtnListener){ + showDialog(activity, + drawableRes, + activity.getString(R.string.btn_confirm), + activity.getString(R.string.btn_cancel), + titleStr, + contentStr, + -1, + true, + positiveBtnListener, + v -> {}); + } + private static void showDialog(Activity activity, int drawableRes, String positiveBtnText, @@ -622,6 +642,23 @@ public static String getAppVersion(Context context) { return versionName; } + public static void applyWindowInsets(Activity activity) { + View rootView = activity.getWindow().getDecorView(); + ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> { + Insets systemBarsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + // Apply insets as padding + v.setPadding( + systemBarsInsets.left, + systemBarsInsets.top, + systemBarsInsets.right, + systemBarsInsets.bottom + ); + // Return CONSUMED to stop the insets from passing to child views + return WindowInsetsCompat.CONSUMED; + }); + ViewCompat.requestApplyInsets(rootView); // Request the insets be applied + } + public interface OnSetNameClickListener { void onClick(String name); } diff --git a/app/src/main/java/com/dimowner/audiorecorder/util/AudioManagerHelper.kt b/app/src/main/java/com/dimowner/audiorecorder/util/AudioManagerHelper.kt new file mode 100644 index 000000000..b8ef24a63 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/util/AudioManagerHelper.kt @@ -0,0 +1,397 @@ +/* + * Copyright 2026 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.util + +import android.content.Context +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build +import androidx.annotation.RequiresApi +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Data class to wrap AudioDeviceInfo with display name + * + * @property id Unique identifier for the device + * @property productName Display name of the device + * @property type AudioDeviceInfo type constant + * @property audioDeviceInfo Original AudioDeviceInfo object + */ +data class BluetoothDeviceInfo( + val id: Int, + val productName: String, + val type: Int, + val audioDeviceInfo: AudioDeviceInfo +) + +/** + * Data class representing the state of Bluetooth microphone availability and routing. + * + * @property isAvailable Whether a Bluetooth microphone is currently connected and available. + * @property isEnabled Whether Bluetooth audio routing is currently enabled for recording. + * @property deviceName The product name of the connected Bluetooth device, if available. + * @property connectedDevices List of all currently connected Bluetooth microphones. + * @property selectedDevice The currently selected Bluetooth device, if any. + */ +data class BluetoothMicState( + val isAvailable: Boolean = false, + val isEnabled: Boolean = false, + val deviceName: String? = null, + val connectedDevices: List = emptyList(), + val selectedDevice: BluetoothDeviceInfo? = null +) + +/** + * Helper class to manage Bluetooth microphone detection and audio routing. + * Handles both modern (API 31+) and legacy Bluetooth SCO APIs. + * + * This class monitors connected Bluetooth audio devices and provides functionality to: + * - Detect when Bluetooth headsets with microphones are connected/disconnected + * - Enable/disable Bluetooth microphone routing + * - Get the name of connected Bluetooth devices + * + * @property context Application context for accessing system services. + */ +@Singleton +class AudioManagerHelper @Inject constructor( + @param:ApplicationContext private val context: Context +) { + private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + private val _bluetoothMicState = MutableStateFlow(BluetoothMicState()) + val bluetoothMicState: StateFlow = _bluetoothMicState.asStateFlow() + + private var isBluetoothScoActive = false + private var previousAudioMode = AudioManager.MODE_NORMAL + + private var selectedBluetoothDevice: BluetoothDeviceInfo? = null + + private val audioDeviceCallback = object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array) { + Timber.d("Audio devices added: ${addedDevices.size}") + updateBluetoothDeviceState() + } + + override fun onAudioDevicesRemoved(removedDevices: Array) { + Timber.d("Audio devices removed: ${removedDevices.size}") + val removedDeviceIds = removedDevices.map { it.id }.toSet() + // Clear selection if the selected device was removed + if (selectedBluetoothDevice != null && removedDeviceIds.contains(selectedBluetoothDevice!!.id)) { + Timber.d("Selected Bluetooth device was removed, clearing selection") + selectedBluetoothDevice = null + // Disable Bluetooth routing + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + audioManager.clearCommunicationDevice() + audioManager.mode = previousAudioMode + } catch (e: Exception) { + Timber.e(e, "Error clearing communication device") + } + } else { + if (isBluetoothScoActive) { + audioManager.stopBluetoothSco() + audioManager.isBluetoothScoOn = false + audioManager.mode = previousAudioMode + isBluetoothScoActive = false + } + } + } + updateBluetoothDeviceState() + } + } + + /** + * Registers the audio device callback to monitor Bluetooth device changes. + * Should be called when the component using this helper becomes active. + */ + fun register() { + Timber.d("Registering AudioDeviceCallback") + audioManager.registerAudioDeviceCallback(audioDeviceCallback, null) + updateBluetoothDeviceState() + } + + /** + * Unregisters the audio device callback to stop monitoring Bluetooth device changes. + * Should be called when the component using this helper becomes inactive. + */ + fun unregister() { + Timber.d("Unregistering AudioDeviceCallback") + audioManager.unregisterAudioDeviceCallback(audioDeviceCallback) + } + + /** + * Enables or disables Bluetooth microphone routing for audio recording. + * + * On API 31+ (Android 12+), uses the modern setCommunicationDevice() API. + * On older versions, falls back to startBluetoothSco() / stopBluetoothSco(). + * + * @param enable true to enable Bluetooth microphone, false to disable. + */ + suspend fun enableBluetoothMic(enable: Boolean) { + Timber.d("enableBluetoothMic: $enable") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + enableBluetoothMicApi31Plus(enable) + } else { + enableBluetoothMicLegacy(enable) + } + + delay(500) + updateBluetoothDeviceState() + } + + /** + * Selects a specific Bluetooth device for audio input. + * For API 31+: Uses AudioManager.setCommunicationDevice(selectedDevice) + * For API < 31: Uses startBluetoothSco() (specific device routing limited by system) + * + * @param device The BluetoothDeviceInfo to select, or null to clear selection + */ + fun selectBluetoothDevice(device: BluetoothDeviceInfo?) { + Timber.d("selectBluetoothDevice: ${device?.productName}") + selectedBluetoothDevice = device + updateBluetoothDeviceState() + } + + /** + * Modern API (31+) implementation for Bluetooth microphone routing. + */ + @RequiresApi(Build.VERSION_CODES.S) + private fun enableBluetoothMicApi31Plus(enable: Boolean) { + try { + if (enable) { + // Use selected device if available, otherwise use first available device + val bluetoothDevice = selectedBluetoothDevice?.audioDeviceInfo + ?: getBluetoothAudioInputDevice() + + if (bluetoothDevice != null) { + previousAudioMode = audioManager.mode + audioManager.mode = AudioManager.MODE_IN_COMMUNICATION + val success = audioManager.setCommunicationDevice(bluetoothDevice) + Timber.d("setCommunicationDevice result: $success for device: ${bluetoothDevice.productName}") + if (!success) { + Timber.w("Failed to set communication device") + audioManager.mode = previousAudioMode + } else { + Timber.d("Success to set communication device!") + } + } else { + Timber.w("No Bluetooth audio input device available") + } + } else { + audioManager.clearCommunicationDevice() + audioManager.mode = previousAudioMode + Timber.d("Cleared communication device and restored audio mode") + } + } catch (e: Exception) { + Timber.e(e, "Error enabling/disabling Bluetooth mic (API 31+)") + } + } + + /** + * Legacy API implementation for Bluetooth microphone routing using SCO. + */ + private fun enableBluetoothMicLegacy(enable: Boolean) { + try { + if (enable) { + if (!isBluetoothScoActive && hasBluetoothAudioInputDevice()) { + previousAudioMode = audioManager.mode + audioManager.mode = AudioManager.MODE_IN_COMMUNICATION + audioManager.startBluetoothSco() + audioManager.isBluetoothScoOn = true + isBluetoothScoActive = true + Timber.d("Started Bluetooth SCO") + } + } else { + if (isBluetoothScoActive) { + audioManager.stopBluetoothSco() + audioManager.isBluetoothScoOn = false + audioManager.mode = previousAudioMode + isBluetoothScoActive = false + Timber.d("Stopped Bluetooth SCO") + } + } + } catch (e: Exception) { + Timber.e(e, "Error enabling/disabling Bluetooth mic (legacy)") + } + } + + /** + * Releases resources and resets audio routing to normal state. + * Should be called when the helper is no longer needed. + */ + fun release() { + Timber.d("Releasing AudioManagerHelper") + + try { + // Stop Bluetooth SCO if active + if (isBluetoothScoActive) { + audioManager.stopBluetoothSco() + audioManager.isBluetoothScoOn = false + isBluetoothScoActive = false + } + + // Clear communication device on API 31+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + audioManager.clearCommunicationDevice() + } + + // Reset audio mode + audioManager.mode = AudioManager.MODE_NORMAL + + // Unregister callback + unregister() + + // Reset state + _bluetoothMicState.value = BluetoothMicState() + } catch (e: Exception) { + Timber.e(e, "Error releasing AudioManagerHelper") + } + } + + /** + * Updates the Bluetooth device state and notifies observers via StateFlow. + */ + private fun updateBluetoothDeviceState(defaultName: String = "Bluetooth Device") { + val connectedDevices = getAvailableCommunicationDevices().map { deviceInfo -> + val productName = deviceInfo.productName.toString().ifEmpty { defaultName } + BluetoothDeviceInfo( + id = deviceInfo.id, + productName = productName, + type = deviceInfo.type, + audioDeviceInfo = deviceInfo + ) + } + + val isAvailable = connectedDevices.isNotEmpty() + val deviceName = if (isAvailable) { + selectedBluetoothDevice?.productName ?: connectedDevices.firstOrNull()?.productName + } else { + null + } + + val isEnabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + isBluetoothEnabledApi31Plus() + } else { + isBluetoothScoActive + } + + // Validate selected device is still in connected devices + val validatedSelectedDevice = if (selectedBluetoothDevice != null) { + connectedDevices.find { it.id == selectedBluetoothDevice!!.id } + } else { + null + } + + // If selected device is no longer valid, clear it + if (selectedBluetoothDevice != null && validatedSelectedDevice == null) { + selectedBluetoothDevice = null + } + + _bluetoothMicState.value = BluetoothMicState( + isAvailable = isAvailable, + isEnabled = isEnabled, + deviceName = deviceName, + connectedDevices = connectedDevices, + selectedDevice = validatedSelectedDevice + ) + + Timber.d("Updated Bluetooth state: ${_bluetoothMicState.value}") + } + + /** + * Checks if Bluetooth routing is currently enabled on API 31+. + */ + @RequiresApi(Build.VERSION_CODES.S) + private fun isBluetoothEnabledApi31Plus(): Boolean { + return try { + val currentDevice = audioManager.communicationDevice + currentDevice != null && isBluetoothInputDevice(currentDevice) + } catch (e: Exception) { + Timber.e(e, "Error checking if Bluetooth is enabled") + false + } + } + + /** + * Checks if any Bluetooth audio input device is currently available. + */ + private fun hasBluetoothAudioInputDevice(): Boolean { + return getAvailableCommunicationDevices().isNotEmpty() + } + + /** + * Gets the first available Bluetooth audio input device on API 31+. + */ + @RequiresApi(Build.VERSION_CODES.S) + private fun getBluetoothAudioInputDevice(): AudioDeviceInfo? { + return getAvailableCommunicationDevices().firstOrNull() + } + + private fun getAvailableCommunicationDevices(): List { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + audioManager.availableCommunicationDevices.filter { + isBluetoothInputDevice(it) + } + } catch (e: Exception) { + Timber.e(e, "Error getting Bluetooth audio input device") + emptyList() + } + } else { + audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS).filter { device -> + isBluetoothInputDevice(device) + } + } + } + + /** + * Checks if the given audio device is a Bluetooth input device (microphone). + * + * @param device The audio device to check. + * @return true if the device is a Bluetooth input device with microphone capability. + */ + private fun isBluetoothInputDevice(device: AudioDeviceInfo): Boolean { +// This code commented out because of this logic is filtering out actual bluetooth headset MIC. +// // Must be an input source (has microphone) +// if (!device.isSource) { +// return false +// } + + // Check for Bluetooth device types + return when (device.type) { + AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> true + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> true + else -> { + // Check for BLE headset on API 31+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + device.type == AudioDeviceInfo.TYPE_BLE_HEADSET + } else { + false + } + } + } + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/util/Extensions.kt b/app/src/main/java/com/dimowner/audiorecorder/util/Extensions.kt index 7d69c3495..9a927db5a 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/util/Extensions.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/util/Extensions.kt @@ -3,6 +3,7 @@ package com.dimowner.audiorecorder.util import android.content.Context import android.content.res.Configuration import android.view.View +import kotlin.math.abs inline var View.isVisible: Boolean get() = visibility == View.VISIBLE @@ -18,3 +19,7 @@ fun isUsingNightModeResources(context: Context): Boolean { else -> false } } + +fun Double.equalsDelta(other: Double, delta: Double = 0.000001) = abs(this - other) < delta + +fun Float.equalsDelta(other: Float, delta: Float = 0.000001f) = abs(this - other) < delta diff --git a/app/src/main/java/com/dimowner/audiorecorder/util/FileUtil.java b/app/src/main/java/com/dimowner/audiorecorder/util/FileUtil.java index e30d369e9..a6eddfc85 100755 --- a/app/src/main/java/com/dimowner/audiorecorder/util/FileUtil.java +++ b/app/src/main/java/com/dimowner/audiorecorder/util/FileUtil.java @@ -594,8 +594,11 @@ private static boolean deleteRecursivelyDirs(File file) { ok &= deleteRecursivelyDirs(new File(file, children[i])); } } - if (ok && file.delete()) { - Log.d(LOG_TAG, "File deleted: " + file.getAbsolutePath()); + if (ok) { + ok = file.delete(); + if (ok) { + Log.d(LOG_TAG, "File deleted: " + file.getAbsolutePath()); + } } } return ok; diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/DefaultValues.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/DefaultValues.kt new file mode 100644 index 000000000..8157ad346 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/DefaultValues.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2 + +import com.dimowner.audiorecorder.v2.data.model.AudioSource +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.NameFormat +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import com.dimowner.audiorecorder.v2.data.model.SortOrder + +object DefaultValues { + const val isAppV2: Boolean = true + const val isDarkTheme: Boolean = false + const val isDynamicTheme: Boolean = false + const val isAskToRename: Boolean = true + const val isKeepScreenOn: Boolean = false + + val DefaultSampleRate: SampleRate = SampleRate.SR44100 + val DefaultBitRate: BitRate = BitRate.BR128 + val DefaultChannelCount: ChannelCount = ChannelCount.Stereo + val DefaultAudioSource: AudioSource = AudioSource.DEFAULT + + val DefaultNameFormat: NameFormat = NameFormat.Record + val DefaultRecordingFormat: RecordingFormat = RecordingFormat.M4a + val DefaultSortOrder: SortOrder = SortOrder.DateAsc + + val Default3GpBitRate: Int = 12000 //TODO: Find a better solution for 3Gp bitrate + val Default3GpSampleRate: SampleRate = SampleRate.SR16000 + val Default3GpChannelCount: ChannelCount = ChannelCount.Mono + + const val DELETED_RECORD_MARK = ".deleted" +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/AppComponents.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/AppComponents.kt new file mode 100644 index 000000000..9d51fdb66 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/AppComponents.kt @@ -0,0 +1,616 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.DeviceFontFamilyName +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.R + +@Composable +fun TextComponent( + textValue: String, + textSize: TextUnit, + fontWeight: FontWeight = FontWeight.Light +) { + Text( + text = textValue, + fontSize = textSize, + fontWeight = fontWeight, + ) +} + +@Preview(showBackground = true) +@Composable +fun TextComponentPreview() { + TextComponent(textValue = "Text to preview", textSize = 24.sp) +} + +@Composable +fun TextFieldComponent(onTextChanged: (name: String) -> Unit) { + + var currentValue by remember { + mutableStateOf("") + } + + val localFocusManager = LocalFocusManager.current + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = currentValue, + onValueChange = { + currentValue = it + onTextChanged(it) + }, + placeholder = { + Text(text = "Enter your name", fontSize = 18.sp) + }, + textStyle = TextStyle.Default.copy(fontSize = 24.sp), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions { + localFocusManager.clearFocus() + } + ) +} + +@Preview(showBackground = true) +@Composable +fun TextFieldComponentPreview() { + TextFieldComponent {} +} + +@Composable +fun AnimalCard(image: Int, selected: Boolean, onImageClicked: (animalName: String) -> Unit) { + val localFocusManager = LocalFocusManager.current + Card( + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .padding(16.dp) + .size(56.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .border( + width = 1.dp, + color = if (selected) Color.Green else Color.Transparent, + shape = RoundedCornerShape(8.dp), + ), + ) { + Image( + modifier = Modifier + .padding(16.dp) + .wrapContentWidth() + .wrapContentHeight() + .clickable { + val animalName = if (image == R.drawable.ic_audiotrack_64) "Cat" else "Dog" + onImageClicked(animalName) + localFocusManager.clearFocus() + }, + painter = painterResource(id = image), + contentDescription = "Animal image", + ) + } + } +} + +@Preview() +@Composable +fun AnimalCardPreview() { + AnimalCard(image = R.drawable.ic_color_lens, false) {} +} + +@Composable +fun InfoCard(animalSelected: String?) { + Card( + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .padding(16.dp), + elevation = CardDefaults.cardElevation() + ) { + Text( + modifier = Modifier.padding(18.dp, 24.dp), + text = if (animalSelected == "Dog") "Dog info" else if (animalSelected == "Cat") "Cat info" else "", + fontSize = 18.sp, + color = Color.Black, + fontWeight = FontWeight.Medium, + + ) + } +} + +@Preview() +@Composable +fun InfoCardPreview() { + InfoCard("This is information card to provide some info") +} + +@Composable +fun TitleBar( + title: String, + onBackPressed: () -> Unit, + actionButtonText: String = "", + onActionClick: (() -> Unit)? = null +) { +// val localFocusManager = LocalFocusManager.current + Row( + modifier = Modifier + .height(60.dp) + .fillMaxWidth() + .padding(0.dp, 4.dp, 0.dp, 0.dp) + .background(color = MaterialTheme.colorScheme.surface), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + IconButton( + onClick = onBackPressed, + modifier = Modifier + .padding(8.dp) + .align(Alignment.CenterVertically), + ) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Navigate back", + modifier = Modifier.size(24.dp) + ) + } + Text( + text = title, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 24.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + Spacer(modifier = Modifier.weight(1f)) + if (onActionClick != null) { + Button( + modifier = Modifier + .padding(8.dp) + .wrapContentSize(), + onClick = { onActionClick() } + ) { + Text( + text = actionButtonText, + fontSize = 16.sp, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun TitleBarPreview() { + TitleBar("Title bar", {}, "BtnText", {}) +} + +@Composable +fun InfoItem(label: String, value: String) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) { + Text( + modifier = Modifier + .padding(16.dp, 8.dp, 16.dp, 2.dp) + .wrapContentWidth() + .wrapContentHeight(), + text = label, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 18.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + Text( + modifier = Modifier + .padding(16.dp, 2.dp, 16.dp, 8.dp) + .wrapContentWidth() + .wrapContentHeight(), + text = value, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 22.sp, + fontWeight = FontWeight.Normal + ) + } +} + +@Preview(showBackground = true) +@Composable +fun InfoItemPreview() { + InfoItem("Label", "Value") +} + +@Composable +fun ConfirmationAlertDialog( + onDismissRequest: () -> Unit, + onConfirmation: () -> Unit, + dialogTitle: String, + dialogText: String, + painter: Painter, + positiveButton: String, + negativeButton: String, +) { + AlertDialog( + title = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier.padding(0.dp, 0.dp, 16.dp, 0.dp), + painter = painter, + contentDescription = dialogTitle + ) + Text(text = dialogTitle) + } + }, + text = { + Text(text = dialogText, fontSize = 18.sp) + }, + onDismissRequest = { + onDismissRequest() + }, + confirmButton = { + TextButton( + onClick = { + onConfirmation() + } + ) { + Text(positiveButton) + } + + }, + dismissButton = { + TextButton( + onClick = { + onDismissRequest() + } + ) { + Text(negativeButton) + } + } + ) +} + +@Composable +fun InfoAlertDialog( + onDismissRequest: () -> Unit, + onConfirmation: () -> Unit, + dialogTitle: String, + dialogText: AnnotatedString, + icon: ImageVector, + dismissButton: String, +) { + AlertDialog( + title = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier.padding(0.dp, 0.dp, 16.dp, 0.dp), + imageVector = icon, + contentDescription = dialogTitle + ) + Text(text = dialogTitle) + } + }, + text = { + Text( + text = dialogText, + style = TextStyle( + fontSize = 18.sp, + lineBreak = LineBreak.Heading, + fontWeight = FontWeight.Normal, + ) + ) + }, + onDismissRequest = { + onDismissRequest() + }, + confirmButton = { + TextButton( + onClick = { + onConfirmation() + } + ) { + Text(dismissButton) + } + }, + ) +} + +@Composable +fun RenameAlertDialog( + recordName: String, + onAcceptClick: (String) -> Unit, + onDismissClick: () -> Unit, + onDontAskAgain: (Boolean) -> Unit = {}, + showDontAskAgain: Boolean = false +) { + val currentValue = remember { mutableStateOf(recordName) } + val checkedState = remember { mutableStateOf(false) } + AlertDialog( + title = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier.padding(0.dp, 0.dp, 16.dp, 0.dp), + painter = painterResource(id = R.drawable.ic_pencil), + contentDescription = stringResource(id = R.string.record_name) + ) + Text(text = stringResource(id = R.string.record_name)) + } + }, + text = { + val localFocusManager = LocalFocusManager.current + Column { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = currentValue.value, + onValueChange = { + currentValue.value = it + }, + placeholder = { + Text(text = stringResource(id = R.string.rename), fontSize = 18.sp) + }, + textStyle = TextStyle.Default.copy(fontSize = 20.sp), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions { + localFocusManager.clearFocus() + } + ) + if (showDontAskAgain) { + Row { + Checkbox( + checked = checkedState.value, + onCheckedChange = { checkedState.value = it }, + ) + Text( + modifier = Modifier.align(Alignment.CenterVertically), + text = stringResource(id = R.string.dont_ask_again), + fontSize = 16.sp, + ) + } + } + } + }, + onDismissRequest = { + onDismissClick() + }, + confirmButton = { + TextButton( + onClick = { + onAcceptClick(currentValue.value) + if (showDontAskAgain) { + onDontAskAgain(checkedState.value) + } + } + ) { + Text(stringResource(id = R.string.btn_save)) + } + + }, + dismissButton = { + TextButton( + onClick = { + onDismissClick() + } + ) { + Text(stringResource(id = R.string.btn_cancel)) + } + } + ) +} + +@Preview(showBackground = true) +@Composable +fun RenameAlertDialogPreview() { + RenameAlertDialog("Record-14", {}, {}, {}, true) +} + +@Composable +fun DropDownMenuItem( + text: String, + iconRes: Int, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .clickable { onClick() }, + ) { + Icon( + modifier = Modifier + .padding(16.dp) + .wrapContentWidth() + .wrapContentHeight(), + painter = painterResource(id = iconRes), + contentDescription = text, + ) + Text( + modifier = Modifier + .padding(0.dp, 16.dp, 16.dp, 16.dp) + .wrapContentSize(), + text = text, + fontSize = 18.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +fun DropDownMenuItemPreview() { + DropDownMenuItem("Label", R.drawable.ic_palette_outline, {}) +} + +@Composable +fun RecordsDropDownMenu( + items: List>, + onItemClick: (T) -> Unit, + expanded: MutableState +) { + DropdownMenu( + modifier = Modifier.wrapContentSize(), + expanded = expanded.value, + onDismissRequest = { expanded.value = false } + ) { + items.forEach { item -> + DropdownMenuItem( + onClick = {}, + text = { + DropDownMenuItem( + text = stringResource(id = item.textResId), + iconRes = item.imageResId, + onClick = { + onItemClick(item.id) + expanded.value = false + } + ) + } + ) + } + } +} + +@Composable +fun DeleteDialog( + dialogText: String, + onAcceptClick: () -> Unit, + onDismissClick: () -> Unit, +) { + ConfirmationAlertDialog( + onDismissRequest = { onDismissClick() }, + onConfirmation = { onAcceptClick() }, + dialogTitle = stringResource(id = R.string.warning), + dialogText = dialogText, + painter = painterResource(id = R.drawable.ic_delete_forever), + positiveButton = stringResource(id = R.string.btn_yes), + negativeButton = stringResource(id = R.string.btn_no) + ) +} + +@Preview(showBackground = true) +@Composable +fun DeleteDialogPreview() { + DeleteDialog(stringResource(id = R.string.move_record_to_trash,"Record-14"), {}, {}) +} + +@Composable +fun SaveAsDialog( + dialogText: String, + onAcceptClick: () -> Unit, + onDismissClick: () -> Unit, +) { + ConfirmationAlertDialog( + onDismissRequest = { onDismissClick() }, + onConfirmation = { onAcceptClick() }, + dialogTitle = stringResource(id = R.string.save_as), + dialogText = dialogText, + painter = painterResource(id = R.drawable.ic_save_alt), + positiveButton = stringResource(id = R.string.btn_yes), + negativeButton = stringResource(id = R.string.btn_no) + ) +} + +@Preview(showBackground = true) +@Composable +fun SaveAsDialogPreview() { + SaveAsDialog( + dialogText = stringResource(id = R.string.record_name_will_be_copied_into_downloads, "Record-14"), + {}, {} + ) +} + +data class DropDownMenuItem( + val id: T, + val textResId: Int, + val imageResId: Int +) + diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/AppExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/AppExtensions.kt new file mode 100644 index 000000000..b6fb84d20 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/AppExtensions.kt @@ -0,0 +1,299 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app + +import android.content.Context +import android.content.res.Resources +import android.text.format.Formatter +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.dimowner.audiorecorder.AppConstants +import com.dimowner.audiorecorder.AppConstantsV2 +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.settings.convertToText +import com.dimowner.audiorecorder.v2.data.RecordsDataSource +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import java.util.Locale + +fun calculateScale( + mills: Long, + shortRecordDuration: Long = AppConstantsV2.SHORT_RECORD, + defaultWidthScale: Float = AppConstantsV2.DEFAULT_WIDTH_SCALE, +): Float { + return when { + mills >= shortRecordDuration -> { + defaultWidthScale + } + else -> { + mills * (defaultWidthScale / shortRecordDuration) + } + } +} + +@SuppressWarnings("MagicNumber") +fun calculateGridStep(durationMills: Long): Long { + var actualStepSec = (durationMills / 1000) / AppConstants.GRID_LINES_COUNT + var k = 1 + while (actualStepSec > 239) { + actualStepSec /= 2 + k *= 2 + } + //Ranges can be better optimised + val gridStep: Long = when (actualStepSec) { + in 0..2 -> 2000 + in 3..6 -> 5000 + in 7..14 -> 10000 + in 15..24 -> 20000 + in 25..44 -> 30000 + in 45..74 -> 60000 + in 75..104 -> 90000 + in 105..149 -> 120000 + in 150..209 -> 180000 + in 210..269 -> 240000 + in 270..329 -> 300000 + in 330..419 -> 360000 + in 420..539 -> 480000 + in 540..659 -> 600000 + in 660..809 -> 720000 + in 810..1049 -> 900000 + in 1050..1349 -> 1200000 + in 1350..1649 -> 1500000 + in 1650..2099 -> 1800000 + in 2100..2699 -> 2400000 + in 2700..3299 -> 3000000 + in 3300..3899 -> 3600000 + else -> 4200000 + } + return gridStep * k +} + +/** + * Readjust waveform amplitudes + * @param [IntArray] of int values where each element represents an amplitude + */ +@SuppressWarnings("MagicNumber") +fun adjustWaveformHeights(frameGains: IntArray): IntArray { + val numFrames = frameGains.size + + //Find the highest gain + var maxGain = 1.0f + for (i in 0 until numFrames) { + if (frameGains[i] > maxGain) { + maxGain = frameGains[i].toFloat() + } + } + // Make sure the range is no more than 0 - 255 + var scaleFactor = 1.0f + if (maxGain > 255.0) { + scaleFactor = 255 / maxGain + } + + // Build histogram of 256 bins and figure out the new scaled max + maxGain = 0.0f + val gainHist = IntArray(256) + for (i in 0 until numFrames) { + var smoothedGain = (frameGains[i] * scaleFactor).toInt() + if (smoothedGain < 0) smoothedGain = 0 + if (smoothedGain > 255) smoothedGain = 255 + if (smoothedGain > maxGain) maxGain = smoothedGain.toFloat() + gainHist[smoothedGain]++ + } + + // Re-calibrate the min to be 5% + var minGain = 0.0f + var sum = 0 + while (minGain < 255 && sum < numFrames / 20) { + sum += gainHist[minGain.toInt()] + minGain++ + } + + // Re-calibrate the max to be 99% + sum = 0 + while (maxGain > 2 && sum < numFrames / 100) { + sum += gainHist[maxGain.toInt()] + maxGain-- + } + + // Compute the heights + val heights = FloatArray(numFrames) + var range = maxGain - minGain + if (range <= 0) { + range = 1.0f + } + for (i in 0 until numFrames) { + var value = (frameGains[i] * scaleFactor - minGain) / range + if (value < 0.0) value = 0.0f + if (value > 1.0) value = 1.0f + heights[i] = value * value + } + val scale = AppConstantsV2.WAVEFORM_AMPLITUDE_MAX_VALUE + val waveformData = IntArray(numFrames) + for (i in 0 until numFrames) { + waveformData[i] = (heights[i] * scale).toInt() + } + //Array of int values where each value between 0 to WAVEFORM_AMPLITUDE_MAX_VALUE + return waveformData +} + +fun formatRecordingFormat( + formatStrings: Array, + recordingFormat: RecordingFormat?, +): String { + return recordingFormat?.convertToText(formatStrings) ?: "" +} + +fun formatSampleRate( + sampleRateStrings: Array, + sampleRate: SampleRate?, +): String { + return sampleRate?.convertToText(sampleRateStrings) ?: "" +} + +fun formatBitRate( + bitrateStrings: Array, + bitRate: BitRate?, +): String { + return bitRate?.convertToText(bitrateStrings) ?: "" +} + +fun formatChannelCount( + channelCountStrings: Array, + channelCount: ChannelCount?, +): String { + return channelCount?.convertToText(channelCountStrings) ?: "" +} + +fun recordingSettingsCombinedText( + recordingFormat: RecordingFormat?, + recordingFormatText: String, + sampleRateText: String, + bitRateText: String, + channelCountText: String +): String { + return when (recordingFormat) { + RecordingFormat.M4a -> { + "$recordingFormatText, $sampleRateText, $bitRateText, $channelCountText" + } + RecordingFormat.Wav, + RecordingFormat.ThreeGp -> { + "$recordingFormatText, $sampleRateText, $channelCountText" + } + else -> "" + } +} + +fun recordInfoCombinedShortText( + recordingFormat: String, + recordSizeText: String, + bitrateText: String, + sampleRateText: String, +): String { + return if (bitrateText.isNotEmpty()) { + "$recordSizeText, $recordingFormat, $bitrateText, $sampleRateText" + } else { + "$recordSizeText, $recordingFormat, $sampleRateText" + } +} + +fun Record.toInfoCombinedText(context: Context): String { + return recordInfoCombinedShortText( + recordingFormat = this.format, + recordSizeText = Formatter.formatShortFileSize(context, this.size), + bitrateText = if (this.bitrate > 0) { + context.getString(R.string.value_kbps, this.bitrate/1000) + } else "", + sampleRateText = context.getString( + R.string.value_khz, + this.sampleRate/1000 + ), + ) +} + +@Composable +fun ComposableLifecycle( + lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, + onEvent: (LifecycleOwner, Lifecycle.Event) -> Unit +) { + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { source, event -> + onEvent(source, event) + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } +} + +@Composable +fun viewModel.observeLifecycleEvents(lifecycle: Lifecycle) { + DisposableEffect(lifecycle) { + lifecycle.addObserver(this@observeLifecycleEvents) + onDispose { + lifecycle.removeObserver(this@observeLifecycleEvents) + } + } +} + +@SuppressWarnings("MagicNumber") +fun formatDuration( + resources: Resources, + durationMillis: Long, +): String { + val totalSeconds = durationMillis / 1000 + val years = (totalSeconds / (365 * 24 * 60 * 60)).toInt() + val days = ((totalSeconds % (365 * 24 * 60 * 60)) / (24 * 60 * 60)).toInt() + val hours = (totalSeconds % (24 * 60 * 60)) / (60 * 60) + val minutes = (totalSeconds % (60 * 60)) / 60 + val seconds = totalSeconds % 60 + + val formattedParts = mutableListOf() + + if (years > 0) formattedParts.add("$years${resources.getQuantityString(R.plurals.years, years)}") + if (days > 0) formattedParts.add("$days${resources.getQuantityString(R.plurals.days, days)}") + if (hours > 0) { + formattedParts.add(String.format(Locale.getDefault(),"%02dh:%02dm:%02ds", hours, minutes, seconds)) + } else { + formattedParts.add(String.format(Locale.getDefault(),"%02dm:%02ds", minutes, seconds)) + } + return formattedParts.joinToString(" ") +} + +/** + * Permanently deletes all records from the recycle bin that have exceeded the + * maximum retention duration defined by [AppConstants.RECORD_IN_TRASH_MAX_DURATION]. + * + * @receiver The [RecordsDataSource] instance. + */ +suspend fun RecordsDataSource.removeOutdatedTrashRecords() { + val currentTime = System.currentTimeMillis() + this.getMovedToRecycleRecords().forEach { removedRecord -> + if (currentTime > removedRecord.removed + AppConstants.RECORD_IN_TRASH_MAX_DURATION) { + this.deleteRecordAndFileForever(removedRecord.id) + } + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/ComposePlaygroundScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/ComposePlaygroundScreen.kt new file mode 100644 index 000000000..b9c50e5be --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/ComposePlaygroundScreen.kt @@ -0,0 +1,241 @@ +package com.dimowner.audiorecorder.v2.app + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.app.main.MainActivity +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import com.dimowner.audiorecorder.v2.app.info.RecordInfoState +import com.dimowner.audiorecorder.v2.app.settings.ChipItem +import com.dimowner.audiorecorder.v2.app.settings.SettingSelector +import com.google.gson.Gson +import timber.log.Timber + +@Composable +fun ComposePlaygroundScreen( + userInputViewModel: UserInputViewModel = viewModel(), + showDetailsScreen: (Pair) -> Unit, + showRecordInfoScreen: (String) -> Unit, + showSettingsScreen: () -> Unit, + showHomeScreen: () -> Unit, + showRecordsScreen: () -> Unit, + showWelcomeScreen: () -> Unit, + showDeletedRecordsScreen: () -> Unit, +) { + val context = LocalContext.current + + val recordInfo = RecordInfoState( + name = "name666", + format = "format777", + duration = 150000000, + size = 1500000, + location = "location888", + created = System.currentTimeMillis(), + sampleRate = 44000, + channelCount = 1, + bitrate = 240000, + ) + + Surface( + modifier = Modifier.fillMaxSize() + ) { + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .weight(weight = 1f, fill = false) + .padding(16.dp) + ) { + TextComponent(textValue = "Name", textSize = 18.sp) + Spacer(modifier = Modifier.size(10.dp)) + TextFieldComponent(onTextChanged = { + userInputViewModel.onEvent(UserDataUiEvents.UserNameEntered(it)) + }) + Spacer(modifier = Modifier.size(10.dp)) + TextComponent(textValue = "What do you like?", textSize = 18.sp) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + AnimalCard(image = R.drawable.ic_audiotrack_64, onImageClicked = { + userInputViewModel.onEvent(UserDataUiEvents.AnimalSelected(it)) + }, selected = userInputViewModel.uiState.value.animalSelected == "Cat") + AnimalCard(image = R.drawable.ic_color_lens, onImageClicked = { + userInputViewModel.onEvent(UserDataUiEvents.AnimalSelected(it)) + }, selected = userInputViewModel.uiState.value.animalSelected == "Dog") + } + Spacer(modifier = Modifier.weight(1f)) + if (userInputViewModel.isValidState()) { + Button(onClick = { + showDetailsScreen( + Pair( + userInputViewModel.uiState.value.nameEntered, + userInputViewModel.uiState.value.animalSelected + ) + ) + }) { + Text(text = "Go to details screen",) + } + } + Row { + Button(onClick = { + val json = Uri.encode(Gson().toJson(recordInfo)) + showRecordInfoScreen(json) + }) { + Text(text = "Record Info",) + } + Spacer(modifier = Modifier.width(16.dp)) + Button(onClick = { showSettingsScreen() }) { + Text(text = "Settings Screen",) + } + } + Row { + Button(onClick = { showHomeScreen() }) { + Text(text = "Home Screen",) + } + Spacer(modifier = Modifier.width(16.dp)) + Button(onClick = { showRecordsScreen() }) { + Text(text = "Records Screen",) + } + } + Row { + Button(onClick = { showWelcomeScreen() }) { + Text(text = "Welcome Screen",) + } + Spacer(modifier = Modifier.width(16.dp)) + Button(onClick = { showDeletedRecordsScreen() }) { + Text(text = "Deleted Records",) + } + } + + // Text variations + Text( + text = "Primary Text", + modifier = Modifier.padding(16.dp), + ) + Text( + text = "Secondary Text", + modifier = Modifier.padding(16.dp), + ) + Text( + text = "Error Text", + modifier = Modifier.padding(16.dp), + ) + + Spacer(modifier = Modifier.size(10.dp)) + + SettingSelector( + name = "Test Name", + chips = listOf( + ChipItem(id = 0, value = SampleRate.SR8000, name = "8000", false), + ChipItem(id = 1, value = SampleRate.SR16000, name = "16000", false), + ChipItem(id = 2, value = SampleRate.SR22500, name = "22500", true), + ChipItem(id = 3, value = SampleRate.SR32000, name = "32000", false), + ChipItem(id = 4, value = SampleRate.SR44100, name = "44100", false), + ChipItem(id = 5, value = SampleRate.SR48000, name = "48000", false), + ), + onSelect = { + Timber.v("onSelect = " + it.name) + }, + onClickInfo = { Timber.v("onClickInfo") } + ) + // Buttons with different states + Button( + onClick = { context.startActivity(Intent(context, MainActivity::class.java)) }, + colors = ButtonDefaults.buttonColors() + ) { + Text(text = "Audio Recorder",) + } + Button( + onClick = {}, + enabled = false, + colors = ButtonDefaults.buttonColors() + ) { + Text(text = "Disabled Button",) + } + Card(elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)) { + Text( + modifier = Modifier.padding(16.dp), + text = "Elevated Surface", + ) + } +// CircularProgressIndicator( +// modifier = Modifier.padding(16.dp) +// ) + LinearProgressIndicator( + progress = { 0.5f }, + modifier = Modifier.padding(16.dp), + ) + Slider( + value = 0.5f, + onValueChange = {}, + ) + Row(modifier = Modifier.fillMaxSize()) { + Switch( + checked = true, + onCheckedChange = {}, + enabled = true, + modifier = Modifier.padding(16.dp) + ) + Switch( + checked = false, + onCheckedChange = {}, + enabled = true, + modifier = Modifier.padding(16.dp) + ) + Switch( + checked = false, + onCheckedChange = {}, + enabled = false, + modifier = Modifier.padding(16.dp) + ) + } + HorizontalDivider( + modifier = Modifier.padding(16.dp) + ) + BottomAppBar( + content = { + Text( + text = "Bottom App Bar", + ) + } + ) + } + } + } +} + +@Preview +@Composable +fun ComposePlaygroundScreenPreview() { + ComposePlaygroundScreen(viewModel(), {}, {}, {}, {}, {}, {}, {}) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/ComposePreviewData.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/ComposePreviewData.kt new file mode 100644 index 000000000..73bdd05ae --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/ComposePreviewData.kt @@ -0,0 +1,641 @@ +package com.dimowner.audiorecorder.v2.app + +import com.dimowner.audiorecorder.AppConstantsV2 +import com.dimowner.audiorecorder.v2.app.components.WaveformState + +fun getTestWaveformData(progress: Long = 30000L): WaveformState { + return WaveformState( + widthScale = calculateScale( + mills = TEST_WAVEFORM_DATA_DURATION_MILLS, + defaultWidthScale = AppConstantsV2.DEFAULT_WIDTH_SCALE + ), + durationMills = TEST_WAVEFORM_DATA_DURATION_MILLS, + progressMills = progress, + waveformData = TEST_WAVEFORM_DATA, + durationSample = TEST_WAVEFORM_DATA.size, + gridStepMills = calculateGridStep(TEST_WAVEFORM_DATA_DURATION_MILLS), + ) +} + +const val TEST_WAVEFORM_DATA_DURATION_MILLS = 58728L + +@SuppressWarnings("MagicNumber") +val TEST_WAVEFORM_DATA = intArrayOf( + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 234, + 526, + 0, + 0, + 0, + 0, + 8424, + 4394, + 7514, + 23400, + 13754, + 10400, + 21118, + 12018, + 24986, + 6656, + 7514, + 10926, + 32767, + 24186, + 21118, + 16250, + 10400, + 7962, + 5850, + 4738, + 4394, + 26624, + 10926, + 5850, + 4738, + 5096, + 4062, + 11466, + 10926, + 14976, + 22626, + 21118, + 30056, + 21118, + 32767, + 23400, + 21866, + 21866, + 23400, + 27462, + 25798, + 24186, + 14976, + 18258, + 11466, + 15606, + 23400, + 29178, + 29178, + 26624, + 16906, + 12018, + 16906, + 12018, + 14358, + 7962, + 22626, + 22626, + 14358, + 6656, + 4738, + 3744, + 2866, + 4394, + 3744, + 4394, + 3146, + 2600, + 416, + 27462, + 28314, + 14976, + 14976, + 14976, + 29178, + 23400, + 24186, + 19662, + 28314, + 26624, + 13162, + 18258, + 18258, + 22626, + 30946, + 19662, + 9886, + 6246, + 5096, + 3744, + 3744, + 4394, + 24186, + 21118, + 7078, + 3146, + 1878, + 3438, + 1878, + 9386, + 21118, + 12584, + 14976, + 12584, + 17576, + 5850, + 29178, + 23400, + 4738, + 4062, + 26624, + 26624, + 18954, + 13754, + 14358, + 13162, + 13754, + 7962, + 17576, + 31850, + 13754, + 13162, + 13754, + 10400, + 8898, + 5850, + 5466, + 2866, + 23400, + 9386, + 7514, + 5850, + 7962, + 7514, + 5466, + 8424, + 6656, + 5850, + 4394, + 19662, + 3438, + 26624, + 22626, + 12018, + 7514, + 24986, + 24186, + 16250, + 30946, + 21118, + 11466, + 9886, + 12584, + 25798, + 30056, + 27462, + 17576, + 16906, + 20384, + 19662, + 18258, + 19662, + 9386, + 19662, + 10400, + 3744, + 3146, + 3744, + 8424, + 4062, + 7078, + 15606, + 22626, + 20384, + 24186, + 18258, + 27462, + 25798, + 12584, + 14358, + 18954, + 24986, + 24986, + 19662, + 20384, + 23400, + 15606, + 16906, + 17576, + 16906, + 13162, + 29178, + 8898, + 7514, + 4738, + 2600, + 2106, + 2866, + 3438, + 28314, + 22626, + 4394, + 1462, + 1098, + 2866, + 1878, + 4394, + 2600, + 2346, + 2346, + 5466, + 4738, + 526, + 29178, + 20384, + 19662, + 6656, + 28314, + 21866, + 20384, + 11466, + 21866, + 15606, + 18954, + 18954, + 16250, + 32767, + 22626, + 9886, + 18954, + 16906, + 13162, + 8898, + 6246, + 5466, + 30056, + 7962, + 5466, + 5850, + 23400, + 17576, + 29178, + 10400, + 14976, + 22626, + 19662, + 20384, + 14976, + 32767, + 13754, + 13162, + 13162, + 32767, + 20384, + 8898, + 7078, + 16250, + 18258, + 15606, + 13162, + 14358, + 29178, + 15606, + 5466, + 4394, + 2106, + 162, + 0, + 0, + 0, + 786, + 58, + 58, + 526, + 104, + 1462, + 526, + 786, + 26, + 0, + 0, + 0, + 0, + 29178, + 17576, + 24986, + 29178, + 28314, + 23400, + 20384, + 30056, + 27462, + 31850, + 32767, + 27462, + 32767, + 25798, + 32767, + 30056, + 29178, + 23400, + 22626, + 31850, + 14976, + 16906, + 30946, + 25798, + 27462, + 22626, + 15606, + 29178, + 13162, + 23400, + 21866, + 24186, + 21118, + 24186, + 28314, + 29178, + 30056, + 16250, + 18954, + 16906, + 29178, + 30056, + 27462, + 20384, + 29178, + 25798, + 12584, + 21118, + 20384, + 31850, + 21866, + 21866, + 26624, + 18954, + 14358, + 21866, + 24186, + 25798, + 27462, + 21118, + 22626, + 21118, + 24986, + 13754, + 13754, + 30056, + 22626, + 10400, + 27462, + 30056, + 24986, + 29178, + 20384, + 23400, + 28314, + 29178, + 29178, + 17576, + 20384, + 23400, + 27462, + 13162, + 24186, + 20384, + 31850, + 25798, + 25798, + 14976, + 24986, + 22626, + 24186, + 23400, + 30056, + 30946, + 17576, + 16250, + 13754, + 16250, + 24986, + 24986, + 17576, + 29178, + 20384, + 30056, + 19662, + 18258, + 24986, + 30056, + 18954, + 24186, + 30946, + 32767, + 32767, + 27462, + 30946, + 13162, + 24186, + 21866, + 31850, + 30056, + 24986, + 30946, + 26624, + 22626, + 21866, + 25798, + 28314, + 30946, + 32767, + 30056, + 30946, + 29178, + 23400, + 28314, + 30056, + 30946, + 26624, + 30946, + 29178, + 21118, + 29178, + 21866, + 29178, + 28314, + 21118, + 31850, + 32767, + 29178, + 31850, + 30946, + 31850, + 25798, + 26624, + 30946, + 32767, + 32767, + 30946, + 28314, + 28314, + 32767, + 30946, + 28314, + 28314, + 31850, + 30946, + 30946, + 30056, + 21866, + 18954, + 30056, + 19662, + 31850, + 29178, + 31850, + 22626, + 25798, + 28314, + 26624, + 29178, + 25798, + 28314, + 28314, + 29178, + 28314, + 27462, + 27462, + 25798, + 22626, + 18258, + 29178, + 23400, + 32767, + 21866, + 18954, + 24186, + 19662, + 14976, + 17576, + 23400, + 23400, + 24986, + 27462, + 26624, + 19662, + 25798, + 21866, + 30056, + 30056, + 30946, + 32767, + 18954, + 28314, + 10926, + 30056, + 24986, + 31850, + 25798, + 29178, + 27462, + 22626, + 24186, + 24986, + 25798, + 17576, + 28314, + 27462, + 29178, + 30056, + 20384, + 18258, + 21118, + 24986, + 24186, + 25798, + 29178, + 29178, + 27462, + 22626, + 16906, + 23400, + 21118, + 27462, + 26624, + 26624, + 31850, + 27462, + 23400, + 26624, + 21866, + 20384, + 25798, + 29178, + 20384, + 13754, + 9886, + 9886, + 10926, + 29178, + 25798, + 24986, + 22626, + 14358, + 1878, + 0, + 0, + 0, + 0, + 0, + 8898, + 2600, + 25798, + 9886, + 6656, + 7962, + 6246, + 6246, + 5096, + 5850, + 6656, + 5096, + 8424, + 8424, + 4062, + 4394, + 4394, + 5850, + 4394, + 9886, + 28314, + 8424, + 6246, + 58, + 6656, + 3438, + 0, + 12018, + 8898, + 7514, + 1878, + 1098, + 58, + 0, + 318, + 318, + 8424, + 12018, + 13162, + 7962, + 7514, + 7514, + 7514, + 6246, + 3146, + 18954, + 84 +) \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/HomeActivity.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/HomeActivity.kt new file mode 100644 index 000000000..5b4cac8f0 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/HomeActivity.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.lifecycleScope +import com.dimowner.audiorecorder.app.main.MainActivity +import com.dimowner.audiorecorder.v2.app.home.HomeViewModel +import com.dimowner.audiorecorder.v2.data.PrefsV2 +import com.dimowner.audiorecorder.v2.navigation.RecorderNavigationGraph +import com.dimowner.audiorecorder.v2.theme.AppTheme +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject + +@AndroidEntryPoint +class HomeActivity: ComponentActivity() { + + private val viewModel: HomeViewModel by viewModels() + + @Inject + lateinit var prefs: PrefsV2 + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + installSplashScreen() + setContent { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + AppTheme( + dynamicColors = prefs.isDynamicTheme, + darkTheme = prefs.isDarkTheme + ) { RecorderApp(lifecycleScope) } + } else { + AppTheme(darkTheme = prefs.isDarkTheme) { RecorderApp(lifecycleScope) } + } + } + } + + @Composable + fun RecorderApp( + coroutineScope: CoroutineScope + ) { + RecorderNavigationGraph(coroutineScope, viewModel, onSwitchToLegacyApp = { + val intent = Intent(this, MainActivity::class.java) + startActivity(intent) + finish() + }) + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/UserInputViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/UserInputViewModel.kt new file mode 100644 index 000000000..b1dbe454e --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/UserInputViewModel.kt @@ -0,0 +1,39 @@ +package com.dimowner.audiorecorder.v2.app + +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel + +class UserInputViewModel: ViewModel() { + + val uiState = mutableStateOf(UserInputScreenState()) + + fun onEvent(event: UserDataUiEvents) { + when (event) { + is UserDataUiEvents.UserNameEntered -> { + uiState.value = uiState.value.copy( + nameEntered = event.name + ) + } + is UserDataUiEvents.AnimalSelected -> { + uiState.value = uiState.value.copy( + animalSelected = event.animalValue + ) + } + } + + } + + fun isValidState(): Boolean { + return uiState.value.animalSelected.isNotEmpty() && uiState.value.nameEntered.isNotEmpty() + } +} + +data class UserInputScreenState( + val nameEntered: String = "", + val animalSelected: String = "" +) + +sealed class UserDataUiEvents { + data class UserNameEntered(val name: String): UserDataUiEvents() + data class AnimalSelected(val animalValue: String): UserDataUiEvents() +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/BluetoothAudioComponents.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/BluetoothAudioComponents.kt new file mode 100644 index 000000000..5660e31f3 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/BluetoothAudioComponents.kt @@ -0,0 +1,308 @@ +/* + * Copyright 2026 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.DeviceFontFamilyName +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.util.BluetoothDeviceInfo +import com.dimowner.audiorecorder.v2.data.model.AudioSource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BluetoothMicSelector( + connectedDevices: List, + selectedDevice: BluetoothDeviceInfo?, + isEnabled: Boolean, + onDeviceSelected: (BluetoothDeviceInfo?) -> Unit, + onToggleEnabled: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + val expanded = remember { mutableStateOf(false) } + val checkState = remember(isEnabled) { mutableStateOf(isEnabled) } + val isAvailable = connectedDevices.isNotEmpty() + + Row( + modifier = modifier + .wrapContentHeight() + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .padding(24.dp, 16.dp, 16.dp, 16.dp) + .wrapContentSize(), + painter = painterResource(id = R.drawable.ic_bluetooth), + contentDescription = stringResource(R.string.bluetooth_microphone_available), + tint = if (isAvailable) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + } + ) + + ExposedDropdownMenuBox( + expanded = expanded.value, + onExpandedChange = { + if (isAvailable) { + expanded.value = !expanded.value + } + }, + modifier = Modifier + .weight(1f) + .padding(0.dp, 12.dp, 0.dp, 12.dp) + ) { + TextField( + value = if (isAvailable) { + selectedDevice?.productName ?: connectedDevices.firstOrNull()?.productName ?: "" + } else { + "" + }, + onValueChange = {}, + readOnly = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded.value) + }, + colors = ExposedDropdownMenuDefaults.textFieldColors( + disabledTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ), + modifier = Modifier + .menuAnchor() + .fillMaxWidth(), + enabled = isAvailable, + textStyle = MaterialTheme.typography.bodyLarge.copy( + fontSize = 16.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Normal + ) + ) + ), + label = { + Text( + text = stringResource(R.string.bluetooth_microphone_available), + fontSize = 12.sp + ) + } + ) + + ExposedDropdownMenu( + expanded = expanded.value, + onDismissRequest = { expanded.value = false } + ) { + connectedDevices.forEach { device -> + DropdownMenuItem( + text = { + Text( + text = device.productName, + fontSize = 16.sp + ) + }, + onClick = { + onDeviceSelected(device) + expanded.value = false + } + ) + } + } + } + + Switch( + checked = checkState.value, + onCheckedChange = { + checkState.value = it + onToggleEnabled(it) + }, + enabled = isAvailable, + modifier = Modifier.padding(8.dp) + ) + } +} + +@Preview(showBackground = true) +@Composable +fun BluetoothMicSelectorUnavailablePreview() { + BluetoothMicSelector( + connectedDevices = emptyList(), + selectedDevice = null, + isEnabled = false, + onDeviceSelected = {}, + onToggleEnabled = {}, + ) +} + +@Composable +fun AudioSourceSelector( + selectedSource: AudioSource, + options: List, + onSourceSelected: (AudioSource) -> Unit, + onInfoClick: () -> Unit, + modifier: Modifier = Modifier +) { + val expanded = remember { mutableStateOf(false) } + + Column( + modifier = modifier + .wrapContentSize(Alignment.TopStart) + ) { + // The DropdownMenu composable + DropdownMenu( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + expanded = expanded.value, + onDismissRequest = { expanded.value = false } + ) { + options.forEach { audioSource -> + DropdownMenuItem( + onClick = { + onSourceSelected(audioSource) + expanded.value = false + }, + text = { + Text( + text = getAudioSourceDisplayName(audioSource), + fontSize = 20.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + } + ) + } + } + + val text = getAudioSourceDisplayName(selectedSource) + Row( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .clickable { expanded.value = !expanded.value }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .padding(24.dp, 16.dp, 16.dp, 16.dp) + .wrapContentSize(), + painter = painterResource(id = R.drawable.ic_audiotrack), + contentDescription = stringResource(R.string.audio_source), + ) + Column( + modifier = Modifier + .padding(0.dp, 12.dp) + .weight(1f) + ) { + Text( + text = stringResource(R.string.audio_source), + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Bold + ) + ), + ) + Text( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + text = text, + fontSize = 20.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + } + Icon( + modifier = Modifier + .wrapContentSize() + .padding(0.dp, 0.dp, 8.dp, 0.dp), + painter = painterResource(id = R.drawable.ic_arrow_down), + contentDescription = text, + ) + IconButton( + onClick = onInfoClick, + modifier = Modifier + .align(Alignment.CenterVertically), + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(id = R.drawable.ic_info), + contentDescription = "", + ) + } + } + } +} + +@Composable +private fun getAudioSourceDisplayName(audioSource: AudioSource): String { + return when (audioSource) { + AudioSource.DEFAULT -> stringResource(R.string.audio_source_default) + AudioSource.MIC -> stringResource(R.string.audio_source_mic) + AudioSource.VOICE_COMMUNICATION -> stringResource(R.string.audio_source_voice_communication) + AudioSource.UNPROCESSED -> stringResource(R.string.audio_source_unprocessed) + } +} + +@Preview(showBackground = true) +@Composable +fun AudioSourceSelectorPreview() { + AudioSourceSelector( + selectedSource = AudioSource.MIC, + options = AudioSource.entries, + onSourceSelected = {}, + onInfoClick = {} + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/ComposeExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/ComposeExtensions.kt new file mode 100644 index 000000000..07411761a --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/ComposeExtensions.kt @@ -0,0 +1,56 @@ +package com.dimowner.audiorecorder.v2.app.components + +import androidx.compose.foundation.clickable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed + +/** + * Wraps an [onClick] lambda with another one that supports debounce clicks. + * The default debounce time is 300ms. + * + * @param debounceTimeMillis The minimum time interval (in milliseconds) required between click + * executions. Defaults to 300ms. + * @param onClick The action to be executed when a valid (non-debounced) click occurs. + * @return Debounced lambda onClick + */ +@Composable +fun onDebounceClick( + onClick: () -> Unit, + debounceTimeMillis: Long = 300L, +): () -> Unit { + var lastClickTimeMillis: Long by remember { mutableLongStateOf(0L) } + return { + val currentTimeMillis = System.currentTimeMillis() + + // Check if enough time has passed since the last click + if (currentTimeMillis - lastClickTimeMillis >= debounceTimeMillis) { + onClick() + lastClickTimeMillis = currentTimeMillis + } else { + //Do nothing + } + } +} + +/** + * A [Modifier] extension function that applies a debounced click listener to any Composable. + * + * @param debounceTimeMillis The minimum time interval (in milliseconds) required between click + * executions. Defaults to 300ms. + * @param onClick The action to be executed when a valid (non-debounced) click occurs. + * @return A [Modifier] that makes the Composable element clickable with debouncing logic. + */ +fun Modifier.onDebounceClickable( + debounceTimeMillis: Long = 300L, + onClick: () -> Unit +): Modifier { + return this.composed { + val clickable = onDebounceClick(debounceTimeMillis = debounceTimeMillis, onClick = { onClick() }) + this.clickable { clickable() } + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/RecordPlaybackPanel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/RecordPlaybackPanel.kt new file mode 100644 index 000000000..b72e9fd29 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/RecordPlaybackPanel.kt @@ -0,0 +1,147 @@ +package com.dimowner.audiorecorder.v2.app.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.v2.app.getTestWaveformData +import com.dimowner.audiorecorder.v2.app.home.HomeScreenState +import com.dimowner.audiorecorder.v2.app.home.LegacySlider +import com.dimowner.audiorecorder.v2.app.home.PlayPanel + +@Composable +internal fun RecordPlaybackPanel( + modifier: Modifier, + uiState: HomeScreenState, + onProgressChange: (Float) -> Unit, + onSeekStart: () -> Unit, + onSeekProgress: (Long) -> Unit, + onSeekEnd: (Long) -> Unit, + onPlayClick: () -> Unit, + onStopClick: () -> Unit, + onPauseClick: () -> Unit, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + modifier = Modifier + .wrapContentSize().padding(12.dp), + textAlign = TextAlign.Center, + text = uiState.time, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + WaveformComposeView( + modifier = Modifier.fillMaxWidth().height(48.dp), + state = uiState.waveformState, + showTimeline = false, + onSeekStart = { + onSeekStart() + }, + onSeekProgress = { mills -> + onSeekProgress(mills) + }, + onSeekEnd = { mills -> + onSeekEnd(mills) + } + ) + Row( + modifier = Modifier.padding(0.dp, 8.dp, 0.dp, 0.dp), + verticalAlignment = Alignment.Bottom, + ) { + Text( + modifier = Modifier + .wrapContentSize() + .padding(8.dp, 0.dp), + textAlign = TextAlign.Start, + text = uiState.startTime, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + ) + Text( + modifier = Modifier + .wrapContentHeight().weight(1f) + .padding(8.dp, 6.dp, 8.dp, 0.dp), + textAlign = TextAlign.Center, + text = uiState.recordName, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 18.sp, + fontWeight = FontWeight.Normal + ) + Text( + modifier = Modifier + .wrapContentSize() + .padding(8.dp, 0.dp), + textAlign = TextAlign.Start, + text = uiState.endTime, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Normal + ) + } + LegacySlider( + progress = uiState.progress, + onProgressChange = onProgressChange + ) + PlayPanel( + modifier = Modifier.wrapContentHeight().fillMaxWidth(), + showPause = uiState.showPause, + showStop = uiState.showStop, + onPlayClick = { onPlayClick() }, + onStopClick = { onStopClick() }, + onPauseClick = { onPauseClick() } + ) + Spacer(modifier = Modifier.height(24.dp)) + } +} + +@Preview +@Composable +fun PlaybackPanelPreview() { + Surface( + modifier = Modifier.fillMaxSize() + ) { + RecordPlaybackPanel( + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + uiState = HomeScreenState( + waveformState = getTestWaveformData(), + startTime = "00:00", + endTime = "3:42", + time = "1:51", + recordName = "Test Record Name", + recordInfo = "1.5 MB, mp4, 192 kbps, 48 kHz", + isContextMenuAvailable = true, + isStopRecordingButtonAvailable = true, + ), + onProgressChange = {}, + onSeekStart = {}, + onSeekProgress = {}, + onSeekEnd = {}, + onPlayClick = {}, + onStopClick = {}, + onPauseClick = {} + ) + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/TouchPanel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/TouchPanel.kt new file mode 100644 index 000000000..106f9c1aa --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/TouchPanel.kt @@ -0,0 +1,163 @@ +package com.dimowner.audiorecorder.v2.app.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Card +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.dimowner.audiorecorder.util.equalsDelta +import com.dimowner.audiorecorder.v2.app.home.HomeScreenState +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.absoluteValue +import kotlin.math.atan +import kotlin.math.roundToInt + +private const val ANIMATION_DURATION = 500 +private const val MAX_MOVE = 250 +private const val PLAY_PANEL_HEIGHT_DP = 300 + +@Composable +fun TouchPanel( + showRecordPlaybackPanel: Boolean, + uiHomeState: HomeScreenState, + onProgressChange: (Float) -> Unit, + onSeekStart: () -> Unit, + onSeekProgress: (Long) -> Unit, + onSeekEnd: (Long) -> Unit, + onPlayClick: () -> Unit, + onStopClick: () -> Unit, + onPauseClick: () -> Unit, +) { + val density = LocalDensity.current + // State to keep track of the Card position + val offsetY = remember { mutableFloatStateOf(0f) } + val maxMove = with(density) { MAX_MOVE.dp.toPx() } + val k = (maxMove / (Math.PI / 2f)).toFloat() + + val startY = with(density) { 12.dp.toPx() } + + var cumulativeDrag = remember { 0f } + val animatableY = remember { Animatable(startY) } + + // Get a CoroutineScope tied to the Composable + val coroutineScope = rememberCoroutineScope() + + // Define a threshold for Y coordinate movement + val playPanelHeight = remember { mutableFloatStateOf(with(density) { PLAY_PANEL_HEIGHT_DP.dp.toPx() }) } + + // Modifier to make the text draggable + val modifier = Modifier + .offset { IntOffset(0, animatableY.value.roundToInt()) } + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { + offsetY.floatValue = startY + cumulativeDrag = startY + }, + onDragEnd = { + // Animate back to start position + if (offsetY.floatValue.absoluteValue > playPanelHeight.floatValue * 0.5) { + coroutineScope.launch { + animatableY.animateTo( +// TODO:Fix constants!! + playPanelHeight.floatValue * 1.5f, + animationSpec = tween(durationMillis = ANIMATION_DURATION) + ) { + val height = playPanelHeight.floatValue * 1.5f + + if (animatableY.value.equalsDelta(height)) { + coroutineScope.launch { + delay(600L) + animatableY.snapTo(startY) + } + } + } + offsetY.floatValue = startY + onStopClick() + } + } else { + coroutineScope.launch { + animatableY.animateTo( + startY, + animationSpec = tween(durationMillis = ANIMATION_DURATION) + ) + } + } + }, + onDragCancel = { + if (offsetY.floatValue.absoluteValue > playPanelHeight.floatValue * 0.5) { + coroutineScope.launch { + animatableY.animateTo( + playPanelHeight.floatValue * 1.5f, + animationSpec = tween(durationMillis = ANIMATION_DURATION) + ) + offsetY.floatValue = startY + onStopClick() + } + } else { + // Animate back to start position + coroutineScope.launch { + animatableY.animateTo( + startY, + animationSpec = tween(durationMillis = ANIMATION_DURATION) + ) + } + } + }, + onDrag = { change, dragAmount -> + change.consume() + cumulativeDrag += dragAmount.y + offsetY.floatValue = cumulativeDrag + offsetY.floatValue = k * atan(offsetY.floatValue / k) + coroutineScope.launch { + animatableY.snapTo(offsetY.floatValue) + } + } + ) + } + + AnimatedVisibility( + visible = showRecordPlaybackPanel, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }) + ) { + Card( + modifier = modifier + .wrapContentSize() + .onSizeChanged { + playPanelHeight.floatValue = it.height.toFloat() + }, + ) { + RecordPlaybackPanel( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + uiState = uiHomeState, + onProgressChange = onProgressChange, + onSeekStart = onSeekStart, + onSeekProgress = onSeekProgress, + onSeekEnd = onSeekEnd, + onPlayClick = onPlayClick, + onStopClick = onStopClick, + onPauseClick = onPauseClick, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/WaveformComposeView.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/WaveformComposeView.kt new file mode 100644 index 000000000..75af2ff7d --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/WaveformComposeView.kt @@ -0,0 +1,429 @@ +package com.dimowner.audiorecorder.v2.app.components + +import android.graphics.Paint +import android.graphics.Typeface +import android.text.TextPaint +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import com.dimowner.audiorecorder.AppConstantsV2 +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.util.AndroidUtils +import com.dimowner.audiorecorder.util.TimeUtils +import com.dimowner.audiorecorder.v2.app.TEST_WAVEFORM_DATA +import com.dimowner.audiorecorder.v2.app.TEST_WAVEFORM_DATA_DURATION_MILLS +import com.dimowner.audiorecorder.v2.app.getTestWaveformData + +private val GIRD_SUBLINE_HEIGHT: Float = AndroidUtils.dpToPx(12) +private val PADD: Float = AndroidUtils.dpToPx(6) + +@Composable +fun WaveformComposeView( + modifier: Modifier, + state: WaveformState, + showTimeline: Boolean, + onSeekStart: () -> Unit, + onSeekEnd: (mills: Long) -> Unit, + onSeekProgress: (mills: Long) -> Unit +) { + val context = LocalContext.current + val density = LocalDensity.current + val viewState = remember { + mutableStateOf(WaveformViewState(drawLinesArray = floatArrayOf())) + } + val waveformColor = MaterialTheme.colorScheme.primary.toArgb() + val gridColor = MaterialTheme.colorScheme.secondary.toArgb() + val lineColor = MaterialTheme.colorScheme.inverseSurface.toArgb() + val textColor = MaterialTheme.colorScheme.onSurfaceVariant.toArgb() + + val paintState = remember { + mutableStateOf( + PaintState( + waveformPaint = Paint().apply { + style = Paint.Style.STROKE + strokeWidth = 1.3f + isAntiAlias = true + alpha = 255 + color = waveformColor + }, + linePaint = Paint().apply { + style = Paint.Style.STROKE + strokeWidth = AndroidUtils.dpToPx(1.5f) + isAntiAlias = true + color = lineColor + }, + gridPaint = Paint().apply { + style = Paint.Style.STROKE + color = gridColor + strokeWidth = AndroidUtils.dpToPx(1) / 2 + }, + scrubberPaint = Paint().apply { + style = Paint.Style.STROKE + strokeWidth = AndroidUtils.dpToPx(2f) + isAntiAlias = false + color = ContextCompat.getColor(context, R.color.md_yellow_A700) + }, + textPaint = Paint().apply { + style = Paint.Style.STROKE + strokeWidth = AndroidUtils.dpToPx(1f) + isAntiAlias = true + textAlign = Paint.Align.CENTER + color = textColor + typeface = Typeface.create("sans-serif-light", Typeface.NORMAL) + textSize = viewState.value.textHeight + }, + ) + ) + } + + Canvas(modifier = modifier + .onSizeChanged { + val durationPx = it.width * state.widthScale + val millsPerPx = state.durationMills / durationPx + val pxPerMill = durationPx / state.durationMills + val pxPerSample = durationPx / state.durationSample + val samplePerPx = state.durationSample / durationPx + val textHeight = with(density) { 14.sp.toPx() } + val waveformShiftPx = updateShift( + viewState.value, it, + -(state.progressMills * pxPerMill).toInt()+it.width/2 + ) + + viewState.value = viewState.value.copy( + textIndent = if (showTimeline) textHeight + PADD else 0f, + waveformShiftPx = waveformShiftPx, + durationPx = durationPx, + millsPerPx = millsPerPx, + pxPerMill = pxPerMill, + pxPerSample = pxPerSample, + samplePerPx = samplePerPx, + drawLinesArray = FloatArray(it.width * 4), + textHeight = textHeight + ) + } + .pointerInput(Unit) { + if (!state.isRecording) { + detectDragGestures( + onDragStart = { + onSeekStart() + }, + onDrag = { change, dragAmount -> + val shift = updateShift( + viewState.value, size, + (viewState.value.waveformShiftPx + dragAmount.x).toInt() + ) + val half = size.width / 2 + viewState.value = viewState.value.copy( + waveformShiftPx = shift + ) + onSeekProgress(((-shift + half) * viewState.value.millsPerPx).toLong()) + }, + onDragEnd = { + val shift = viewState.value.waveformShiftPx.toInt() + val half = size.width / 2 + onSeekEnd(((-shift + half) * viewState.value.millsPerPx).toLong()) + }, + ) + } + } + ) { + drawIntoCanvas { canvas -> + drawGrid(canvas, size, viewState.value, state, showTimeline, paintState.value) + drawStartAndEnd(canvas, size, viewState.value, state, paintState.value) + drawWaveform(canvas, size, viewState.value, state, paintState.value) + //Draw scrubber + canvas.nativeCanvas.drawLine( + size.width / 2f, + 0f, + size.width / 2f, + size.height, + paintState.value.scrubberPaint + ) + } + } +} + +private fun drawStartAndEnd( + canvas: Canvas, + size: Size, + viewState: WaveformViewState, + state: WaveformState, + paintState: PaintState +) { + //Draw waveform start indication + canvas.nativeCanvas.drawLine( + viewState.waveformShiftPx, + viewState.textIndent, + viewState.waveformShiftPx, + size.height - viewState.textIndent, + paintState.linePaint + ) + //Draw waveform end indication + canvas.nativeCanvas.drawLine( + viewState.waveformShiftPx + state.waveformData.size * viewState.pxPerSample, + viewState.textIndent, + viewState.waveformShiftPx + state.waveformData.size * viewState.pxPerSample, + size.height - viewState.textIndent, + paintState.linePaint + ) +} + +private fun drawGrid( + canvas: Canvas, + size: Size, + viewState: WaveformViewState, + state: WaveformState, + showTimeline: Boolean, + paintState: PaintState +) { + val subStepPx = (state.gridStepMills / 2) * viewState.pxPerMill + val halfWidthMills = (size.width / 2) * viewState.millsPerPx + val gridEndMills = state.durationMills + halfWidthMills.toInt() + state.gridStepMills + val halfScreenStepCount = (halfWidthMills/state.gridStepMills).toInt() + + for (indexMills in -halfScreenStepCount*state.gridStepMills until gridEndMills step state.gridStepMills) { + val sampleIndexPx = indexMills * viewState.pxPerMill + val xPos = (viewState.waveformShiftPx + sampleIndexPx) + if (xPos >= -state.gridStepMills && xPos <= size.width + state.gridStepMills) { + //Draw grid lines + //Draw main grid line + canvas.nativeCanvas.drawLine( + xPos, + viewState.textIndent, + xPos, + size.height - viewState.textIndent, + paintState.gridPaint + ) + val xSubPos = xPos + subStepPx + //Draw grid top sub-line + canvas.nativeCanvas.drawLine( + xSubPos, + viewState.textIndent, + xSubPos, + GIRD_SUBLINE_HEIGHT + viewState.textIndent, + paintState.gridPaint + ) + //Draw grid bottom sub-line + canvas.nativeCanvas.drawLine( + xSubPos, + size.height - GIRD_SUBLINE_HEIGHT - viewState.textIndent, + xSubPos, + size.height - viewState.textIndent, + paintState.gridPaint + ) + + if (showTimeline) { + //Draw timeline texts + if (indexMills >= 0) { + val text = TimeUtils.formatTimeIntervalHourMin(indexMills) + //Bottom timeline text + canvas.nativeCanvas.drawText(text, xPos, size.height - PADD, paintState.textPaint) + //Top timeline text + canvas.nativeCanvas.drawText(text, xPos, viewState.textHeight, paintState.textPaint) + } + } + } + } +} + +private fun drawWaveform( + canvas: Canvas, + size: Size, + viewState: WaveformViewState, + state: WaveformState, + paintState: PaintState +) { + if (state.waveformData.isNotEmpty()) { + for (i in viewState.drawLinesArray.indices) { + viewState.drawLinesArray[i] = 0f + } + val half = size.height / 2 + val textIndent = viewState.textIndent + var step = 0 + for (index in 0 until viewState.durationPx.toInt()) { + var sampleIndex = (index * viewState.samplePerPx).toInt() + if (sampleIndex >= state.waveformData.size) { + sampleIndex = state.waveformData.size - 1 + } + val xPos = viewState.waveformShiftPx + index + if (xPos >= 0 && xPos <= size.width && step + 3 < viewState.drawLinesArray.size) { + viewState.drawLinesArray[step] = xPos + viewState.drawLinesArray[step + 1] = (half + state.waveformData[sampleIndex]*(half-textIndent)/AppConstantsV2.WAVEFORM_AMPLITUDE_MAX_VALUE + 1) + viewState.drawLinesArray[step + 2] = xPos + viewState.drawLinesArray[step + 3] = (half - state.waveformData[sampleIndex]*(half-textIndent)/AppConstantsV2.WAVEFORM_AMPLITUDE_MAX_VALUE - 1) + step += 4 + } + } + canvas.nativeCanvas.drawLines(viewState.drawLinesArray, 0, + viewState.drawLinesArray.size, paintState.waveformPaint) + } +} + +private fun updateShift( + viewState: WaveformViewState, + size: IntSize, + px: Int +): Float { + var shift = px.toFloat() + val half = size.width/2 + if (shift <= -viewState.durationPx+half) { + shift = -viewState.durationPx+half + } + if (shift > half) { + shift = half.toFloat() + } + return shift +} + +@Preview(showBackground = true) +@Composable +fun WaveformComposeViewPreview() { + WaveformComposeView( + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + state = getTestWaveformData(), + showTimeline = true, + onSeekStart = {}, + onSeekProgress = { mills -> + }, + onSeekEnd = { mills -> + } + ) +} + +@Preview(showBackground = true) +@Composable +fun WaveformComposeViewRecordingPreview() { + val scale = TEST_WAVEFORM_DATA_DURATION_MILLS * (AppConstantsV2.DEFAULT_WIDTH_SCALE / AppConstantsV2.SHORT_RECORD) + WaveformComposeView( + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + state = WaveformState( + widthScale = scale, + progressMills = TEST_WAVEFORM_DATA_DURATION_MILLS, + durationMills = TEST_WAVEFORM_DATA_DURATION_MILLS, + waveformData = TEST_WAVEFORM_DATA, + durationSample = TEST_WAVEFORM_DATA.size, + gridStepMills = 2000 + ), + showTimeline = true, + onSeekStart = {}, + onSeekProgress = { mills -> + }, + onSeekEnd = { mills -> + } + ) +} + +data class PaintState( + val waveformPaint: Paint = Paint(), + val linePaint: Paint = Paint(), + val gridPaint: Paint = Paint(), + val scrubberPaint: Paint = Paint(), + val textPaint: Paint = TextPaint(), +) + +data class WaveformViewState( + val waveformShiftPx: Float = 0F, + val textHeight: Float = AndroidUtils.dpToPx(14), + val textIndent: Float = textHeight + PADD, + + val drawLinesArray: FloatArray, + val durationPx: Float = 0F, + val millsPerPx: Float = 0F, + val pxPerMill: Float = 0F, + val pxPerSample: Float = 0F, + val samplePerPx: Float = 0F, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is WaveformViewState) return false + + if (waveformShiftPx != other.waveformShiftPx) return false + if (textHeight != other.textHeight) return false + if (textIndent != other.textIndent) return false + if (!drawLinesArray.contentEquals(other.drawLinesArray)) return false + if (durationPx != other.durationPx) return false + if (millsPerPx != other.millsPerPx) return false + if (pxPerMill != other.pxPerMill) return false + if (pxPerSample != other.pxPerSample) return false + if (samplePerPx != other.samplePerPx) return false + + return true + } + + override fun hashCode(): Int { + var result = waveformShiftPx.hashCode() + result = 31 * result + textHeight.hashCode() + result = 31 * result + textIndent.hashCode() + result = 31 * result + drawLinesArray.contentHashCode() + result = 31 * result + durationPx.hashCode() + result = 31 * result + millsPerPx.hashCode() + result = 31 * result + pxPerMill.hashCode() + result = 31 * result + pxPerSample.hashCode() + result = 31 * result + samplePerPx.hashCode() + return result + } +} + +data class WaveformState( + val durationMills: Long = 0L, + val progressMills: Long = 0L, + /** Waveform data where 1 element is a sample and value of the element is amplitude (value between 0-1000). */ + val waveformData: IntArray = intArrayOf(), + /** If true, view in Recording mode, otherwise view in Playback mode. Playback mode by default. */ + val isRecording: Boolean = false, + + /** 1 means that waveform will take whole view width. 2 means that waveform will take double view width to draw. */ + val widthScale: Float = 1.5f, + val durationSample: Int = 0, + val gridStepMills: Long = 4000, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is WaveformState) return false + + if (durationMills != other.durationMills) return false + if (progressMills != other.progressMills) return false + if (!waveformData.contentEquals(other.waveformData)) return false + if (widthScale != other.widthScale) return false + if (isRecording != other.isRecording) return false + if (durationSample != other.durationSample) return false + if (gridStepMills != other.gridStepMills) return false + + return true + } + + override fun hashCode(): Int { + var result = durationMills.hashCode() + result = 31 * result + progressMills.hashCode() + result = 31 * result + waveformData.contentHashCode() + result = 31 * result + widthScale.hashCode() + result = 31 * result + isRecording.hashCode() + result = 31 * result + durationSample + result = 31 * result + gridStepMills.hashCode() + return result + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/deleted/DeletedRecordsScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/deleted/DeletedRecordsScreen.kt new file mode 100644 index 000000000..98890628b --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/deleted/DeletedRecordsScreen.kt @@ -0,0 +1,170 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.deleted + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.ConfirmationAlertDialog +import com.dimowner.audiorecorder.v2.app.TitleBar +import com.dimowner.audiorecorder.v2.app.deleted.widget.DeletedRecordsListItemWidget +import com.google.gson.Gson +import timber.log.Timber + +@Composable +internal fun DeletedRecordsScreen( + onPopBackStack: () -> Unit, + showRecordInfoScreen: (String) -> Unit, + uiState: DeletedRecordsScreenState, + event: DeletedRecordsScreenEvent?, + onAction: (DeletedRecordsScreenAction) -> Unit, +) { + + val showDeleteAllDialog = remember { mutableStateOf(false) } + + LaunchedEffect(key1 = event) { + when (event) { + is DeletedRecordsScreenEvent.RecordInformationEvent -> { + val json = Uri.encode(Gson().toJson(event.recordInfo)) + Timber.v("ON EVENT: ShareRecord json = $json") + showRecordInfoScreen(json) + } + + else -> { + Timber.v("ON EVENT: Unknown") + //Do nothing + } + } + } + + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + ) { innerPadding -> + Surface( + modifier = Modifier.fillMaxSize().padding(innerPadding) + ) { + Column(modifier = Modifier.fillMaxSize()) { + TitleBar( + title = stringResource(id = R.string.trash), + onBackPressed = { onPopBackStack() }, + actionButtonText = stringResource(id = R.string.delete_all2), + onActionClick = if (uiState.records.isNotEmpty()) { + { showDeleteAllDialog.value = true } + } else null + ) + Text( + modifier = Modifier + .padding(16.dp, 8.dp) + .wrapContentSize(), + text = stringResource(id = R.string.trash_info), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Normal + ) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(color = MaterialTheme.colorScheme.inverseOnSurface) + ) + LazyColumn( + modifier = Modifier.fillMaxWidth(), + ) { + items(uiState.records) { record -> + DeletedRecordsListItemWidget( + name = record.name, + details = record.details, + onClickItem = { + onAction(DeletedRecordsScreenAction.ShowRecordInfo(record.recordId)) + }, + onClickRestore = { + onAction(DeletedRecordsScreenAction.RestoreRecord(record.recordId)) + }, + onClickDelete = { + onAction(DeletedRecordsScreenAction.DeleteForeverRecord(record.recordId)) + }, + ) + } + } + } + if (showDeleteAllDialog.value) { + ConfirmationAlertDialog( + onDismissRequest = { showDeleteAllDialog.value = false }, + onConfirmation = { + onAction(DeletedRecordsScreenAction.DeleteAllRecordsFromRecycle) + showDeleteAllDialog.value = false + }, + dialogTitle = stringResource(id = R.string.warning), + dialogText = stringResource(id = R.string.delete_all_records), + painter = painterResource(id = R.drawable.ic_delete_forever), + positiveButton = stringResource(id = R.string.btn_yes), + negativeButton = stringResource(id = R.string.btn_no) + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun DeletedRecordsScreenPreview() { + DeletedRecordsScreen({}, {}, + uiState = DeletedRecordsScreenState( + records = listOf( + DeletedRecordListItem( + recordId = 0, + name = "Record Name 1", + details = "4.5 MB, mp3, 128 kbps, 32 kHz", + duration = "5:21", + isBookmarked = false + ), + DeletedRecordListItem( + recordId = 1, + name = "Record Name 2", + details = "9.2 MB, M4a, 192 kbps, 48 kHz", + duration = "2:43", + isBookmarked = true + ) + ) + ), null, {}) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/deleted/DeletedRecordsViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/deleted/DeletedRecordsViewModel.kt new file mode 100644 index 000000000..59df6f29f --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/deleted/DeletedRecordsViewModel.kt @@ -0,0 +1,174 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.app.deleted + +import android.app.Application +import android.content.Context +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.dimowner.audiorecorder.util.TimeUtils +import com.dimowner.audiorecorder.v2.app.info.RecordInfoState +import com.dimowner.audiorecorder.v2.app.info.toRecordInfoState +import com.dimowner.audiorecorder.v2.app.toInfoCombinedText +import com.dimowner.audiorecorder.v2.data.RecordsDataSource +import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher +import com.dimowner.audiorecorder.v2.di.qualifiers.MainDispatcher +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +internal class DeletedRecordsViewModel @Inject constructor( + private val recordsDataSource: RecordsDataSource, + @param:MainDispatcher private val mainDispatcher: CoroutineDispatcher, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + @ApplicationContext context: Context +) : AndroidViewModel(context as Application) { + + private val _state = mutableStateOf(DeletedRecordsScreenState()) + val state: State = _state + + private val _event = MutableSharedFlow() + val event: SharedFlow = _event + + init { + updateState() + } + + private fun updateState() { + viewModelScope.launch(ioDispatcher) { + val records = recordsDataSource.getMovedToRecycleRecords() + withContext(mainDispatcher) { + val context: Context = getApplication().applicationContext + _state.value = DeletedRecordsScreenState( + records = records.map { it.toDeletedRecordListItem(context) } + ) + } + } + } + + fun deleteAllRecordsFromRecycle() { + viewModelScope.launch(ioDispatcher) { + if (recordsDataSource.clearRecycle()) { + withContext(mainDispatcher) { + _state.value = DeletedRecordsScreenState( + records = emptyList() + ) + } + } else { + //TODO: Show failed to remove records message + withContext(mainDispatcher) { + updateState() + } + } + } + } + + fun showRecordInfo(recordId: Long) { + viewModelScope.launch(ioDispatcher) { + recordsDataSource.getRecord(recordId)?.toRecordInfoState()?.let { + emitEvent(DeletedRecordsScreenEvent.RecordInformationEvent(it)) + } + } + } + + fun restoreRecord(recordId: Long) { + viewModelScope.launch(ioDispatcher) { + if (recordId != -1L && recordsDataSource.restoreRecordFromRecycle(recordId)) { + //TODO: Show success message + withContext(mainDispatcher) { + _state.value = DeletedRecordsScreenState( + records = state.value.records.filter { it.recordId != recordId } + ) + } + } else { + //TODO: Show error message + } + } + } + + fun deleteForeverRecord(recordId: Long) { + viewModelScope.launch(ioDispatcher) { + if (recordId != -1L && recordsDataSource.deleteRecordAndFileForever(recordId)) { + //TODO: Show success message + withContext(mainDispatcher) { + _state.value = DeletedRecordsScreenState( + records = state.value.records.filter { it.recordId != recordId } + ) + } + } else { + //TODO: Show error message + } + } + } + + fun onAction(action: DeletedRecordsScreenAction) { + when (action) { + is DeletedRecordsScreenAction.ShowRecordInfo -> showRecordInfo(action.recordId) + is DeletedRecordsScreenAction.RestoreRecord -> restoreRecord(action.recordId) + is DeletedRecordsScreenAction.DeleteForeverRecord -> deleteForeverRecord(action.recordId) + DeletedRecordsScreenAction.DeleteAllRecordsFromRecycle -> deleteAllRecordsFromRecycle() + } + } + + private fun emitEvent(event: DeletedRecordsScreenEvent) { + viewModelScope.launch { + _event.emit(event) + } + } +} + +internal data class DeletedRecordsScreenState( + val records: List = emptyList(), +) + +internal data class DeletedRecordListItem( + val recordId: Long, + val name: String, + val details: String, + val duration: String, + val isBookmarked: Boolean +) + +internal sealed class DeletedRecordsScreenEvent { + data class RecordInformationEvent(val recordInfo: RecordInfoState) : DeletedRecordsScreenEvent() +} + +internal sealed class DeletedRecordsScreenAction { + data class ShowRecordInfo(val recordId: Long) : DeletedRecordsScreenAction() + data class RestoreRecord(val recordId: Long) : DeletedRecordsScreenAction() + data class DeleteForeverRecord(val recordId: Long) : DeletedRecordsScreenAction() + data object DeleteAllRecordsFromRecycle : DeletedRecordsScreenAction() +} + +internal fun Record.toDeletedRecordListItem(context: Context): DeletedRecordListItem { + return DeletedRecordListItem( + recordId = this.id, + name = this.name, + details = this.toInfoCombinedText(context), + duration = TimeUtils.formatTimeIntervalHourMinSec2(this.durationMills), + isBookmarked = this.isBookmarked + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/deleted/widget/DeletedRecordsListItemWidget.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/deleted/widget/DeletedRecordsListItemWidget.kt new file mode 100644 index 000000000..c111e0990 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/deleted/widget/DeletedRecordsListItemWidget.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.deleted.widget + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.DeviceFontFamilyName +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.ConfirmationAlertDialog + +@Composable +fun DeletedRecordsListItemWidget( + name: String, + details: String, + onClickItem: () -> Unit, + onClickRestore: () -> Unit, + onClickDelete: () -> Unit, +) { + val showDeleteDialog = remember { mutableStateOf(false) } + val showRestoreDialog = remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .clickable { onClickItem() } + .fillMaxWidth() + .wrapContentHeight() + ) { + Column( + modifier = Modifier.wrapContentSize(), + horizontalAlignment = Alignment.Start + ) { + Text( + modifier = Modifier + .padding(12.dp, 8.dp, 12.dp, 2.dp) + .wrapContentWidth() + .wrapContentHeight(), + text = name, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 20.sp, + fontWeight = FontWeight.Medium + ) + Text( + modifier = Modifier + .padding(12.dp, 2.dp) + .wrapContentWidth() + .wrapContentHeight(), + text = details, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + } + Row( + modifier = Modifier.align(Alignment.End) + ) { + Button( + modifier = Modifier.padding(4.dp).wrapContentSize(), + onClick = { showRestoreDialog.value = true } + ) { + Text( + text = stringResource(id = R.string.restore), + fontSize = 16.sp, + fontWeight = FontWeight.Light, + ) + } + Button( + modifier = Modifier.padding(4.dp).wrapContentSize(), + onClick = { showDeleteDialog.value = true } + ) { + Text( + text = stringResource(id = R.string.delete), + fontSize = 16.sp, + fontWeight = FontWeight.Light, + ) + } + } + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(color = MaterialTheme.colorScheme.inverseOnSurface) + ) + if (showDeleteDialog.value) { + ConfirmationAlertDialog( + onDismissRequest = { showDeleteDialog.value = false }, + onConfirmation = { + onClickDelete() + showDeleteDialog.value = false + }, + dialogTitle = stringResource(id = R.string.warning), + dialogText = stringResource(id = R.string.delete_record_forever, name), + painter = painterResource(id = R.drawable.ic_delete_forever), + positiveButton = stringResource(id = R.string.btn_yes), + negativeButton = stringResource(id = R.string.btn_no) + ) + } + if (showRestoreDialog.value) { + ConfirmationAlertDialog( + onDismissRequest = { showRestoreDialog.value = false }, + onConfirmation = { + onClickRestore() + showRestoreDialog.value = false + }, + dialogTitle = stringResource(id = R.string.warning), + dialogText = stringResource(id = R.string.restore_record, name), + painter = painterResource(id = R.drawable.ic_restore_from_trash), + positiveButton = stringResource(id = R.string.btn_yes), + negativeButton = stringResource(id = R.string.btn_no) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun DeletedRecordsListItemWidgetPreview() { + DeletedRecordsListItemWidget("Label", "Value", {}, {}, {}) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeComponents.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeComponents.kt new file mode 100644 index 000000000..84cf6eb6e --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeComponents.kt @@ -0,0 +1,615 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSizeIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +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.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.DeviceFontFamilyName +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.RecordsDropDownMenu +import com.dimowner.audiorecorder.v2.app.components.onDebounceClick + +@Composable +fun TopAppBar( + onImportClick: () -> Unit, + onHomeMenuItemClick: (HomeDropDownMenuItemId) -> Unit, + showMenuButton: Boolean = true +) { + val expanded = remember { mutableStateOf(false) } + + Box( + modifier = Modifier + .height(60.dp) + .fillMaxWidth() + .padding(0.dp, 4.dp, 0.dp, 0.dp) + .background(color = MaterialTheme.colorScheme.surface), + ) { + IconButton( + onClick = onImportClick, + modifier = Modifier + .padding(8.dp) + .align(Alignment.CenterStart), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_import), + contentDescription = stringResource(id = R.string.btn_import), + modifier = Modifier.size(24.dp) + ) + } + Text( + modifier = Modifier + .wrapContentSize() + .align(Alignment.Center), + textAlign = TextAlign.Center, + text = stringResource(id = R.string.app_name), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 24.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Medium + ) + ), + ) + + if (showMenuButton) { + Box( + modifier = Modifier.align(Alignment.CenterEnd) + ) { + RecordsDropDownMenu( + items = remember { getHomeDroDownMenuItems() }, + onItemClick = { itemId -> + onHomeMenuItemClick(itemId) + }, + expanded = expanded + ) + IconButton( + onClick = { expanded.value = true }, + modifier = Modifier.padding(8.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_more_vert), + contentDescription = stringResource(id = androidx.compose.ui.R.string.dropdown_menu), + modifier = Modifier.size(24.dp) + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun TopAppBarPreview() { + TopAppBar({}, {}) +} + +@Composable +fun PlayPanel( + modifier: Modifier, + showStop: Boolean, + showPause: Boolean, + onPlayClick: () -> Unit, + onStopClick: () -> Unit, + onPauseClick: () -> Unit, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + IconButton( + onClick = if (showPause) onPauseClick else onPlayClick, + modifier = Modifier + .size(42.dp) + .align(Alignment.CenterVertically), + ) { + val imageResourceId = if (showPause) { + R.drawable.ic_pause + } else { + R.drawable.ic_play + } + Icon( + painter = painterResource(id = imageResourceId), + contentDescription = stringResource(id = R.string.btn_play), + ) + } + if (showStop) { + Spacer(modifier = Modifier.size(8.dp)) + IconButton( + onClick = onStopClick, + modifier = Modifier + .size(42.dp) + .align(Alignment.CenterVertically), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_stop), + contentDescription = stringResource(id = R.string.button_stop), + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun PlayPanelPreview() { + PlayPanel( + modifier = Modifier + .wrapContentSize() + .padding(8.dp, 8.dp), + showPause = false, + showStop = true, + onPlayClick = {}, + onStopClick = {}, + onPauseClick = {}, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LegacySlider( + //Progress is value between 0 - 1f + progress: Float = 0f, + onProgressChange: (Float) -> Unit, + enabled: Boolean = true, +) { + val interactionSource = remember { MutableInteractionSource() } + val trackHeight = 4.dp + val thumbSize = DpSize(16.dp, 16.dp) + val zeroThumbSize = DpSize(0.dp, 0.dp) + + Slider( + interactionSource = interactionSource, + modifier = Modifier + .requiredSizeIn(minWidth = thumbSize.width, minHeight = trackHeight) + .padding(0.dp, 0.dp), + value = progress, + enabled = enabled, + onValueChange = { onProgressChange(it) }, + thumb = { + val modifier = Modifier + .size(if (enabled) thumbSize else zeroThumbSize) + .shadow(1.dp, CircleShape, clip = false) + .indication( + interactionSource = interactionSource, + indication = ripple(bounded = false, radius = 16.dp) + ) + SliderDefaults.Thumb(interactionSource = interactionSource, modifier = modifier) + }, + track = { + val modifier = Modifier + .height(trackHeight) + .padding(horizontal = if (enabled) 0.dp else 8.dp) + + SliderDefaults.Track( + sliderState = it, + modifier = modifier, + thumbTrackGapSize = 0.dp, + trackInsideCornerSize = 0.dp, + drawStopIndicator = null + ) + } + ) +} + +@Preview(showBackground = true) +@Composable +fun LegacySliderPreview() { + LegacySlider( + progress = 0.5f, + onProgressChange = {} + ) +} + +@Composable +fun BottomBar( + onSettingsClick: () -> Unit, + onRecordsListClick: () -> Unit, + onStartRecordingClick: () -> Unit, + onPauseRecordingClick: () -> Unit, + onResumeRecordingClick: () -> Unit, + onStopRecordingClick: () -> Unit, + onDeleteRecordingClick: () -> Unit, + bottomBarState: BottomBarState +) { + Row( + modifier = Modifier + .wrapContentHeight() + .padding(16.dp, 0.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + IconButton( + onClick = onDebounceClick(onSettingsClick), + modifier = Modifier + .size(42.dp) + .align(Alignment.CenterVertically), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_settings), + contentDescription = stringResource(id = R.string.settings), + ) + } + Spacer(modifier = Modifier.weight(1f)) + when (bottomBarState) { + BottomBarState.READY_TO_START_RECORDING -> { + CircleButton( + modifier = Modifier.size(80.dp), + text = stringResource(R.string.button_record), + onClick = onDebounceClick(onStartRecordingClick), + ) + } + BottomBarState.RECORDING -> { + RecordingProgressPanel( + modifier = Modifier, + onPauseRecordingClick = onDebounceClick(onPauseRecordingClick), + onStopRecordingClick = onDebounceClick(onStopRecordingClick), + ) + } + BottomBarState.PAUSED -> { + RecordingPausePanel( + modifier = Modifier, + onResumeRecordingClick = onDebounceClick(onResumeRecordingClick), + onStopRecordingClick = onDebounceClick(onStopRecordingClick), + onDeleteRecordingClick = onDebounceClick(onDeleteRecordingClick), + ) + } + } + Spacer(modifier = Modifier.weight(1f)) + IconButton( + onClick = onDebounceClick(onRecordsListClick), + modifier = Modifier + .size(42.dp) + .align(Alignment.CenterVertically), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_list), + contentDescription = stringResource(id = R.string.records), + ) + } + } +} + +@Composable +fun CircleButton( + modifier: Modifier, + text: String, + onClick: () -> Unit +) { + Button( + onClick = onClick, + contentPadding = PaddingValues(0.dp), + shape = CircleShape, + modifier = modifier + ) { + Text( + text = text, + fontSize = 13.sp + ) + } +} + +@Preview(showBackground = true) +@Composable +fun CircleButtonPreview() { + CircleButton( + modifier = Modifier.size(64.dp), + text = "RECORD", + onClick = {} + ) +} + +@Composable +fun RecordingProgressPanel( + modifier: Modifier, + onPauseRecordingClick: () -> Unit, + onStopRecordingClick: () -> Unit, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + + Spacer(modifier = Modifier.size(width = 62.dp, 54.dp)) + CircleButton( + modifier = Modifier.size(80.dp), + text = stringResource(R.string.button_pause), + onClick = onDebounceClick(onPauseRecordingClick), + ) + Spacer(modifier = Modifier.width(8.dp)) + + IconButton( + onClick = onDebounceClick(onStopRecordingClick), + modifier = Modifier + .size(54.dp) + .align(Alignment.CenterVertically), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_stop), + contentDescription = "Stop recording", //TODO: Use string resource + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun RecordingProgressPanelPreview() { + RecordingProgressPanel(Modifier, {}, {}) +} + +@Composable +fun RecordingPausePanel( + modifier: Modifier, + onResumeRecordingClick: () -> Unit, + onStopRecordingClick: () -> Unit, + onDeleteRecordingClick: () -> Unit, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + modifier = Modifier.size(86.dp, 48.dp), + onClick = onDebounceClick(onDeleteRecordingClick), + contentPadding = PaddingValues(6.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFD01716), + contentColor = Color.White + ), + elevation = ButtonDefaults.buttonElevation( + defaultElevation = 0.dp, // Removes the default shadow + pressedElevation = 0.dp // Prevents a shadow when pressed + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.End, + text = stringResource(R.string.delete), + fontSize = 13.sp + ) + Icon( + modifier = Modifier.size(32.dp).padding(4.dp), + painter = painterResource(id = R.drawable.ic_delete_forever_36), + contentDescription = stringResource(id = R.string.delete), + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + CircleButton( + modifier = Modifier.size(80.dp), + text = stringResource(R.string.button_resume), + onClick = onDebounceClick(onResumeRecordingClick), + ) + Spacer(modifier = Modifier.width(8.dp)) + Button( + modifier = Modifier.size(86.dp, 48.dp), + onClick = onDebounceClick(onStopRecordingClick), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF48A54B), + contentColor = Color.White + ), + contentPadding = PaddingValues(6.dp), + elevation = ButtonDefaults.buttonElevation( + defaultElevation = 0.dp, // Removes the default shadow + pressedElevation = 0.dp // Prevents a shadow when pressed + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + modifier = Modifier.size(32.dp).padding(4.dp), + painter = painterResource(id = R.drawable.ic_stop), + contentDescription = stringResource(R.string.button_stop), + ) + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Start, + text = stringResource(R.string.button_stop), + fontSize = 13.sp + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun RecordingPausePanelPreview() { + RecordingPausePanel(Modifier, {}, {}, {}) +} + +@Preview(showBackground = true) +@Composable +fun BottomBarReadyPreview() { + BottomBar({}, {}, {}, {}, {}, {}, {}, BottomBarState.READY_TO_START_RECORDING) +} + +@Preview(showBackground = true) +@Composable +fun BottomBarRecordingPreview() { + BottomBar({}, {}, {}, {}, {}, {}, {}, BottomBarState.RECORDING) +} + +@Preview(showBackground = true) +@Composable +fun BottomBarPausedPreview() { + BottomBar({}, {}, {}, {}, {}, {}, {}, BottomBarState.PAUSED) +} + +@Composable +fun TimePanel( + recordName: String, + recordInfo: String, + recordDuration: String, + timeStart: String, + timeEnd: String, + progress: Float, + isSliderEnabled: Boolean, + onRenameClick: () -> Unit, + onProgressChange: (Float) -> Unit +) { + Column( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + modifier = Modifier + .wrapContentSize(), + textAlign = TextAlign.Center, + text = recordDuration, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 60.sp, + fontWeight = FontWeight.Bold + ) + Text( + modifier = Modifier + .wrapContentSize() + .padding(0.dp, 0.dp, 0.dp, 4.dp), + textAlign = TextAlign.Center, + text = recordName, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 22.sp, + fontWeight = FontWeight.Normal + ) + Row { + Text( + modifier = Modifier + .wrapContentSize() + .padding(4.dp, 0.dp), + textAlign = TextAlign.Start, + text = timeStart, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Normal + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier + .wrapContentSize(), + textAlign = TextAlign.Center, + text = recordInfo, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 14.sp, + fontWeight = FontWeight.Light + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier + .wrapContentSize() + .padding(4.dp, 0.dp), + textAlign = TextAlign.Start, + text = timeEnd, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Normal + ) + } + LegacySlider( + progress = progress, + onProgressChange = onProgressChange, + enabled = isSliderEnabled + ) + } +} + +@Preview(showBackground = true) +@Composable +fun TimePanelPreview() { + TimePanel( + "Record-14", + "1.2Mb, M4a, " + + "44.1kHz", + "02:23", + "00:00", + "05:32", + 0.3f, + isSliderEnabled = true, + onRenameClick = {}, + onProgressChange = { prgress ->}, + ) +} + +@Preview(showBackground = true) +@Composable +fun TimePanelRecordingProgressPreview() { + TimePanel( + "Recording...", + "1.2Mb, M4a, " + + "44.1kHz", + "02:23", + "", + "", + 0.0f, + isSliderEnabled = false, + onRenameClick = {}, + onProgressChange = { prgress ->}, + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeDropDownMenuItemId.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeDropDownMenuItemId.kt new file mode 100644 index 000000000..c8bf9d811 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeDropDownMenuItemId.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.home + +enum class HomeDropDownMenuItemId { + SHARE, INFORMATION, RENAME, OPEN_WITH, SAVE_AS, DELETE +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeExtensions.kt new file mode 100644 index 000000000..0f4139a42 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeExtensions.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.home + +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.DropDownMenuItem + +fun getHomeDroDownMenuItems(): List> { + return HomeDropDownMenuItemId.entries.map { + when (it) { + HomeDropDownMenuItemId.SHARE -> DropDownMenuItem( + id = it, textResId = R.string.share, imageResId = R.drawable.ic_share + ) + HomeDropDownMenuItemId.INFORMATION -> DropDownMenuItem( + id = it, textResId = R.string.info, imageResId = R.drawable.ic_info + ) + HomeDropDownMenuItemId.RENAME -> DropDownMenuItem( + id = it, textResId = R.string.rename, imageResId = R.drawable.ic_pencil + ) + HomeDropDownMenuItemId.OPEN_WITH -> DropDownMenuItem( + id = it, textResId = R.string.open_with, imageResId = R.drawable.ic_open_with + ) + HomeDropDownMenuItemId.SAVE_AS -> DropDownMenuItem( + id = it, textResId = R.string.save_as, imageResId = R.drawable.ic_save_alt + ) + HomeDropDownMenuItemId.DELETE -> DropDownMenuItem( + id = it, textResId = R.string.delete, imageResId = R.drawable.ic_delete_forever + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeScreen.kt new file mode 100644 index 000000000..b1d431845 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeScreen.kt @@ -0,0 +1,440 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.home + +import android.Manifest +import android.content.pm.PackageManager +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.util.TimeUtils +import com.dimowner.audiorecorder.v2.app.ComposableLifecycle +import com.dimowner.audiorecorder.v2.app.DeleteDialog +import com.dimowner.audiorecorder.v2.app.RenameAlertDialog +import com.dimowner.audiorecorder.v2.app.SaveAsDialog +import com.dimowner.audiorecorder.v2.app.components.BluetoothMicSelector +import com.dimowner.audiorecorder.v2.app.components.WaveformComposeView +import com.dimowner.audiorecorder.v2.app.components.WaveformState +import com.dimowner.audiorecorder.v2.app.getTestWaveformData +import com.dimowner.audiorecorder.v2.app.lostrecords.LostRecordsDialog +import com.dimowner.audiorecorder.v2.data.model.Record +import com.google.gson.Gson +import kotlinx.coroutines.launch +import timber.log.Timber + +@Composable +internal fun HomeScreen( + showRecordsScreen: () -> Unit, + showSettingsScreen: () -> Unit, + showRecordInfoScreen: (String) -> Unit, + showLostRecordsScreen: (Record) -> Unit, + uiState: HomeScreenState, + event: HomeScreenEvent?, + onAction: (HomeScreenAction) -> Unit +) { + + ComposableLifecycle { _, event -> + when (event) { + Lifecycle.Event.ON_START -> { + Timber.d("HomeScreen: On Start") + onAction(HomeScreenAction.InitHomeScreen) + } + else -> {} + } + } + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + val showRenameDialog = remember { mutableStateOf(false) } + val showDeleteDialog = remember { mutableStateOf(false) } + val showSaveAsDialog = remember { mutableStateOf(false) } + + val context = LocalContext.current + + // Permission launcher for audio recording + val recordAudioPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + // Permission granted - start recording immediately + onAction(HomeScreenAction.OnStartRecordingClick) + } else { + // Permission denied - show snackbar + scope.launch { + snackbarHostState.showSnackbar( + message = context.getString(R.string.msg_permission_microphone_denied), + duration = SnackbarDuration.Long + ) + } + } + } + + // Helper function to handle record button click with permission check + val handleRecordButtonClick: () -> Unit = { + when (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)) { + PackageManager.PERMISSION_GRANTED -> { + // Permission already granted - start recording + onAction(HomeScreenAction.OnStartRecordingClick) + } + else -> { + // Permission not granted - request it + recordAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + } + + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + // Handle the selected document URI here + if (uri != null) { + onAction(HomeScreenAction.ImportAudioFile(uri)) + } + } + + LaunchedEffect(key1 = event) { + when (event) { + HomeScreenEvent.ShowImportErrorError -> { + Timber.v("ON EVENT: ShowImportErrorError") + } + + is HomeScreenEvent.RecordInformationEvent -> { + val json = Uri.encode(Gson().toJson(event.recordInfo)) + Timber.v("ON EVENT: ShareRecord json = $json") + showRecordInfoScreen(json) + } + is HomeScreenEvent.RecordMovedToRecycleSnack -> { + scope.launch { + val message = if (event.recordName != null) { + context.getString(R.string.msg_recording_moved_to_trash, event.recordName) + } else { + context.getString(R.string.msg_recording_canceled) + } + val result = snackbarHostState + .showSnackbar( + message = message, + actionLabel = context.getString(R.string.action_undo), + duration = SnackbarDuration.Short + ) + when (result) { + SnackbarResult.ActionPerformed -> { + onAction(HomeScreenAction.RestoreRecordFromRecycle(event.recordId)) + } + SnackbarResult.Dismissed -> { + /* Handle snackbar dismissed */ + } + } + } + } + is HomeScreenEvent.ShowInfoSnack -> { + scope.launch { + snackbarHostState + .showSnackbar( + message = event.message, + duration = SnackbarDuration.Short + ) + } + } + is HomeScreenEvent.ShowErrorSnack -> { + scope.launch { + snackbarHostState + .showSnackbar( + message = event.message, + duration = SnackbarDuration.Short + ) + } + } + + else -> { + Timber.v("ON EVENT: Unknown") + //Do nothing + } + } + } + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + ) { innerPadding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TopAppBar( + onImportClick = { + launcher.launch("audio/*") + }, + onHomeMenuItemClick = { + when (it) { + HomeDropDownMenuItemId.SHARE -> { + onAction(HomeScreenAction.ShareActiveRecord) + } + + HomeDropDownMenuItemId.INFORMATION -> { + onAction(HomeScreenAction.ShowActiveRecordInfo) + } + + HomeDropDownMenuItemId.RENAME -> { + showRenameDialog.value = true + } + + HomeDropDownMenuItemId.OPEN_WITH -> { + onAction(HomeScreenAction.OpenActiveRecordWithAnotherApp) + } + + HomeDropDownMenuItemId.SAVE_AS -> { + showSaveAsDialog.value = true + } + + HomeDropDownMenuItemId.DELETE -> { + showDeleteDialog.value = true + } + } + }, + showMenuButton = uiState.isContextMenuAvailable + ) + + // Show Bluetooth and Audio Source settings when there are available BT devices. + if (!uiState.isShowLoadingProgress + && uiState.connectedBluetoothDevices.isNotEmpty() + ) { + BluetoothMicSelector( + connectedDevices = uiState.connectedBluetoothDevices, + selectedDevice = uiState.selectedBluetoothDevice, + isEnabled = uiState.isBluetoothMicEnabled, + onDeviceSelected = { device -> + onAction(HomeScreenAction.SelectBluetoothDevice(device)) + }, + onToggleEnabled = { enabled -> + onAction(HomeScreenAction.SetBluetoothMicEnabled(enabled)) + } + ) + } + Spacer( + modifier = Modifier + .weight(1f) + .wrapContentHeight() + ) + if (uiState.isShowLoadingProgress) { + //Show nothing because of progress takes very short period of time + } else if (uiState.isShowWaveform) { + WaveformComposeView( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + state = uiState.waveformState, + showTimeline = true, + onSeekStart = { + onAction(HomeScreenAction.OnSeekStart) + }, + onSeekProgress = { mills -> + onAction(HomeScreenAction.OnSeekProgress(mills)) + }, + onSeekEnd = { mills -> + onAction(HomeScreenAction.OnSeekEnd(mills)) + } + ) + PlayPanel( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(8.dp, 8.dp), + showPause = uiState.showPause, + showStop = uiState.showStop, + onPlayClick = { onAction(HomeScreenAction.OnPlayClick) }, + onStopClick = { onAction(HomeScreenAction.OnStopClick) }, + onPauseClick = { onAction(HomeScreenAction.OnPauseClick) } + ) + } else { + Image( + painter = painterResource(id = R.drawable.waveform), + contentDescription = stringResource(R.string.app_name), + modifier = Modifier.wrapContentSize(), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary) + ) + } + Spacer( + modifier = Modifier + .weight(1f) + .wrapContentHeight() + ) + + TimePanel( + uiState.recordName, + uiState.recordInfo, + uiState.time, + uiState.startTime, + uiState.endTime, + uiState.progress, + uiState.isShowWaveform, + onRenameClick = {}, + onProgressChange = { onAction(HomeScreenAction.OnProgressBarStateChange(it)) } + ) + BottomBar( + onSettingsClick = { showSettingsScreen() }, + onRecordsListClick = { showRecordsScreen() }, + onStartRecordingClick = handleRecordButtonClick, + onPauseRecordingClick = { onAction(HomeScreenAction.OnPauseRecordingClick) }, + onResumeRecordingClick = { onAction(HomeScreenAction.OnResumeRecordingClick) }, + onStopRecordingClick = { onAction(HomeScreenAction.OnStopRecordingClick) }, + onDeleteRecordingClick = { onAction(HomeScreenAction.OnDeleteRecordingProgressClick) }, + bottomBarState = uiState.bottomBarState + ) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(12.dp) + ) + if (showDeleteDialog.value) { + DeleteDialog( + dialogText = stringResource(id = R.string.move_record_to_trash, uiState.recordName), + onAcceptClick = { + showDeleteDialog.value = false + onAction(HomeScreenAction.DeleteActiveRecord) + }, onDismissClick = { + showDeleteDialog.value = false + } + ) + } else if (showSaveAsDialog.value) { + SaveAsDialog( + dialogText = stringResource( + id = R.string.record_name_will_be_copied_into_downloads, uiState.recordName), + onAcceptClick = { + showSaveAsDialog.value = false + onAction(HomeScreenAction.SaveActiveRecordAs) + }, onDismissClick = { + showSaveAsDialog.value = false + } + ) + } else if (showRenameDialog.value) { + RenameAlertDialog( + uiState.recordName, + onAcceptClick = { + showRenameDialog.value = false + onAction(HomeScreenAction.RenameActiveRecord(it)) + }, onDismissClick = { + showRenameDialog.value = false + } + ) + } + if (uiState.showLostRecordsDialog) { + LostRecordsDialog( + onDismiss = { + onAction(HomeScreenAction.DismissLostRecordsDialog) + }, + onDetailsClick = { + onAction(HomeScreenAction.DismissLostRecordsDialog) + uiState.lostRecord?.let { + showLostRecordsScreen(it) + } + } + ) + } + } + } + } +} + +@Preview +@Composable +fun HomeScreenPreview() { + HomeScreen( + {}, {}, {}, {}, uiState = HomeScreenState( + waveformState = getTestWaveformData(), + progress = 0.4f, + startTime = "00:00", + endTime = "3:42", + time = "1:51", + recordName = "Test Record Name", + recordInfo = "1.5 MB, mp4, 192 kbps, 48 kHz", + isContextMenuAvailable = true, + isStopRecordingButtonAvailable = true, + isShowWaveform = true, + ), null, {}) +} + +@Preview +@Composable +fun HomeScreenEmptyPreview() { + HomeScreen({}, {}, {}, {}, uiState = HomeScreenState(), null, {}) +} + +@Preview +@Composable +fun HomeScreenShowProgressPreview() { + HomeScreen({}, {}, {}, {}, uiState = HomeScreenState( + isShowLoadingProgress = true + ), null, {}) +} + +@Preview +@Composable +fun HomeScreenShowRecordingProgressPreview() { + HomeScreen( + {}, {}, {}, {}, + uiState = HomeScreenState( + isShowLoadingProgress = false, + isShowWaveform = false, + bottomBarState = BottomBarState.RECORDING, + waveformState = WaveformState(), + time = TimeUtils.formatTimeIntervalHourMinSec2(15000L), + showPause = false, + showStop = false, + recordName = stringResource(R.string.recording_progress), + recordInfo = "1.5 MB, mp4, 192 kbps, 48 kHz", + ), + null, {}, + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt new file mode 100644 index 000000000..2e9979b65 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt @@ -0,0 +1,1029 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.app.home + +import android.animation.TypeEvaluator +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.app.Application +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.net.Uri +import android.os.IBinder +import android.os.ParcelFileDescriptor +import android.view.animation.DecelerateInterpolator +import androidx.annotation.StringRes +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.dimowner.audiorecorder.ARApplication +import com.dimowner.audiorecorder.AppConstants +import com.dimowner.audiorecorder.AppConstantsV2 +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.app.DecodeService +import com.dimowner.audiorecorder.app.DecodeServiceListener +import com.dimowner.audiorecorder.app.DownloadService +import com.dimowner.audiorecorder.audio.AudioDecoder +import com.dimowner.audiorecorder.audio.player.PlayerContractNew +import com.dimowner.audiorecorder.exception.AppException +import com.dimowner.audiorecorder.exception.CantCreateFileException +import com.dimowner.audiorecorder.exception.ErrorParser +import com.dimowner.audiorecorder.util.AndroidUtils +import com.dimowner.audiorecorder.util.AudioManagerHelper +import com.dimowner.audiorecorder.util.BluetoothDeviceInfo +import com.dimowner.audiorecorder.util.FileUtil +import com.dimowner.audiorecorder.util.TimeUtils +import com.dimowner.audiorecorder.v2.app.adjustWaveformHeights +import com.dimowner.audiorecorder.v2.app.calculateGridStep +import com.dimowner.audiorecorder.v2.app.calculateScale +import com.dimowner.audiorecorder.v2.app.components.WaveformState +import com.dimowner.audiorecorder.v2.app.info.RecordInfoState +import com.dimowner.audiorecorder.v2.app.info.toRecordInfoState +import com.dimowner.audiorecorder.v2.app.toInfoCombinedText +import com.dimowner.audiorecorder.v2.audio.RecorderEvent +import com.dimowner.audiorecorder.v2.audio.RecorderV2 +import com.dimowner.audiorecorder.v2.data.FileDataSource +import com.dimowner.audiorecorder.v2.data.PrefsV2 +import com.dimowner.audiorecorder.v2.data.RecordsDataSource +import com.dimowner.audiorecorder.v2.data.extensions.isLostRecord +import com.dimowner.audiorecorder.v2.data.model.AudioSource +import com.dimowner.audiorecorder.v2.data.model.NameFormat +import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher +import com.dimowner.audiorecorder.v2.di.qualifiers.MainDispatcher +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File +import java.io.IOException +import javax.inject.Inject + +private const val ANIMATION_DURATION = 330L //mills. + +@SuppressWarnings("LongParameterList") +@HiltViewModel +class HomeViewModel @Inject constructor( + private val recordsDataSource: RecordsDataSource, + private val fileDataSource: FileDataSource, + private val prefs: PrefsV2, + private val audioPlayer: PlayerContractNew.Player, + private val audioRecorder: RecorderV2, + private val audioManagerHelper: AudioManagerHelper, + @param:MainDispatcher private val mainDispatcher: CoroutineDispatcher, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + @ApplicationContext context: Context, +) : AndroidViewModel(context as Application) { + + private val _state = mutableStateOf(HomeScreenState()) + val state: State = _state + + private val _event = MutableSharedFlow() + val event: SharedFlow = _event + + private val connection: ServiceConnection = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, service: IBinder) { + val binder = service as DecodeService.LocalBinder + val decodeService = binder.getService() + decodeService.setDecodeListener(object : DecodeServiceListener { + override fun onStartProcessing() { + //Do nothing + } + + override fun onFinishProcessing(decodedData: IntArray) { + viewModelScope.launch(ioDispatcher) { + //TODO: Handle the case when active racord has changed during decoding. + recordsDataSource.getActiveRecord()?.let { + recordsDataSource.updateRecord( + it.copy( + amps = decodedData + ) + ) + } + } + } + }) + } + + override fun onServiceDisconnected(arg0: ComponentName) { + //Do nothing + } + + override fun onBindingDied(name: ComponentName) { + //Do nothing + } + } + + init { + viewModelScope.launch { + //TODO: these events should be handled in a service + subscribeRecorderUpdates() + } + subscribePlayerUpdates() + + // Register AudioManagerHelper and subscribe to Bluetooth mic state + audioManagerHelper.register() + viewModelScope.launch { + audioManagerHelper.bluetoothMicState.collect { bluetoothState -> + _state.value = _state.value.copy( + isBluetoothMicAvailable = bluetoothState.isAvailable, + isBluetoothMicEnabled = bluetoothState.isEnabled, + bluetoothDeviceName = bluetoothState.deviceName, + connectedBluetoothDevices = bluetoothState.connectedDevices, + selectedBluetoothDevice = bluetoothState.selectedDevice + ) + } + } + } + + private suspend fun subscribeRecorderUpdates() { + val context: Context = getApplication().applicationContext + audioRecorder.subscribeRecorderEvents().collect { event -> + Timber.d("HomeViewModel audioRecorder: event: $event") + when (event) { + is RecorderEvent.OnError -> { + handleError(event.exception) + } + + RecorderEvent.OnStartRecording -> { + _state.value = state.value.copy( + bottomBarState = BottomBarState.RECORDING, + waveformState = WaveformState(), + isShowWaveform = false, + startTime = "", + endTime = "", + recordName = context.getString(R.string.recording_progress), + ) + withContext(ioDispatcher) { + recordsDataSource.getRecord(prefs.recordedRecordId)?.let { + _state.value = state.value.copy( + recordInfo = it.toInfoCombinedText(context) + ) + } + } + } + is RecorderEvent.OnRecordingProgress -> { + _state.value = _state.value.copy( + time = TimeUtils.formatTimeIntervalHourMinSec2(event.durationMills), + showPause = false, + showStop = false, + isShowWaveform = false + ) + } + RecorderEvent.OnPauseRecording -> { + _state.value = state.value.copy( + bottomBarState = BottomBarState.PAUSED, + recordName = context.getString(R.string.recording_paused), + ) + } + RecorderEvent.OnResumeRecording -> { + _state.value = state.value.copy( + bottomBarState = BottomBarState.RECORDING, + recordName = context.getString(R.string.recording_progress), + ) + } + is RecorderEvent.OnMaxDurationReached -> { + handleMaxDurationReached() + } + RecorderEvent.OnStopRecording -> { + handleRecordingStopped() + resetRecordedRecordPartCounter() + prefs.recordedRecordBaseName = null + } + } + } + } + + private fun subscribePlayerUpdates() { + audioPlayer.addPlayerCallback(callback = object : PlayerContractNew.PlayerCallback { + override fun onStartPlay() { + _state.value = _state.value.copy( + showPause = true, + showStop = true, + ) + } + + override fun onPlayProgress(mills: Long) { + if (!_state.value.isSeek) { + _state.value = _state.value.copy( + waveformState = _state.value.waveformState.copy( + progressMills = mills + ), + progress = millsToProgress(mills, _state.value.waveformState.durationMills), + time = TimeUtils.formatTimeIntervalHourMinSec2(mills), + showPause = true, + showStop = true, + ) + } + } + + override fun onPausePlay() { + _state.value = _state.value.copy( + showPause = false, + showStop = true + ) + } + + override fun onSeek(mills: Long) { + //Do nothing + } + + override fun onStopPlay() { + _state.value = _state.value.copy( + showPause = false, + showStop = false + ) + moveToStart() + } + + override fun onError(throwable: AppException) { + Timber.e(throwable) + handleError(throwable) + } + }) + } + + private suspend fun handleRecordingStopped() { + // - Read recorded file info + // - Update recorded file duration, size, format, bitrate, sample rate, channel count + // - Move updated to recycle if requested to delete the record, otherwise set it as active record + withContext(ioDispatcher) { + val recordedRecordId = prefs.recordedRecordId + if (recordedRecordId >= 0) { + val record = recordsDataSource.getRecord(recordedRecordId) + if (record != null) { + val output = File(record.path) + val info = AudioDecoder.readRecordInfo(output); + val success = recordsDataSource.updateRecord( + record.copy( + durationMills = info.duration / 1000, + format = info.format, + size = info.size, + sampleRate = info.sampleRate, + channelCount = info.channelCount, + bitrate = info.bitrate, + ) + ) + if (_state.value.isDeleteRecordingProgressRequested) { + moveRecordToRecycle(recordedRecordId, false) + } else { + if (success) { + prefs.activeRecordId = recordedRecordId + //Record saved successfully + showInfoMessage(R.string.msg_recording_saved) + } else { + //Failed to save record + showInfoMessage(R.string.msg_save_recording_failed) + } + updateState() + } + } else { + if (!_state.value.isDeleteRecordingProgressRequested) { + //Failed to save record + showInfoMessage(R.string.msg_save_recording_failed) + } + updateState() + } + prefs.recordedRecordId = -1 + } + } + } + + private fun handleMaxDurationReached() { + viewModelScope.launch(ioDispatcher) { + handleRecordingStopped() + + val partCounter = prefs.recordedRecordPartCounter + recordsDataSource.getActiveRecord()?.let { + val baseName = prefs.recordedRecordBaseName + if (baseName != null) { + //Rename saved record to record name and part 1 at the end. + //Because the first part has base name without part number by default. + if (partCounter == 1) { + recordsDataSource.renameRecord(it, getPartName(baseName, partCounter)) + updateState(false) + } + + //Get record part name for the next part. + val recordName = getPartName(baseName, partCounter + 1) + handleStartRecordingClick(recordName) + } else { + //In case if there something wrong with base record name, just start normal recording. + handleStartRecordingClick(getNewRecordName()) + } + } + } + // Load the selected audio source from preferences + _state.value = _state.value.copy( + selectedAudioSource = prefs.settingAudioSource + ) + } + + private fun handleError(exception: AppException) { + val context: Context = getApplication().applicationContext + emitEvent( + HomeScreenEvent.ShowErrorSnack( + context.getString(ErrorParser.parseException(exception)) + ) + ) + } + + private fun showInfoMessage(@StringRes resId: Int) { + val context: Context = getApplication().applicationContext + emitEvent( + HomeScreenEvent.ShowInfoSnack( + context.getString(resId) + ) + ) + } + + private fun showInfoMessage(@StringRes resId: Int, vararg formatArgs: Any) { + val context: Context = getApplication().applicationContext + emitEvent( + HomeScreenEvent.ShowInfoSnack( + context.getString(resId, *formatArgs) + ) + ) + } + + fun init() { + showLoadingProgress(true) + viewModelScope.launch(ioDispatcher) { + updateState(false) + } + + val context: Context = getApplication().applicationContext + val intent = Intent(context, DecodeService::class.java) + context.bindService(intent, connection, Context.BIND_AUTO_CREATE) + } + + private suspend fun updateState(resetPlayProgress: Boolean = true) { + val context: Context = getApplication().applicationContext + val activeRecord = recordsDataSource.getActiveRecord() + if (activeRecord != null) { + val lostRecord = if (activeRecord.isLostRecord()) { + activeRecord + } else { + null + } + withContext(mainDispatcher) { + _state.value = _state.value.copy( + waveformState = _state.value.waveformState.copy( + widthScale = calculateScale( + activeRecord.durationMills, + defaultWidthScale = AppConstantsV2.DEFAULT_WIDTH_SCALE + ), + durationMills = activeRecord.durationMills, + progressMills = if (resetPlayProgress) 0L else _state.value.waveformState.progressMills, + waveformData = adjustWaveformHeights(activeRecord.amps), + durationSample = activeRecord.amps.size, + gridStepMills = calculateGridStep(activeRecord.durationMills) + ), + startTime = context.getString(R.string.zero_time), + endTime = TimeUtils.formatTimeIntervalHourMinSec2(activeRecord.durationMills), + time = context.getString(R.string.zero_time), + recordName = activeRecord.name, + recordInfo = activeRecord.toInfoCombinedText(context), + isContextMenuAvailable = true, + isShowWaveform = true, + isShowLoadingProgress = false, + isDeleteRecordingProgressRequested = false, + showLostRecordsDialog = lostRecord != null, + lostRecord = lostRecord, + ) + } + } else { + val bottomBarState = audioRecorder.toBottomBarState() + if (audioRecorder.isRecording) { + recordsDataSource.getRecord(prefs.recordedRecordId)?.let { + val recordInfo = it.toInfoCombinedText(context) + if (bottomBarState == BottomBarState.RECORDING) { + _state.value = state.value.copy( + bottomBarState = BottomBarState.RECORDING, + waveformState = WaveformState(), + isShowLoadingProgress = false, + isShowWaveform = false, + startTime = "", + endTime = "", + recordInfo = recordInfo, + recordName = context.getString(R.string.recording_progress), + ) + } else if (bottomBarState == BottomBarState.PAUSED) { + _state.value = state.value.copy( + bottomBarState = BottomBarState.PAUSED, + waveformState = WaveformState(), + isShowLoadingProgress = false, + isShowWaveform = false, + startTime = "", + endTime = "", + recordInfo = recordInfo, + recordName = context.getString(R.string.recording_paused), + ) + } + } + } else { + withContext(mainDispatcher) { + //isShowProgress = false is default value. So it cancels progress + _state.value = HomeScreenState( + bottomBarState = bottomBarState + ) + } + } + } + } + + private fun RecorderV2.toBottomBarState(): BottomBarState { + return if (this.isPaused) { + BottomBarState.PAUSED + } else if (this.isRecording) { + BottomBarState.RECORDING + } else { + BottomBarState.READY_TO_START_RECORDING + } + } + + @SuppressLint("Recycle") + fun importAudioFile(uri: Uri) { + val context: Context = getApplication().applicationContext + showLoadingProgress(true) + viewModelScope.launch(ioDispatcher) { + try { + val parcelFileDescriptor: ParcelFileDescriptor? = + context.contentResolver.openFileDescriptor(uri, "r") + val fileDescriptor = parcelFileDescriptor?.fileDescriptor + val name: String? = DocumentFile.fromSingleUri(context, uri)?.name + if (name != null) { + val newFile: File = fileDataSource.createRecordFile(name) + if (FileUtil.copyFile(fileDescriptor, newFile)) { //TODO: Fix + val info = AudioDecoder.readRecordInfo(newFile) + + //Do 2 step import: 1) Import record with empty waveform. + //2) Process and update waveform in background. + val record = Record( + id = 0, + name = FileUtil.removeFileExtension(newFile.name), //TODO: Fix + durationMills = if (info.duration >= 0) info.duration / 1000 else 0, + created = newFile.lastModified(), + added = System.currentTimeMillis(), + removed = -1, + path = newFile.absolutePath, + format = info.format, + size = info.size, + sampleRate = info.sampleRate, + channelCount = info.channelCount, + bitrate = info.bitrate, + isBookmarked = false, + isWaveformProcessed = false, + isMovedToRecycle = false, + amps = IntArray(ARApplication.longWaveformSampleCount), + ) + val id = recordsDataSource.insertRecord(record) + withContext(mainDispatcher) { + audioPlayer.stop() + } + prefs.activeRecordId = id + updateState() + decodeRecord(record.path, record.durationMills) + } + } else { + //TODO: Show an error + } + } catch (e: SecurityException) { + Timber.e(e) + } catch (e: IOException) { + Timber.e(e) + } catch (e: OutOfMemoryError) { + Timber.e(e) + } catch (e: IllegalStateException) { + Timber.e(e) + } catch (ex: CantCreateFileException) { + Timber.e(ex) + } + } + } + + private fun decodeRecord(path: String, durationMills: Long) { + DecodeService.startNotificationV2( + getApplication().applicationContext, + path, + durationMills + ) + } + + fun shareActiveRecord() { + viewModelScope.launch(ioDispatcher) { + val activeRecord = recordsDataSource.getActiveRecord() + if (activeRecord != null) { + withContext(mainDispatcher) { + AndroidUtils.shareAudioFile( + getApplication().applicationContext, + activeRecord.path, + activeRecord.name, + activeRecord.format + ) + } + } + } + } + + fun showActiveRecordInfo() { + viewModelScope.launch(ioDispatcher) { + recordsDataSource.getActiveRecord()?.toRecordInfoState()?.let { + emitEvent(HomeScreenEvent.RecordInformationEvent(it)) + } + } + } + + fun renameActiveRecord(newName: String) { + showLoadingProgress(true) + viewModelScope.launch(ioDispatcher) { + val activeRecord = recordsDataSource.getActiveRecord() + if (activeRecord != null) { + recordsDataSource.renameRecord(activeRecord, newName) + updateState(false) + } + } + } + + fun openActiveRecordWithAnotherApp() { + viewModelScope.launch(ioDispatcher) { + val activeRecord = recordsDataSource.getActiveRecord() + if (activeRecord != null) { + withContext(mainDispatcher) { + AndroidUtils.openAudioFile( + getApplication().applicationContext, + activeRecord.path, + activeRecord.name + ) + } + } + } + } + + fun saveActiveRecordAs() { + viewModelScope.launch(ioDispatcher) { + val activeRecord = recordsDataSource.getActiveRecord() + if (activeRecord != null) { + DownloadService.startNotification( + getApplication().applicationContext, + activeRecord.path + ) + } + } + } + + fun deleteActiveRecord() { + moveRecordToRecycle(prefs.activeRecordId) + } + + private fun moveRecordToRecycle(recordId: Long, showName: Boolean = true) { + if (audioPlayer.isPlaying()) { + audioPlayer.stop() + } + showLoadingProgress(true) + viewModelScope.launch(ioDispatcher) { + val record = recordsDataSource.getRecord(recordId) + if (record != null && recordsDataSource.moveRecordToRecycle(recordId)) { + prefs.activeRecordId = -1 + updateState() + emitEvent( + HomeScreenEvent.RecordMovedToRecycleSnack( + recordId, + if (showName) record.name else null + ) + ) + } else { + val context: Context = getApplication().applicationContext + emitEvent( + HomeScreenEvent.ShowErrorSnack( + context.getString(R.string.msg_move_to_trash_failed) + ) + ) + + withContext(mainDispatcher) { + showLoadingProgress(false) + } + } + } + } + + fun handleSeekStart() { + _state.value = _state.value.copy( + isSeek = true + ) + } + + fun handleSeekProgress(mills: Long) { + _state.value = _state.value.copy( + time = TimeUtils.formatTimeIntervalHourMinSec2(mills), + progress = millsToProgress(mills, _state.value.waveformState.durationMills), + waveformState = _state.value.waveformState.copy( + progressMills = mills + ) + ) + } + + fun handleSeekEnd(mills: Long) { + _state.value = _state.value.copy( + time = TimeUtils.formatTimeIntervalHourMinSec2(mills), + progress = millsToProgress(mills, _state.value.waveformState.durationMills), + isSeek = false, + waveformState = _state.value.waveformState.copy( + progressMills = mills, + ) + ) + if (!audioPlayer.isPlaying()) { + _state.value = _state.value.copy( + showPause = false, + showStop = true + ) + } + audioPlayer.seek(mills) + } + + fun handleProgressBarStateChange(value: Float) { + val mills = (_state.value.waveformState.durationMills * value).toLong() + _state.value = _state.value.copy( + time = TimeUtils.formatTimeIntervalHourMinSec2(mills), + progress = millsToProgress(mills, _state.value.waveformState.durationMills), + waveformState = _state.value.waveformState.copy( + progressMills = mills + ) + ) + audioPlayer.seek(mills) + } + + fun handlePlayClick() { + if (!audioPlayer.isPlaying()) { + viewModelScope.launch(ioDispatcher) { + val activeRecord = recordsDataSource.getActiveRecord() + if (activeRecord != null) { + withContext(mainDispatcher) { + audioPlayer.play(activeRecord.path) + } + } + } + } else { + Timber.e("Playback did not started because already playing") + } + } + + fun handlePlaybackPauseClick() { + audioPlayer.pause() + } + + fun handlePlaybackStopClick() { + audioPlayer.stop() + } + + // - Has available space + // - Is already recoding + // - If is playing, stop playback + // - Create a record file + // - Create empty record in the database with created file path + // - Set it as active record + // - Start recording + suspend fun handleStartRecordingClick(recordName: String) { + withContext(mainDispatcher) { + audioPlayer.stop() + } + val availableTimeSeconds = convertSpaceBytesToTimeInSeconds( + spaceBytes = fileDataSource.getAvailableSpace(), + recordingFormat = prefs.settingRecordingFormat, + sampleRate = prefs.settingSampleRate.value, + bitrate = prefs.settingBitrate.value, + channels = prefs.settingChannelCount.value, + ) + + withContext(ioDispatcher) { + if (availableTimeSeconds > AppConstants.MIN_REMAIN_RECORDING_TIME && !audioRecorder.isRecording) { + if (audioPlayer.isPlaying()) { + audioPlayer.stop() + } + val recordFile = fileDataSource.createRecordFile(addExtension(recordName)) + val record = Record( + id = 0, + name = recordName, + durationMills = 0, + created = recordFile.lastModified(), + added = System.currentTimeMillis(), + removed = -1, + path = recordFile.absolutePath, + format = prefs.settingRecordingFormat.value, + size = 0, + sampleRate = prefs.settingSampleRate.value, + channelCount = prefs.settingChannelCount.value, + bitrate = prefs.settingBitrate.value, + isBookmarked = false, + isWaveformProcessed = false, + isMovedToRecycle = false, + amps = IntArray(ARApplication.longWaveformSampleCount) + ) + val id = recordsDataSource.insertRecord(record) + prefs.activeRecordId = -1 + prefs.recordedRecordId = id + + audioRecorder.startRecording( + outputFile = recordFile, + channelCount = prefs.settingChannelCount.value, + sampleRate = prefs.settingSampleRate.value, + bitrate = prefs.settingBitrate.value, + maxRecordingDurationMills = prefs.maxRecordingDurationMills, + audioSource = _state.value.selectedAudioSource.value, + ) + incrementRecordedRecordPartCounter() + } + } + } + + fun handlePauseRecordingClick() { + audioRecorder.pauseRecording() + } + + fun handleResumeRecordingClick() { + audioRecorder.resumeRecording() + } + + fun handleStopRecordingClick() { + audioRecorder.stopRecording() + _state.value = state.value.copy( + waveformState = _state.value.waveformState.copy(isRecording = false), + bottomBarState = BottomBarState.READY_TO_START_RECORDING + ) + } + + fun handleOnDeleteRecordingProgressClick() { + audioRecorder.stopRecording() + _state.value = state.value.copy( + isDeleteRecordingProgressRequested = true + ) + } + + fun handleRestoreRecordFromRecycle(recordId: Long) { + showLoadingProgress(true) + viewModelScope.launch(ioDispatcher) { + if (recordsDataSource.restoreRecordFromRecycle(recordId)) { + prefs.activeRecordId = recordId + val record = recordsDataSource.getRecord(recordId) + showInfoMessage(R.string.msg_recording_restored, record?.name ?: "") + updateState() + } else { + showInfoMessage(R.string.msg_operation_failed_generic) + + withContext(mainDispatcher) { + showLoadingProgress(false) + } + } + } + } + + private fun millsToProgress(mills: Long, duration: Long): Float { + return if (duration <= 0) { + 0f + } else { + mills / duration.toFloat() + } + } + + fun moveToStart() { + val moveAnimator = ValueAnimator.ofObject( + LongEvaluator(), + _state.value.waveformState.progressMills, + 0L + ) + moveAnimator.interpolator = DecelerateInterpolator() + moveAnimator.duration = ANIMATION_DURATION + moveAnimator.addUpdateListener { animation: ValueAnimator -> + val moveValMills = animation.animatedValue as Long + handleSeekProgress(moveValMills) + } + moveAnimator.start() + } + + fun showLoadingProgress(value: Boolean) { + _state.value = _state.value.copy(isShowLoadingProgress = value) + } + + fun getNewRecordName(): String { + val recordName = when (prefs.settingNamingFormat) { + NameFormat.Record -> { + prefs.incrementRecordCounter() + FileUtil.generateRecordNameCounted(prefs.recordCounter) + } + NameFormat.Date -> FileUtil.generateRecordNameDateVariant() + NameFormat.DateUs -> FileUtil.generateRecordNameDateUS() + NameFormat.DateIso8601 -> FileUtil.generateRecordNameDateISO8601() + NameFormat.Timestamp -> FileUtil.generateRecordNameMills() + } + + return recordName + } + + fun incrementRecordedRecordPartCounter() { + prefs.recordedRecordPartCounter += 1 + } + + fun resetRecordedRecordPartCounter() { + prefs.recordedRecordPartCounter = 0 + } + + private fun getPartName(baseName: String, partCounter: Int): String { + return "${baseName}_$partCounter" + } + + fun addExtension(name: String): String { + return FileUtil.addExtension(name, prefs.settingRecordingFormat.value) + } + + //TODO: This function shouldn't be here + private fun convertSpaceBytesToTimeInSeconds( + spaceBytes: Long, + recordingFormat: RecordingFormat, + sampleRate: Int, + bitrate: Int, + channels: Int + ): Long { + return when (recordingFormat) { + RecordingFormat.ThreeGp -> 1000L * (spaceBytes / (AppConstants.RECORD_ENCODING_BITRATE_12000 / 8)) + RecordingFormat.M4a -> 1000L * (spaceBytes / (bitrate / 8)) + RecordingFormat.Wav -> 1000L * (spaceBytes / (sampleRate * channels * 2)) + } + } + + @SuppressWarnings("CyclomaticComplexMethod") + fun onAction(action: HomeScreenAction) { + when (action) { + HomeScreenAction.InitHomeScreen -> init() + is HomeScreenAction.ImportAudioFile -> importAudioFile(action.uri) + HomeScreenAction.ShareActiveRecord -> shareActiveRecord() + HomeScreenAction.ShowActiveRecordInfo -> showActiveRecordInfo() + HomeScreenAction.OpenActiveRecordWithAnotherApp -> openActiveRecordWithAnotherApp() + HomeScreenAction.DeleteActiveRecord -> deleteActiveRecord() + HomeScreenAction.SaveActiveRecordAs -> saveActiveRecordAs() + is HomeScreenAction.RenameActiveRecord -> { + viewModelScope.launch(mainDispatcher) { + renameActiveRecord(action.newName) + } + } + HomeScreenAction.OnSeekStart -> handleSeekStart() + is HomeScreenAction.OnSeekProgress -> handleSeekProgress(action.mills) + is HomeScreenAction.OnSeekEnd -> handleSeekEnd(action.mills) + is HomeScreenAction.OnProgressBarStateChange -> handleProgressBarStateChange(action.value) + HomeScreenAction.OnPauseClick -> handlePlaybackPauseClick() + HomeScreenAction.OnPlayClick -> handlePlayClick() + HomeScreenAction.OnStopClick -> handlePlaybackStopClick() + //Recording + HomeScreenAction.OnStartRecordingClick -> { + viewModelScope.launch(mainDispatcher) { + resetRecordedRecordPartCounter() + val recordName = getNewRecordName() + handleStartRecordingClick(recordName) + prefs.recordedRecordBaseName = recordName + } + } + HomeScreenAction.OnPauseRecordingClick -> handlePauseRecordingClick() + HomeScreenAction.OnResumeRecordingClick -> handleResumeRecordingClick() + HomeScreenAction.OnStopRecordingClick -> handleStopRecordingClick() + HomeScreenAction.OnDeleteRecordingProgressClick -> handleOnDeleteRecordingProgressClick() + is HomeScreenAction.RestoreRecordFromRecycle -> handleRestoreRecordFromRecycle(action.recordId) + is HomeScreenAction.SetBluetoothMicEnabled -> { + viewModelScope.launch { + audioManagerHelper.enableBluetoothMic(action.enabled) + } + } + is HomeScreenAction.SelectBluetoothDevice -> { + audioManagerHelper.selectBluetoothDevice(action.device) + } + HomeScreenAction.DismissLostRecordsDialog -> dismissLostRecordsDialog() + } + } + + private fun dismissLostRecordsDialog() { + _state.value = _state.value.copy( + showLostRecordsDialog = false, + lostRecord = null + ) + } + + private fun emitEvent(event: HomeScreenEvent) { + viewModelScope.launch { + _event.emit(event) + } + } + + override fun onCleared() { + super.onCleared() + try { + audioManagerHelper.release() + } catch (e: Exception) { + Timber.e(e, "Error releasing AudioManagerHelper") + } + } +} + +data class HomeScreenState( + val waveformState: WaveformState = WaveformState(), + val startTime: String = "", + val endTime: String = "", + val time: String = TimeUtils.formatTimeIntervalHourMinSec2(0), + //Progress is value between 0 - 1f + val progress: Float = 0f, + val recordName: String = "", + val recordInfo: String = "", + val isShowWaveform: Boolean = false, + // Indicates loading progress + val isShowLoadingProgress: Boolean = false, + val isContextMenuAvailable: Boolean = false, + val isStopRecordingButtonAvailable: Boolean = false, + val bottomBarState: BottomBarState = BottomBarState.READY_TO_START_RECORDING, + val showPause: Boolean = false, + val showStop: Boolean = false, + val isSeek: Boolean = false, + val isDeleteRecordingProgressRequested: Boolean = false, + // Bluetooth mic state + val isBluetoothMicAvailable: Boolean = false, + val isBluetoothMicEnabled: Boolean = false, + val bluetoothDeviceName: String? = null, + val connectedBluetoothDevices: List = emptyList(), + val selectedBluetoothDevice: BluetoothDeviceInfo? = null, + // Audio source selection + val selectedAudioSource: AudioSource = AudioSource.MIC, + // Lost records + val showLostRecordsDialog: Boolean = false, + val lostRecord: Record? = null, +) { + fun isRecording(): Boolean { + return this.bottomBarState == BottomBarState.RECORDING || this.bottomBarState == BottomBarState.PAUSED + } +} + +enum class BottomBarState { + READY_TO_START_RECORDING, + RECORDING, + PAUSED, +} + +sealed class HomeScreenAction { + data object InitHomeScreen : HomeScreenAction() + data class ImportAudioFile(val uri: Uri) : HomeScreenAction() + data object ShareActiveRecord : HomeScreenAction() + data object ShowActiveRecordInfo : HomeScreenAction() + data object OpenActiveRecordWithAnotherApp : HomeScreenAction() + data object DeleteActiveRecord : HomeScreenAction() + data class RestoreRecordFromRecycle(val recordId: Long) : HomeScreenAction() + data object SaveActiveRecordAs : HomeScreenAction() + data class RenameActiveRecord(val newName: String) : HomeScreenAction() + data object OnSeekStart : HomeScreenAction() + data object OnPlayClick : HomeScreenAction() + data object OnPauseClick : HomeScreenAction() + data object OnStopClick : HomeScreenAction() + data object OnStartRecordingClick : HomeScreenAction() + data object OnPauseRecordingClick : HomeScreenAction() + data object OnResumeRecordingClick : HomeScreenAction() + data object OnStopRecordingClick : HomeScreenAction() + data object OnDeleteRecordingProgressClick : HomeScreenAction() + data class OnSeekProgress(val mills: Long) : HomeScreenAction() + data class OnSeekEnd(val mills: Long) : HomeScreenAction() + data class OnProgressBarStateChange(val value: Float) : HomeScreenAction() + data class SetBluetoothMicEnabled(val enabled: Boolean) : HomeScreenAction() + data class SelectBluetoothDevice(val device: BluetoothDeviceInfo?) : HomeScreenAction() + data object DismissLostRecordsDialog : HomeScreenAction() +} + +sealed class HomeScreenEvent { + data class RecordMovedToRecycleSnack(val recordId: Long, val recordName: String?) : + HomeScreenEvent() + data object ShowImportErrorError : HomeScreenEvent() + data class ShowErrorSnack(val message: String) : HomeScreenEvent() + data class ShowInfoSnack(val message: String) : HomeScreenEvent() + data class RecordInformationEvent(val recordInfo: RecordInfoState) : HomeScreenEvent() +} + +private class LongEvaluator : TypeEvaluator { + override fun evaluate(fraction: Float, startValue: Long, endValue: Long): Long { + return startValue + ((endValue - startValue) * fraction).toLong() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/AssetParamType.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/AssetParamType.kt new file mode 100644 index 000000000..a92e1b42c --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/AssetParamType.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.info + +import android.os.Build +import android.os.Bundle +import androidx.navigation.NavType +import com.google.gson.Gson + +class AssetParamType : NavType(isNullableAllowed = false) { + + override fun get(bundle: Bundle, key: String): RecordInfoState? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + bundle.getParcelable(key, RecordInfoState::class.java) + } else { + bundle.getParcelable(key) + } + } + + override fun parseValue(value: String): RecordInfoState { + return Gson().fromJson(value, RecordInfoState::class.java) + } + + override fun put(bundle: Bundle, key: String, value: RecordInfoState) { + bundle.putParcelable(key, value) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/Mapper.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/Mapper.kt new file mode 100644 index 000000000..58c1a7c20 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/Mapper.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.info + +import com.dimowner.audiorecorder.v2.data.model.Record + +fun Record.toRecordInfoState(): RecordInfoState { + return RecordInfoState( + name = this.name, + format = this.format, + duration = this.durationMills, + size = this.size, + location = this.path, + created = this.created, + sampleRate = this.sampleRate, + channelCount = this.channelCount, + bitrate = this.bitrate, + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/RecordInfoScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/RecordInfoScreen.kt new file mode 100644 index 000000000..999c767ae --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/RecordInfoScreen.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.info + +import android.text.format.Formatter +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.util.TimeUtils +import com.dimowner.audiorecorder.v2.app.InfoItem +import com.dimowner.audiorecorder.v2.app.TitleBar + +@Composable +fun RecordInfoScreen( + onPopBackStack: () -> Unit, + recordInfo: RecordInfoState? +) { + val context = LocalContext.current + + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + ) { innerPadding -> + Surface( + modifier = Modifier.fillMaxSize().padding(innerPadding) + ) { + Column(modifier = Modifier.fillMaxSize()) { + TitleBar( + stringResource(id = R.string.info), + onBackPressed = { onPopBackStack() } + ) + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .weight(weight = 1f, fill = false) + ) { + Spacer(modifier = Modifier.size(8.dp)) + if (recordInfo != null) { + InfoItem(stringResource(R.string.rec_name), recordInfo.name) + InfoItem(stringResource(R.string.rec_format), recordInfo.format) + if (recordInfo.bitrate > 0) { + InfoItem( + stringResource(R.string.bitrate), + stringResource(id = R.string.value_kbps, recordInfo.bitrate / 1000) + ) + } + InfoItem( + stringResource(R.string.channels), + stringResource( + when (recordInfo.channelCount) { + 1 -> R.string.mono + 2 -> R.string.stereo + else -> R.string.empty + } + ) + ) + InfoItem( + stringResource(R.string.sample_rate), + stringResource(id = R.string.value_khz, recordInfo.sampleRate / 1000) + ) + if (recordInfo.duration > 0) { + InfoItem( + stringResource(R.string.rec_duration), + TimeUtils.formatTimeIntervalHourMinSec2(recordInfo.duration) + ) + } + InfoItem( + stringResource(R.string.rec_size), + Formatter.formatShortFileSize(context, recordInfo.size) + ) + InfoItem(stringResource(R.string.rec_location), recordInfo.location) + InfoItem( + stringResource(R.string.rec_created), + TimeUtils.formatDateTimeLocale(recordInfo.created) + ) + } else { + Text( + modifier = Modifier.fillMaxSize().align(Alignment.CenterHorizontally), + text = stringResource(id = R.string.error_unknown), + textAlign = TextAlign.Center + ) + } + Spacer(modifier = Modifier.size(8.dp)) + } + } + } + } +} + +@Preview +@Composable +fun RecordInfoScreenPreview() { + RecordInfoScreen({}, RecordInfoState( + name = "name666", + format = "format777", + duration = 150000000, + size = 1500000, + location = "location888", + created = System.currentTimeMillis(), + sampleRate = 44000, + channelCount = 1, + bitrate = 240000, + )) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/RecordInfoState.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/RecordInfoState.kt new file mode 100644 index 000000000..a3496a177 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/RecordInfoState.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.info + +import android.os.Parcelable +import com.dimowner.audiorecorder.AppConstants +import kotlinx.parcelize.Parcelize + +@Parcelize +data class RecordInfoState( + val name: String, + val format: String, + val duration: Long, + val size: Long, + val location: String, + val created: Long, + val sampleRate: Int, + val channelCount: Int, + val bitrate: Int, +) : Parcelable { + + val nameWithExtension: String + get() = name + AppConstants.EXTENSION_SEPARATOR + format +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/lostrecords/LostRecordsDialog.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/lostrecords/LostRecordsDialog.kt new file mode 100644 index 000000000..8a20ff91e --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/lostrecords/LostRecordsDialog.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.lostrecords + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.R + +@Composable +fun LostRecordsDialog( + onDismiss: () -> Unit, + onDetailsClick: () -> Unit, +) { + AlertDialog( + title = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier.padding(0.dp, 0.dp, 16.dp, 0.dp), + painter = painterResource(id = R.drawable.ic_warning_yellow), + contentDescription = stringResource(id = R.string.warning) + ) + Text(text = stringResource(id = R.string.warning)) + } + }, + text = { + Text( + text = stringResource(id = R.string.error_lost_records), + fontSize = 18.sp + ) + }, + onDismissRequest = { + onDismiss() + }, + confirmButton = { + TextButton( + onClick = { + onDetailsClick() + } + ) { + Text(stringResource(id = R.string.btn_details)) + } + }, + dismissButton = { + TextButton( + onClick = { + onDismiss() + } + ) { + Text(stringResource(id = R.string.btn_ok)) + } + } + ) +} + +@Preview(showBackground = true) +@Composable +fun LostRecordsDialogPreview() { + LostRecordsDialog( + onDismiss = {}, + onDetailsClick = {} + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/lostrecords/LostRecordsScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/lostrecords/LostRecordsScreen.kt new file mode 100644 index 000000000..c6f604c7c --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/lostrecords/LostRecordsScreen.kt @@ -0,0 +1,198 @@ +/* + * Copyright 2026 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.lostrecords + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.DeleteDialog +import com.dimowner.audiorecorder.v2.app.TitleBar +import com.dimowner.audiorecorder.v2.app.lostrecords.widget.LostRecordsListItemWidget +import com.google.gson.Gson +import timber.log.Timber + +@Composable +internal fun LostRecordsScreen( + onPopBackStack: () -> Unit, + showRecordInfoScreen: (String) -> Unit, + uiState: LostRecordsScreenState, + event: LostRecordsScreenEvent?, + onAction: (LostRecordsScreenAction) -> Unit, +) { + val showDeleteAllDialog = remember { mutableStateOf(false) } + + LaunchedEffect(key1 = event) { + when (event) { + is LostRecordsScreenEvent.RecordInformationEvent -> { + val json = Uri.encode(Gson().toJson(event.recordInfo)) + Timber.v("ON EVENT: RecordInfo json = $json") + showRecordInfoScreen(json) + } + else -> { + Timber.v("ON EVENT: Unknown") + //Do nothing + } + } + } + + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + ) { innerPadding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + Column(modifier = Modifier.fillMaxSize()) { + TitleBar( + title = stringResource(id = R.string.lost_records), + onBackPressed = { onPopBackStack() }, + actionButtonText = stringResource(id = R.string.delete_all2), + onActionClick = if (uiState.records.isNotEmpty()) { + { showDeleteAllDialog.value = true } + } else null + ) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(color = MaterialTheme.colorScheme.inverseOnSurface) + ) + Text( + modifier = Modifier.fillMaxWidth().padding(16.dp, 8.dp), + text = stringResource(R.string.records_were_removed), + fontSize = 16.sp + ) + if (uiState.records.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Text( + modifier = Modifier.wrapContentSize(), + text = stringResource(id = R.string.no_records), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 20.sp, + fontWeight = FontWeight.Normal + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + ) { + items(uiState.records) { record -> + LostRecordsListItemWidget( + name = record.name, + duration = record.duration, + path = record.path, + size = record.size, + onClickItem = { + onAction(LostRecordsScreenAction.ShowRecordInfo(record.recordId)) + }, + onClickDelete = { + onAction(LostRecordsScreenAction.DeleteRecord(record.recordId)) + }, + ) + } + } + } + } + if (showDeleteAllDialog.value) { + DeleteDialog( + dialogText = stringResource(id = R.string.delete_all_records), + onAcceptClick = { + onAction(LostRecordsScreenAction.DeleteAllRecords) + showDeleteAllDialog.value = false + }, + onDismissClick = { + showDeleteAllDialog.value = false + }, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun LostRecordsScreenPreview() { + LostRecordsScreen( + onPopBackStack = {}, + showRecordInfoScreen = {}, + uiState = LostRecordsScreenState( + records = listOf( + LostRecordListItem( + recordId = 0, + name = "Record Name 1", + duration = "5:21", + size = "3.11Mb", + path = "/storage/emulated/0/AudioRecorder/Recording_001.m4a" + ), + LostRecordListItem( + recordId = 1, + name = "Record Name 2", + duration = "2:43", + size = "1.25Mb", + path = "/storage/emulated/0/AudioRecorder/Recording_002.m4a" + ) + ) + ), + event = null, + onAction = {} + ) +} + +@Preview(showBackground = true) +@Composable +fun LostRecordsScreenEmptyPreview() { + LostRecordsScreen( + onPopBackStack = {}, + showRecordInfoScreen = {}, + uiState = LostRecordsScreenState(records = emptyList()), + event = null, + onAction = {} + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/lostrecords/LostRecordsViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/lostrecords/LostRecordsViewModel.kt new file mode 100644 index 000000000..c51bd398e --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/lostrecords/LostRecordsViewModel.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2026 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.lostrecords + +import android.app.Application +import android.content.Context +import android.text.format.Formatter +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.dimowner.audiorecorder.util.TimeUtils +import com.dimowner.audiorecorder.v2.app.info.RecordInfoState +import com.dimowner.audiorecorder.v2.app.info.toRecordInfoState +import com.dimowner.audiorecorder.v2.data.RecordsDataSource +import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher +import com.dimowner.audiorecorder.v2.di.qualifiers.MainDispatcher +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +internal class LostRecordsViewModel @Inject constructor( + private val recordsDataSource: RecordsDataSource, + @param:MainDispatcher private val mainDispatcher: CoroutineDispatcher, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + @ApplicationContext context: Context +) : AndroidViewModel(context as Application) { + + private val _state = mutableStateOf(LostRecordsScreenState()) + val state: State = _state + + private val _event = MutableSharedFlow() + val event: SharedFlow = _event + + fun deleteRecord(recordId: Long) { + viewModelScope.launch(ioDispatcher) { + if (recordsDataSource.deleteLostRecord(recordId)) { + withContext(mainDispatcher) { + _state.value = _state.value.copy( + records = _state.value.records.filter { it.recordId != recordId } + ) + } + } + } + } + + fun deleteAllRecords() { + viewModelScope.launch(ioDispatcher) { + val recordIds = _state.value.records.map { it.recordId } + for (id in recordIds) { + recordsDataSource.deleteLostRecord(id) + } + withContext(mainDispatcher) { + _state.value = _state.value.copy( + records = emptyList() + ) + } + } + } + + fun loadRecordsByIds(ids: String) { + viewModelScope.launch(ioDispatcher) { + val context: Context = getApplication().applicationContext + val recordIds = ids.split(",") + .mapNotNull { it.trim().toLongOrNull() } + + if (recordIds.isNotEmpty()) { + val records = recordsDataSource.getRecords(recordIds) + val lostRecordItems = records.map { it.toLostRecordListItem(context) } + + withContext(mainDispatcher) { + _state.value = _state.value.copy( + records = lostRecordItems + ) + } + } + } + } + + fun showRecordInfo(recordId: Long) { + viewModelScope.launch(ioDispatcher) { + recordsDataSource.getRecord(recordId)?.toRecordInfoState()?.let { + emitEvent(LostRecordsScreenEvent.RecordInformationEvent(it)) + } + } + } + + fun onAction(action: LostRecordsScreenAction) { + when (action) { + is LostRecordsScreenAction.DeleteRecord -> deleteRecord(action.recordId) + LostRecordsScreenAction.DeleteAllRecords -> deleteAllRecords() + is LostRecordsScreenAction.ShowRecordInfo -> showRecordInfo(action.recordId) + } + } + + private fun emitEvent(event: LostRecordsScreenEvent) { + viewModelScope.launch { + _event.emit(event) + } + } +} + +internal data class LostRecordsScreenState( + val records: List = emptyList(), +) + +internal data class LostRecordListItem( + val recordId: Long, + val name: String, + val duration: String, + val size: String, + val path: String, +) + +internal sealed class LostRecordsScreenEvent { + data class RecordInformationEvent(val recordInfo: RecordInfoState) : LostRecordsScreenEvent() +} + +internal sealed class LostRecordsScreenAction { + data class DeleteRecord(val recordId: Long) : LostRecordsScreenAction() + data object DeleteAllRecords : LostRecordsScreenAction() + data class ShowRecordInfo(val recordId: Long) : LostRecordsScreenAction() +} + +internal fun Record.toLostRecordListItem(context: Context): LostRecordListItem { + return LostRecordListItem( + recordId = this.id, + name = this.name, + duration = TimeUtils.formatTimeIntervalHourMinSec2(this.durationMills), + size = Formatter.formatShortFileSize(context, this.size), + path = this.path, + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/lostrecords/widget/LostRecordsListItemWidget.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/lostrecords/widget/LostRecordsListItemWidget.kt new file mode 100644 index 000000000..868c1efaa --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/lostrecords/widget/LostRecordsListItemWidget.kt @@ -0,0 +1,153 @@ +/* + * Copyright 2026 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.lostrecords.widget + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.DeviceFontFamilyName +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.DeleteDialog + +@Composable +fun LostRecordsListItemWidget( + name: String, + duration: String, + size: String, + path: String, + onClickItem: () -> Unit, + onClickDelete: () -> Unit, +) { + val showDeleteDialog = remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .clickable { onClickItem() } + .fillMaxWidth() + .wrapContentHeight(), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .padding(12.dp, 8.dp, 0.dp, 8.dp) + .weight(1f) + .wrapContentHeight(), + horizontalAlignment = Alignment.Start + ) { + Text( + modifier = Modifier + .padding(0.dp, 0.dp, 0.dp, 2.dp) + .wrapContentWidth() + .wrapContentHeight(), + text = name, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 20.sp, + fontWeight = FontWeight.Medium, + overflow = TextOverflow.Ellipsis + ) + Text( + modifier = Modifier + .padding(0.dp, 2.dp) + .wrapContentWidth() + .wrapContentHeight(), + text = "${stringResource(R.string.rec_duration)} $duration ${stringResource(R.string.rec_size)} $size", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + Text( + modifier = Modifier + .padding(0.dp, 2.dp, 0.dp, 0.dp) + .fillMaxWidth() + .wrapContentHeight(), + text = path, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + overflow = TextOverflow.Ellipsis + ) + } + Button( + modifier = Modifier + .padding(4.dp) + .wrapContentSize(), + onClick = { showDeleteDialog.value = true } + ) { + Text( + text = stringResource(id = R.string.delete), + fontSize = 16.sp, + fontWeight = FontWeight.Light, + ) + } + if (showDeleteDialog.value) { + DeleteDialog( + dialogText = stringResource(id = R.string.delete_record, name), + onAcceptClick = { + onClickDelete() + showDeleteDialog.value = false + }, + onDismissClick = { + showDeleteDialog.value = false + }, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun LostRecordsListItemWidgetPreview() { + LostRecordsListItemWidget( + name = "Recording 001", + duration = "5:21", + size = "3.50Mb", + path = "/storage/emulated/0/AudioRecorder/Recording_001.m4a", + onClickItem = {}, + onClickDelete = {} + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsComponents.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsComponents.kt new file mode 100644 index 000000000..38057631a --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsComponents.kt @@ -0,0 +1,398 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.records + +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.DeviceFontFamilyName +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.RecordsDropDownMenu +import com.dimowner.audiorecorder.v2.app.records.models.RecordDropDownMenuItemId +import com.dimowner.audiorecorder.v2.app.records.models.SortDropDownMenuItemId +import timber.log.Timber + +@Composable +fun RecordsTopBar( + title: String, + subTitle: String, + bookmarksSelected: Boolean, + onBackPressed: () -> Unit, + onSortItemClick: (SortDropDownMenuItemId) -> Unit, + onBookmarksClick: (Boolean) -> Unit, +) { + val expanded = remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .height(64.dp) + .fillMaxWidth() + .background(color = MaterialTheme.colorScheme.surface), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + IconButton( + onClick = onBackPressed, + modifier = Modifier + .padding(8.dp) + .align(Alignment.CenterVertically), + ) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Navigate back", + modifier = Modifier.size(24.dp) + ) + } + + Column { + Text( + text = title, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 22.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + Text( + modifier = Modifier + .wrapContentWidth() + .wrapContentHeight(), + text = subTitle, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + } + Spacer(modifier = Modifier.weight(1f)) + Box { + RecordsDropDownMenu( + items = remember { getSortDroDownMenuItems() }, + onItemClick = { itemId -> + onSortItemClick(itemId) + Timber.v("On Drop Down Menu item click id = $itemId") + }, + expanded = expanded + ) + IconButton( + onClick = { + expanded.value = true + }, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_sort), + contentDescription = stringResource(id = androidx.compose.ui.R.string.dropdown_menu), + modifier = Modifier + .size(36.dp) + .padding(6.dp) + ) + } + } + IconButton( + onClick = { + onBookmarksClick(!bookmarksSelected) + }, + ) { + Icon( + painter = if (bookmarksSelected) { + painterResource(id = R.drawable.ic_bookmark) + } else { + painterResource(id = R.drawable.ic_bookmark_bordered) + }, + contentDescription = stringResource(id = androidx.compose.ui.R.string.dropdown_menu), + modifier = Modifier + .size(36.dp) + .padding(6.dp) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun RecordsTopBarPreview() { + RecordsTopBar("Title bar", "By date", false, {}, {}, {}) +} + +@Composable +fun RecordListItemView( + name: String, + details: String, + duration: String, + isBookmarked: Boolean, + isSelected: Boolean, + isShowMenuButton: Boolean, + onClickItem: () -> Unit, + onLongClickItem: () -> Unit, + onClickBookmark: (Boolean) -> Unit, + onClickMenu: (RecordDropDownMenuItemId) -> Unit, +) { + val expanded = remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .background(color = if (isSelected) colorResource(R.color.selected_item_color) else Color.Transparent) + .combinedClickable( + onClick = { + onClickItem() + }, + onLongClick = { + onLongClickItem() + } + ) + .fillMaxWidth() + .wrapContentHeight() + ) { + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.Start + ) { + Text( + modifier = Modifier + .padding(16.dp, 10.dp, 12.dp, 2.dp) + .fillMaxWidth() + .wrapContentHeight(), + text = name, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 18.sp, + fontWeight = FontWeight.Medium + ) + Text( + modifier = Modifier + .padding(16.dp, 2.dp, 12.dp, 10.dp) + .fillMaxWidth() + .wrapContentHeight(), + text = details, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + } + Column( + modifier = Modifier + .wrapContentSize() + .padding(0.dp, 4.dp), + horizontalAlignment = Alignment.End + ) { + IconButton( + onClick = { onClickBookmark(!isBookmarked) }, + modifier = Modifier + .width(36.dp) + .height(32.dp) + ) { + Icon( + painter = if (isBookmarked) { + painterResource(id = R.drawable.ic_bookmark_small) + } else { + painterResource(id = R.drawable.ic_bookmark_bordered_small) + }, + contentDescription = stringResource(id = R.string.bookmarks), + modifier = Modifier + .padding(6.dp) + .size(36.dp) + .fillMaxHeight() + ) + } + Text( + modifier = Modifier + .padding(0.dp, 2.dp, 8.dp, 12.dp) + .wrapContentWidth() + .wrapContentHeight(), + text = duration, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + } + if (isShowMenuButton) { + Box( + modifier = Modifier.align(Alignment.CenterVertically), + ) { + // The DropdownMenu composable + RecordsDropDownMenu( + items = remember { getRecordsDroDownMenuItems() }, + onItemClick = { itemId -> + Timber.v("On Drop Down Menu item click id = $itemId") + onClickMenu(itemId) + }, + expanded = expanded + ) + IconButton( + onClick = { expanded.value = !expanded.value }, + modifier = Modifier + .width(36.dp) + .height(60.dp) + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(id = androidx.compose.ui.R.string.dropdown_menu), + modifier = Modifier + .width(36.dp) + .padding(4.dp) + .fillMaxHeight() + ) + } + } + } else { + Spacer(modifier = Modifier.width(36.dp).height(60.dp)) + } + } +} + +@Preview(showBackground = true) +@Composable +fun RecordListItemPreview() { + RecordListItemView("Label", "Value", "Duration", true, true, true, {}, {}, {}, {}) +} + +@Composable +fun MultiSelectMenu( + selectedItemsCount: Int, + onCancelClick: () -> Unit, + onShareClick: () -> Unit, + onDownloadClick: () -> Unit, + onDeleteClick: () -> Unit, +) { + Row( + modifier = Modifier + .height(64.dp) + .fillMaxWidth() + .background(color = MaterialTheme.colorScheme.surface), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + IconButton( + onClick = onCancelClick, + modifier = Modifier + .padding(8.dp) + .align(Alignment.CenterVertically), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Navigate back", + modifier = Modifier.size(24.dp) + ) + } + + Text( + text = stringResource(R.string.selected, selectedItemsCount), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 22.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + + Spacer(modifier = Modifier.weight(1f)) + + IconButton( + onClick = onShareClick, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_share), + contentDescription = stringResource(id = R.string.share), + modifier = Modifier + .size(36.dp) + .padding(6.dp) + ) + } + IconButton( + onClick = onDownloadClick, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_save_alt), + contentDescription = stringResource(id = R.string.save_as), + modifier = Modifier + .size(36.dp) + .padding(6.dp) + ) + } + IconButton( + onClick = onDeleteClick, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_delete), + contentDescription = stringResource(id = R.string.delete), + modifier = Modifier + .size(36.dp) + .padding(6.dp) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun MultiSelectMenuPreview() { + MultiSelectMenu(3, {},{}, {}, {}) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsExtensions.kt new file mode 100644 index 000000000..9aa345953 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsExtensions.kt @@ -0,0 +1,251 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.records + +import android.content.Context +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.util.TimeUtils +import com.dimowner.audiorecorder.util.TimeUtils.formatDateSmartLocale +import com.dimowner.audiorecorder.v2.app.DropDownMenuItem +import com.dimowner.audiorecorder.v2.app.records.models.RecordDropDownMenuItemId +import com.dimowner.audiorecorder.v2.app.records.models.SortDropDownMenuItemId +import com.dimowner.audiorecorder.v2.data.model.SortOrder + +fun getRecordsDroDownMenuItems(): List> { + return RecordDropDownMenuItemId.entries.map { + when (it) { + RecordDropDownMenuItemId.SHARE -> DropDownMenuItem( + id = it, textResId = R.string.share, imageResId = R.drawable.ic_share + ) + RecordDropDownMenuItemId.INFORMATION -> DropDownMenuItem( + id = it, textResId = R.string.info, imageResId = R.drawable.ic_info + ) + RecordDropDownMenuItemId.RENAME -> DropDownMenuItem( + id = it, textResId = R.string.rename, imageResId = R.drawable.ic_pencil + ) + RecordDropDownMenuItemId.OPEN_WITH -> DropDownMenuItem( + id = it, textResId = R.string.open_with, imageResId = R.drawable.ic_open_with + ) + RecordDropDownMenuItemId.SAVE_AS -> DropDownMenuItem( + id = it, textResId = R.string.save_as, imageResId = R.drawable.ic_save_alt + ) + RecordDropDownMenuItemId.DELETE -> DropDownMenuItem( + id = it, textResId = R.string.delete, imageResId = R.drawable.ic_delete_forever + ) + } + } +} + +fun getSortDroDownMenuItems(): List> { + return SortDropDownMenuItemId.entries.map { + when (it) { + SortDropDownMenuItemId.DATE_DESC -> DropDownMenuItem( + id = it, textResId = R.string.by_date, imageResId = R.drawable.ic_calendar_today + ) + SortDropDownMenuItemId.DATE_ASC -> DropDownMenuItem( + id = it, textResId = R.string.by_date_desc, imageResId = R.drawable.ic_calendar_today + ) + SortDropDownMenuItemId.NAME -> DropDownMenuItem( + id = it, textResId = R.string.by_name, imageResId = R.drawable.ic_sort_by_alpha + ) + SortDropDownMenuItemId.NAME_DESC -> DropDownMenuItem( + id = it, textResId = R.string.by_name_desc, imageResId = R.drawable.ic_sort_by_alpha + ) + SortDropDownMenuItemId.DURATION_DESC -> DropDownMenuItem( + id = it, textResId = R.string.by_duration, imageResId = R.drawable.ic_access_time + ) + SortDropDownMenuItemId.DURATION -> DropDownMenuItem( + id = it, textResId = R.string.by_duration_desc, imageResId = R.drawable.ic_access_time + ) + } + } +} + +fun SortOrder.toText(context: Context): String { + return when (this) { + SortOrder.DateDesc -> context.getString(R.string.by_date) + SortOrder.DateAsc -> context.getString(R.string.by_date_desc) + SortOrder.NameAsc -> context.getString(R.string.by_name) + SortOrder.NameDesc -> context.getString(R.string.by_name_desc) + SortOrder.DurationShortest -> context.getString(R.string.by_duration_desc) + SortOrder.DurationLongest -> context.getString(R.string.by_duration) + } +} + +fun SortDropDownMenuItemId.toSortOrder(): SortOrder { + return when (this) { + SortDropDownMenuItemId.DATE_DESC -> SortOrder.DateDesc + SortDropDownMenuItemId.DATE_ASC -> SortOrder.DateAsc + SortDropDownMenuItemId.NAME -> SortOrder.NameAsc + SortDropDownMenuItemId.NAME_DESC -> SortOrder.NameDesc + SortDropDownMenuItemId.DURATION -> SortOrder.DurationShortest + SortDropDownMenuItemId.DURATION_DESC -> SortOrder.DurationLongest + } +} + +/** + * Updates the [recordsMap] within the current [RecordsScreenState] + * and returning a new [RecordsScreenState] instance. + * + * This ensures the entire state object remains immutable while reflecting the change + * to a single record in the nested map structure. + * + * @param recordId The unique ID of the record to find and update. + * @param onUpdate A function that takes the old [RecordListItem] and returns the new, updated one. + * @return A new [RecordsScreenState] with the updated record map. + */ +fun RecordsScreenState.updateRecordInMap( + recordId: Long, + onUpdate: (oldRecord: RecordListItem) -> RecordListItem +): RecordsScreenState { + return this.copy( + recordsMap = this.recordsMap.mapRecordInMap(recordId) { record -> + onUpdate(record) + } + ) +} + +/** + * Immutably finds and updates a single [RecordListItem] within the nested map structure. + * + * It iterates through each list in the map and applies the [onUpdate] lambda only to the + * record whose [recordId] matches the provided ID, preserving all other records and groups. + * + * @param recordId The unique ID of the record to find and update. + * @param onUpdate A function that takes the old [RecordListItem] and returns the new, updated one. + * @return A new [Map] with the single record updated. + */ +fun Map>.mapRecordInMap( + recordId: Long, + onUpdate: (oldRecord: RecordListItem) -> RecordListItem +): Map> { + return this.mapValues { (_, recordList) -> + recordList.map { record -> + if (record.recordId == recordId) { + onUpdate(record) + } else { + record + } + } + } +} + +/** + * Immutably removes a single [RecordListItem] with the matching [recordId] from the map. + * + * This function performs two filtering steps: + * 1. Filters the records within each list, removing the targeted record. + * 2. Filters the map itself, removing any date entries (keys) whose list of records + * became empty after the first step. + * + * @param recordId The unique ID of the record to remove. + * @return A new [Map] with the record removed, and potentially, an empty list group removed. + */ +fun Map>.removeRecordFromMap( + recordId: Long, +): Map> { + + // Filtering out the record to be removed. + val mapWithFilteredLists = this.mapValues { (_, recordList) -> + recordList.filter { record -> + record.recordId != recordId + } + } + + // Filtering out any date keys that now have an empty list. + return mapWithFilteredLists.filterValues { recordList -> + recordList.isNotEmpty() + } +} + +/** + * Immutably adds a single [RecordListItem] to the map, placing it in the correct date group, + * Ensures the group remains sorted. + * 1. Identifies the correct group key using the provided [sortOrder]. + * 2. Appends the record to the existing list for that key, or creates a new list if the key doesn't exist. + * 3. Sort the list based on the active SortOrder. + * 4. Returns a new Map containing the updated data. + */ +fun Map>.addRecordToMap( + context: Context, + record: RecordListItem, + sortOrder: SortOrder +): Map> { + // Determine the key where this record belongs + val key = if (sortOrder.isSortOrderByDate()) { + formatDateSmartLocale(record.added, context) + } else { + "" + } + + // Get the current list for that key (or empty if it's a new date) + val currentList = this[key] ?: emptyList() + + // Add the new record + val newList = currentList + record + + // Sort the list based on the active SortOrder + val sortedList = newList.sort(sortOrder) + + return this + (key to sortedList) +} + +/** + * Returns a new list of [RecordListItem] sorted according to the specified [SortOrder]. + * @param sortOrder The strategy used to determine the element sequence. + * @return A sorted copy of the original list. + */ +fun List.sort(sortOrder: SortOrder): List { + return when (sortOrder) { + SortOrder.DateAsc -> this.sortedBy { it.added } + SortOrder.DateDesc -> this.sortedByDescending { it.added } + SortOrder.NameAsc -> this.sortedBy { it.name } + SortOrder.NameDesc -> this.sortedByDescending { it.name } + SortOrder.DurationShortest -> this.sortedBy { it.duration } + SortOrder.DurationLongest -> this.sortedByDescending { it.duration } + } +} + +/** + * Groups the list of [RecordListItem] objects into groups of records divided by date. + * This function is designed to support a UI with conditional sticky headers. + * @param context The [Context] required by [TimeUtils.formatDateSmartLocale] to generate + * localized date strings. + * @param sortOrder Records list sort order. + * @return A [Map] where keys are the formatted date strings and values are the + * corresponding lists of [RecordListItem] objects. + */ +fun List.groupRecordsByDate( + context: Context, + sortOrder: SortOrder +): Map> { + return this.groupBy { + if (sortOrder.isSortOrderByDate()) { + formatDateSmartLocale(it.added, context) + } else { + "" + } + } +} + +/** + * Checks if the current [SortOrder] is related to sorting by date + * @return `true` if the sort order is [SortOrder.DateAsc] or [SortOrder.DateDesc], `false` otherwise. + */ +fun SortOrder.isSortOrderByDate(): Boolean { + return this == SortOrder.DateAsc || this == SortOrder.DateDesc +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsScreen.kt new file mode 100644 index 000000000..3eb1d415d --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsScreen.kt @@ -0,0 +1,643 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.records + +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +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.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.ComposableLifecycle +import com.dimowner.audiorecorder.v2.app.DeleteDialog +import com.dimowner.audiorecorder.v2.app.RenameAlertDialog +import com.dimowner.audiorecorder.v2.app.SaveAsDialog +import com.dimowner.audiorecorder.v2.app.components.TouchPanel +import com.dimowner.audiorecorder.v2.app.getTestWaveformData +import com.dimowner.audiorecorder.v2.app.home.HomeScreenAction +import com.dimowner.audiorecorder.v2.app.home.HomeScreenState +import com.dimowner.audiorecorder.v2.app.lostrecords.LostRecordsDialog +import com.dimowner.audiorecorder.v2.app.records.models.RecordDropDownMenuItemId +import com.dimowner.audiorecorder.v2.app.settings.SettingsItem +import com.dimowner.audiorecorder.v2.data.model.Record +import com.google.gson.Gson +import kotlinx.coroutines.launch +import timber.log.Timber + +private const val ANIMATION_DURATION = 500 +private const val MAX_MOVE = 250 + + +//TODO: Add simple waveform to each record item +//TODO: Make app bar with 'Trash' button scrollable together with records list. + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun RecordsScreen( + onPopBackStack: () -> Unit, + showRecordInfoScreen: (String) -> Unit, + showDeletedRecordsScreen: () -> Unit, + showLostRecordsScreen: (List) -> Unit, + uiState: RecordsScreenState, + event: RecordsScreenEvent?, + onAction: (RecordsScreenAction) -> Unit, + uiHomeState: HomeScreenState, + onHomeAction: (HomeScreenAction) -> Unit, +) { +// val density = LocalDensity.current +// // State to keep track of the Card position +// val offsetY = remember { mutableFloatStateOf(0f) } +// val maxMove = with(density) { MAX_MOVE.dp.toPx() } +// val k = (maxMove / (Math.PI / 2f)).toFloat() +// val startY = with(density) { 12.dp.toPx() } +// +// val animatableY = remember { Animatable(startY) } +// + // Get a CoroutineScope tied to the Composable + val coroutineScope = rememberCoroutineScope() +// +// // Define a threshold for Y coordinate movement +// val playPanelHeight = remember { mutableFloatStateOf(with(density) { 300.dp.toPx() }) } +// +// // Modifier to make the text draggable +// val modifier = Modifier +// .offset { IntOffset(0, animatableY.value.roundToInt()) } +// .pointerInput(Unit) { +// detectDragGestures( +// onDragStart = { +// offsetY.floatValue = startY +// }, +// onDragEnd = { +// // Animate back to start position +// if (offsetY.floatValue.absoluteValue > playPanelHeight.floatValue * 0.5) { +// coroutineScope.launch { +// animatableY.animateTo( +//// TODO:Fix constants!! +// playPanelHeight.floatValue * 1.5f, +// animationSpec = tween(durationMillis = ANIMATION_DURATION) +// ) +// offsetY.floatValue = startY +// onHomeAction(HomeScreenAction.OnStopClick) +// } +// } else { +// coroutineScope.launch { +// animatableY.animateTo( +// startY, +// animationSpec = tween(durationMillis = ANIMATION_DURATION) +// ) +// } +// } +// }, +// onDragCancel = { +// if (offsetY.floatValue.absoluteValue > playPanelHeight.floatValue * 0.5) { +// coroutineScope.launch { +// animatableY.animateTo( +// playPanelHeight.floatValue * 1.5f, +// animationSpec = tween(durationMillis = ANIMATION_DURATION) +// ) +// offsetY.floatValue = startY +// onHomeAction(HomeScreenAction.OnStopClick) +// } +// } else { +// // Animate back to start position +// coroutineScope.launch { +// animatableY.animateTo( +// startY, +// animationSpec = tween(durationMillis = ANIMATION_DURATION) +// ) +// } +// } +// }, +// onDrag = { change, dragAmount -> +// change.consume() +// offsetY.floatValue += change.position.y +// offsetY.floatValue = k * atan(offsetY.floatValue / k) +// coroutineScope.launch { +// animatableY.snapTo(offsetY.floatValue) +// } +// } +// ) +// } + + val context = LocalContext.current + + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + ComposableLifecycle { _, event -> + when (event) { + Lifecycle.Event.ON_START -> { + Timber.d("RecordsScreen: On Start") + onAction(RecordsScreenAction.InitRecordsScreen( + showPlayPanel = uiHomeState.waveformState.progressMills > 0) + ) + } + Lifecycle.Event.ON_STOP -> { + Timber.d("RecordsScreen: On Stop") + onAction(RecordsScreenAction.OnStopRecordsScreen) + } + else -> {} + } + } + LaunchedEffect(key1 = event) { + when (event) { + is RecordsScreenEvent.RecordInformationEvent -> { + val json = Uri.encode(Gson().toJson(event.recordInfo)) + Timber.v("ON EVENT: ShareRecord json = $json") + showRecordInfoScreen(json) + } + is RecordsScreenEvent.RecordMovedToRecycleSnack -> { + scope.launch { + val message = context.getString(R.string.msg_recording_moved_to_trash, event.recordName) + + val result = snackbarHostState + .showSnackbar( + message = message, + actionLabel = context.getString(R.string.action_undo), + duration = SnackbarDuration.Short + ) + when (result) { + SnackbarResult.ActionPerformed -> { + onAction(RecordsScreenAction.RestoreRecordFromRecycle(event.recordId)) + } + SnackbarResult.Dismissed -> { + /* Handle snackbar dismissed */ + } + } + } + } + is RecordsScreenEvent.FewRecordsMovedToRecycleSnack -> { + scope.launch { + snackbarHostState + .showSnackbar( + message = context.getString( + R.string.msg_few_recordings_moved_to_trash, + event.movedCount, + event.expectedCount + ), + duration = SnackbarDuration.Short + ) + } + } + is RecordsScreenEvent.ShowInfoSnack -> { + scope.launch { + snackbarHostState + .showSnackbar( + message = event.message, + duration = SnackbarDuration.Short + ) + } + } + is RecordsScreenEvent.ShowErrorSnack -> { + scope.launch { + snackbarHostState + .showSnackbar( + message = event.message, + duration = SnackbarDuration.Short + ) + } + } + + else -> { + Timber.v("ON EVENT: Unknown") + //Do nothing + } + } + } + + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + ) { innerPadding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomStart, + ) { + if (uiState.isShowLoadingProgress) { + //Show nothing because of progress takes very short period of time + } else if (uiState.recordsMap.isEmpty()) { + Column( + modifier = Modifier + .wrapContentSize() + .align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + modifier = Modifier.wrapContentSize(), + painter = painterResource(id = R.drawable.ic_audiotrack_64), + contentDescription = "Image Description", + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary) + ) + Text( + modifier = Modifier.wrapContentSize(), + text = if (uiState.bookmarksSelected) { + stringResource(R.string.no_bookmarks) + } else { + stringResource(R.string.no_records) + }, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 20.sp, + fontWeight = FontWeight.Normal + ) + } + } + Column(modifier = Modifier.fillMaxSize()) { + if (uiState.selectedRecords.isEmpty()) { + RecordsTopBar( + stringResource(id = R.string.records), + uiState.sortOrder.toText(context), + bookmarksSelected = uiState.bookmarksSelected, + onBackPressed = { onPopBackStack() }, + onSortItemClick = { order -> + onAction(RecordsScreenAction.UpdateListWithSortOrder(order)) + }, + onBookmarksClick = { bookmarksSelected -> + onAction( + RecordsScreenAction.UpdateListWithBookmarks( + bookmarksSelected + ) + ) + } + ) + } else { + MultiSelectMenu( + selectedItemsCount = uiState.selectedRecords.size, + onCancelClick = { onAction(RecordsScreenAction.MultiSelectCancel)}, + onShareClick = { + onAction(RecordsScreenAction.MultiSelectShare(uiState.selectedRecords)) + }, + onDownloadClick = { + onAction(RecordsScreenAction.MultiSelectSaveAsRequest) + }, + onDeleteClick = { + onAction(RecordsScreenAction.MultiSelectMoveToRecycleRequest) + }, + ) + } + if (uiState.showDeletedRecordsButton) { + SettingsItem(stringResource(R.string.trash), R.drawable.ic_delete) { + showDeletedRecordsScreen() + } + } + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) { + uiState.recordsMap.forEach { (date, recordsOnDate) -> + //Sticky date header + stickyHeader { + if (date.isEmpty()) { + Box(modifier = Modifier) {} + } else { + Surface( + modifier = Modifier.fillParentMaxWidth(), + ) { + Text( + text = date, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + 16.dp, 10.dp, 16.dp, 2.dp + ), + textAlign = TextAlign.Center + ) + } + } + } + //The list of items for that specific date + items(recordsOnDate) { record -> + RecordListItemView( + name = record.name, + details = record.details, + duration = record.duration, + isBookmarked = record.isBookmarked, + isSelected = record.recordId == uiState.activeRecord?.recordId + || uiState.selectedRecords.contains(record), + isShowMenuButton = uiState.selectedRecords.isEmpty() + && record.recordId != uiState.recordedRecordId, + onClickItem = { + if (!uiState.isRecording) { + if (uiState.selectedRecords.isEmpty()) { + onAction(RecordsScreenAction.OnItemSelect(record)) + onHomeAction(HomeScreenAction.InitHomeScreen) + onHomeAction(HomeScreenAction.OnPlayClick) + } else { + onAction(RecordsScreenAction.MultiSelectAddItem(record)) + } + } + }, + onLongClickItem = { + if (!uiState.isRecording) { + onAction(RecordsScreenAction.MultiSelectAddItem(record)) + } + }, + onClickBookmark = { isBookmarked -> + onAction( + RecordsScreenAction.BookmarkRecord( + record.recordId, + isBookmarked + ) + ) + }, + onClickMenu = { + when (it) { + RecordDropDownMenuItemId.SHARE -> { + onAction(RecordsScreenAction.ShareRecord(record.recordId)) + } + + RecordDropDownMenuItemId.INFORMATION -> { + onAction(RecordsScreenAction.ShowRecordInfo(record.recordId)) + } + + RecordDropDownMenuItemId.RENAME -> { + onAction( + RecordsScreenAction.OnRenameRecordRequest( + record + ) + ) + } + + RecordDropDownMenuItemId.OPEN_WITH -> { + onAction( + RecordsScreenAction.OpenRecordWithAnotherApp( + record.recordId + ) + ) + } + + RecordDropDownMenuItemId.SAVE_AS -> { + onAction(RecordsScreenAction.OnSaveAsRequest(record)) + } + + RecordDropDownMenuItemId.DELETE -> { + onAction( + RecordsScreenAction.OnMoveToRecycleRecordRequest( + record + ) + ) + } + } + }, + ) + } + } + } + if (uiState.showMoveToRecycleDialog) { + uiState.operationSelectedRecord?.let { record -> + DeleteDialog( + dialogText = stringResource(id = R.string.move_record_to_trash, record.name), + onAcceptClick = { + onAction(RecordsScreenAction.MoveRecordToRecycle(record.recordId)) + }, onDismissClick = { + onAction(RecordsScreenAction.OnMoveToRecycleRecordDismiss) + } + ) + } + } else if (uiState.showSaveAsDialog) { + uiState.operationSelectedRecord?.let { record -> + SaveAsDialog( + dialogText = stringResource( + id = R.string.record_name_will_be_copied_into_downloads, + record.name), + onAcceptClick = { + onAction(RecordsScreenAction.SaveRecordAs(record.recordId)) + }, onDismissClick = { + onAction(RecordsScreenAction.OnSaveAsDismiss) + } + ) + } + } else if (uiState.showRenameDialog) { + uiState.operationSelectedRecord?.let { record -> + RenameAlertDialog(record.name, onAcceptClick = { + onAction(RecordsScreenAction.RenameRecord(record.recordId, it)) + }, onDismissClick = { + onAction(RecordsScreenAction.OnRenameRecordDismiss) + }) + } + } else if (uiState.showMoveToRecycleMultipleDialog) { + val count = uiState.selectedRecords.size + val titleText = pluralStringResource( + id = R.plurals.delete_selected_records, + count = count, count) + DeleteDialog(titleText, onAcceptClick = { + onAction(RecordsScreenAction.MultiSelectMoveToRecycle) + }, onDismissClick = { + onAction(RecordsScreenAction.MultiSelectMoveToRecycleDismiss) + }) + } else if (uiState.showSaveAsMultipleDialog) { + val count = uiState.selectedRecords.size + val titleText = pluralStringResource( + id = R.plurals.download_selected_records, + count = count, count) + + SaveAsDialog(titleText, + onAcceptClick = { + onAction(RecordsScreenAction.MultiSelectSaveAs) + }, onDismissClick = { + onAction(RecordsScreenAction.MultiSelectSaveAsDismiss) + } + ) + } + if (uiState.showLostRecordsDialog) { + LostRecordsDialog( + onDismiss = { + onAction(RecordsScreenAction.DismissLostRecordsDialog) + }, + onDetailsClick = { + onAction(RecordsScreenAction.DismissLostRecordsDialog) + showLostRecordsScreen(uiState.lostRecords) + } + ) + } + } + TouchPanel( + showRecordPlaybackPanel = uiState.showRecordPlaybackPanel, + uiHomeState = uiHomeState, + onProgressChange = { + onHomeAction( + HomeScreenAction.OnProgressBarStateChange( + it + ) + ) + }, + onSeekStart = { onHomeAction(HomeScreenAction.OnSeekStart) }, + onSeekProgress = { onHomeAction(HomeScreenAction.OnSeekProgress(it)) }, + onSeekEnd = { onHomeAction(HomeScreenAction.OnSeekEnd(it)) }, + onPlayClick = { onHomeAction(HomeScreenAction.OnPlayClick) }, + onStopClick = { + coroutineScope.launch { + onHomeAction(HomeScreenAction.OnStopClick) + } + }, + onPauseClick = { onHomeAction(HomeScreenAction.OnPauseClick) }, + ) +// AnimatedVisibility( +// visible = uiState.showRecordPlaybackPanel, +// enter = slideInVertically(initialOffsetY = { it }), +// exit = slideOutVertically(targetOffsetY = { it }) +// ) { +// Card( +// modifier = modifier +// .wrapContentSize() +// .onSizeChanged { +// playPanelHeight.floatValue = it.height.toFloat() +// }, +// ) { +// RecordPlaybackPanel( +// modifier = Modifier +// .fillMaxWidth() +// .wrapContentHeight(), +// uiState = uiHomeState, +// onProgressChange = { +// onHomeAction( +// HomeScreenAction.OnProgressBarStateChange( +// it +// ) +// ) +// }, +// onSeekStart = { onHomeAction(HomeScreenAction.OnSeekStart) }, +// onSeekProgress = { onHomeAction(HomeScreenAction.OnSeekProgress(it)) }, +// onSeekEnd = { onHomeAction(HomeScreenAction.OnSeekEnd(it)) }, +// onPlayClick = { onHomeAction(HomeScreenAction.OnPlayClick) }, +// onStopClick = { +// coroutineScope.launch { +// onHomeAction(HomeScreenAction.OnStopClick) +// } +// }, +// onPauseClick = { onHomeAction(HomeScreenAction.OnPauseClick) }, +// ) +// } +// } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun RecordsScreenPreview() { + RecordsScreen({}, {}, {}, {}, + RecordsScreenState( + recordsMap = mapOf( + Pair("Today", listOf( + RecordListItem( + recordId = 1, + name = "Test record 1", + details = "1.5 MB, mp4, 192 kbps, 48 kHz", + duration = "3:15", + added = 100000000, + isBookmarked = true + ), + RecordListItem( + recordId = 2, + name = "Test record 2", + details = "4.5 MB, mp3, 128 kbps, 32 kHz", + duration = "8:15", + added = 0, + isBookmarked = false + ) + )) + ), + showDeletedRecordsButton = true, + showRenameDialog = false, + showMoveToRecycleDialog = false, + showSaveAsDialog = false, + operationSelectedRecord = RecordListItem( + recordId = 2, + name = "Test record 2", + details = "4.5 MB, mp3, 128 kbps, 32 kHz", + duration = "8:15", + added = 0, + isBookmarked = false + ) + ), + null, {}, + uiHomeState = HomeScreenState( + waveformState = getTestWaveformData(), + startTime = "00:00", + endTime = "3:42", + time = "1:51", + recordName = "Test Record Name", + recordInfo = "1.5 MB, mp4, 192 kbps, 48 kHz", + isContextMenuAvailable = true, + isStopRecordingButtonAvailable = true, + ), + onHomeAction = {} + ) +} + +@Preview(showBackground = true) +@Composable +fun RecordsScreenEmptyPreview() { + RecordsScreen({}, {}, {}, {}, + RecordsScreenState(), + null, {}, + uiHomeState = HomeScreenState(), + onHomeAction = {} + ) +} + + +@Preview(showBackground = true) +@Composable +fun RecordsScreenLoadingPreview() { + RecordsScreen({}, {}, {}, {}, + RecordsScreenState(isShowLoadingProgress = true), + null, {}, + uiHomeState = HomeScreenState(), + onHomeAction = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsViewModel.kt new file mode 100644 index 000000000..eb2c35c3d --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsViewModel.kt @@ -0,0 +1,732 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.app.records + +import android.app.Application +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.app.DownloadService +import com.dimowner.audiorecorder.audio.player.PlayerContractNew +import com.dimowner.audiorecorder.audio.player.PlayerContractNew.PlayerCallback +import com.dimowner.audiorecorder.exception.AppException +import com.dimowner.audiorecorder.util.AndroidUtils +import com.dimowner.audiorecorder.util.TimeUtils +import com.dimowner.audiorecorder.v2.app.info.RecordInfoState +import com.dimowner.audiorecorder.v2.app.info.toRecordInfoState +import com.dimowner.audiorecorder.v2.app.records.models.SortDropDownMenuItemId +import com.dimowner.audiorecorder.v2.app.toInfoCombinedText +import com.dimowner.audiorecorder.v2.audio.RecorderV2 +import com.dimowner.audiorecorder.v2.data.PrefsV2 +import com.dimowner.audiorecorder.v2.data.RecordsDataSource +import com.dimowner.audiorecorder.v2.data.extensions.checkForLostRecords +import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.data.model.SortOrder +import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher +import com.dimowner.audiorecorder.v2.di.qualifiers.MainDispatcher +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +const val DEFAULT_PAGE_SIZE = 200 + +@HiltViewModel +internal class RecordsViewModel @Inject constructor( + private val recordsDataSource: RecordsDataSource, + private val prefs: PrefsV2, + private val audioPlayer: PlayerContractNew.Player, + private val audioRecorder: RecorderV2, + @param:MainDispatcher private val mainDispatcher: CoroutineDispatcher, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + @ApplicationContext context: Context, +) : AndroidViewModel(context as Application) { + + private val _state = mutableStateOf(RecordsScreenState()) + val state: State = _state + + private val _event = MutableSharedFlow() + val event: SharedFlow = _event + + private val playerCallback: PlayerCallback = object : PlayerCallback { + override fun onStartPlay() { + _state.value = _state.value.copy( + showRecordPlaybackPanel = true, + ) + } + override fun onPlayProgress(mills: Long) { + //Do nothing + } + override fun onPausePlay() { + //Do nothing + } + override fun onSeek(mills: Long) { + //Do nothing + } + override fun onStopPlay() { + _state.value = _state.value.copy( + showRecordPlaybackPanel = false, + activeRecord = null, + ) + } + override fun onError(throwable: AppException) { + //Do nothing + } + } + + fun init(showPlayPanel: Boolean) { + showLoadingProgress(true) + viewModelScope.launch(ioDispatcher) { + initState(showPlayPanel) + } + audioPlayer.addPlayerCallback(playerCallback) + } + + fun onStop() { + audioPlayer.removePlayerCallback(playerCallback) + } + + private suspend fun initState(showPlayPanel: Boolean) { + val context: Context = getApplication().applicationContext + val sortOrder = state.value.sortOrder + val records = recordsDataSource.getRecords( + sortOrder = sortOrder, + page = 1, + pageSize = DEFAULT_PAGE_SIZE, + isBookmarked = false, + ) + val deletedRecordsCount = recordsDataSource.getMovedToRecycleRecordsCount() + val lostRecords = checkForLostRecords(records) + withContext(mainDispatcher) { + _state.value = RecordsScreenState( + sortOrder = sortOrder, + recordsMap = records.map { + it.toRecordListItem(context) + }.groupRecordsByDate(context, sortOrder), + showDeletedRecordsButton = deletedRecordsCount > 0, + deletedRecordsCount = deletedRecordsCount, + showRecordPlaybackPanel = showPlayPanel, + isRecording = audioRecorder.isRecording, + recordedRecordId = prefs.recordedRecordId, + showLostRecordsDialog = lostRecords.isNotEmpty(), + lostRecords = lostRecords, + ) + showLoadingProgress(false) + } + } + + fun updateListWithBookmarks(bookmarksSelected: Boolean) { + viewModelScope.launch(ioDispatcher) { + val sortOrder = state.value.sortOrder + val records = recordsDataSource.getRecords( + sortOrder = sortOrder, + page = 1, + pageSize = DEFAULT_PAGE_SIZE, + isBookmarked = bookmarksSelected, + ) + val context = getApplication().applicationContext + withContext(mainDispatcher) { + _state.value = _state.value.copy( + recordsMap = records.map { + it.toRecordListItem(context) + }.groupRecordsByDate(context, sortOrder), + bookmarksSelected = bookmarksSelected + ) + } + } + } + + fun bookmarkRecord(recordId: Long, addToBookmarks: Boolean) { + viewModelScope.launch(ioDispatcher) { + recordsDataSource.getRecord(recordId)?.let { + recordsDataSource.updateRecord(it.copy(isBookmarked = addToBookmarks)) + } + val updated = recordsDataSource.getRecord(recordId) + if (updated != null) { + withContext(mainDispatcher) { + _state.value = _state.value.updateRecordInMap(recordId) { oldRecord -> + oldRecord.copy(isBookmarked = addToBookmarks) + } + } + } + } + } + + fun onItemSelect(record: RecordListItem) { + multiSelectCancel() + audioPlayer.stop() + prefs.activeRecordId = record.recordId + _state.value = _state.value.copy( + activeRecord = record + ) + } + + fun updateListWithSortOrder(sortOrderId: SortDropDownMenuItemId) { + viewModelScope.launch(ioDispatcher) { + val sortOrder = sortOrderId.toSortOrder() + val records = recordsDataSource.getRecords( + sortOrder = sortOrder, + page = 1, + pageSize = DEFAULT_PAGE_SIZE, + isBookmarked = _state.value.bookmarksSelected, + ) + val context = getApplication().applicationContext + withContext(mainDispatcher) { + _state.value = _state.value.copy( + recordsMap = records.map { + it.toRecordListItem(context) + }.groupRecordsByDate(context, sortOrder), + sortOrder = sortOrder + ) + } + } + } + + fun shareRecord(recordId: Long) { + multiSelectCancel() + viewModelScope.launch(ioDispatcher) { + val record = recordsDataSource.getRecord(recordId) + if (record != null) { + withContext(mainDispatcher) { + AndroidUtils.shareAudioFile( + getApplication().applicationContext, + record.path, + record.name, + record.format + ) + } + } + } + } + + fun showRecordInfo(recordId: Long) { + multiSelectCancel() + viewModelScope.launch(ioDispatcher) { + recordsDataSource.getRecord(recordId)?.toRecordInfoState()?.let { + emitEvent(RecordsScreenEvent.RecordInformationEvent(it)) + } + } + } + + fun onRenameRecordRequest(record: RecordListItem) { + multiSelectCancel() + _state.value = _state.value.copy( + showRenameDialog = true, + operationSelectedRecord = record + ) + } + + fun onRenameRecordDismiss() { + _state.value = _state.value.copy( + showRenameDialog = false, + ) + } + + fun renameRecord(recordId: Long, newName: String) { + viewModelScope.launch(ioDispatcher) { + recordsDataSource.getRecord(recordId)?.let { record -> + if (recordsDataSource.renameRecord(record, newName)) { + _state.value = _state.value.copy( + showRenameDialog = false, + operationSelectedRecord = null, + recordsMap = _state.value.recordsMap.mapRecordInMap(recordId) { oldRecord -> + if (recordId == record.id) { + oldRecord.copy(name = newName) + } else { + oldRecord + } + } + ) + } else { + _state.value = _state.value.copy( + showRenameDialog = false, + operationSelectedRecord = null + ) + } + } + } + } + + fun openRecordWithAnotherApp(recordId: Long) { + multiSelectCancel() + audioPlayer.stop() + viewModelScope.launch(ioDispatcher) { + val record = recordsDataSource.getRecord(recordId) + if (record != null) { + withContext(mainDispatcher) { + AndroidUtils.openAudioFile( + getApplication().applicationContext, + record.path, + record.name + ) + } + } + } + } + + fun onSaveAsRequest(record: RecordListItem) { + multiSelectCancel() + _state.value = _state.value.copy( + showSaveAsDialog = true, + operationSelectedRecord = record + ) + } + + fun onSaveAsDismiss() { + _state.value = _state.value.copy( + showSaveAsDialog = false, + ) + } + + fun saveRecordAs(recordId: Long) { + multiSelectCancel() + viewModelScope.launch(ioDispatcher) { + recordsDataSource.getRecord(recordId)?.let { + DownloadService.startNotification( + getApplication().applicationContext, + it.path + ) + } + _state.value = _state.value.copy( + showSaveAsDialog = false, + operationSelectedRecord = null + ) + } + } + + fun onMoveToRecycleRecordRequest(record: RecordListItem) { + multiSelectCancel() + _state.value = _state.value.copy( + showMoveToRecycleDialog = true, + operationSelectedRecord = record + ) + } + + fun onMoveToRecycleRecordDismiss() { + _state.value = _state.value.copy( + showMoveToRecycleDialog = false, + ) + } + + private fun moveRecordToRecycle(recordId: Long) { + val activeRecordId = prefs.activeRecordId + if (audioPlayer.isPlaying() && recordId == activeRecordId) { + audioPlayer.stop() + } + showLoadingProgress(true) + viewModelScope.launch(ioDispatcher) { + val record = recordsDataSource.getRecord(recordId) + if (record != null && recordsDataSource.moveRecordToRecycle(recordId)) { + if (recordId == activeRecordId) { + prefs.activeRecordId = -1 + } + withContext(mainDispatcher) { + _state.value = _state.value.copy( + recordsMap = _state.value.recordsMap.removeRecordFromMap(recordId), + showMoveToRecycleDialog = false, + showDeletedRecordsButton = true, + operationSelectedRecord = null, + activeRecord = if (recordId == activeRecordId) null else _state.value.activeRecord, + isShowLoadingProgress = false + ) + } + emitEvent( + RecordsScreenEvent.RecordMovedToRecycleSnack( + recordId, + record.name + ) + ) + } else { + val context: Context = getApplication().applicationContext + emitEvent( + RecordsScreenEvent.ShowErrorSnack( + context.getString(R.string.msg_move_to_trash_failed) + ) + ) + + withContext(mainDispatcher) { + showLoadingProgress(false) + } + } + } + } + + fun handleRestoreRecordFromRecycle(recordId: Long) { + showLoadingProgress(true) + viewModelScope.launch(ioDispatcher) { + if (recordsDataSource.restoreRecordFromRecycle(recordId)) { + prefs.activeRecordId = recordId + val record = recordsDataSource.getRecord(recordId) + showInfoMessage(R.string.msg_recording_restored, record?.name ?: "") + + //Update list state. Put removed record back into the list. + if (record != null) { + withContext(mainDispatcher) { + val context: Context = getApplication().applicationContext + _state.value = _state.value.copy( + recordsMap = _state.value.recordsMap.addRecordToMap( + context, + record.toRecordListItem(context), + state.value.sortOrder + ), + ) + } + } + } else { + showInfoMessage(R.string.msg_operation_failed_generic) + + withContext(mainDispatcher) { + showLoadingProgress(false) + } + } + } + } + + private fun showLoadingProgress(value: Boolean) { + _state.value = _state.value.copy(isShowLoadingProgress = value) + } + + private fun showInfoMessage(@StringRes resId: Int) { + val context: Context = getApplication().applicationContext + emitEvent( + RecordsScreenEvent.ShowInfoSnack( + context.getString(resId) + ) + ) + } + + private fun showInfoMessage(@StringRes resId: Int, vararg formatArgs: Any) { + val context: Context = getApplication().applicationContext + emitEvent( + RecordsScreenEvent.ShowInfoSnack( + context.getString(resId, *formatArgs) + ) + ) + } + + @SuppressWarnings("CyclomaticComplexMethod") + fun onAction(action: RecordsScreenAction) { + when (action) { + is RecordsScreenAction.InitRecordsScreen -> init(action.showPlayPanel) + is RecordsScreenAction.OnStopRecordsScreen -> onStop() + is RecordsScreenAction.UpdateListWithSortOrder -> updateListWithSortOrder(action.sortOrderId) + is RecordsScreenAction.UpdateListWithBookmarks -> updateListWithBookmarks(action.bookmarksSelected) + is RecordsScreenAction.BookmarkRecord -> bookmarkRecord(action.recordId, action.addToBookmarks) + is RecordsScreenAction.OnItemSelect -> onItemSelect(action.record) + is RecordsScreenAction.ShareRecord -> shareRecord(action.recordId) + is RecordsScreenAction.ShowRecordInfo -> showRecordInfo(action.recordId) + is RecordsScreenAction.OnRenameRecordRequest -> onRenameRecordRequest(action.record) + is RecordsScreenAction.OpenRecordWithAnotherApp -> openRecordWithAnotherApp(action.recordId) + is RecordsScreenAction.OnSaveAsRequest -> onSaveAsRequest(action.record) + is RecordsScreenAction.OnMoveToRecycleRecordRequest -> onMoveToRecycleRecordRequest(action.record) + is RecordsScreenAction.MoveRecordToRecycle -> moveRecordToRecycle(action.recordId) + RecordsScreenAction.OnMoveToRecycleRecordDismiss -> onMoveToRecycleRecordDismiss() + is RecordsScreenAction.RestoreRecordFromRecycle -> handleRestoreRecordFromRecycle(action.recordId) + is RecordsScreenAction.SaveRecordAs -> saveRecordAs(action.recordId) + RecordsScreenAction.OnSaveAsDismiss -> onSaveAsDismiss() + is RecordsScreenAction.RenameRecord -> renameRecord(action.recordId, action.newName) + RecordsScreenAction.OnRenameRecordDismiss -> onRenameRecordDismiss() + is RecordsScreenAction.MultiSelectAddItem -> multiSelectAdd(action.selectedRecord) + RecordsScreenAction.MultiSelectCancel -> multiSelectCancel() + is RecordsScreenAction.MultiSelectMoveToRecycle -> multiSelectMoveToRecycle() + is RecordsScreenAction.MultiSelectMoveToRecycleRequest -> + multiSelectMoveToRecycleRequest() + RecordsScreenAction.MultiSelectMoveToRecycleDismiss -> multiSelectMoveToRecycleDismiss() + is RecordsScreenAction.MultiSelectSaveAs -> multiSelectSaveAs() + is RecordsScreenAction.MultiSelectSaveAsRequest -> multiSelectSaveAsRequest() + RecordsScreenAction.MultiSelectSaveAsDismiss -> multiSelectSaveAsDismiss() + is RecordsScreenAction.MultiSelectShare -> multiSelectShare(action.selectedRecords) + RecordsScreenAction.DismissLostRecordsDialog -> dismissLostRecordsDialog() + } + } + + private fun multiSelectAdd(selected: RecordListItem) { + audioPlayer.stop() + val records = _state.value.selectedRecords.toMutableList() + if (records.contains(selected)) { + records.remove(selected) + } else { + records.add(selected) + } + _state.value = _state.value.copy( + selectedRecords = records, + ) + } + + private fun multiSelectCancel() { + _state.value = _state.value.copy( + selectedRecords = emptyList(), + ) + } + + private fun multiSelectShare(selectedRecords: List) { + viewModelScope.launch(ioDispatcher) { + val recordList = recordsDataSource.getRecords(selectedRecords.map { it.recordId }) + if (recordList.isNotEmpty()) { + withContext(mainDispatcher) { + AndroidUtils.shareAudioFiles( + getApplication().applicationContext, + recordList.map { it.path } + ) + multiSelectCancel() + } + } else { + val context: Context = getApplication().applicationContext + emitEvent( + RecordsScreenEvent.ShowErrorSnack( + context.getString(R.string.error_unknown) + ) + ) + } + } + } + + private fun multiSelectSaveAsRequest() { + _state.value = _state.value.copy( + showSaveAsMultipleDialog = true, + ) + } + + private fun multiSelectSaveAs() { + viewModelScope.launch(ioDispatcher) { + val recordList = recordsDataSource.getRecords(state.value.selectedRecords.map { it.recordId }) + if (recordList.isNotEmpty()) { + withContext(mainDispatcher) { + //Download record file with Service + DownloadService.startNotification( + getApplication().applicationContext, + recordList + .map { it.path } + .toCollection(ArrayList()) + ) + multiSelectCancel() + _state.value = _state.value.copy( + showSaveAsMultipleDialog = false, + ) + } + } else { + val context: Context = getApplication().applicationContext + emitEvent( + RecordsScreenEvent.ShowErrorSnack( + context.getString(R.string.error_unknown) + ) + ) + } + } + } + + private fun multiSelectMoveToRecycleRequest() { + _state.value = _state.value.copy( + showMoveToRecycleMultipleDialog = true, + ) + } + + private fun multiSelectMoveToRecycleDismiss() { + _state.value = _state.value.copy( + showMoveToRecycleMultipleDialog = false, + ) + } + + private fun multiSelectSaveAsDismiss() { + _state.value = _state.value.copy( + showSaveAsMultipleDialog = false, + ) + } + + private fun multiSelectMoveToRecycle() { + showLoadingProgress(true) + viewModelScope.launch(ioDispatcher) { + val deletedCount = recordsDataSource.moveRecordsToRecycle(state.value.selectedRecords.map { it.recordId }) + if (deletedCount > 0) { + val context: Context = getApplication().applicationContext + val sortOrder = state.value.sortOrder + val records = recordsDataSource.getRecords( + sortOrder = sortOrder, + page = 1, + pageSize = DEFAULT_PAGE_SIZE, + isBookmarked = state.value.bookmarksSelected, + ) + val recordsInRecycleCount = recordsDataSource.getMovedToRecycleRecordsCount() + val selectedRecords = state.value.selectedRecords + val isActiveRecordDeleted = selectedRecords.map { it.recordId }.contains(prefs.activeRecordId) + withContext(mainDispatcher) { + multiSelectCancel() + _state.value = _state.value.copy( + recordsMap = records.map { + it.toRecordListItem(context) + }.groupRecordsByDate(context, sortOrder), + showDeletedRecordsButton = recordsInRecycleCount > 0, + deletedRecordsCount = recordsInRecycleCount, + showMoveToRecycleMultipleDialog = false, + activeRecord = if (isActiveRecordDeleted) null else _state.value.activeRecord, + isShowLoadingProgress = false + ) + } + multipleMoveToRecycleSuccessSnack(selectedRecords, deletedCount) + } else { + multipleMoveToRecycleFailSnack() + withContext(mainDispatcher) { + showLoadingProgress(false) + } + } + } + } + + private fun multipleMoveToRecycleSuccessSnack(selectedRecords: List, deletedRecordsCount: Int) { + if (selectedRecords.size == 1) { + val record = selectedRecords.first() + emitEvent( + RecordsScreenEvent.RecordMovedToRecycleSnack( + record.recordId, + record.name + ) + ) + } else { + emitEvent( + RecordsScreenEvent.FewRecordsMovedToRecycleSnack( + deletedRecordsCount, + selectedRecords.size + ) + ) + } + } + + private fun multipleMoveToRecycleFailSnack() { + val context: Context = getApplication().applicationContext + if (state.value.selectedRecords.size > 1) { + emitEvent( + RecordsScreenEvent.ShowErrorSnack( + context.getString(R.string.msg_multiple_move_to_trash_failed) + ) + ) + } else { + emitEvent( + RecordsScreenEvent.ShowErrorSnack( + context.getString(R.string.msg_move_to_trash_failed) + ) + ) + } + } + + private fun emitEvent(event: RecordsScreenEvent) { + viewModelScope.launch { + _event.emit(event) + } + } + + fun dismissLostRecordsDialog() { + _state.value = _state.value.copy( + showLostRecordsDialog = false, + lostRecords = emptyList() + ) + } +} + +data class RecordsScreenState( + val recordsMap: Map> = emptyMap(), + val selectedRecords: List = emptyList(), + val sortOrder: SortOrder = SortOrder.DateDesc, + val bookmarksSelected: Boolean = false, + val showDeletedRecordsButton: Boolean = false, + val showRecordPlaybackPanel: Boolean = false, + val deletedRecordsCount: Int = 0, + val isShowLoadingProgress: Boolean = false, + + val showRenameDialog: Boolean = false, + val showMoveToRecycleDialog: Boolean = false, + val showMoveToRecycleMultipleDialog: Boolean = false, + val showSaveAsDialog: Boolean = false, + val showSaveAsMultipleDialog: Boolean = false, + //Lost records + val showLostRecordsDialog: Boolean = false, + val lostRecords: List = emptyList(), + //A record for which some operation requested (rename, save as, delete) + val operationSelectedRecord: RecordListItem? = null, + val activeRecord: RecordListItem? = null, + val isRecording: Boolean = false, + val recordedRecordId: Long = -1, +) + +data class RecordListItem( + val recordId: Long, + val name: String, + val details: String, + val duration: String, + val added: Long, + val isBookmarked: Boolean +) + +internal sealed class RecordsScreenEvent { + data class RecordInformationEvent(val recordInfo: RecordInfoState) : RecordsScreenEvent() + data class RecordMovedToRecycleSnack(val recordId: Long, val recordName: String) : + RecordsScreenEvent() + data class FewRecordsMovedToRecycleSnack(val movedCount: Int, val expectedCount: Int) : + RecordsScreenEvent() + data class ShowErrorSnack(val message: String) : RecordsScreenEvent() + data class ShowInfoSnack(val message: String) : RecordsScreenEvent() +} + +internal sealed class RecordsScreenAction { + data class InitRecordsScreen(val showPlayPanel: Boolean) : RecordsScreenAction() + data object OnStopRecordsScreen : RecordsScreenAction() + data class UpdateListWithSortOrder(val sortOrderId: SortDropDownMenuItemId) : RecordsScreenAction() + data class UpdateListWithBookmarks(val bookmarksSelected: Boolean) : RecordsScreenAction() + data class OnItemSelect(val record: RecordListItem) : RecordsScreenAction() + data class BookmarkRecord(val recordId: Long, val addToBookmarks: Boolean) : RecordsScreenAction() + data class ShareRecord(val recordId: Long) : RecordsScreenAction() + data class ShowRecordInfo(val recordId: Long) : RecordsScreenAction() + data class OnRenameRecordRequest(val record: RecordListItem) : RecordsScreenAction() + data class OpenRecordWithAnotherApp(val recordId: Long) : RecordsScreenAction() + data class OnSaveAsRequest(val record: RecordListItem) : RecordsScreenAction() + data class OnMoveToRecycleRecordRequest(val record: RecordListItem) : RecordsScreenAction() + data class MoveRecordToRecycle(val recordId: Long) : RecordsScreenAction() + data class RestoreRecordFromRecycle(val recordId: Long) : RecordsScreenAction() + data object OnMoveToRecycleRecordDismiss : RecordsScreenAction() + data class SaveRecordAs(val recordId: Long) : RecordsScreenAction() + data object OnSaveAsDismiss : RecordsScreenAction() + data class RenameRecord(val recordId: Long, val newName: String) : RecordsScreenAction() + data object OnRenameRecordDismiss : RecordsScreenAction() + data class MultiSelectAddItem(val selectedRecord: RecordListItem) : RecordsScreenAction() + data object MultiSelectCancel : RecordsScreenAction() + data class MultiSelectShare(val selectedRecords: List) : RecordsScreenAction() + data object MultiSelectSaveAs : RecordsScreenAction() + data object MultiSelectSaveAsRequest : RecordsScreenAction() + data object MultiSelectSaveAsDismiss : RecordsScreenAction() + data object MultiSelectMoveToRecycle : RecordsScreenAction() + data object MultiSelectMoveToRecycleRequest : RecordsScreenAction() + data object MultiSelectMoveToRecycleDismiss : RecordsScreenAction() + data object DismissLostRecordsDialog : RecordsScreenAction() +} + +internal fun Record.toRecordListItem(context: Context): RecordListItem { + return RecordListItem( + recordId = this.id, + name = this.name, + details = this.toInfoCombinedText(context), + duration = TimeUtils.formatTimeIntervalHourMinSec2(this.durationMills), + added = this.added, + isBookmarked = this.isBookmarked + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/models/RecordDropDownMenuItemId.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/models/RecordDropDownMenuItemId.kt new file mode 100644 index 000000000..9f3f83581 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/models/RecordDropDownMenuItemId.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.records.models + +enum class RecordDropDownMenuItemId { + SHARE, INFORMATION, RENAME, OPEN_WITH, SAVE_AS, DELETE +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/models/SortDropDownMenuItemId.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/models/SortDropDownMenuItemId.kt new file mode 100644 index 000000000..f029140ed --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/models/SortDropDownMenuItemId.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.records.models + +enum class SortDropDownMenuItemId { + DATE_DESC, DATE_ASC, NAME, NAME_DESC, DURATION, DURATION_DESC +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/DurationPickerDialog.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/DurationPickerDialog.kt new file mode 100644 index 000000000..a1a2d53ab --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/DurationPickerDialog.kt @@ -0,0 +1,161 @@ +/* + * Copyright 2026 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimeInput +import androidx.compose.material3.rememberTimePickerState +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.dimowner.audiorecorder.R + +private const val MIN_DURATION_MINUTES = 1 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DurationPickerDialog( + currentHours: Int, + currentMinutes: Int, + onDismiss: () -> Unit, + onConfirm: (hours: Int, minutes: Int) -> Unit +) { + val timePickerState = rememberTimePickerState( + initialHour = currentHours, + initialMinute = currentMinutes, + is24Hour = true, + ) + + var showError by remember { mutableStateOf(false) } + var showWarning by remember { mutableStateOf(false) } + + LaunchedEffect(timePickerState.hour, timePickerState.minute) { + showWarning = isDurationLongerThanTwoHours(timePickerState.hour, timePickerState.minute) + } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Title + Text( + text = stringResource(R.string.recording_duration), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Subtitle/hint + Text( + text = stringResource(R.string.duration_picker_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Material3 TimeInput component - provides native time picker UI + TimeInput( + state = timePickerState, + ) + + // Error message + if (showError) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.error_duration_at_least_one_minute), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + if (showWarning) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.warning_duration_limit_recommended), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.btn_cancel)) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + val totalMinutes = (timePickerState.hour * 60) + timePickerState.minute + if (totalMinutes >= MIN_DURATION_MINUTES) { + onConfirm(timePickerState.hour, timePickerState.minute) + } else { + showError = true + } + } + ) { + Text(stringResource(R.string.btn_apply)) + } + } + } + } + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsComponents.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsComponents.kt new file mode 100644 index 000000000..bf7785fb1 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsComponents.kt @@ -0,0 +1,686 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.settings + +import android.os.Parcelable +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.DeviceFontFamilyName +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.InfoAlertDialog +import com.dimowner.audiorecorder.v2.data.model.SampleRate + +@Composable +fun SettingsItem( + label: String, + iconRes: Int, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .clickable { onClick() }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .padding(24.dp, 16.dp, 16.dp, 16.dp) + .wrapContentWidth() + .wrapContentHeight(), + painter = painterResource(id = iconRes), + contentDescription = label, + ) + SettingsItemText(text = label) + } +} + +@Composable +fun SettingsItemText(text: String) { + Text( + modifier = Modifier + .padding(0.dp, 12.dp, 0.dp, 12.dp) + .fillMaxWidth() + .wrapContentHeight(), + text = text, + fontSize = 20.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) +} + +@Preview(showBackground = true) +@Composable +fun SettingsItemPreview() { + SettingsItem("Label", R.drawable.ic_color_lens, {}) +} + +@Composable +fun SettingsItemCheckBox( + checked: Boolean, + label: String, + iconRes: Int, + onCheckedChange: ((Boolean) -> Unit), + enabled: Boolean = true, +) { + val checkState = remember { mutableStateOf(checked) } + Row( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .padding(24.dp, 16.dp, 16.dp, 16.dp) + .wrapContentWidth() + .wrapContentHeight(), + painter = painterResource(id = iconRes), + contentDescription = label + ) + Text( + modifier = Modifier + .padding(0.dp, 12.dp, 0.dp, 12.dp) + .weight(1f) + .wrapContentHeight(), + text = label, + fontSize = 20.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + Switch( + checked = checkState.value, + onCheckedChange = { + checkState.value = it + onCheckedChange(it) + }, + enabled = enabled, + modifier = Modifier.padding(8.dp) + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SettingsItemCheckBoxPreview() { + SettingsItemCheckBox(true,"Label", R.drawable.ic_color_lens, {}) +} + +@Composable +fun AppInfoView(appName: String, version: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + textAlign = TextAlign.Center, + text = appName, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 20.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Medium + ) + ), + ) + Text( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + textAlign = TextAlign.Center, + text = version, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 18.sp, + fontWeight = FontWeight.Light + ) + } +} + +@Preview(showBackground = true) +@Composable +fun AppInfoViewPreview() { + AppInfoView("App Name", "App Version") +} + +@Composable +fun InfoTextView(value: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp, 0.dp, 16.dp, 0.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + text = value, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 18.sp, + fontWeight = FontWeight.Light + ) + } +} + +@Preview(showBackground = true) +@Composable +fun InfoTextViewPreview() { + InfoTextView("InfoTextView") +} + +@Composable +fun ResetRecordingSettingsPanel( + sizePerMin: String, + recordingSettingsText: String, + onClick: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(4.dp), + ) { + Row( + modifier = Modifier.wrapContentSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .wrapContentHeight() + .padding(8.dp), + ) { + Text( + textAlign = TextAlign.Start, + text = sizePerMin, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Light + ) + Text( + textAlign = TextAlign.Start, + text = recordingSettingsText, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Light + ) + } + Button( + modifier = Modifier + .padding(8.dp) + .wrapContentSize(), + + onClick = { onClick() } + ) { + Text( + text = stringResource(id = R.string.btn_reset), + fontSize = 18.sp, + fontWeight = FontWeight.Light, + ) + } + } + + } +} + +@Preview(showBackground = true) +@Composable +fun ResetRecordingSettingsPanelPreview() { + ResetRecordingSettingsPanel("ResetRecordingSettingsPanel", "m4a, mono", {}) +} + +@Composable +fun SettingSelector( + name: String, + chips: List>, + onSelect: (ChipItem) -> Unit, + onClickInfo: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .animateContentSize() + .wrapContentHeight() + ) { + Row( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth(), + verticalAlignment = Alignment.Bottom + ) { + Text( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .weight(1f) + .padding(4.dp), + textAlign = TextAlign.Start, + text = name, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Light + ) + IconButton( + onClick = onClickInfo, + modifier = Modifier + .align(Alignment.CenterVertically), + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(id = R.drawable.ic_info), + contentDescription = name, + ) + } + } + + ChipsPanel( + modifier = Modifier.wrapContentSize(), + chips = chips, + onSelect = onSelect + ) + } +} + +@Composable +fun ChipComponent( + modifier: Modifier = Modifier, + item: ChipItem, + onSelect: (ChipItem) -> Unit, +) { + Card( + modifier = modifier + .wrapContentSize() + .padding(2.dp, 0.dp), + shape = RoundedCornerShape(18.dp), + border = if (item.isSelected) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { null }, + onClick = { onSelect(item) } + ) { + Row( + modifier = Modifier + .wrapContentSize() + .padding(if (item.isSelected) 12.dp else 25.dp, 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (item.isSelected) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = item.name, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(26.dp) + .padding(0.dp, 3.dp, 3.dp, 3.dp) + .align(Alignment.CenterVertically) + ) + } + Text( + modifier = Modifier + .wrapContentSize() + .padding(2.dp), + textAlign = TextAlign.Start, + text = item.name, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 17.sp, + fontWeight = FontWeight.Normal + ) + } + } + +} + +@Preview(showBackground = true) +@Composable +fun ChipComponentPreview() { + ChipComponent( + Modifier.wrapContentSize(), + ChipItem(id = 0, value = SampleRate.SR8000, name = "8000", false) + ) {} +} + +//@Composable +//fun calculateChipsPositions(chips: List, screenWidth: Float): Map { +// val map = mutableMapOf() +// var row = 0 +// var col = 0 +// var cumulativeWidth = 0f +// val textStyle = TextStyle( +// fontSize = 17.sp, +// fontWeight = FontWeight.Normal, +// textAlign = TextAlign.Start +// ) +// chips.forEach { chip -> +// val chipWidthExtra = 58 //Dp +// val chipWidth = measureTextWidth(chip.name, textStyle).value + chipWidthExtra.dp.value +// cumulativeWidth += chipWidth +// if (cumulativeWidth > screenWidth) { +// map[row] = col +// row++ +// col = 1 +// cumulativeWidth = chipWidth +// } else { +// col++ +// map[row] = col +// } +// } +// return map +//} + +@Composable +fun measureTextWidth(text: String, style: TextStyle): Dp { + val textMeasurer = rememberTextMeasurer() + val widthInPixels = textMeasurer.measure(text, style).size.width + return with(LocalDensity.current) { widthInPixels.toDp() } +} + +@Preview(showBackground = true) +@Composable +fun SettingSelectorPreview() { + SettingSelector("SettingsSelector", chips = getTestChips(), {}, {}) +} + +@Composable +fun ChipsPanel( + modifier: Modifier = Modifier, + chips: List>, + onSelect: (ChipItem) -> Unit, +) { + Layout( + modifier = modifier, + content = { chips.map { + ChipComponent(modifier = Modifier.wrapContentSize(), item = it, onSelect = onSelect) } + } + ) { measurables, constraints -> + // List of measured children + val placeables = measurables.map { measurable -> + // Measure each children + measurable.measure(constraints) + } + val totalHeight = calculatePositionsDefault(placeables, constraints.maxWidth) + + // Set the size of the layout as big as it can + layout(constraints.maxWidth, totalHeight) { + calculatePositionsDefault( + temp = placeables, + constraints.maxWidth, + ) { placeable, x, y -> placeable.place(x, y) } + } + } +} + +@Preview(showBackground = true) +@Composable +fun ChipsPanelPreview() { + ChipsPanel(Modifier.wrapContentSize(), getTestChips()) {} +} + +fun calculatePositionsDefault( + temp: List, + viewWidth: Int, + onPlace: ((Placeable, x: Int, y: Int) -> Unit)? = null +): Int { + var posY = 0 + if (temp.isNotEmpty()) { + var posX = 0 + val rowHeight = temp.first().measuredHeight + var rowCount = 0 + rowCount++ + if (temp.isNotEmpty()) { + var availableWidth = viewWidth + for (i in temp.indices) { + if (availableWidth < temp[i].measuredWidth) { + rowCount++ + posY += rowHeight + availableWidth = viewWidth + posX = 0 + } + onPlace?.invoke(temp[i], posX, posY) + val width: Int = (temp[i].measuredWidth) + posX += width + availableWidth -= width + } + posY += rowHeight + } + } + return posY +} + +@Composable +fun DropDownSetting( + items: List, + selectedItem: NameFormatItem?, + onSelect: (NameFormatItem) -> Unit, +) { + val expanded = remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentSize(Alignment.TopStart) + ) { + // The DropdownMenu composable + DropdownMenu( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + expanded = expanded.value, + onDismissRequest = { expanded.value = false } + ) { + items.forEach { item -> + DropdownMenuItem( + onClick = { + onSelect(item) + expanded.value = false + }, text = { + SettingsItemText(text = item.nameText) + } + ) + } + } + val text = selectedItem?.nameText ?: stringResource(id = R.string.empty) + Row( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .clickable { expanded.value = !expanded.value }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .padding(24.dp, 16.dp, 16.dp, 16.dp) + .wrapContentSize(), + painter = painterResource(id = R.drawable.ic_title), + contentDescription = text, + ) + Column( + modifier = Modifier + .padding(0.dp, 12.dp) + .weight(1f) + ) { + Text( + text = stringResource(id = R.string.record_name_format), + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Bold + ) + ), + ) + Text( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + text = text, + fontSize = 20.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + } + Icon( + modifier = Modifier + .wrapContentSize() + .padding(0.dp, 0.dp, 12.dp, 0.dp), + painter = painterResource(id = R.drawable.ic_arrow_down), + contentDescription = text, + ) + } + } +} + +@Composable +fun SettingsInfoDialog(openDialog: MutableState, message: String) { + if (openDialog.value) { + InfoAlertDialog( + onDismissRequest = { openDialog.value = false }, + onConfirmation = { + openDialog.value = false + }, + dialogTitle = stringResource(id = R.string.info), + dialogText = buildAnnotatedString { + append(message) + }, + icon = Icons.Default.Info, + dismissButton = stringResource(id = R.string.btn_ok) + ) + } +} + +@Composable +fun SettingsInfoDialog( + openDialog: MutableState, + message: AnnotatedString, + title: String = stringResource(id = R.string.info) +) { + if (openDialog.value) { + InfoAlertDialog( + onDismissRequest = { openDialog.value = false }, + onConfirmation = { + openDialog.value = false + }, + dialogTitle = title, + dialogText = message, + icon = Icons.Default.Info, + dismissButton = stringResource(id = R.string.btn_got_it) + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SettingsInfoDialogPreview() { + SettingsInfoDialog( + remember {mutableStateOf(true) }, + buildAnnotatedString { + append("Information massage") + } + ) +} + +@Composable +fun SettingsWarningDialog(openDialog: MutableState, message: String) { + if (openDialog.value) { + InfoAlertDialog( + onDismissRequest = { openDialog.value = false }, + onConfirmation = { + openDialog.value = false + }, + dialogTitle = stringResource(id = R.string.warning), + dialogText = buildAnnotatedString { + append(message) + }, + icon = Icons.Default.Warning, + dismissButton = stringResource(id = R.string.btn_ok) + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SettingsWarningDialogPreview() { + SettingsWarningDialog(remember {mutableStateOf(true) }, "Warning message") +} + +private fun getTestChips(): List> { + return listOf( + ChipItem(id = 0, value = SampleRate.SR8000, name = "8000", false), + ChipItem(id = 1, value = SampleRate.SR16000, name = "16000", false), + ChipItem(id = 2, value = SampleRate.SR22500, name = "22500", true), + ChipItem(id = 3, value = SampleRate.SR32000, name = "32000", false), + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsExtensions.kt new file mode 100644 index 000000000..e73f37536 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsExtensions.kt @@ -0,0 +1,304 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.settings + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.graphics.Typeface +import android.net.Uri +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.core.text.HtmlCompat +import com.dimowner.audiorecorder.AppConstants +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.util.AndroidUtils +import com.dimowner.audiorecorder.util.FileUtil +import com.dimowner.audiorecorder.v2.DefaultValues +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.NameFormat +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import timber.log.Timber + +fun makeNameFormats(): List { + return listOf( + NameFormatItem( + NameFormat.Record, FileUtil.generateRecordNameCounted(1) + ".m4a" + ), + NameFormatItem( + NameFormat.Date, FileUtil.generateRecordNameDateVariant() + ".m4a" + ), + NameFormatItem( + NameFormat.DateUs, FileUtil.generateRecordNameDateUS() + ".m4a" + ), + NameFormatItem( + NameFormat.DateIso8601, FileUtil.generateRecordNameDateISO8601() + ".m4a" + ), + NameFormatItem( + NameFormat.Timestamp, FileUtil.generateRecordNameMills() + ".m4a" + ), + ) +} + +fun NameFormat.toNameFormatItem(): NameFormatItem { + val text = when (this) { + NameFormat.Record -> FileUtil.generateRecordNameCounted(1) + ".m4a" + NameFormat.Date -> FileUtil.generateRecordNameDateVariant() + ".m4a" + NameFormat.DateUs -> FileUtil.generateRecordNameDateUS() + ".m4a" + NameFormat.DateIso8601 -> FileUtil.generateRecordNameDateISO8601() + ".m4a" + NameFormat.Timestamp -> FileUtil.generateRecordNameMills() + ".m4a" + } + return NameFormatItem(this, text) +} + +private fun rateIntentForUrl(url: String, context: Context): Intent { + val intent = Intent( + Intent.ACTION_VIEW, Uri.parse(String.format("%s?id=%s", url, context.packageName)) + ) + var flags = Intent.FLAG_ACTIVITY_NO_HISTORY or Intent.FLAG_ACTIVITY_MULTIPLE_TASK + flags = flags or Intent.FLAG_ACTIVITY_NEW_DOCUMENT + intent.addFlags(flags) + return intent +} + + +fun rateApp(context: Context) { + try { + val rateIntent = rateIntentForUrl("market://details", context) + context.startActivity(rateIntent) + } catch (e: ActivityNotFoundException) { + Timber.e(e) + val rateIntent = rateIntentForUrl("https://play.google.com/store/apps/details", context) + context.startActivity(rateIntent) + } +} + +fun requestFeature(context: Context, onError: (String) -> Unit) { + val i = Intent(Intent.ACTION_SEND) + i.setType("message/rfc822") + i.putExtra(Intent.EXTRA_EMAIL, arrayOf(AppConstants.REQUESTS_RECEIVER)) + i.putExtra( + Intent.EXTRA_SUBJECT, + "[" + context.getString(R.string.app_name) + + "] " + AndroidUtils.getAppVersion(context) + + " - " + context.getString(R.string.request) + ) + try { + val chooser = Intent.createChooser(i, context.getString(R.string.send_email)) + chooser.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(chooser) + } catch (ex: ActivityNotFoundException) { + Timber.e(ex) + onError(context.getString(R.string.email_clients_not_found)) + } +} + +/** + * Converts a [Spanned] into an [AnnotatedString] trying to keep as much formatting as possible. + * + * Currently supports `bold`, `italic`, `underline` and `color`. + */ +fun Spanned.toAnnotatedString(): AnnotatedString = buildAnnotatedString { + val spanned = this@toAnnotatedString + append(spanned.toString()) + getSpans(0, spanned.length, Any::class.java).forEach { span -> + val start = getSpanStart(span) + val end = getSpanEnd(span) + when (span) { + is StyleSpan -> when (span.style) { + Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end) + Typeface.BOLD_ITALIC -> { + addStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic), start, end) + } + } + is UnderlineSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end) + is ForegroundColorSpan -> addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end) + } + } +} + +/** + * Converts an HTML-formatted string resource to an AnnotatedString + * that can be displayed with proper formatting in Compose Text. + * + * @param resId The string resource ID containing HTML formatting + * @return AnnotatedString with preserved formatting (bold, italic, underline, etc.) + */ +@Composable +fun htmlStringResource(@StringRes resId: Int): AnnotatedString { + val text = stringResource(resId) + val spanned = HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_COMPACT) + return spanned.toAnnotatedString() +} + +fun RecordingFormat.convertToText(formatStrings: Array): String { + return formatStrings[this.index] +} + +fun BitRate.convertToText(bitrateStrings: Array): String { + return bitrateStrings[this.index] +} + +fun ChannelCount.convertToText(channelCountStrings: Array): String { + return channelCountStrings[this.index] +} + +fun SampleRate.convertToText(channelCountStrings: Array): String { + return channelCountStrings[this.index] +} + +@SuppressWarnings("MagicNumber") +fun sizeMbPerMin( + recordingFormat: RecordingFormat?, + sampleRate: SampleRate?, + bitrate: BitRate?, + channels: ChannelCount? +): Float { + if (recordingFormat == null) return 0F + return when (recordingFormat) { + RecordingFormat.M4a -> { + if (bitrate == null) { + 0F + } else { + (60F * (bitrate.value / 8F)) / 1000000f + } + } + RecordingFormat.ThreeGp -> (60F * (DefaultValues.Default3GpBitRate / 8F)) / 1000000f + RecordingFormat.Wav -> { + if (sampleRate != null && channels != null) { + (60F * (sampleRate.value * channels.value * 2F)) / 1000000f + } else { + 0F + } + } + } +} + +fun getChannelCounts( + format: RecordingFormat, + selected: ChannelCount?, + strings: Array +): List> { + return when (format) { + RecordingFormat.M4a, + RecordingFormat.Wav -> { + ChannelCount.entries.toList().mapIndexed { i, channelCount -> + ChipItem( + id = i, + value = channelCount, + name = strings[i], + isSelected = channelCount == selected + ) + } + } + RecordingFormat.ThreeGp -> { + listOf( + ChipItem( + id = 0, + value = ChannelCount.Mono, + name = strings[1], + isSelected = ChannelCount.Mono == selected + ) + ) + } + } +} + +fun getBitRates( + format: RecordingFormat, + selected: BitRate?, + strings: Array +): List> { + return when (format) { + RecordingFormat.M4a -> { + BitRate.entries.toList().mapIndexed { i, bitRate -> + ChipItem( + id = i, + value = bitRate, + name = strings[i], + isSelected = bitRate == selected + ) + } + } + RecordingFormat.Wav, + RecordingFormat.ThreeGp -> listOf() + } +} + +fun getSampleRates( + format: RecordingFormat, + selected: SampleRate?, + strings: Array +): List> { + return when (format) { + RecordingFormat.M4a, + RecordingFormat.Wav -> { + SampleRate.entries.toList().mapIndexed { i, sampleRate -> + ChipItem( + id = i, + value = sampleRate, + name = strings[i], + isSelected = sampleRate == selected + ) + } + } + RecordingFormat.ThreeGp -> listOf( + ChipItem( + id = 0, + value = SampleRate.SR8000, + name = strings[0], + isSelected = SampleRate.SR8000 == selected + ), + ChipItem( + id = 1, + value = SampleRate.SR16000, + name = strings[1], + isSelected = SampleRate.SR16000 == selected + ) + ) + } +} + +/** + * Determines if a duration strictly exceeds two hours (120 minutes). + * @param hours The number of hours. + * @param minutes The number of minutes. + * @return `true` if the total duration is greater than 120 minutes; `false` if it is + * 120 minutes (2h 0m) or less. + */ +fun isDurationLongerThanTwoHours(hours: Int, minutes: Int): Boolean { + if (hours < 0 || minutes < 0) return false + + val durationMinutes = hours * 60 + minutes + return durationMinutes > 120 +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsScreen.kt new file mode 100644 index 000000000..a0ee69cde --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsScreen.kt @@ -0,0 +1,501 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.settings + +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.ComposableLifecycle +import com.dimowner.audiorecorder.v2.app.TitleBar +import com.dimowner.audiorecorder.v2.app.components.AudioSourceSelector +import com.dimowner.audiorecorder.v2.app.formatDuration +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.NameFormat +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import timber.log.Timber + +@Composable +internal fun SettingsScreen( + onPopBackStack: () -> Unit, + showDeletedRecordsScreen: () -> Unit, + uiState: SettingsState, + onAction: (SettingsScreenAction) -> Unit, +) { + val context = LocalContext.current + + val openInfoDialog = remember { mutableStateOf(false) } + val openWarningDialog = remember { mutableStateOf(false) } + val infoText = remember { mutableStateOf("") } + val infoTextAnnotated = remember { mutableStateOf(null) } + val warningText = remember { mutableStateOf("") } + + val isExpandedBitRatePanel = remember { mutableStateOf(true) } + + ComposableLifecycle { _, event -> + when (event) { + Lifecycle.Event.ON_CREATE -> { + Timber.d("SettingsScreen: onCreate") + onAction(SettingsScreenAction.InitSettingsScreen) + } + Lifecycle.Event.ON_START -> { + Timber.d("SettingsScreen: On Start") + } + + Lifecycle.Event.ON_RESUME -> { + Timber.d("SettingsScreen: On Resume") + } + + Lifecycle.Event.ON_PAUSE -> { + Timber.d("SettingsScreen: On Pause") + } + + Lifecycle.Event.ON_STOP -> { + Timber.d("SettingsScreen: On Stop") + } + + Lifecycle.Event.ON_DESTROY -> { + Timber.d("SettingsScreen: On Destroy") + } + else -> {} + } + } + + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + ) { innerPadding -> + Surface( + modifier = Modifier.fillMaxSize().padding(innerPadding) + ) { + Column(modifier = Modifier.fillMaxSize()) { + TitleBar( + stringResource(R.string.settings), + onBackPressed = { + onPopBackStack() + }) + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .weight(weight = 1f, fill = false) + ) { + Spacer(modifier = Modifier.size(8.dp)) + SettingsItem(stringResource(R.string.trash), R.drawable.ic_delete) { + showDeletedRecordsScreen() + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + SettingsItemCheckBox( + uiState.isDynamicColors, + stringResource(R.string.dynamic_theme_colors), + R.drawable.ic_palette_outline, + { + onAction(SettingsScreenAction.SetDynamicTheme(it)) + }) + } + SettingsItemCheckBox( + uiState.isDarkTheme, + stringResource(R.string.dark_theme), + R.drawable.ic_dark_mode, + { + onAction(SettingsScreenAction.SetDarkTheme(it)) + }) + SettingsItemCheckBox( + uiState.isKeepScreenOn, + stringResource(R.string.keep_screen_on), + R.drawable.ic_lightbulb_on, + { + onAction(SettingsScreenAction.SetKeepScreenOn(it)) + }) + SettingsItemCheckBox( + uiState.isShowRenameDialog, + stringResource(R.string.ask_to_rename), + R.drawable.ic_pencil, + { + onAction(SettingsScreenAction.SetShowRenamingDialog(it)) + }) + DropDownSetting( + items = uiState.nameFormats, + selectedItem = uiState.selectedNameFormat, + onSelect = { + onAction(SettingsScreenAction.SetNameFormat(it)) + } + ) + Spacer(modifier = Modifier.size(8.dp)) + ResetRecordingSettingsPanel( + stringResource(id = R.string.size_per_min, uiState.sizePerMin), + uiState.recordingSettingsText + ) { + onAction(SettingsScreenAction.ResetRecordingSettings) + } + val infoFormat = stringResource(R.string.info_format) + SettingSelector( + name = stringResource(id = R.string.recording_format), + chips = uiState.recordingSettings.map { it.recordingFormat }, + onSelect = { + onAction(SettingsScreenAction.SelectRecordingFormat(it.value)) + }, + onClickInfo = { + infoText.value = infoFormat + infoTextAnnotated.value = null + openInfoDialog.value = true + } + ) + val selectedFormat = + uiState.recordingSettings.firstOrNull { it.recordingFormat.isSelected } + val infoFrequency = stringResource(R.string.info_frequency) + SettingSelector( + name = stringResource(id = R.string.sample_rate), + chips = selectedFormat?.sampleRates ?: emptyList(), + onSelect = { + onAction(SettingsScreenAction.SelectSampleRate(it.value)) + }, + onClickInfo = { + infoText.value = infoFrequency + infoTextAnnotated.value = null + openInfoDialog.value = true + } + ) + if (isExpandedBitRatePanel.value != !selectedFormat?.bitRates.isNullOrEmpty()) { + isExpandedBitRatePanel.value = !selectedFormat?.bitRates.isNullOrEmpty() + } + AnimatedVisibility(visible = isExpandedBitRatePanel.value) { + val infoBitrate = stringResource(R.string.info_bitrate) + SettingSelector( + name = stringResource(id = R.string.bitrate), + chips = selectedFormat?.bitRates ?: emptyList(), + onSelect = { + onAction(SettingsScreenAction.SelectBitrate(it.value)) + }, + onClickInfo = { + infoText.value = infoBitrate + infoTextAnnotated.value = null + openInfoDialog.value = true + } + ) + } + val infoChannels = htmlStringResource(R.string.info_channels_html) + SettingSelector( + name = stringResource(id = R.string.channels), + chips = selectedFormat?.channelCounts ?: emptyList(), + onSelect = { + onAction(SettingsScreenAction.SelectChannelCount(it.value)) + }, + onClickInfo = { + infoText.value = "" + infoTextAnnotated.value = infoChannels + openInfoDialog.value = true + } + ) + Spacer(modifier = Modifier.size(8.dp)) + val infoAudioSource = htmlStringResource(R.string.info_audio_source_html) + AudioSourceSelector( + selectedSource = uiState.selectedAudioSource, + options = uiState.audioSourceOptions, + onSourceSelected = { audioSource -> + onAction(SettingsScreenAction.SetAudioSource(audioSource)) + }, + onInfoClick = { + infoText.value = "" + infoTextAnnotated.value = infoAudioSource + openInfoDialog.value = true + } + ) + Spacer(modifier = Modifier.size(8.dp)) + MaxDurationSettingRow( + currentValue = uiState.maxRecordingDurationMinutes, + onAction = onAction + ) + Spacer(modifier = Modifier.size(8.dp)) + SettingsItem(stringResource(R.string.rate_app), R.drawable.ic_thumbs) { + rateApp(context) + } + SettingsItem(stringResource(R.string.request), R.drawable.ic_chat_bubble) { + requestFeature(context) { + warningText.value = it + openWarningDialog.value = true + } + } + Spacer(modifier = Modifier.size(8.dp)) + InfoTextView( + stringResource( + id = R.string.total_record_count, + (uiState.totalRecordCount) + ) + ) + InfoTextView( + stringResource( + id = R.string.total_duration, + formatDuration(context.resources, (uiState.totalRecordDuration)) + ) + ) + InfoTextView( + stringResource( + id = R.string.available_space, + (uiState.availableSpace) + ) + ) + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Start, + text = stringResource(R.string.switch_to_legacy_app), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 20.sp, + fontWeight = FontWeight.Light + ) + Button( + modifier = Modifier + .wrapContentSize(), + onClick = { + onAction(SettingsScreenAction.SetAppV2(false)) + } + ) { + Text( + text = stringResource(id = R.string.btn_apply), + fontSize = 18.sp, + fontWeight = FontWeight.Light, + ) + } + } + AppInfoView(uiState.appName, uiState.appVersion) + Spacer(modifier = Modifier.size(8.dp)) + } + if (openInfoDialog.value) { + val annotated = infoTextAnnotated.value + if (annotated != null) { + SettingsInfoDialog(openInfoDialog, annotated) + } else { + SettingsInfoDialog(openInfoDialog, infoText.value) + } + } + if (openWarningDialog.value) { + SettingsWarningDialog(openWarningDialog, warningText.value) + } + } + } + } +} + +@Composable +internal fun MaxDurationSettingRow( + currentValue: Int, + onAction: (SettingsScreenAction) -> Unit, + modifier: Modifier = Modifier, +) { + val showDialog = remember { mutableStateOf(false) } + + // Convert minutes to hours and minutes for display and dialog + val hours = currentValue / 60 + val minutes = currentValue % 60 + + MaxDurationSettingItem( + currentHours = hours, + currentMinutes = minutes, + onClick = { showDialog.value = true }, + modifier = modifier, + ) + + if (showDialog.value) { + DurationPickerDialog( + currentHours = hours, + currentMinutes = minutes, + onDismiss = { showDialog.value = false }, + onConfirm = { newHours, newMinutes -> + val totalMinutes = (newHours * 60) + newMinutes + onAction(SettingsScreenAction.SetMaxRecordingDuration(totalMinutes)) + showDialog.value = false + } + ) + } +} + +@Composable +fun MaxDurationSettingItem( + currentHours: Int, + currentMinutes: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .clickable { onClick() } + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .padding(16.dp) + .wrapContentSize(), + painter = painterResource(id = R.drawable.ic_access_time), + contentDescription = stringResource(R.string.recording_duration), + ) + Column( + modifier = Modifier + .weight(1f) + .wrapContentHeight() + ) { + Text( + text = stringResource(R.string.recording_duration), + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.recording_duration_subtitle), + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp) + ) + } + + // Duration badge/chip + Card( + modifier = Modifier + .wrapContentSize() + .padding(start = 8.dp), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + ) { + Text( + text = formatDurationDisplay(currentHours, currentMinutes), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + } +} + +/** + * Formats a duration specified in hours and minutes into a human-readable string. + * + * @param hours The number of hours (0-23) + * @param minutes The number of minutes (0-59) + * @return A formatted string like "2h 30m", "5h 0m", "45m", or "0m" + * + * Examples: + * - formatDurationDisplay(2, 30) returns "2h 30m" + * - formatDurationDisplay(5, 0) returns "5h 0m" + * - formatDurationDisplay(0, 45) returns "45m" + * - formatDurationDisplay(0, 0) returns "0m" + */ +@Composable +fun formatDurationDisplay(hours: Int, minutes: Int): String { + return when { + hours > 0 && minutes > 0 -> { + stringResource(R.string.duration_hours_and_minutes, hours, minutes) + } + hours > 0 -> { + stringResource(R.string.duration_hours_and_minutes, hours, minutes) + } + minutes > 0 -> { + stringResource(R.string.duration_minutes, minutes) + } + else -> { + stringResource(R.string.duration_minutes, 0) + } + } +} + +@Preview +@Composable +fun SettingsScreenPreview() { + SettingsScreen({}, {}, uiState = SettingsState( + isDynamicColors = true, + isDarkTheme = false, + isAppV2 = false, + isKeepScreenOn = false, + isShowRenameDialog = true, + nameFormats = listOf(NameFormatItem(NameFormat.Record, "Name text")), + selectedNameFormat = NameFormatItem(NameFormat.Record, "Name text"), + recordingSettings = listOf(RecordingSetting( + recordingFormat = ChipItem(id = 0, value = RecordingFormat.M4a, name = "M4a", isSelected = true), + sampleRates = listOf( + ChipItem(id = 0, value = SampleRate.SR16000, name = "16 kHz", isSelected = false), + ChipItem(id = 0, value = SampleRate.SR22500, name = "22.5 kHz", isSelected = false), + ChipItem(id = 0, value = SampleRate.SR32000, name = "32 kHz", isSelected = false), + ChipItem(id = 1, value = SampleRate.SR44100, name = "44.1 kHz", isSelected = true), + ChipItem(id = 1, value = SampleRate.SR48000, name = "48 kHz", isSelected = false), + ), + bitRates = listOf( + ChipItem(id = 0, value = BitRate.BR48, name = "48 kbps", isSelected = false), + ChipItem(id = 0, value = BitRate.BR96, name = "96 kbps", isSelected = false), + ChipItem(id = 1, value = BitRate.BR128, name = "128 kbps", isSelected = true), + ChipItem(id = 1, value = BitRate.BR192, name = "192 kbps", isSelected = false), + ), + channelCounts = listOf( + ChipItem(id = 0, value = ChannelCount.Mono, name = "Mono", isSelected = false), + ChipItem(id = 1, value = ChannelCount.Stereo, name = "Stereo", isSelected = true), + ) + ), + ), + sizePerMin = "10", + recordingSettingsText = "recordingSettingsText", + rateAppLink = "rateAppLink", + feedbackEmail = "feedbackEmail", + totalRecordCount = 10, + totalRecordDuration = 1000500, + availableSpace = 1010101010, + appName = "App Name", + appVersion = "1.0.0", + maxRecordingDurationMinutes = 120, + ), {}) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsState.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsState.kt new file mode 100644 index 000000000..b4a157228 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsState.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.settings + +import android.os.Parcelable +import com.dimowner.audiorecorder.v2.data.model.AudioSource +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.NameFormat +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import kotlinx.parcelize.Parcelize + +@Parcelize +data class SettingsState( + val isDynamicColors: Boolean, + val isDarkTheme: Boolean, + val isAppV2: Boolean, + val isKeepScreenOn: Boolean, + val isShowRenameDialog: Boolean, + val nameFormats: List, + val selectedNameFormat: NameFormatItem, + val recordingSettings: List, + val sizePerMin: String, + val recordingSettingsText: String, + val rateAppLink: String, + val feedbackEmail: String, + val totalRecordCount: Int, + val totalRecordDuration: Long, + val availableSpace: Long, + val appName: String, + val appVersion: String, + val maxRecordingDurationMinutes: Int, + // Audio source selection + val selectedAudioSource: AudioSource = AudioSource.MIC, + val audioSourceOptions: List = AudioSource.entries, +) : Parcelable + +@Parcelize +data class RecordingSetting( + val recordingFormat: ChipItem, + val sampleRates: List>, + val bitRates: List>, + val channelCounts: List>, +) : Parcelable + +@Parcelize +data class ChipItem( + val id: Int, + val value: T, + val name: String, + val isSelected: Boolean +) : Parcelable + +@Parcelize +data class NameFormatItem( + val nameFormat: NameFormat, + val nameText: String, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsViewModel.kt new file mode 100644 index 000000000..3f8c41bdc --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsViewModel.kt @@ -0,0 +1,439 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.app.settings + +import android.content.Context +import android.os.Parcelable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.audio.player.PlayerContractNew +import com.dimowner.audiorecorder.util.AndroidUtils +import com.dimowner.audiorecorder.v2.DefaultValues +import com.dimowner.audiorecorder.v2.app.formatBitRate +import com.dimowner.audiorecorder.v2.app.formatChannelCount +import com.dimowner.audiorecorder.v2.app.formatRecordingFormat +import com.dimowner.audiorecorder.v2.app.formatSampleRate +import com.dimowner.audiorecorder.v2.app.recordingSettingsCombinedText +import com.dimowner.audiorecorder.v2.app.removeOutdatedTrashRecords +import com.dimowner.audiorecorder.v2.data.PrefsV2 +import com.dimowner.audiorecorder.v2.data.RecordsDataSource +import com.dimowner.audiorecorder.v2.data.model.AudioSource +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher +import com.dimowner.audiorecorder.v2.di.qualifiers.MainDispatcher +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +internal class SettingsViewModel @Inject constructor( + private val prefs: PrefsV2, + private val recordsDataSource: RecordsDataSource, + private val audioPlayer: PlayerContractNew.Player, + @param:MainDispatcher private val mainDispatcher: CoroutineDispatcher, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + @ApplicationContext context: Context, +) : ViewModel() { + + private val decimalFormat: DecimalFormat + + private val selectedFormat = prefs.settingRecordingFormat + private val selectedSampleRate = prefs.settingSampleRate + private val selectedBitRate = prefs.settingBitrate + private val selectedChannelCount = prefs.settingChannelCount + + private val formatsStrings = context.resources.getStringArray(R.array.formats2) + private val sampleRateStrings = context.resources.getStringArray(R.array.sample_rates2) + private val bitRateStrings = context.resources.getStringArray(R.array.bit_rates2) + private val channelCountsStrings = context.resources.getStringArray(R.array.channels) + + init { + val formatSymbols = DecimalFormatSymbols(Locale.getDefault()) + formatSymbols.decimalSeparator = '.' + decimalFormat = DecimalFormat("#.#", formatSymbols) + } + + private val _state: MutableState = mutableStateOf( + //TODO: Move default state creation into a function + SettingsState( + isDynamicColors = prefs.isDynamicTheme, + isDarkTheme = prefs.isDarkTheme, + isAppV2 = prefs.isAppV2, + isKeepScreenOn = prefs.isKeepScreenOn, + isShowRenameDialog = prefs.askToRenameAfterRecordingStopped, + selectedNameFormat = prefs.settingNamingFormat.toNameFormatItem(), + nameFormats = makeNameFormats(), + recordingSettings = RecordingFormat.entries.toList().mapIndexed { index, format -> + RecordingSetting( + recordingFormat = ChipItem( + id = index, + value = format, + name = formatsStrings[index], + isSelected = format == selectedFormat + ), + sampleRates = getSampleRates( + selectedFormat, + selectedSampleRate, + sampleRateStrings + ), + bitRates = getBitRates( + selectedFormat, + selectedBitRate, + bitRateStrings + ), + channelCounts = getChannelCounts( + selectedFormat, + selectedChannelCount, + channelCountsStrings + ), + ) + }, + sizePerMin = decimalFormat.format( + sizeMbPerMin( + selectedFormat, + selectedSampleRate, + selectedBitRate, + selectedChannelCount + ) + ), + recordingSettingsText = recordingSettingsCombinedText( + selectedFormat, + formatRecordingFormat(formatsStrings, selectedFormat), + formatSampleRate(sampleRateStrings, selectedSampleRate), + formatBitRate(bitRateStrings, selectedBitRate), + formatChannelCount(channelCountsStrings, selectedChannelCount), + ), + rateAppLink = "link",//TODO: Fix hardcoded value + feedbackEmail = "email",//TODO: Fix hardcoded value + totalRecordCount = 0, + totalRecordDuration = 0, + availableSpace = 0, + appName = context.getString(R.string.app_name), + appVersion = context.getString(R.string.version, AndroidUtils.getAppVersion(context)), + maxRecordingDurationMinutes = prefs.maxRecordingDurationMills / 60000, + ) + ) + + val state: State = _state + + fun initSettings() { + viewModelScope.launch(ioDispatcher) { + val recordsCount = recordsDataSource.getRecordsCount() + val recordsDuration = recordsDataSource.getRecordTotalDuration() + withContext(mainDispatcher) { + _state.value = _state.value.copy( + totalRecordCount = recordsCount, totalRecordDuration = recordsDuration, + // Load the selected audio source from preferences + selectedAudioSource = prefs.settingAudioSource + ) + } + recordsDataSource.removeOutdatedTrashRecords() + } + } + + fun setAudioSource(audioSource: AudioSource) { + _state.value = _state.value.copy(selectedAudioSource = audioSource) + prefs.settingAudioSource = audioSource + } + + fun executeFirstRun() { + if (prefs.isFirstRun) { + prefs.confirmFirstRunExecuted() + } + } + + fun handleUseAppV2(value: Boolean) { + if (prefs.isAppV2 != value) { + prefs.isAppV2 = value + } + audioPlayer.stop() + } + + fun setDarkTheme(value: Boolean) { + if (prefs.isDarkTheme != value) { + prefs.isDarkTheme = value + } + } + + fun setDynamicTheme(value: Boolean) { + if (prefs.isDynamicTheme != value) { + prefs.isDynamicTheme = value + } + } + + fun setKeepScreenOn(value: Boolean) { + prefs.isKeepScreenOn = value + _state.value = _state.value.copy(isKeepScreenOn = value) + } + + fun setShowRenamingDialog(value: Boolean) { + prefs.askToRenameAfterRecordingStopped = value + _state.value = _state.value.copy(isShowRenameDialog = value) + } + + fun setNameFormat(value: NameFormatItem) { + prefs.settingNamingFormat = value.nameFormat + _state.value = _state.value.copy(selectedNameFormat = value) + } + + fun resetRecordingSettings() { + prefs.settingRecordingFormat = DefaultValues.DefaultRecordingFormat + prefs.settingSampleRate = DefaultValues.DefaultSampleRate + prefs.settingBitrate = DefaultValues.DefaultBitRate + prefs.settingChannelCount = DefaultValues.DefaultChannelCount + prefs.settingAudioSource = DefaultValues.DefaultAudioSource + _state.value = _state.value.copy( + recordingSettings = _state.value.recordingSettings.map { formatSetting -> + RecordingSetting( + recordingFormat = formatSetting.recordingFormat.updateSelected( + DefaultValues.DefaultRecordingFormat + ), + sampleRates = getSampleRates( + DefaultValues.DefaultRecordingFormat, + DefaultValues.DefaultSampleRate, + sampleRateStrings + ), + bitRates = getBitRates( + DefaultValues.DefaultRecordingFormat, + DefaultValues.DefaultBitRate, + bitRateStrings + ), + channelCounts = getChannelCounts( + DefaultValues.DefaultRecordingFormat, + DefaultValues.DefaultChannelCount, + channelCountsStrings + ), + ) + }, + ).recordingSettingsUpdated() + } + + fun selectRecordingFormat(value: RecordingFormat) { + prefs.settingRecordingFormat = value + _state.value = _state.value.copy( + recordingSettings = _state.value.recordingSettings.map { item -> + item.copy( + recordingFormat = item.recordingFormat.updateSelected(value), + sampleRates = getSampleRates( + value, + prefs.settingSampleRate, + sampleRateStrings + ), + bitRates = getBitRates( + value, + prefs.settingBitrate, + bitRateStrings + ), + channelCounts = getChannelCounts( + value, + prefs.settingChannelCount, + channelCountsStrings + ), + ) + } + ).validate3GpSelectedAndAdjust(value) + .recordingSettingsUpdated() + } + + private fun SettingsState.validate3GpSelectedAndAdjust(format: RecordingFormat): SettingsState { + return if (format == RecordingFormat.ThreeGp) { + val formatSetting = this.recordingSettings.firstOrNull { + it.recordingFormat.value == format + } + val hasSelectedSampleRate = formatSetting?.sampleRates?.any { it.isSelected } ?: false + val hasSelectedChannelCount = formatSetting?.channelCounts?.any { it.isSelected } ?: false + if (!hasSelectedSampleRate || !hasSelectedChannelCount) { + this.copy( + recordingSettings = recordingSettings.map { recordingSetting -> + if (recordingSetting.recordingFormat.value == format) { + recordingSetting.copy( + sampleRates = if (hasSelectedSampleRate) { + recordingSetting.sampleRates + } else { + prefs.settingSampleRate = DefaultValues.Default3GpSampleRate + recordingSetting.sampleRates.map { + if (it.value == DefaultValues.Default3GpSampleRate) { + it.copy(isSelected = true) + } else { + it + } + } + }, + channelCounts = if (hasSelectedChannelCount) { + recordingSetting.channelCounts + } else { + prefs.settingChannelCount = DefaultValues.Default3GpChannelCount + recordingSetting.channelCounts.map { + if (it.value == DefaultValues.Default3GpChannelCount) { + it.copy(isSelected = true) + } else { + it + } + } + } + ) + } else { + recordingSetting + } + } + ) + } else { + this + } + } else { + return this + } + } + + fun selectSampleRate(value: SampleRate) { + prefs.settingSampleRate = value + _state.value = _state.value.copy( + recordingSettings = _state.value.recordingSettings.map { formatSetting -> + if (formatSetting.recordingFormat.isSelected) { + formatSetting.copy( + sampleRates = formatSetting.sampleRates.map { item -> + item.updateSelected(value) + } + ) + } else { + formatSetting + } + } + ).recordingSettingsUpdated() + } + + fun selectBitrate(value: BitRate) { + prefs.settingBitrate = value + _state.value = _state.value.copy( + recordingSettings = _state.value.recordingSettings.map { formatSetting -> + if (formatSetting.recordingFormat.isSelected) { + formatSetting.copy( + bitRates = formatSetting.bitRates.map { item -> + item.updateSelected(value) + } + ) + } else { + formatSetting + } + } + ).recordingSettingsUpdated() + } + + fun selectChannelCount(value: ChannelCount) { + prefs.settingChannelCount = value + _state.value = _state.value.copy( + recordingSettings = _state.value.recordingSettings.map { formatSetting -> + if (formatSetting.recordingFormat.isSelected) { + formatSetting.copy( + channelCounts = formatSetting.channelCounts.map { item -> + item.updateSelected(value) + } + ) + } else { + formatSetting + } + } + ).recordingSettingsUpdated() + } + + fun setMaxRecordingDuration(durationMinutes: Int) { + if (durationMinutes > 0) { + prefs.maxRecordingDurationMills = durationMinutes * 60 * 1000 + _state.value = _state.value.copy(maxRecordingDurationMinutes = durationMinutes) + } + } + + fun onAction(action: SettingsScreenAction) { + when (action) { + SettingsScreenAction.InitSettingsScreen -> initSettings() + is SettingsScreenAction.SetDynamicTheme -> setDynamicTheme(action.value) + is SettingsScreenAction.SetDarkTheme -> setDarkTheme(action.value) + is SettingsScreenAction.SetKeepScreenOn -> setKeepScreenOn(action.value) + is SettingsScreenAction.SetShowRenamingDialog -> setShowRenamingDialog(action.value) + is SettingsScreenAction.SetNameFormat -> setNameFormat(action.value) + SettingsScreenAction.ResetRecordingSettings -> resetRecordingSettings() + is SettingsScreenAction.SelectRecordingFormat -> selectRecordingFormat(action.value) + is SettingsScreenAction.SelectSampleRate -> selectSampleRate(action.value) + is SettingsScreenAction.SelectBitrate -> selectBitrate(action.value) + is SettingsScreenAction.SelectChannelCount -> selectChannelCount(action.value) + is SettingsScreenAction.SetMaxRecordingDuration -> setMaxRecordingDuration(action.durationMinutes) + is SettingsScreenAction.SetAudioSource -> setAudioSource(action.audioSource) + SettingsScreenAction.ExecuteFirstRun -> executeFirstRun() + is SettingsScreenAction.SetAppV2 -> handleUseAppV2(action.value) + } + } + + private fun SettingsState.recordingSettingsUpdated(): SettingsState { + val settings = this.recordingSettings.firstOrNull { it.recordingFormat.isSelected } + return this.copy( + sizePerMin = decimalFormat.format( + sizeMbPerMin( + settings?.recordingFormat?.value, + settings?.sampleRates?.firstOrNull { it.isSelected }?.value, + settings?.bitRates?.firstOrNull { it.isSelected }?.value, + settings?.channelCounts?.firstOrNull { it.isSelected }?.value, + ) + ), + recordingSettingsText = recordingSettingsCombinedText( + settings?.recordingFormat?.value, + formatRecordingFormat(formatsStrings, settings?.recordingFormat?.value), + formatSampleRate(sampleRateStrings, settings?.sampleRates?.firstOrNull { it.isSelected }?.value), + formatBitRate(bitRateStrings, settings?.bitRates?.firstOrNull { it.isSelected }?.value), + formatChannelCount(channelCountsStrings, settings?.channelCounts?.firstOrNull { it.isSelected }?.value), + ) + ) + } + + private fun ChipItem.updateSelected(value: T): ChipItem { + return if (this.value == value) { + this.copy(isSelected = true) + } else { + this.copy(isSelected = false) + } + } +} + +internal sealed class SettingsScreenAction { + data object InitSettingsScreen : SettingsScreenAction() + data class SetAppV2(val value: Boolean) : SettingsScreenAction() + data class SetDynamicTheme(val value: Boolean) : SettingsScreenAction() + data class SetDarkTheme(val value: Boolean) : SettingsScreenAction() + data class SetKeepScreenOn(val value: Boolean) : SettingsScreenAction() + data class SetShowRenamingDialog(val value: Boolean) : SettingsScreenAction() + data class SetNameFormat(val value: NameFormatItem) : SettingsScreenAction() + data object ResetRecordingSettings : SettingsScreenAction() + data class SelectRecordingFormat(val value: RecordingFormat) : SettingsScreenAction() + data class SelectSampleRate(val value: SampleRate) : SettingsScreenAction() + data class SelectBitrate(val value: BitRate) : SettingsScreenAction() + data class SelectChannelCount(val value: ChannelCount) : SettingsScreenAction() + data class SetMaxRecordingDuration(val durationMinutes: Int) : SettingsScreenAction() + data class SetAudioSource(val audioSource: AudioSource) : SettingsScreenAction() + data object ExecuteFirstRun : SettingsScreenAction() +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/WelcomeSetupSettingsScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/WelcomeSetupSettingsScreen.kt new file mode 100644 index 000000000..ef4843202 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/WelcomeSetupSettingsScreen.kt @@ -0,0 +1,302 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.settings + +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.ComposableLifecycle +import com.dimowner.audiorecorder.v2.app.TitleBar +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.NameFormat +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import timber.log.Timber + +@Composable +internal fun WelcomeSetupSettingsScreen( + onPopBackStack: () -> Unit, + onApplySettings: () -> Unit, + uiState: SettingsState, + onAction: (SettingsScreenAction) -> Unit, +) { + val context = LocalContext.current + + val openInfoDialog = remember { mutableStateOf(false) } + val infoText = remember { mutableStateOf("") } + + val isExpandedBitRatePanel = remember { mutableStateOf(true) } + + ComposableLifecycle { _, event -> + when (event) { + Lifecycle.Event.ON_CREATE -> { + Timber.d("SettingsScreen: onCreate") + onAction(SettingsScreenAction.InitSettingsScreen) + } + else -> {} + } + } + + Surface( + modifier = Modifier.fillMaxSize() + ) { + Column(modifier = Modifier.fillMaxSize()) { + TitleBar( + stringResource(R.string.setup), + onBackPressed = { onPopBackStack() }) + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .weight(weight = 1f, fill = true) + ) { + Spacer(modifier = Modifier.size(8.dp)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + SettingsItemCheckBox( + uiState.isDynamicColors, + stringResource(R.string.dynamic_theme_colors), + R.drawable.ic_palette_outline, + { + onAction(SettingsScreenAction.SetDynamicTheme(it)) + }) + } + SettingsItemCheckBox( + uiState.isDarkTheme, + stringResource(R.string.dark_theme), + R.drawable.ic_dark_mode, + { + onAction(SettingsScreenAction.SetDarkTheme(it)) + }) + DropDownSetting( + items = uiState.nameFormats, + selectedItem = uiState.selectedNameFormat, + onSelect = { + onAction(SettingsScreenAction.SetNameFormat(it)) + } + ) + SettingSelector( + name = stringResource(id = R.string.recording_format), + chips = uiState.recordingSettings.map { it.recordingFormat }, + onSelect = { + onAction(SettingsScreenAction.SelectRecordingFormat(it.value)) + }, + onClickInfo = { + infoText.value = context.getString(R.string.info_format) + openInfoDialog.value = true + } + ) + val selectedFormat = uiState.recordingSettings.firstOrNull { it.recordingFormat.isSelected } + SettingSelector( + name = stringResource(id = R.string.sample_rate), + chips = selectedFormat?.sampleRates ?: emptyList(), + onSelect = { + onAction(SettingsScreenAction.SelectSampleRate(it.value)) + }, + onClickInfo = { + infoText.value = context.getString(R.string.info_frequency) + openInfoDialog.value = true + } + ) + if (isExpandedBitRatePanel.value != !selectedFormat?.bitRates.isNullOrEmpty()) { + isExpandedBitRatePanel.value = !selectedFormat?.bitRates.isNullOrEmpty() + } + AnimatedVisibility(visible = isExpandedBitRatePanel.value) { + SettingSelector( + name = stringResource(id = R.string.bitrate), + chips = selectedFormat?.bitRates ?: emptyList(), + onSelect = { + onAction(SettingsScreenAction.SelectBitrate(it.value)) + }, + onClickInfo = { + infoText.value = context.getString(R.string.info_bitrate) + openInfoDialog.value = true + } + ) + } + SettingSelector( + name = stringResource(id = R.string.channels), + chips = selectedFormat?.channelCounts ?: emptyList(), + onSelect = { + onAction(SettingsScreenAction.SelectChannelCount(it.value)) + }, + onClickInfo = { + infoText.value = context.getString(R.string.info_channels) + openInfoDialog.value = true + } + ) + Spacer(modifier = Modifier.size(8.dp)) + } + Column { + Row { + Icon( + modifier = Modifier + .padding(4.dp) + .wrapContentSize() + .align(Alignment.CenterVertically), + painter = painterResource(id = R.drawable.ic_info), + contentDescription = stringResource(id = R.string.info) + ) + Column { + Text( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(4.dp, 8.dp, 4.dp, 0.dp), + textAlign = TextAlign.Start, + text = stringResource( + id = R.string.size_per_min, + uiState.sizePerMin + ), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + ) + val selectedFormat = uiState.recordingSettings.firstOrNull { + it.recordingFormat.isSelected + } + Text( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(4.dp, 4.dp, 4.dp, 0.dp), + textAlign = TextAlign.Start, + text = selectedFormat?.recordingFormat?.value?.toFormatInfo() ?: "", + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + ) + } + } + Row { + Button( + modifier = Modifier + .padding(8.dp) + .wrapContentHeight() + .weight(1F), + onClick = { + onAction(SettingsScreenAction.ResetRecordingSettings) + } + ) { + Text( + text = stringResource(id = R.string.btn_reset), + fontSize = 18.sp, + ) + } + Button( + modifier = Modifier + .padding(8.dp) + .wrapContentHeight() + .weight(1F), + onClick = { + onAction(SettingsScreenAction.ExecuteFirstRun) + onApplySettings() + } + ) { + Text( + text = stringResource(id = R.string.btn_apply), + fontSize = 18.sp, + ) + } + } + } + if (openInfoDialog.value) { + SettingsInfoDialog(openInfoDialog, infoText.value) + } + } + } +} + +@Composable +fun RecordingFormat.toFormatInfo(): String { + return when (this) { + RecordingFormat.M4a -> stringResource(id = R.string.info_m4a) + RecordingFormat.Wav -> stringResource(id = R.string.info_wav) + RecordingFormat.ThreeGp -> stringResource(id = R.string.info_3gp) + } +} + +@Preview +@Composable +fun WelcomeSetupSettingsScreenPreview() { + WelcomeSetupSettingsScreen({}, {}, uiState = SettingsState( + isDynamicColors = true, + isAppV2 = false, + isDarkTheme = false, + isKeepScreenOn = false, + isShowRenameDialog = true, + nameFormats = listOf(NameFormatItem(NameFormat.Record, "Name text")), + selectedNameFormat = NameFormatItem(NameFormat.Record, "Name text"), + recordingSettings = listOf(RecordingSetting( + recordingFormat = ChipItem(id = 0, value = RecordingFormat.M4a, name = "M4a", isSelected = true), + sampleRates = listOf( + ChipItem(id = 0, value = SampleRate.SR16000, name = "16 kHz", isSelected = false), + ChipItem(id = 0, value = SampleRate.SR22500, name = "22.5 kHz", isSelected = false), + ChipItem(id = 0, value = SampleRate.SR32000, name = "32 kHz", isSelected = false), + ChipItem(id = 1, value = SampleRate.SR44100, name = "44.1 kHz", isSelected = true), + ChipItem(id = 1, value = SampleRate.SR48000, name = "48 kHz", isSelected = false), + ), + bitRates = listOf( + ChipItem(id = 0, value = BitRate.BR48, name = "48 kbps", isSelected = false), + ChipItem(id = 0, value = BitRate.BR96, name = "96 kbps", isSelected = false), + ChipItem(id = 1, value = BitRate.BR128, name = "128 kbps", isSelected = true), + ChipItem(id = 1, value = BitRate.BR192, name = "192 kbps", isSelected = false), + ), + channelCounts = listOf( + ChipItem(id = 0, value = ChannelCount.Mono, name = "Mono", isSelected = false), + ChipItem(id = 1, value = ChannelCount.Stereo, name = "Stereo", isSelected = true), + ) + ), + ), + sizePerMin = "10", + recordingSettingsText = "recordingSettingsText", + rateAppLink = "rateAppLink", + feedbackEmail = "feedbackEmail", + totalRecordCount = 10, + totalRecordDuration = 1000500, + availableSpace = 1010101010, + appName = "App Name", + appVersion = "1.0.0", + maxRecordingDurationMinutes = 120, + ), {}) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/welcome/WelcomeScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/welcome/WelcomeScreen.kt new file mode 100644 index 000000000..de7e33fbe --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/welcome/WelcomeScreen.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.welcome + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.R + +@Composable +fun WelcomeScreen( + onGetStarted: () -> Unit +) { + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + ) { innerPadding -> + Surface( + modifier = Modifier.fillMaxSize().padding(innerPadding) + ) { + Column(modifier = Modifier.wrapContentSize()) { + Icon( + modifier = Modifier + .padding(16.dp) + .wrapContentSize() + .align(Alignment.CenterHorizontally), + painter = painterResource(id = R.drawable.waveform), + contentDescription = stringResource(id = R.string.app_name) + ) + Text( + modifier = Modifier + .padding(8.dp) + .wrapContentSize() + .align(Alignment.CenterHorizontally), + text = stringResource(id = R.string.app_name), + fontSize = 36.sp, + fontWeight = FontWeight.Light, + textAlign = TextAlign.Center + ) + Text( + modifier = Modifier + .wrapContentSize() + .align(Alignment.CenterHorizontally), + text = stringResource(id = R.string.welcome_1), + fontSize = 24.sp, + fontWeight = FontWeight.Light, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.size(42.dp)) + Button( + modifier = Modifier + .padding(8.dp) + .wrapContentSize() + .align(Alignment.CenterHorizontally), + onClick = { onGetStarted() } + ) { + Text( + text = stringResource(id = R.string.btn_get_started), + fontSize = 18.sp, + ) + } + } + } + } +} + +@Preview +@Composable +fun WelcomeScreenPreview() { + WelcomeScreen({}) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecorderDelegate.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecorderDelegate.kt new file mode 100644 index 000000000..f1f9f471d --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecorderDelegate.kt @@ -0,0 +1,21 @@ +package com.dimowner.audiorecorder.v2.audio + +import com.dimowner.audiorecorder.v2.data.PrefsV2 +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AudioRecorderDelegate @Inject constructor( + private val prefs: PrefsV2, + private val audioRecorder: AudioRecorderV2, +) { + + fun provideAudioRecorder(): RecorderV2 { + return when (prefs.settingRecordingFormat) { + RecordingFormat.M4a -> audioRecorder + RecordingFormat.Wav -> TODO("Not implemented") + RecordingFormat.ThreeGp -> TODO("Not implemented") + } + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecorderV2.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecorderV2.kt new file mode 100644 index 000000000..107aef6c8 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecorderV2.kt @@ -0,0 +1,251 @@ +package com.dimowner.audiorecorder.v2.audio + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import android.os.Handler +import android.os.Looper +import com.dimowner.audiorecorder.AppConstants.RECORDING_VISUALIZATION_INTERVAL +import com.dimowner.audiorecorder.exception.AlreadyRecordingException +import com.dimowner.audiorecorder.exception.InvalidOutputFile +import com.dimowner.audiorecorder.exception.RecorderInitException +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import java.io.File +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AudioRecorderV2 @Inject constructor( + @param:ApplicationContext private val applicationContext: Context, + private val coroutineScope: CoroutineScope, +) : RecorderV2 { + + private var mediaRecorder: MediaRecorder? = null + private var recordFile: File? = null + private var updateTime: Long = 0 + private var durationMills: Long = 0 + + private var _isRecording: Boolean = false + private var _isPaused: Boolean = false + override val isRecording: Boolean + get() = _isRecording + override val isPaused: Boolean + get() = _isPaused + + // Using Handler tied to the main Looper for UI thread synchronization and timing updates + private val handler = Handler(Looper.getMainLooper()) + + private val _event = MutableSharedFlow() + override fun subscribeRecorderEvents(): Flow { + return _event + } + + override fun startRecording( + outputFile: File, + channelCount: Int, + sampleRate: Int, + bitrate: Int, + maxRecordingDurationMills: Int, + audioSource: Int, + ): Boolean { + Timber.d("Start Recording outputFile: ${outputFile.absolutePath} channelCount: $channelCount" + + " sampleRate: $sampleRate bitrate: $bitrate maxRecordingDurationMills: $maxRecordingDurationMills" + + " audioSource: $audioSource") + if (_isRecording) { + Timber.e("Recording is already in progress.") + emitEvent(RecorderEvent.OnError(AlreadyRecordingException())) + return false + } + return if (outputFile.exists() && outputFile.isFile) { + recordFile = outputFile + val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(applicationContext) + } else { + MediaRecorder() + } + this.mediaRecorder = recorder + + recorder.apply { + setAudioSource(audioSource) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setAudioChannels(channelCount) + setAudioSamplingRate(sampleRate) + setAudioEncodingBitRate(bitrate) + setMaxDuration(maxRecordingDurationMills) + setOnInfoListener { _, what, _ -> + handleRecorderInfo(what) + } + setOutputFile(outputFile.absolutePath) + } + + try { + recorder.prepare() + recorder.start() + updateTime = System.currentTimeMillis() + _isRecording = true + scheduleRecordingTimeUpdate() + emitEvent(RecorderEvent.OnStartRecording) + _isPaused = false + true + } catch (e: IOException) { + Timber.e(e, "prepare() failed") + emitEvent(RecorderEvent.OnError(RecorderInitException())) + false + } catch (e: IllegalStateException) { + Timber.e(e, "start() failed due to illegal state") + emitEvent(RecorderEvent.OnError(RecorderInitException())) + false + } + } else { + emitEvent(RecorderEvent.OnError(InvalidOutputFile())) + false + } + } + + override fun resumeRecording(): Boolean { + if (!_isRecording || !_isPaused) return false + + return try { + mediaRecorder?.let { recorder -> + recorder.resume() + updateTime = System.currentTimeMillis() + scheduleRecordingTimeUpdate() + emitEvent(RecorderEvent.OnResumeRecording) + _isPaused = false + true + } ?: false + } catch (e: IllegalStateException) { + Timber.e(e, "resumeRecording() failed") + emitEvent(RecorderEvent.OnError(RecorderInitException())) + false + } + } + + override fun pauseRecording(): Boolean { + if (!_isRecording) { + Timber.e("Recording has already stopped or hasn't started") + return false + } + return if (!_isPaused) { + try { + mediaRecorder?.let { recorder -> + recorder.pause() + durationMills += System.currentTimeMillis() - updateTime + pauseRecordingTimer() + emitEvent(RecorderEvent.OnPauseRecording) + _isPaused = true + true + } ?: false + } catch (e: IllegalStateException) { + Timber.e(e, "pauseRecording() failed") + emitEvent(RecorderEvent.OnError(RecorderInitException())) + false + } + } else { + Timber.e("Recording has already paused") + false + } + } + + override fun stopRecording(): Boolean { + return stopRecording(skipStopRecordingEventEmit = false) + } + + private fun stopRecording(skipStopRecordingEventEmit: Boolean): Boolean { + if (!_isRecording) { + Timber.e("Recording has already stopped or hasn't started") + return false + } + + stopRecordingTimer() + val isStopSucceed = try { + mediaRecorder?.let { + it.setOnInfoListener(null) + it.stop() + true + }?: false + } catch (e: IllegalStateException) { + // This can happen if start() failed and stop() is called, or if the recorder + // was never fully prepared/started. + Timber.e(e, "stopRecording() problems") + false + } finally { + // Always release resources + mediaRecorder?.release() + mediaRecorder = null + } + + if (!skipStopRecordingEventEmit) { + emitEvent(RecorderEvent.OnStopRecording) + } + + // Reset all state + durationMills = 0 + recordFile = null + _isRecording = false + _isPaused = false + return isStopSucceed + } + + private fun handleRecorderInfo(what: Int) { + if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED) { + Timber.d("Max recording duration reached. Stop recording") + + if (stopRecording(skipStopRecordingEventEmit = true)) { + emitEvent(RecorderEvent.OnMaxDurationReached) + } + } else { + //Do nothing + } + } + + private fun emitEvent(event: RecorderEvent) { + coroutineScope.launch { + _event.emit(event) + } + } + + /** + * Runnable logic to update recording progress and amplitude. + */ + private val recordingTimeUpdateRunnable = Runnable { + if (isRecording && !isPaused) { + val currentRecorder = mediaRecorder + if (currentRecorder != null) { + try { + val curTime = System.currentTimeMillis() + durationMills += curTime - updateTime + updateTime = curTime + val amplitude = currentRecorder.maxAmplitude + emitEvent(RecorderEvent.OnRecordingProgress(durationMills = durationMills, amplitude = amplitude)) + } catch (e: IllegalStateException) { + Timber.e(e, "Error reading amplitude or updating progress") + } + scheduleRecordingTimeUpdate() + } + } + } + + private fun scheduleRecordingTimeUpdate() { + // Remove any pending messages before scheduling a new one + handler.removeCallbacks(recordingTimeUpdateRunnable) + handler.postDelayed(recordingTimeUpdateRunnable, RECORDING_VISUALIZATION_INTERVAL.toLong()) + } + + private fun stopRecordingTimer() { + handler.removeCallbacks(recordingTimeUpdateRunnable) + updateTime = 0 + } + + private fun pauseRecordingTimer() { + handler.removeCallbacks(recordingTimeUpdateRunnable) + updateTime = 0 + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/RecorderV2.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/RecorderV2.kt new file mode 100644 index 000000000..84d8587d6 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/RecorderV2.kt @@ -0,0 +1,33 @@ +package com.dimowner.audiorecorder.v2.audio + +import com.dimowner.audiorecorder.exception.AppException; +import kotlinx.coroutines.flow.Flow + +import java.io.File; + +interface RecorderV2 { + fun subscribeRecorderEvents(): Flow + fun startRecording( + outputFile: File, + channelCount: Int, + sampleRate: Int, + bitrate: Int, + maxRecordingDurationMills: Int, + audioSource: Int, + ): Boolean + fun resumeRecording(): Boolean + fun pauseRecording(): Boolean + fun stopRecording(): Boolean + val isRecording: Boolean + val isPaused: Boolean +} + +sealed class RecorderEvent { + object OnStartRecording: RecorderEvent() + object OnPauseRecording: RecorderEvent() + object OnResumeRecording: RecorderEvent() + data class OnRecordingProgress(val durationMills: Long, val amplitude: Int): RecorderEvent() + object OnStopRecording: RecorderEvent() + object OnMaxDurationReached: RecorderEvent() + data class OnError(val exception: AppException): RecorderEvent() +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSource.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSource.kt new file mode 100644 index 000000000..6f306db36 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSource.kt @@ -0,0 +1,42 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data + +import android.content.Context +import com.dimowner.audiorecorder.exception.CantCreateFileException +import java.io.File + +interface FileDataSource { + + fun getRecordingDir(): File? + + @Throws(CantCreateFileException::class) + fun createRecordFile(fileName: String): File + + fun deleteRecordFile(path: String): Boolean + + fun markAsRecordDeleted(path: String): String? + + fun unmarkRecordAsDeleted(path: String): String? + + fun renameFile(path: String, newName: String): File? + + @Throws(IllegalArgumentException::class) + fun getAvailableSpace(): Long + + fun requestSystemMoreMemory(context: Context, file: File, requiredSpace: Long) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSourceImpl.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSourceImpl.kt new file mode 100644 index 000000000..19b233770 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSourceImpl.kt @@ -0,0 +1,82 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data + +import android.annotation.SuppressLint +import android.content.Context +import com.dimowner.audiorecorder.AppConstants +import com.dimowner.audiorecorder.exception.CantCreateFileException +import com.dimowner.audiorecorder.v2.data.extensions.createFile +import com.dimowner.audiorecorder.v2.data.extensions.deleteFileAndChildren +import com.dimowner.audiorecorder.v2.data.extensions.getPrivateMusicStorageDir +import com.dimowner.audiorecorder.v2.data.extensions.markFileAsDeleted +import com.dimowner.audiorecorder.v2.data.extensions.renameFileWithExtension +import com.dimowner.audiorecorder.v2.data.extensions.requestAllocateSpace +import com.dimowner.audiorecorder.v2.data.extensions.unmarkFileAsDeleted +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FileDataSourceImpl @Inject internal constructor( + @ApplicationContext context: Context +): FileDataSource { + + private val recordDirectory: File? by lazy { + getPrivateMusicStorageDir(context, AppConstants.RECORDS_DIR) + } + + override fun getRecordingDir(): File? { + return recordDirectory + } + + override fun createRecordFile(fileName: String): File { + val recordFile = recordDirectory?.let { + createFile(it, fileName) + } + if (recordFile != null) { + return recordFile + } + throw CantCreateFileException() + } + + override fun deleteRecordFile(path: String): Boolean { + return deleteFileAndChildren(File(path)) + } + + override fun markAsRecordDeleted(path: String): String? { + return markFileAsDeleted(File(path))?.absolutePath + } + + override fun unmarkRecordAsDeleted(path: String): String? { + return unmarkFileAsDeleted(File(path))?.absolutePath + } + + override fun renameFile(path: String, newName: String): File? { + return renameFileWithExtension(File(path), newName) + } + + @SuppressLint("UsableSpace") + override fun getAvailableSpace(): Long { + return recordDirectory?.usableSpace ?: 0 + } + + override fun requestSystemMoreMemory(context: Context, file: File, requiredSpace: Long) { + requestAllocateSpace(context, file, requiredSpace) + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/Mappers.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/Mappers.kt new file mode 100644 index 000000000..05bf689fe --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/Mappers.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.data + +import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.data.room.RecordEntity +import com.dimowner.audiorecorder.data.database.Record as OldRecord + +fun RecordEntity.toRecord(): Record { + return Record( + id = id, + name = name, + durationMills = duration, + created = created, + added = added, + removed = removed, + path = path, + format = format, + size = size, + sampleRate = sampleRate, + channelCount = channelCount, + bitrate = bitrate, + isBookmarked = isBookmarked, + isWaveformProcessed = isWaveformProcessed, + isMovedToRecycle = isMovedToRecycle, + amps = amps, + ) +} + +fun Record.toRecordEntity(): RecordEntity { + return RecordEntity( + id = this.id, + name = this.name, + duration = this.durationMills, + created = this.created, + added = this.added, + removed = this.removed, + path = this.path, + format = this.format, + size = this.size, + sampleRate = this.sampleRate, + channelCount = this.channelCount, + bitrate = this.bitrate, + isBookmarked = this.isBookmarked, + isWaveformProcessed = this.isWaveformProcessed, + isMovedToRecycle = this.isMovedToRecycle, + amps = this.amps, + ) +} + +/** + * Converts old SQLite Record to new Room Record model. + * Used during database migration from SQLiteHelper to Room. + * + * @param isMovedToRecycle Whether this record is in the trash/recycle bin + * @return New Record model compatible with Room database + */ +fun OldRecord.toRecordV2(isMovedToRecycle: Boolean): Record { + return Record( + id = this.id.toLong(), + name = this.name ?: "", + durationMills = this.duration/1000, //OldRecord stores duration in microseconds + created = this.created, + added = this.added, + removed = this.removed, + path = this.path, + format = this.format ?: "", + size = this.size, + sampleRate = this.sampleRate, + channelCount = this.channelCount, + bitrate = this.bitrate, + isBookmarked = this.isBookmarked, + isWaveformProcessed = this.isWaveformProcessed, + isMovedToRecycle = isMovedToRecycle, + amps = this.amps ?: IntArray(0), + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt new file mode 100644 index 000000000..9bd7a7c47 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.data + +import com.dimowner.audiorecorder.v2.data.model.AudioSource +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.NameFormat +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import com.dimowner.audiorecorder.v2.data.model.SortOrder + +interface PrefsV2 { + val isFirstRun: Boolean + fun confirmFirstRunExecuted() + + var askToRenameAfterRecordingStopped: Boolean + + var activeRecordId: Long + var recordedRecordId: Long + var recordedRecordPartCounter: Int + var recordedRecordBaseName: String? + + val recordCounter: Long + fun incrementRecordCounter() + + var isKeepScreenOn: Boolean + + var recordsSortOrder: SortOrder + + var isDynamicTheme: Boolean + var isDarkTheme: Boolean + var isAppV2: Boolean + + var settingNamingFormat: NameFormat + var settingRecordingFormat: RecordingFormat + var settingSampleRate: SampleRate + var settingBitrate: BitRate + var settingChannelCount: ChannelCount + var settingAudioSource: AudioSource + + var maxRecordingDurationMills: Int + + fun resetRecordingSettings() + + fun fullPreferenceReset() +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt new file mode 100644 index 000000000..e036d2caa --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt @@ -0,0 +1,280 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data + +import android.content.Context +import android.content.SharedPreferences +import com.dimowner.audiorecorder.AppConstants.PREF_KEY_IS_APP_V2 +import com.dimowner.audiorecorder.AppConstants.PREF_NAME +import com.dimowner.audiorecorder.v2.DefaultValues +import com.dimowner.audiorecorder.v2.data.model.AudioSource +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.NameFormat +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import com.dimowner.audiorecorder.v2.data.model.SortOrder +import com.dimowner.audiorecorder.v2.data.model.convertToBitRate +import com.dimowner.audiorecorder.v2.data.model.convertToChannelCount +import com.dimowner.audiorecorder.v2.data.model.convertToNameFormat +import com.dimowner.audiorecorder.v2.data.model.convertToRecordingFormat +import com.dimowner.audiorecorder.v2.data.model.convertToSampleRate +import com.dimowner.audiorecorder.v2.data.model.convertToSortOrder +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import androidx.core.content.edit +import com.dimowner.audiorecorder.AppConstants.PREF_KEY_IS_FIRST_RUN +import com.dimowner.audiorecorder.AppConstants.PREF_KEY_KEEP_SCREEN_ON +import com.dimowner.audiorecorder.AppConstants.PREF_KEY_RECORD_COUNTER +import com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_BITRATE +import com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_CHANNEL_COUNT +import com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_NAMING_FORMAT +import com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_RECORDING_FORMAT +import com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_SAMPLE_RATE +import com.dimowner.audiorecorder.AppConstantsV2 + +/** + * App V2 preferences implementation + */ +@Singleton +class PrefsV2Impl @Inject internal constructor(@ApplicationContext context: Context) : PrefsV2 { + + private val sharedPreferences: SharedPreferences = + context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + + override val isFirstRun: Boolean + get() = sharedPreferences.getBoolean(PREF_KEY_IS_FIRST_RUN, true) + + override fun confirmFirstRunExecuted() { + sharedPreferences.edit { + putBoolean(PREF_KEY_IS_FIRST_RUN, false) //Set to False, because next app start won't be first + } + } + + override var askToRenameAfterRecordingStopped: Boolean + get() = sharedPreferences.getBoolean( + PREF_KEY_ASK_TO_RENAME_AFTER_RECORDING_STOPPED, DefaultValues.isAskToRename + ) + set(value) { + sharedPreferences.edit { + putBoolean(PREF_KEY_ASK_TO_RENAME_AFTER_RECORDING_STOPPED, value) + } + } + + override var activeRecordId: Long + get() = sharedPreferences.getLong(PREF_KEY_ACTIVE_RECORD_ID, -1) + set(value) { + sharedPreferences.edit { + putLong(PREF_KEY_ACTIVE_RECORD_ID, value) + } + } + + override var recordedRecordId: Long + get() = sharedPreferences.getLong(PREF_KEY_RECORDED_RECORD_ID, -1) + set(value) { + sharedPreferences.edit { + putLong(PREF_KEY_RECORDED_RECORD_ID, value) + } + } + + override var recordedRecordPartCounter: Int + get() = sharedPreferences.getInt(PREF_KEY_RECORDED_RECORD_PART_COUNTER, 0) + set(value) { + sharedPreferences.edit { + putInt(PREF_KEY_RECORDED_RECORD_PART_COUNTER, value) + } + } + + override var recordedRecordBaseName: String? + get() = sharedPreferences.getString(PREF_KEY_RECORDED_RECORD_BASE_NAME, null) + set(value) { + sharedPreferences.edit { + putString(PREF_KEY_RECORDED_RECORD_BASE_NAME, value) + } + } + + override val recordCounter: Long + get() = sharedPreferences.getLong(PREF_KEY_RECORD_COUNTER, 1) + + override fun incrementRecordCounter() { + sharedPreferences.edit { + putLong(PREF_KEY_RECORD_COUNTER, recordCounter + 1) + } + } + + override var isKeepScreenOn: Boolean + get() = sharedPreferences.getBoolean(PREF_KEY_KEEP_SCREEN_ON, DefaultValues.isKeepScreenOn) + set(value) { + sharedPreferences.edit { + putBoolean(PREF_KEY_KEEP_SCREEN_ON, value) + } + } + + override var recordsSortOrder: SortOrder + get() = sharedPreferences.getString( + PREF_KEY_RECORDS_SORT_ORDER, + SortOrder.DateAsc.toString() + )?.convertToSortOrder() ?: SortOrder.DateAsc + set(value) { + sharedPreferences.edit { + putString(PREF_KEY_RECORDS_SORT_ORDER, value.toString()) + } + } + + override var isDynamicTheme: Boolean + get() = sharedPreferences.getBoolean(PREF_KEY_IS_DYNAMIC_THEME, DefaultValues.isDynamicTheme) + set(value) { + sharedPreferences.edit { + putBoolean(PREF_KEY_IS_DYNAMIC_THEME, value) + } + } + + override var isDarkTheme: Boolean + get() = sharedPreferences.getBoolean(PREF_KEY_IS_DARK_THEME, DefaultValues.isDarkTheme) + set(value) { + sharedPreferences.edit { + putBoolean(PREF_KEY_IS_DARK_THEME, value) + } + } + + override var isAppV2: Boolean + get() = sharedPreferences.getBoolean(PREF_KEY_IS_APP_V2, DefaultValues.isAppV2) + set(value) { + sharedPreferences.edit { + putBoolean(PREF_KEY_IS_APP_V2, value) + } + } + + override var settingNamingFormat: NameFormat + get() = sharedPreferences.getString( + PREF_KEY_SETTING_NAMING_FORMAT, + DefaultValues.DefaultNameFormat.name + )?.convertToNameFormat() ?: DefaultValues.DefaultNameFormat + set(value) { + sharedPreferences.edit { + putString(PREF_KEY_SETTING_NAMING_FORMAT, value.name) + } + } + + override var settingRecordingFormat: RecordingFormat + get() = sharedPreferences.getString( + PREF_KEY_SETTING_RECORDING_FORMAT, + RecordingFormat.M4a.value + )?.convertToRecordingFormat() ?: RecordingFormat.M4a + set(value) { + sharedPreferences.edit { + putString(PREF_KEY_SETTING_RECORDING_FORMAT, value.value) + } + } + + override var settingSampleRate: SampleRate + get() = sharedPreferences.getInt( + PREF_KEY_SETTING_SAMPLE_RATE, + DefaultValues.DefaultSampleRate.value + ).convertToSampleRate() ?: DefaultValues.DefaultSampleRate + set(value) { + sharedPreferences.edit { + putInt(PREF_KEY_SETTING_SAMPLE_RATE, value.value) + } + } + + override var settingBitrate: BitRate + get() = sharedPreferences.getInt( + PREF_KEY_SETTING_BITRATE, + DefaultValues.DefaultBitRate.value + ).convertToBitRate() ?: DefaultValues.DefaultBitRate + set(value) { + sharedPreferences.edit { + putInt(PREF_KEY_SETTING_BITRATE, value.value) + } + } + + override var settingChannelCount: ChannelCount + get() = sharedPreferences.getInt( + PREF_KEY_SETTING_CHANNEL_COUNT, + DefaultValues.DefaultChannelCount.value + ).convertToChannelCount() ?: DefaultValues.DefaultChannelCount + set(value) { + sharedPreferences.edit { + putInt(PREF_KEY_SETTING_CHANNEL_COUNT, value.value) + } + } + + override var settingAudioSource: AudioSource + get() = sharedPreferences.getInt( + PREF_KEY_SETTING_AUDIO_SOURCE, + DefaultValues.DefaultAudioSource.value + ).let { AudioSource.fromValue(it) } + set(value) { + sharedPreferences.edit { + putInt(PREF_KEY_SETTING_AUDIO_SOURCE, value.value) + } + } + + override var maxRecordingDurationMills: Int + get() = sharedPreferences.getInt( + PREF_KEY_MAX_RECORDING_DURATION_MILLS, + AppConstantsV2.DEFAULT_MAX_RECORDING_DURATION_MS + ) + set(value) { + sharedPreferences.edit { + putInt(PREF_KEY_MAX_RECORDING_DURATION_MILLS, value) + } + } + + override fun resetRecordingSettings() { + sharedPreferences.edit { + putString( + PREF_KEY_SETTING_RECORDING_FORMAT, + DefaultValues.DefaultRecordingFormat.value + ) + putInt( + PREF_KEY_SETTING_SAMPLE_RATE, + DefaultValues.DefaultSampleRate.value + ) + putInt( + PREF_KEY_SETTING_BITRATE, + DefaultValues.DefaultBitRate.value + ) + putInt( + PREF_KEY_SETTING_CHANNEL_COUNT, + DefaultValues.DefaultChannelCount.value + ) + } + } + + override fun fullPreferenceReset() { + sharedPreferences.edit { + clear() + } + } + + companion object { + private const val PREF_KEY_ASK_TO_RENAME_AFTER_RECORDING_STOPPED = + "ask_to_rename_after_recording_stopped" + private const val PREF_KEY_ACTIVE_RECORD_ID = "active_record_id" + private const val PREF_KEY_RECORDED_RECORD_ID = "recorded_record_id" + private const val PREF_KEY_RECORDED_RECORD_PART_COUNTER = "recorded_record_part_counter" + private const val PREF_KEY_RECORDED_RECORD_BASE_NAME = "recorded_record_base_name" + private const val PREF_KEY_RECORDS_SORT_ORDER = "pref_records_sort_order" + private const val PREF_KEY_IS_DYNAMIC_THEME = "pref_is_dynamic_theme" + private const val PREF_KEY_IS_DARK_THEME = "pref_is_dark_theme" + private const val PREF_KEY_MAX_RECORDING_DURATION_MILLS = "pref_key_max_recording_duration_mills" + private const val PREF_KEY_SETTING_AUDIO_SOURCE = "pref_key_setting_audio_source" + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSource.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSource.kt new file mode 100644 index 000000000..e580ee242 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSource.kt @@ -0,0 +1,63 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data + +import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.data.model.SortOrder + +interface RecordsDataSource { + + suspend fun getRecord(id: Long): Record? + suspend fun getRecords(ids: List): List + + suspend fun getActiveRecord(): Record? + + suspend fun getAllRecords(): List + suspend fun getMovedToRecycleRecords(): List + suspend fun getMovedToRecycleRecordsCount(): Int + + suspend fun getRecords( + page: Int, + pageSize: Int, + sortOrder: SortOrder = SortOrder.DateDesc, + isBookmarked: Boolean = false, + ): List + + suspend fun insertRecord(record: Record): Long + + suspend fun updateRecord(record: Record): Boolean + + suspend fun updateRecords(records: List): Int + + suspend fun renameRecord(record: Record, newName: String): Boolean + + suspend fun getRecordsCount(): Int + + suspend fun getRecordTotalDuration(): Long + + suspend fun deleteRecordAndFileForever(id: Long): Boolean + + suspend fun moveRecordToRecycle(id: Long): Boolean + + suspend fun moveRecordsToRecycle(ids: List): Int + + suspend fun restoreRecordFromRecycle(id: Long): Boolean + + suspend fun clearRecycle(): Boolean + + suspend fun deleteLostRecord(id: Long): Boolean +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImpl.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImpl.kt new file mode 100644 index 000000000..32aba4c7d --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImpl.kt @@ -0,0 +1,438 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data + +import androidx.sqlite.db.SimpleSQLiteQuery +import com.dimowner.audiorecorder.v2.data.extensions.toRecordsSortColumnName +import com.dimowner.audiorecorder.v2.data.extensions.toSqlSortOrder +import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.data.model.RecordEditOperation +import com.dimowner.audiorecorder.v2.data.model.SortOrder +import com.dimowner.audiorecorder.v2.data.room.RecordDao +import com.dimowner.audiorecorder.v2.data.room.RecordEditDao +import com.dimowner.audiorecorder.v2.data.room.RecordEditEntity +import com.dimowner.audiorecorder.v2.data.room.RecordEntity +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@SuppressWarnings("TooGenericExceptionCaught") +@Singleton +class RecordsDataSourceImpl @Inject internal constructor( + private val prefs: PrefsV2, + private val recordDao: RecordDao, + private val recordEditDao: RecordEditDao, + private val fileDataSource: FileDataSource, +) : RecordsDataSource { + + override suspend fun getRecord(id: Long): Record? { + return if (id >= 0) { + recordDao.getRecordById(id)?.toRecord() + } else { + null + } + } + + override suspend fun getRecords(ids: List): List { + val validIds = ids.filter { it >= 0 } + return if (validIds.isNotEmpty()) { + recordDao.getRecordsByIds(ids).map{ it.toRecord() } + } else { + emptyList() + } + } + + override suspend fun getActiveRecord(): Record? { + val id = prefs.activeRecordId + return if (id >= 0) { + recordDao.getRecordById(id)?.toRecord() + } else { + null + } + } + + override suspend fun getAllRecords(): List { + return recordDao.getAllRecords().map { it.toRecord() } + } + + override suspend fun getMovedToRecycleRecords(): List { + return recordDao.getMovedToRecycleRecords().map { it.toRecord() } + } + + override suspend fun getMovedToRecycleRecordsCount(): Int { + return recordDao.getMovedToRecycleRecordsCount() + } + + override suspend fun getRecords( + page: Int, + pageSize: Int, + sortOrder: SortOrder, + isBookmarked: Boolean + ): List { + val sb = StringBuilder() + sb.append("SELECT * FROM records") + sb.append(" WHERE isMovedToRecycle = 0") + if (isBookmarked) { + sb.append(" AND isBookmarked = 1") + } + sb.append(" ORDER BY ${sortOrder.toRecordsSortColumnName()} ${sortOrder.toSqlSortOrder()}") + sb.append(" LIMIT $pageSize") + sb.append(" OFFSET " + ((page - 1) * pageSize)) + return recordDao.getRecordsRewQuery(SimpleSQLiteQuery(sb.toString())).map { it.toRecord() } + } + + override suspend fun insertRecord(record: Record): Long { + return recordDao.insertRecord(record.toRecordEntity()) + } + + override suspend fun updateRecord(record: Record): Boolean { + return recordDao.updateRecord(record.toRecordEntity()) == 1 + } + + override suspend fun updateRecords(records: List): Int { + return recordDao.updateRecords(records.map { it.toRecordEntity() }) + } + + override suspend fun renameRecord(record: Record, newName: String): Boolean { + //TODO: this function requires improvements + try { + val transactionId = recordEditDao.insertRecordsEditOperation( + createRenameEditOperation(record.id, newName) + ) + val renamed = try { + fileDataSource.renameFile(record.path, newName) + } catch (e: Exception) { + Timber.e(e) + null + } + val result = if (renamed == null) { + //The first step has failed. Finish edit operation and return an error. + deleteEditRecordOperation(transactionId) + false + } else { + val isUpdated = try { + //Perform the step 2 + recordDao.updateRecord( + record.copy( + name = newName, + path = renamed.absolutePath + ).toRecordEntity() + ) + deleteEditRecordOperation(transactionId) + true + } catch (e: Exception) { + Timber.e(e) + //The second step has failed. Rollback the first step - rename file back. + val rolledBack = try { + fileDataSource.renameFile(renamed.absolutePath, record.name) + } catch (e: Exception) { + Timber.e(e) + null + } + if (rolledBack != null) { + //File name rolled back successfully. Finish edit operation and return an error. + deleteEditRecordOperation(transactionId) + } else { + //Failed to rollback file. Keep edit operation in the database to repeat it later. + } + false + } + isUpdated + } + return result + } catch (e: Exception) { + Timber.e(e) + return false + } + } + + override suspend fun getRecordsCount(): Int { + return recordDao.getRecordsCount() + } + + override suspend fun getRecordTotalDuration(): Long { + return recordDao.getRecordTotalDuration() + } + + override suspend fun deleteRecordAndFileForever(id: Long): Boolean { + return recordDao.getRecordById(id)?.let { recordToDelete -> + return@let deleteRecordAndFileForever(recordToDelete) + } ?: false + } + + private fun deleteRecordAndFileForever(record: RecordEntity): Boolean { + try { + val transactionId = recordEditDao.insertRecordsEditOperation( + createDeleteForeverEditOperation(record.id) + ) + + //The first step - delete record from database + val isRecordDeleted = try { + recordDao.deleteRecordById(record.id) + true + } catch (e: Exception) { + Timber.e(e) + false + } + val result = if (isRecordDeleted) { + //The second step - delete record file + val isFileDeleted = try { + fileDataSource.deleteRecordFile(record.path) + } catch (e: Exception) { + Timber.e(e) + false + } + if (isFileDeleted) { + //The second step has succeed. Finish edit operation and return an success. + deleteEditRecordOperation(transactionId) + true + } else { + //Failed to delete file. Keep edit operation in the database to repeat it later. + false + } + } else { + //The first step has failed. Finish edit operation and return an error. + deleteEditRecordOperation(transactionId) + false + } + return result + } catch (e: Exception) { + Timber.e(e) + return false + } + } + + override suspend fun moveRecordToRecycle(id: Long): Boolean { + return recordDao.getRecordById(id)?.let { recordToRecycle -> + return@let recordDao.updateRecord( + recordToRecycle.copy(isMovedToRecycle = true, removed = System.currentTimeMillis()) + ) == 1 + } ?: false + } + + override suspend fun moveRecordsToRecycle(ids: List): Int { + val recordsToRecycle = recordDao.getRecordsByIds(ids).map { + it.copy(isMovedToRecycle = true, removed = System.currentTimeMillis()) + } + return recordDao.updateRecords(recordsToRecycle) + } + + @Deprecated("Too complex logic. We don't need to mark record file as deleted") + internal fun moveRecordToRecycle(recordToRecycle: RecordEntity): Boolean { + try { + //Save edit operation. Start transaction + val transactionId = recordEditDao.insertRecordsEditOperation( + createMoveToRecycleEditOperation(recordToRecycle.id) + ) + //The first step. Mark record file as deleted. + val path = try { + fileDataSource.markAsRecordDeleted(recordToRecycle.path) + } catch (e: Exception) { + Timber.e(e) + null + } + val result = if (path != null) { + //The second step. Update record in the database + val isUpdated = try { + recordDao.updateRecord( + recordToRecycle.copy( + path = path, + isMovedToRecycle = true, + removed = System.currentTimeMillis(), + ) + ) + true + } catch (e: Exception) { + Timber.e(e) + false + } + if (isUpdated) { + //The second step has succeed. Finish edit operation and return an success. + deleteEditRecordOperation(transactionId) + true + } else { + //The second step has failed. Rollback the first step. + val unmarkPath = try { + fileDataSource.unmarkRecordAsDeleted(path) + } catch (e: Exception) { + Timber.e(e) + null + } + if (unmarkPath != null) { + //File rolled back successfully. Finish edit operation and return an error. + deleteEditRecordOperation(transactionId) + } else { + //Failed to rollback file. Keep edit operation in the database to repeat it later. + } + false + } + } else { + //The first step has failed. Finish edit operation and return an error. + //Rollback not needed. + deleteEditRecordOperation(transactionId) + false + } + return result + } catch (e: Exception) { + Timber.e(e) + return false + } + } + + override suspend fun restoreRecordFromRecycle(id: Long): Boolean { + return recordDao.getRecordById(id)?.let { recordToRestore -> + return@let restoreRecordFromRecycle(recordToRestore) + } ?: false + } + + private fun restoreRecordFromRecycle(recordToRestore: RecordEntity): Boolean { + try { + //Save edit operation. Start transaction + val transactionId = recordEditDao.insertRecordsEditOperation( + createRestoreFromRecycleEditOperation(recordToRestore.id) + ) + //The first step. Unmark record file as deleted. + val path = try { + fileDataSource.unmarkRecordAsDeleted(recordToRestore.path) + } catch (e: Exception) { + Timber.e(e) + null + } + val result = if (path != null) { + //The second step. Update record in the database + val isUpdated = try { + recordDao.updateRecord( + recordToRestore.copy( + path = path, + isMovedToRecycle = false + ) + ) + true + } catch (e: Exception) { + Timber.e(e) + false + } + if (isUpdated) { + //The second step has succeed. Finish edit operation and return an success. + deleteEditRecordOperation(transactionId) + true + } else { + //The second step has failed. Rollback the first step. + val unmarkPath = try { + fileDataSource.markAsRecordDeleted(path) + } catch (e: Exception) { + Timber.e(e) + null + } + if (unmarkPath != null) { + //File rolled back successfully. Finish edit operation and return an error. + deleteEditRecordOperation(transactionId) + } else { + //Failed to rollback file. Keep edit operation in the database to repeat it later. + } + false + } + } else { + //The first step has failed. Finish edit operation and return an error. + //Rollback not needed. + deleteEditRecordOperation(transactionId) + false + } + return result + } catch (e: Exception) { + Timber.e(e) + return false + } + } + + override suspend fun clearRecycle(): Boolean { + val records = recordDao.getMovedToRecycleRecords() + return if (records.isNotEmpty()) { + var result = true + for (recordToDelete in records) { + if (!deleteRecordAndFileForever(recordToDelete)) { + result = false + } + } + result + } else { + false + } + } + + private fun createRenameEditOperation(recordId: Long, renameName: String): RecordEditEntity { + return RecordEditEntity( + recordId = recordId, + editOperation = RecordEditOperation.Rename, + renameName = renameName, + created = System.currentTimeMillis(), + retryCount = 0, + ) + } + + private fun createMoveToRecycleEditOperation(recordId: Long): RecordEditEntity { + return RecordEditEntity( + recordId = recordId, + editOperation = RecordEditOperation.MoveToRecycle, + renameName = null, + created = System.currentTimeMillis(), + retryCount = 0, + ) + } + + private fun createRestoreFromRecycleEditOperation(recordId: Long): RecordEditEntity { + return RecordEditEntity( + recordId = recordId, + editOperation = RecordEditOperation.RestoreFromRecycle, + renameName = null, + created = System.currentTimeMillis(), + retryCount = 0, + ) + } + + private fun createDeleteForeverEditOperation(recordId: Long): RecordEditEntity { + return RecordEditEntity( + recordId = recordId, + editOperation = RecordEditOperation.DeleteForever, + renameName = null, + created = System.currentTimeMillis(), + retryCount = 0, + ) + } + + private fun deleteEditRecordOperation(id: Long) { + try { + recordEditDao.deleteRecordEditOperationById(id) + } catch (e: Exception) { + Timber.e(e) + } + } + + override suspend fun deleteLostRecord(id: Long): Boolean { + return try { + // For lost records, the file doesn't exist, so we only need to delete from DB + recordDao.deleteRecordById(id) + if (prefs.activeRecordId == id) { + prefs.activeRecordId = -1 + } + true + } catch (e: Exception) { + Timber.e(e) + false + } + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/DataExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/DataExtensions.kt new file mode 100644 index 000000000..cc2d2e197 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/DataExtensions.kt @@ -0,0 +1,38 @@ +package com.dimowner.audiorecorder.v2.data.extensions + +import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.data.model.SortOrder + +const val RECORDS_COLUMN_ADDED = "added" +const val RECORDS_COLUMN_NAME = "name" +const val RECORDS_COLUMN_DURATION = "duration" + +fun SortOrder.toSqlSortOrder(): String { + return when (this) { + SortOrder.DateDesc, + SortOrder.NameDesc, + SortOrder.DurationLongest -> "DESC" + SortOrder.DateAsc, + SortOrder.NameAsc, + SortOrder.DurationShortest -> "ASC" + } +} + +fun SortOrder.toRecordsSortColumnName(): String { + return when (this) { + SortOrder.DateAsc, + SortOrder.DateDesc -> RECORDS_COLUMN_ADDED + SortOrder.NameAsc, + SortOrder.NameDesc -> RECORDS_COLUMN_NAME + SortOrder.DurationShortest, + SortOrder.DurationLongest -> RECORDS_COLUMN_DURATION + } +} + +fun checkForLostRecords(records: List): List { + return records.filter { !isFileExists(it.path) } +} + +fun Record.isLostRecord(): Boolean { + return !isFileExists(this.path) +} \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensions.kt new file mode 100644 index 000000000..49abb3617 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensions.kt @@ -0,0 +1,215 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data.extensions + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.os.Environment +import android.os.ParcelFileDescriptor +import android.os.storage.StorageManager +import androidx.core.net.toUri +import com.dimowner.audiorecorder.v2.DefaultValues.DELETED_RECORD_MARK +import timber.log.Timber +import java.io.File +import java.io.IOException + +private const val RETRY_COUNT = 3 + +/** + * Create a file. + * Also create parent directories if they are not exist. + * If file with specified name already exists, add suffix (-1 or -2 or -3...) to the file name. + * @param directory Path to directory. + * @param fileName File name. + */ +@Throws(IOException::class) +fun createFile(directory: File, fileName: String): File { + if (!directory.exists()) { + directory.mkdirs() // Create the directory if it doesn't exist + } + + var newFileName = fileName + var suffix = 1 + + // Check if the file with the same name already exists + while (File(directory, newFileName).exists()) { + // Append a numeric suffix to the file name + newFileName = "${fileName.substringBeforeLast('.')}-$suffix.${fileName.substringAfterLast('.')}" + suffix++ + } + + val file = File(directory, newFileName) + try { + file.createNewFile() + } catch (e: IOException) { + // Handle any exceptions related to file creation + Timber.e(e) + throw e + } + file.verifyCanReadWrite() + return file +} + +@Throws(IOException::class) +fun File.verifyCanReadWrite() { + if (!this.canRead()) { + throw IOException("Can't read file") + } else if (!this.canWrite()) { + throw IOException("Can't write file") + } +} + +fun deleteFileAndChildren(file: File): Boolean { + if (!file.exists()) { + // File doesn't exist, so nothing to delete + return false + } + + if (file.isDirectory) { + // Recursively delete all files and subdirectories + file.listFiles()?.forEach { child -> + if (!deleteFileAndChildren(child)) { + // Failed to delete a child file or directory + return false + } + } + } + + // Delete the current file or empty directory + return file.delete() +} + +/** + * Rename a file + * Example: + * fileToRename: /data/Files/FileName.txt newName: RenamedFile + * return: /data/Files/RenamedFile.txt + * @param fileToRename File that needs to be renamed. + * @param newName New file name + * @return Renamed file + */ +fun renameFileWithExtension(fileToRename: File, newName: String): File? { + if (!fileToRename.exists() || fileToRename.nameWithoutExtension == newName) { + // Source file doesn't exist + return null + } + + // Get the original extension + val originalExtension = fileToRename.extension + + // Append the original extension to the renamed file + val newFileName = "${newName}.$originalExtension" + val newFile = File(fileToRename.parentFile, newFileName) + + // Try renaming the file up to 3 times + repeat(RETRY_COUNT) { + if (fileToRename.renameTo(newFile)) { + return newFile + } + } + return null +} + +fun markFileAsDeleted(file: File): File? { + if (!file.exists()) { + // File doesn't exist, so nothing to mark as deleted + return null + } + + val trashSuffix = DELETED_RECORD_MARK + val originalName = file.name + val trashName = "${originalName.removeSuffix(trashSuffix)}$trashSuffix" + + val trashFile = File(file.parentFile, trashName) + + // Rename the file to move it to the trash + repeat(RETRY_COUNT) { + if (file.renameTo(trashFile)) { + return trashFile + } + } + return null +} + +fun unmarkFileAsDeleted(trashFile: File): File? { + if (!trashFile.exists()) { + // Trash file doesn't exist, nothing to unmark + return null + } + + val trashSuffix = DELETED_RECORD_MARK + val originalName = trashFile.name.removeSuffix(trashSuffix) + val restoredFile = File(trashFile.parentFile, originalName) + + // Rename the trash file back to its original name + if (!trashFile.renameTo(restoredFile)) { + if (!trashFile.renameTo(restoredFile)) { + return if (trashFile.renameTo(restoredFile)) { + restoredFile + } else { + null + } + } + } + return restoredFile +} + +@SuppressLint("SdCardPath") +fun getPrivateMusicStorageDir(context: Context, directoryName: String): File? { + // Get the app-specific directory for music files + val musicDir = context.getExternalFilesDir(Environment.DIRECTORY_MUSIC) + ?: context.filesDir // Fallback to internal storage if external storage is not available + + val directory = File(musicDir, directoryName) + // Create the directory if it doesn't exist + val result: File? = if (!directory.exists() && !directory.mkdirs()) { + //App dir now is not available. + //If nothing helped then hardcode recording dir + val lastResortDirectory = File("/data/data/${context.packageName}/files/$directoryName") + if (!lastResortDirectory.exists() && !lastResortDirectory.mkdirs()) { + null + } else { + lastResortDirectory + } + } else { + directory + } + return result +} + +/** + * Request system for free space. Call this function request system clear cached files + * belonging to other apps (as needed) to meet request. + * */ +fun requestAllocateSpace(context: Context, file: File, requiredSpace: Long) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager + val parcelFileDescriptor: ParcelFileDescriptor? = + context.contentResolver.openFileDescriptor(file.toUri(), "r") + try { + storageManager.allocateBytes(parcelFileDescriptor?.fileDescriptor, requiredSpace) + } catch (e: IOException) { + Timber.e(e) + } + parcelFileDescriptor?.close() + } +} + +fun isFileExists(path: String): Boolean { + return File(path).exists() +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/AudioSource.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/AudioSource.kt new file mode 100644 index 000000000..d7f56372a --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/AudioSource.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2026 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.data.model + +import android.media.MediaRecorder + +enum class AudioSource(val value: Int) { + DEFAULT(MediaRecorder.AudioSource.DEFAULT), + MIC(MediaRecorder.AudioSource.MIC), + VOICE_COMMUNICATION(MediaRecorder.AudioSource.VOICE_COMMUNICATION), + UNPROCESSED(MediaRecorder.AudioSource.UNPROCESSED); + + companion object { + fun fromValue(value: Int): AudioSource { + return entries.find { it.value == value } ?: DEFAULT + } + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/BitRate.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/BitRate.kt new file mode 100644 index 000000000..876b44cd1 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/BitRate.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.data.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Bitrate in Kbps (thousands bits per second) + */ +@SuppressWarnings("MagicNumber") +@Parcelize +enum class BitRate(val value: Int, val index: Int): Parcelable { + BR48(48000, 0), + BR96(96000, 1), + BR128(128000, 2), + BR192(192000, 3), + BR256(256000, 4), +} + +fun Int.convertToBitRate(): BitRate? { + return if (this == BitRate.BR48.value) BitRate.BR48 + else if (this == BitRate.BR96.value) BitRate.BR96 + else if (this == BitRate.BR128.value) BitRate.BR128 + else if (this == BitRate.BR192.value) BitRate.BR192 + else if (this == BitRate.BR256.value) BitRate.BR256 + else null +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/ChannelCount.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/ChannelCount.kt new file mode 100644 index 000000000..b1cce2a43 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/ChannelCount.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.data.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class ChannelCount(val value: Int, val index: Int): Parcelable { + Stereo(value = 2, index = 0), + Mono(value = 1, index = 1), +} + +fun Int.convertToChannelCount(): ChannelCount? { + return if (this == ChannelCount.Mono.value) ChannelCount.Mono + else if (this == ChannelCount.Stereo.value) ChannelCount.Stereo + else null +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/NameFormat.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/NameFormat.kt new file mode 100644 index 000000000..c7fbac7f5 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/NameFormat.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.data.model + +enum class NameFormat { + Record, Timestamp, Date, DateUs, DateIso8601 +} + +fun String.convertToNameFormat(): NameFormat? { + return if (this.equals(NameFormat.Record.toString(), true)) NameFormat.Record + else if (this.equals(NameFormat.Timestamp.toString(), true)) NameFormat.Timestamp + else if (this.equals(NameFormat.Date.toString(), true)) NameFormat.Date + else if (this.equals(NameFormat.DateUs.toString(), true)) NameFormat.DateUs + else if (this.equals(NameFormat.DateIso8601.toString(), true)) NameFormat.DateIso8601 + else null +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/Record.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/Record.kt new file mode 100644 index 000000000..fbb4c908b --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/Record.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.data.model + +data class Record( + val id: Long, + val name: String, + val durationMills: Long, + val created: Long, + val added: Long, + /** Date when record removed. Required to be able to remove the record automatically from Trash after it expired. */ + val removed: Long, + var path: String, + val format: String, + val size: Long, + val sampleRate: Int, + val channelCount: Int, + val bitrate: Int, + val isBookmarked: Boolean, + val isWaveformProcessed: Boolean, + val isMovedToRecycle: Boolean, + val amps: IntArray, +) { + + @SuppressWarnings("CyclomaticComplexMethod") + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Record + + if (id != other.id) return false + if (name != other.name) return false + if (durationMills != other.durationMills) return false + if (created != other.created) return false + if (added != other.added) return false + if (removed != other.removed) return false + if (path != other.path) return false + if (format != other.format) return false + if (size != other.size) return false + if (sampleRate != other.sampleRate) return false + if (channelCount != other.channelCount) return false + if (bitrate != other.bitrate) return false + if (isBookmarked != other.isBookmarked) return false + if (isWaveformProcessed != other.isWaveformProcessed) return false + if (isMovedToRecycle != other.isMovedToRecycle) return false + return amps.contentEquals(other.amps) + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + durationMills.hashCode() + result = 31 * result + created.hashCode() + result = 31 * result + added.hashCode() + result = 31 * result + removed.hashCode() + result = 31 * result + path.hashCode() + result = 31 * result + format.hashCode() + result = 31 * result + size.hashCode() + result = 31 * result + sampleRate + result = 31 * result + channelCount + result = 31 * result + bitrate + result = 31 * result + isBookmarked.hashCode() + result = 31 * result + isWaveformProcessed.hashCode() + result = 31 * result + isMovedToRecycle.hashCode() + result = 31 * result + amps.contentHashCode() + return result + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordEditOperation.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordEditOperation.kt new file mode 100644 index 000000000..6cc8db7aa --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordEditOperation.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.data.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class RecordEditOperation: Parcelable { + Rename, + MoveToRecycle, + RestoreFromRecycle, + DeleteForever, +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordingFormat.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordingFormat.kt new file mode 100644 index 000000000..63db4d906 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordingFormat.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.data.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class RecordingFormat(val value: String, val index: Int) : Parcelable { + M4a("m4a", 0), Wav("wav", 1), ThreeGp("3gp", 2) +} + +fun String.convertToRecordingFormat(): RecordingFormat? { + return if (this.equals(RecordingFormat.M4a.value, true)) RecordingFormat.M4a + else if (this.equals(RecordingFormat.Wav.value, true)) RecordingFormat.Wav + else if (this.equals(RecordingFormat.ThreeGp.value, true)) RecordingFormat.ThreeGp + else null +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/SampleRate.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/SampleRate.kt new file mode 100644 index 000000000..43e54fd46 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/SampleRate.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.data.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class SampleRate(val value: Int, val index: Int): Parcelable { + SR8000(value = 8000, index = 0), + SR16000(value = 16000, index = 1), + SR22500(value = 22500, index = 2), + SR32000(value = 32000, index = 3), + SR44100(value = 44100, index = 4), + SR48000(value = 48000, index = 5), +} + +fun Int.convertToSampleRate(): SampleRate? { + return if (this == SampleRate.SR8000.value) SampleRate.SR8000 + else if (this == SampleRate.SR16000.value) SampleRate.SR16000 + else if (this == SampleRate.SR22500.value) SampleRate.SR22500 + else if (this == SampleRate.SR32000.value) SampleRate.SR32000 + else if (this == SampleRate.SR44100.value) SampleRate.SR44100 + else if (this == SampleRate.SR48000.value) SampleRate.SR48000 + else null +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/SortOrder.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/SortOrder.kt new file mode 100644 index 000000000..5ab3b03e3 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/SortOrder.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.data.model + +/** + * Represents the available sorting options for records list, + * defining both the criteria (e.g., Date, Name, Duration) and the + * direction (e.g., Ascending, Descending). + */ +enum class SortOrder { + /** Sorts by date in ascending order (oldest to newest). */ + DateAsc, + + /** Sorts by date in descending order (newest to oldest). */ + DateDesc, + + /** Sorts by name in ascending order (alphabetical, A-Z). */ + NameAsc, + + /** Sorts by name in descending order (reverse alphabetical, Z-A). */ + NameDesc, + + /** Sorts by duration in ascending order (shortest to longest). */ + DurationShortest, + + /** Sorts by duration in descending order (longest to shortest). */ + DurationLongest +} + +fun String.convertToSortOrder(): SortOrder? { + return if (this == SortOrder.DateAsc.toString()) SortOrder.DateAsc + else if (this == SortOrder.DateDesc.toString()) SortOrder.DateDesc + else if (this == SortOrder.NameAsc.toString()) SortOrder.NameAsc + else if (this == SortOrder.NameDesc.toString()) SortOrder.NameDesc + else if (this == SortOrder.DurationShortest.toString()) SortOrder.DurationShortest + else if (this == SortOrder.DurationLongest.toString()) SortOrder.DurationLongest + else null +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/AppDatabase.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/AppDatabase.kt new file mode 100644 index 000000000..71e305dd0 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/AppDatabase.kt @@ -0,0 +1,49 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data.room + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +const val DATABASE_NAME = "app_database" + +@Database(entities = [RecordEntity::class, RecordEditEntity::class], version = 1, exportSchema = false) +abstract class AppDatabase : RoomDatabase() { + + abstract fun recordDao(): RecordDao + + abstract fun recordEditDao(): RecordEditDao + + companion object { + @Volatile + private var INSTANCE: AppDatabase? = null + + fun getDatabase(context: Context): AppDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + DATABASE_NAME + ).build() + INSTANCE = instance + instance + } + } + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/Converters.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/Converters.kt new file mode 100644 index 000000000..53605d906 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/Converters.kt @@ -0,0 +1,44 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data.room + +import androidx.room.TypeConverter +import com.dimowner.audiorecorder.v2.data.model.RecordEditOperation + +class Converters { + + @TypeConverter + fun fromIntArray(intArray: IntArray): String { + return intArray.joinToString(separator = ",") + } + + @TypeConverter + fun toIntArray(value: String): IntArray { + return if (value.isBlank()) { + // If the input string is blank, return an empty IntArray. + intArrayOf() + } else { + value.split(",").map { it.toInt() }.toIntArray() + } + } + + @TypeConverter + fun toRecordEditOperation(value: String) = enumValueOf(value) + + @TypeConverter + fun fromRecordEditOperation(value: RecordEditOperation) = value.name +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordDao.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordDao.kt new file mode 100644 index 000000000..13fe013f6 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordDao.kt @@ -0,0 +1,78 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data.room + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.RawQuery +import androidx.room.Update +import androidx.sqlite.db.SupportSQLiteQuery + +@Dao +interface RecordDao { + + @Query("SELECT * FROM records WHERE id = :recordId") + fun getRecordById(recordId: Long): RecordEntity? + + @Query("SELECT * FROM records WHERE id IN (:recordIds)") + fun getRecordsByIds(recordIds: List): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertRecord(record: RecordEntity): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertRecords(records: List) + + @Update + fun updateRecord(record: RecordEntity): Int // Returns the number of updated rows + + @Update + fun updateRecords(records: List): Int // Returns the total number of updated rows + + @Delete + fun deleteRecord(record: RecordEntity) + + @Query("DELETE FROM records WHERE id = :recordId") + fun deleteRecordById(recordId: Long) + + @Query("DELETE FROM records") + fun deleteAllRecords() + + @Query("SELECT COUNT(*) FROM records WHERE isMovedToRecycle = 0") + fun getRecordsCount(): Int + + @Query("SELECT SUM(duration) AS total_duration FROM records WHERE isMovedToRecycle = 0") + fun getRecordTotalDuration(): Long + + @Query("SELECT * FROM records WHERE isMovedToRecycle = 0 ORDER BY added DESC LIMIT :pageSize OFFSET :offset") + fun getRecordsByPage(pageSize: Int, offset: Int): List + + @Query("SELECT * FROM records WHERE isMovedToRecycle = 0 ORDER BY added DESC") + fun getAllRecords(): List + + @Query("SELECT * FROM records WHERE isMovedToRecycle = 1 ORDER BY removed DESC") + fun getMovedToRecycleRecords(): List + + @Query("SELECT COUNT(*) FROM records WHERE isMovedToRecycle = 1") + fun getMovedToRecycleRecordsCount(): Int + + @RawQuery + fun getRecordsRewQuery(query: SupportSQLiteQuery): List +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordEditDao.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordEditDao.kt new file mode 100644 index 000000000..cf9890c28 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordEditDao.kt @@ -0,0 +1,49 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data.room + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update + +@Dao +interface RecordEditDao { + + @Query("SELECT * FROM record_edit ORDER BY created DESC") + fun getAllRecordsEditOperations(): List + + @Query("SELECT * FROM record_edit WHERE id = :recordId") + fun getRecordsEditOperationById(recordId: Long): RecordEditEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertRecordsEditOperation(record: RecordEditEntity): Long + + @Update + fun updateRecordsEditOperation(record: RecordEditEntity) + + @Delete + fun deleteRecordsEditOperation(record: RecordEditEntity) + + @Query("DELETE FROM record_edit") + fun deleteAllRecordsEditOperations() + + @Query("DELETE FROM record_edit WHERE id = :editOperationId") + fun deleteRecordEditOperationById(editOperationId: Long) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordEditEntity.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordEditEntity.kt new file mode 100644 index 000000000..76634eac7 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordEditEntity.kt @@ -0,0 +1,34 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import com.dimowner.audiorecorder.v2.data.model.RecordEditOperation + +@Entity(tableName = "record_edit") +@TypeConverters(Converters::class) +data class RecordEditEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(name = "recordId") val recordId: Long, + @ColumnInfo(name = "editOperation") val editOperation: RecordEditOperation, + @ColumnInfo(name = "renameName") val renameName: String?, + @ColumnInfo(name = "created") val created: Long, + @ColumnInfo(name = "retryCount") val retryCount: Int, +) diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordEntity.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordEntity.kt new file mode 100644 index 000000000..6624852e5 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordEntity.kt @@ -0,0 +1,88 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters + +@Entity(tableName = "records") +@TypeConverters(Converters::class) +data class RecordEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "duration") val duration: Long, + @ColumnInfo(name = "created") val created: Long, + @ColumnInfo(name = "added") val added: Long, + @ColumnInfo(name = "removed") val removed: Long, + @ColumnInfo(name = "path") val path: String, + @ColumnInfo(name = "format") val format: String, + @ColumnInfo(name = "size") val size: Long, + @ColumnInfo(name = "sampleRate") val sampleRate: Int, + @ColumnInfo(name = "channelCount") val channelCount: Int, + @ColumnInfo(name = "bitrate") val bitrate: Int, + @ColumnInfo(name = "isBookmarked") val isBookmarked: Boolean, + @ColumnInfo(name = "isWaveformProcessed") val isWaveformProcessed: Boolean, + @ColumnInfo(name = "isMovedToRecycle") val isMovedToRecycle: Boolean, + @ColumnInfo(name = "amps") val amps: IntArray, +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RecordEntity + + if (id != other.id) return false + if (name != other.name) return false + if (duration != other.duration) return false + if (created != other.created) return false + if (added != other.added) return false + if (removed != other.removed) return false + if (path != other.path) return false + if (format != other.format) return false + if (size != other.size) return false + if (sampleRate != other.sampleRate) return false + if (channelCount != other.channelCount) return false + if (bitrate != other.bitrate) return false + if (isBookmarked != other.isBookmarked) return false + if (isWaveformProcessed != other.isWaveformProcessed) return false + if (isMovedToRecycle != other.isMovedToRecycle) return false + return amps.contentEquals(other.amps) + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + duration.hashCode() + result = 31 * result + created.hashCode() + result = 31 * result + added.hashCode() + result = 31 * result + removed.hashCode() + result = 31 * result + path.hashCode() + result = 31 * result + format.hashCode() + result = 31 * result + size.hashCode() + result = 31 * result + sampleRate + result = 31 * result + channelCount + result = 31 * result + bitrate + result = 31 * result + isBookmarked.hashCode() + result = 31 * result + isWaveformProcessed.hashCode() + result = 31 * result + isMovedToRecycle.hashCode() + result = 31 * result + amps.contentHashCode() + return result + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/di/AppModule.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/di/AppModule.kt new file mode 100644 index 000000000..ddbf26ef1 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/di/AppModule.kt @@ -0,0 +1,60 @@ +package com.dimowner.audiorecorder.v2.di + +import com.dimowner.audiorecorder.audio.player.AudioPlayerNew +import com.dimowner.audiorecorder.audio.player.PlayerContractNew +import com.dimowner.audiorecorder.v2.audio.AudioRecorderDelegate +import com.dimowner.audiorecorder.v2.audio.RecorderV2 +import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher +import com.dimowner.audiorecorder.v2.di.qualifiers.MainDispatcher +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +class AppModule { + + @IoDispatcher + @Provides + fun provideIoDispatcher(): CoroutineDispatcher { + return Dispatchers.IO + } + + @MainDispatcher + @Provides + fun provideMainDispatcher(): CoroutineDispatcher { + return Dispatchers.Main + } + + @Singleton + @Provides + fun providePlayerContractNew(): PlayerContractNew.Player { + return AudioPlayerNew() + } + + @Singleton + @Provides + fun provideRecorderV2( + audioRecorderDelegate: AudioRecorderDelegate + ): RecorderV2 { + return audioRecorderDelegate.provideAudioRecorder() + } + + /** + * Provides a CoroutineScope scoped to the application's lifetime. + * It uses a SupervisorJob so that a failure of a child coroutine does not cancel others. + * It uses Dispatchers.Default for background work. + */ + @Provides + @Singleton + fun provideApplicationScope(): CoroutineScope { + // Use SupervisorJob() to prevent child coroutine failures from propagating + return CoroutineScope(SupervisorJob() + Dispatchers.Default) + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/di/DataSourceModule.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/di/DataSourceModule.kt new file mode 100644 index 000000000..9d5e70713 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/di/DataSourceModule.kt @@ -0,0 +1,30 @@ +package com.dimowner.audiorecorder.v2.di + +import com.dimowner.audiorecorder.v2.data.FileDataSource +import com.dimowner.audiorecorder.v2.data.FileDataSourceImpl +import com.dimowner.audiorecorder.v2.data.PrefsV2 +import com.dimowner.audiorecorder.v2.data.PrefsV2Impl +import com.dimowner.audiorecorder.v2.data.RecordsDataSource +import com.dimowner.audiorecorder.v2.data.RecordsDataSourceImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +abstract class DataSourceModule { + + @Singleton + @Binds + abstract fun bindPrefs(impl: PrefsV2Impl): PrefsV2 + + @Singleton + @Binds + abstract fun bindFileDataSource(impl: FileDataSourceImpl): FileDataSource + + @Singleton + @Binds + abstract fun bindRecordsDataSource(impl: RecordsDataSourceImpl): RecordsDataSource +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/di/DatabaseModule.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/di/DatabaseModule.kt new file mode 100644 index 000000000..adeb25e0c --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/di/DatabaseModule.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.di + +import android.content.Context +import com.dimowner.audiorecorder.v2.data.room.AppDatabase +import com.dimowner.audiorecorder.v2.data.room.RecordDao +import com.dimowner.audiorecorder.v2.data.room.RecordEditDao +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 + +@InstallIn(SingletonComponent::class) +@Module +class DatabaseModule { + + @Singleton + @Provides + fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase { + return AppDatabase.getDatabase(context) + } + + @Provides + fun providePlantDao(appDatabase: AppDatabase): RecordDao { + return appDatabase.recordDao() + } + + @Provides + fun provideRecordEditDao(appDatabase: AppDatabase): RecordEditDao { + return appDatabase.recordEditDao() + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/di/qualifiers/IoDispatcher.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/di/qualifiers/IoDispatcher.kt new file mode 100644 index 000000000..f22268835 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/di/qualifiers/IoDispatcher.kt @@ -0,0 +1,7 @@ +package com.dimowner.audiorecorder.v2.di.qualifiers + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class IoDispatcher \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/di/qualifiers/MainDispatcher.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/di/qualifiers/MainDispatcher.kt new file mode 100644 index 000000000..7e78a8f3b --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/di/qualifiers/MainDispatcher.kt @@ -0,0 +1,7 @@ +package com.dimowner.audiorecorder.v2.di.qualifiers + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class MainDispatcher diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/navigation/RecorerNavigationGraph.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/navigation/RecorerNavigationGraph.kt new file mode 100644 index 000000000..46380fa6f --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/navigation/RecorerNavigationGraph.kt @@ -0,0 +1,205 @@ +package com.dimowner.audiorecorder.v2.navigation + +import android.os.Build +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.EaseIn +import androidx.compose.animation.core.EaseOut +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.dimowner.audiorecorder.v2.app.deleted.DeletedRecordsScreen +import com.dimowner.audiorecorder.v2.app.deleted.DeletedRecordsViewModel +import com.dimowner.audiorecorder.v2.app.home.HomeScreen +import com.dimowner.audiorecorder.v2.app.home.HomeViewModel +import com.dimowner.audiorecorder.v2.app.info.AssetParamType +import com.dimowner.audiorecorder.v2.app.info.RecordInfoState +import com.dimowner.audiorecorder.v2.app.info.RecordInfoScreen +import com.dimowner.audiorecorder.v2.app.lostrecords.LostRecordsScreen +import com.dimowner.audiorecorder.v2.app.lostrecords.LostRecordsViewModel +import com.dimowner.audiorecorder.v2.app.records.RecordsScreen +import com.dimowner.audiorecorder.v2.app.records.RecordsViewModel +import com.dimowner.audiorecorder.v2.app.settings.SettingsScreen +import com.dimowner.audiorecorder.v2.app.settings.SettingsScreenAction +import com.dimowner.audiorecorder.v2.app.settings.SettingsViewModel +import com.dimowner.audiorecorder.v2.app.settings.WelcomeSetupSettingsScreen +import com.dimowner.audiorecorder.v2.app.welcome.WelcomeScreen +import kotlinx.coroutines.CoroutineScope + +private const val ANIMATION_DURATION = 120 + +@Composable +fun RecorderNavigationGraph( + coroutineScope: CoroutineScope, + homeViewModel: HomeViewModel, + onSwitchToLegacyApp: () -> Unit, +) { + val navController = rememberNavController() + NavHost( + navController = navController, + startDestination = Routes.HOME_SCREEN, + enterTransition = { enterTransition(this) }, + exitTransition = { exitTransition(this) }, + popEnterTransition = { popEnterTransition(this) }, + popExitTransition = { popExitTransition(this) } + ) { + composable(Routes.HOME_SCREEN) { + HomeScreen( + showRecordsScreen = { navController.navigate(Routes.RECORDS_SCREEN) }, + showSettingsScreen = { navController.navigate(Routes.SETTINGS_SCREEN) }, + showRecordInfoScreen = { json -> + navController.navigate(Routes.RECORD_INFO_SCREEN +"/${json}") + }, + showLostRecordsScreen = { lostRecord -> + val idsString = lostRecord.id.toString() + navController.navigate("${Routes.LOST_RECORDS_SCREEN}/$idsString") + }, + uiState = homeViewModel.state.value, + event = homeViewModel.event.collectAsState(null).value, + onAction = { homeViewModel.onAction(it) } + ) + } + composable(Routes.RECORDS_SCREEN) { + val recordsViewModel: RecordsViewModel = hiltViewModel() + RecordsScreen( + onPopBackStack = { + navController.popBackStack() + }, + showRecordInfoScreen = { json -> + navController.navigate(Routes.RECORD_INFO_SCREEN +"/${json}") + }, showDeletedRecordsScreen = { + navController.navigate(Routes.DELETED_RECORDS_SCREEN) + }, showLostRecordsScreen = { lostRecords -> + val idsString = lostRecords.joinToString(",") { it.id.toString() } + navController.navigate("${Routes.LOST_RECORDS_SCREEN}/$idsString") + }, uiState = recordsViewModel.state.value, + event = recordsViewModel.event.collectAsState(null).value, + onAction = { + recordsViewModel.onAction(it) + }, + uiHomeState = homeViewModel.state.value, + onHomeAction = { homeViewModel.onAction(it) } + ) + } + composable(Routes.DELETED_RECORDS_SCREEN) { + val deletedViewModel: DeletedRecordsViewModel = hiltViewModel() + DeletedRecordsScreen(onPopBackStack = { + navController.popBackStack() + }, + showRecordInfoScreen = { json -> + navController.navigate(Routes.RECORD_INFO_SCREEN +"/${json}") + }, uiState = deletedViewModel.state.value, + event = deletedViewModel.event.collectAsState(null).value, + onAction = { deletedViewModel.onAction(it) } + ) + } + composable( + "${Routes.LOST_RECORDS_SCREEN}/{${Routes.LOST_RECORD_IDS}}", + arguments = listOf( + navArgument(Routes.LOST_RECORD_IDS) { + type = NavType.StringType + } + ) + ) { backStackEntry -> + val lostRecordsViewModel: LostRecordsViewModel = hiltViewModel() + val idsString = backStackEntry.arguments?.getString(Routes.LOST_RECORD_IDS) ?: "" + lostRecordsViewModel.loadRecordsByIds(idsString) + LostRecordsScreen( + onPopBackStack = { + navController.popBackStack() + }, + showRecordInfoScreen = { json -> + navController.navigate(Routes.RECORD_INFO_SCREEN +"/${json}") + }, + uiState = lostRecordsViewModel.state.value, + event = lostRecordsViewModel.event.collectAsState(null).value, + onAction = { lostRecordsViewModel.onAction(it) } + ) + } + composable(Routes.SETTINGS_SCREEN) { + val settingsViewModel: SettingsViewModel = hiltViewModel() + SettingsScreen(onPopBackStack = { + navController.popBackStack() + }, showDeletedRecordsScreen = { + navController.navigate(Routes.DELETED_RECORDS_SCREEN) + }, uiState = settingsViewModel.state.value, + onAction = { + settingsViewModel.onAction(it) + if (it is SettingsScreenAction.SetAppV2) { + onSwitchToLegacyApp() + } + } + ) + } + composable(Routes.WELCOME_SCREEN) { + WelcomeScreen(onGetStarted = { + navController.navigate(Routes.WELCOME_SETUP_SETTINGS_SCREEN) + }) + } + composable(Routes.WELCOME_SETUP_SETTINGS_SCREEN) { + val settingsViewModel: SettingsViewModel = hiltViewModel() + WelcomeSetupSettingsScreen(onPopBackStack = { + navController.popBackStack() + }, onApplySettings = { + navController.navigate(Routes.HOME_SCREEN) { + popUpTo(0) + } + }, uiState = settingsViewModel.state.value, + onAction = { settingsViewModel.onAction(it) } + ) + } + composable( + "${Routes.RECORD_INFO_SCREEN}/{${Routes.RECORD_INFO}}", + arguments = listOf( + navArgument(Routes.RECORD_INFO) { + type = AssetParamType() + } + ), + ) { + val recordInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + it.arguments?.getParcelable(Routes.RECORD_INFO, RecordInfoState::class.java) + } else { + it.arguments?.getParcelable(Routes.RECORD_INFO) + } + RecordInfoScreen(onPopBackStack = { + navController.popBackStack() + }, recordInfo) + } + } +} + +private fun enterTransition(scope: AnimatedContentTransitionScope): EnterTransition { + return scope.slideIntoContainer( + animationSpec = tween(ANIMATION_DURATION, easing = EaseIn), + towards = AnimatedContentTransitionScope.SlideDirection.Start + ) +} + +private fun exitTransition(scope: AnimatedContentTransitionScope): ExitTransition { + return scope.slideOutOfContainer( + animationSpec = tween(ANIMATION_DURATION, easing = EaseIn), + towards = AnimatedContentTransitionScope.SlideDirection.Start + ) +} + +private fun popEnterTransition(scope: AnimatedContentTransitionScope): EnterTransition { + return scope.slideIntoContainer( + animationSpec = tween(ANIMATION_DURATION, easing = EaseOut), + towards = AnimatedContentTransitionScope.SlideDirection.End + ) +} + +private fun popExitTransition(scope: AnimatedContentTransitionScope): ExitTransition { + return scope.slideOutOfContainer( + animationSpec = tween(ANIMATION_DURATION, easing = EaseOut), + towards = AnimatedContentTransitionScope.SlideDirection.End + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/navigation/Routes.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/navigation/Routes.kt new file mode 100644 index 000000000..1a794fffd --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/navigation/Routes.kt @@ -0,0 +1,16 @@ +package com.dimowner.audiorecorder.v2.navigation + +object Routes { + + const val HOME_SCREEN = "HOME_SCREEN" + const val RECORDS_SCREEN = "RECORDS_SCREEN" + const val SETTINGS_SCREEN = "SETTINGS_SCREEN" + const val RECORD_INFO_SCREEN = "RECORD_INFO_SCREEN" + const val DELETED_RECORDS_SCREEN = "DELETED_RECORDS_SCREEN" + const val LOST_RECORDS_SCREEN = "LOST_RECORDS_SCREEN" + const val WELCOME_SCREEN = "WELCOME_SCREEN" + const val WELCOME_SETUP_SETTINGS_SCREEN = "WELCOME_SETUP_SETTINGS_SCREEN" + + const val RECORD_INFO = "RECORD_INFO" + const val LOST_RECORD_IDS = "lost_record_ids" +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Color.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Color.kt new file mode 100644 index 000000000..b1552b496 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Color.kt @@ -0,0 +1,92 @@ +package com.dimowner.audiorecorder.v2.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val BlueGray700 = Color(0xFF1C2733) +val BlueGray500 = Color(0xFF253343) +val WhiteTransparent88 = Color(0x1EFFFFFF) +val BlackTransparent88 = Color(0x1E000000) + +val Pink500 = Color(0xFFE91E63) +val Purple500 = Color(0xFF9C27B0) +val DeepPurple500 = Color(0xFF673AB7) +val Blue500 = Color(0xFF5677FC) +val Teal500 = Color(0xFF009688) +val Green500 = Color(0xFF4CAF50) +val YellowA700 = Color(0xFFFFD600) +val Amber800 = Color(0xFFFF8F00) +val DeepOrange500 = Color(0xFFFF5722) +val Brown500 = Color(0xFF795548) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) + +//val GreyLight = Color(0xFFBBBBBB) +//val BlackTransparent80 = Color(0x32000000) + +val md_theme_light_primary = Color(0xFF00629F) +val md_theme_light_onPrimary = Color(0xFFFFFFFF) +val md_theme_light_primaryContainer = Color(0xFFD0E4FF) +val md_theme_light_onPrimaryContainer = Color(0xFF001D34) +val md_theme_light_secondary = Color(0xFF00629F) +val md_theme_light_onSecondary = Color(0xFFFFFFFF) +val md_theme_light_secondaryContainer = Color(0xFFD0E4FF) +val md_theme_light_onSecondaryContainer = Color(0xFF001D34) +val md_theme_light_tertiary = Color(0xFF9C3C61) +val md_theme_light_onTertiary = Color(0xFFFFFFFF) +val md_theme_light_tertiaryContainer = Color(0xFFFFD9E2) +val md_theme_light_onTertiaryContainer = Color(0xFF3E001D) +val md_theme_light_error = Color(0xFFBA1A1A) +val md_theme_light_errorContainer = Color(0xFFFFDAD6) +val md_theme_light_onError = Color(0xFFFFFFFF) +val md_theme_light_onErrorContainer = Color(0xFF410002) +val md_theme_light_background = Color(0xFFFAFCFF) +val md_theme_light_onBackground = Color(0xFF001F2A) +val md_theme_light_surface = Color(0xFFFAFCFF) +val md_theme_light_onSurface = Color(0xFF001F2A) +val md_theme_light_surfaceVariant = Color(0x1E000000) +val md_theme_light_onSurfaceVariant = Color(0xFF42474E) +val md_theme_light_outline = Color(0xFF73777F) +val md_theme_light_inverseOnSurface = Color(0xFFE1F4FF) +val md_theme_light_inverseSurface = Color(0xFF003547) +val md_theme_light_inversePrimary = Color(0xFF9ACBFF) +val md_theme_light_shadow = Color(0xFF000000) +val md_theme_light_surfaceTint = Color(0xFF00629F) +val md_theme_light_outlineVariant = Color(0xFFC2C7CF) +val md_theme_light_scrim = Color(0xFF000000) + +val md_theme_dark_primary = Color(0xFF9ACBFF) +val md_theme_dark_onPrimary = Color(0xFF003355) +val md_theme_dark_primaryContainer = Color(0xFF004A79) +val md_theme_dark_onPrimaryContainer = Color(0xFFD0E4FF) +val md_theme_dark_secondary = Color(0xFF9ACBFF) +val md_theme_dark_onSecondary = Color(0xFF003355) +val md_theme_dark_secondaryContainer = Color(0xFF004A79) +val md_theme_dark_onSecondaryContainer = Color(0xFFD0E4FF) +val md_theme_dark_tertiary = Color(0xFFFFB0C8) +val md_theme_dark_onTertiary = Color(0xFF610A33) +val md_theme_dark_tertiaryContainer = Color(0xFF7E244A) +val md_theme_dark_onTertiaryContainer = Color(0xFFFFD9E2) +val md_theme_dark_error = Color(0xFFFFB4AB) +val md_theme_dark_errorContainer = Color(0xFF93000A) +val md_theme_dark_onError = Color(0xFF690005) +val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val md_theme_dark_background = Color(0xFF001F2A) +val md_theme_dark_onBackground = Color(0xFFBFE9FF) +val md_theme_dark_surface = Color(0xFF001F2A) +val md_theme_dark_onSurface = Color(0xFFBFE9FF) +val md_theme_dark_surfaceVariant = Color(0x1EFFFFFF) +val md_theme_dark_onSurfaceVariant = Color(0xFFC2C7CF) +val md_theme_dark_outline = Color(0xFF8C9199) +val md_theme_dark_inverseOnSurface = Color(0xFF001F2A) +val md_theme_dark_inverseSurface = Color(0xFFBFE9FF) +val md_theme_dark_inversePrimary = Color(0xFF00629F) +val md_theme_dark_shadow = Color(0xFF000000) +val md_theme_dark_surfaceTint = Color(0xFF9ACBFF) +val md_theme_dark_outlineVariant = Color(0xFF42474E) +val md_theme_dark_scrim = Color(0xFF000000) diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Shapes.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Shapes.kt new file mode 100644 index 000000000..6a21c3399 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Shapes.kt @@ -0,0 +1,13 @@ +package com.dimowner.audiorecorder.v2.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +val shapes = Shapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(24.dp), + extraLarge = RoundedCornerShape(32.dp) +) diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Theme.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Theme.kt new file mode 100644 index 000000000..75156471b --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Theme.kt @@ -0,0 +1,188 @@ +package com.dimowner.audiorecorder.v2.theme + +import android.app.Activity +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + + +private val DarkColorScheme = darkColorScheme( + primary = BlueGray700, + secondary = Color.Red, + tertiary = Pink80, + background = BlueGray700, + onPrimary = Pink500, + primaryContainer = Purple500, + onPrimaryContainer = DeepPurple500, + inversePrimary = Blue500, + onSecondary = Teal500, + secondaryContainer = Green500, + onSecondaryContainer = YellowA700, + onTertiary = Amber800, + tertiaryContainer = DeepOrange500, + onTertiaryContainer = Brown500, + onBackground = Pink500, + surface = Purple500, + onSurface = DeepPurple500, + surfaceVariant = Blue500, + onSurfaceVariant = Teal500, + surfaceTint = Green500, + inverseSurface = YellowA700, + inverseOnSurface = Amber800, + error = DeepOrange500, + onError = Brown500, + errorContainer = Pink500, + onErrorContainer = Purple500, + outline = DeepPurple500, + outlineVariant = Blue500, + scrim = Teal500, + surfaceBright = Green500, + surfaceContainer = YellowA700, + surfaceContainerHigh = Amber800, + surfaceContainerHighest = DeepOrange500, + surfaceContainerLow = Brown500, + surfaceContainerLowest = Pink500, + surfaceDim = Purple500, +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40, + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +// Material 3 color schemes +private val LightColors = lightColorScheme( + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + error = md_theme_light_error, + errorContainer = md_theme_light_errorContainer, + onError = md_theme_light_onError, + onErrorContainer = md_theme_light_onErrorContainer, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + outline = md_theme_light_outline, + inverseOnSurface = md_theme_light_inverseOnSurface, + inverseSurface = md_theme_light_inverseSurface, + inversePrimary = md_theme_light_inversePrimary, + surfaceTint = md_theme_light_surfaceTint, + outlineVariant = md_theme_light_outlineVariant, + scrim = md_theme_light_scrim, +) + +private val DarkColors = darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + error = md_theme_dark_error, + errorContainer = md_theme_dark_errorContainer, + onError = md_theme_dark_onError, + onErrorContainer = md_theme_dark_onErrorContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + outline = md_theme_dark_outline, + inverseOnSurface = md_theme_dark_inverseOnSurface, + inverseSurface = md_theme_dark_inverseSurface, + inversePrimary = md_theme_dark_inversePrimary, + surfaceTint = md_theme_dark_surfaceTint, + outlineVariant = md_theme_dark_outlineVariant, + scrim = md_theme_dark_scrim, +) + +@Composable +@RequiresApi(Build.VERSION_CODES.S) +fun AppTheme( + dynamicColors: Boolean, + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val context = LocalContext.current + val colors = when { + dynamicColors && darkTheme -> dynamicDarkColorScheme(context) + dynamicColors && !darkTheme -> dynamicLightColorScheme(context) + !dynamicColors && !darkTheme -> LightColors + else -> DarkColors + } + AppTheme(colors, darkTheme, content) +} + +@Composable +fun AppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colors = if (darkTheme) DarkColors else LightColors + AppTheme(colors, darkTheme, content) +} + +@Composable +private fun AppTheme( + colors: ColorScheme, + darkTheme: Boolean, + content: @Composable () -> Unit +) { + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colors.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + MaterialTheme( + colorScheme = colors, + typography = typography, + shapes = shapes, + content = content + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Type.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Type.kt new file mode 100644 index 000000000..ae8aa9903 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Type.kt @@ -0,0 +1,41 @@ +package com.dimowner.audiorecorder.v2.theme + + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Material 3 typography +val typography = Typography( + headlineSmall = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp + ), + titleLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp + ), + bodyLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + labelMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) +) diff --git a/app/src/main/res/drawable/ic_apps.xml b/app/src/main/res/drawable/ic_apps.xml new file mode 100644 index 000000000..83b3afb43 --- /dev/null +++ b/app/src/main/res/drawable/ic_apps.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_down.xml b/app/src/main/res/drawable/ic_arrow_down.xml new file mode 100644 index 000000000..b2ab526d5 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_bluetooth.xml b/app/src/main/res/drawable/ic_bluetooth.xml new file mode 100644 index 000000000..63d857516 --- /dev/null +++ b/app/src/main/res/drawable/ic_bluetooth.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_dark_mode.xml b/app/src/main/res/drawable/ic_dark_mode.xml new file mode 100644 index 000000000..8942d658f --- /dev/null +++ b/app/src/main/res/drawable/ic_dark_mode.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_palette_outline.xml b/app/src/main/res/drawable/ic_palette_outline.xml new file mode 100644 index 000000000..cb4385b36 --- /dev/null +++ b/app/src/main/res/drawable/ic_palette_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_file_browser.xml b/app/src/main/res/layout/activity_file_browser.xml index dbef50e15..9f925e5c1 100644 --- a/app/src/main/res/layout/activity_file_browser.xml +++ b/app/src/main/res/layout/activity_file_browser.xml @@ -123,7 +123,7 @@ android:paddingEnd="@dimen/spacing_normal" android:paddingRight="@dimen/spacing_normal" android:paddingBottom="@dimen/spacing_small" - android:text="@string/records_was_removed" + android:text="@string/records_were_removed" android:textColor="@color/text_primary_light" /> + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/layout_migration_notification_small.xml b/app/src/main/res/layout/layout_migration_notification_small.xml new file mode 100644 index 000000000..858e9cbc6 --- /dev/null +++ b/app/src/main/res/layout/layout_migration_notification_small.xml @@ -0,0 +1,39 @@ + + + + + + + + + + diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index cfb3d9a23..a04b367ed 100644 Binary files a/app/src/main/res/values-bg/strings.xml and b/app/src/main/res/values-bg/strings.xml differ diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 83d014a83..b81bcc8d3 100755 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -14,12 +14,12 @@ Espai disponible: %s Esborra totes les gravacions Esborra-ho tot - No s\'han trobat alguns fitxers de gravació al directori de gravacions. Possiblement van ser eliminats o moguts. + No s\'han trobat alguns fitxers de gravació al directori de gravacions. Possiblement van ser eliminats o moguts. No es pot accedir al fitxer de la gravació. Malauradament, l\'aplicació ja no té accés a l\'emmagatzematge públic d\'Android.\nPodeu trobar el fitxer de gravació en aquesta ubicació del telèfon:\n%s Avís. Voleu esborrar aquesta gravació? - Vouleu moure %s a la paperera? + Vouleu moure %s a la paperera? Voleu esborrar la gravació %s per sempre? Voleu tirar a la paperera %d gravació seleccionada? diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e8d9b3f67..3b12ecf3c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -18,7 +18,7 @@ Eliminar todo ¡Advertencia! ¿Eliminar esta grabación? - ¿Mover %s a la basura? + ¿Mover %s a la basura? ¿Eliminar la grabación %s permanentemente? Start recording Recording feature disabled diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 547544788..bec3197c2 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -15,12 +15,12 @@ Place disponible: %s Supprimer tout les enregistrements Tout supprimer - Un ou plusieurs enregistrements n\'ont pas été trouvé dans le dossier des enregistrements. Ils ont potentiellement été déplacé ou supprimé. + Un ou plusieurs enregistrements n\'ont pas été trouvé dans le dossier des enregistrements. Ils ont potentiellement été déplacé ou supprimé. Le fichier d\'enregistrement n\'est pas accessible ! Malheureusement, l\'application n\'a plus accès au stockage public d\'Android.\nVous pouvez retrouver le fichier d\'enregistrement dans votre ordiphone à cet endroit: \n%s Attention! Supprimer cet enregistrement ? - Déplacer %s dans la corbeille ? + Déplacer %s dans la corbeille ? Supprimer l\'enregistrement %s pour toujours ? Déplacer les %d l\'enregistrement sélectionné à la corbeille ? diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 1c510d24d..886fed3de 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -15,12 +15,12 @@ Доступное место: %s Удалить все записи Удалить все - Некоторые записи не были найдены в папке с записями. Возможно они были удалены или перемещены. + Некоторые записи не были найдены в папке с записями. Возможно они были удалены или перемещены. Нет доступа к файлу записи! К сожалению, у приложения больше нет доступа к общедоступному хранилищу Android.\nВы можете найти файл записи на вашем смартфоне по такому адресу:\n%s Внимание! Удалить эту запись? - Переместить в корзину %s? + Переместить в корзину %s? Удалить запись %s навсегда? Переместить в корзину %d выбранную запись? diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 167a60612..eda669ab2 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -15,12 +15,12 @@ Mevcut alan: %s Bütün kayıtları sil Hepsini sil - Bazı kayıtlar dizinde bulunamadı, silinmiş veya taşınmış olabilir. + Bazı kayıtlar dizinde bulunamadı, silinmiş veya taşınmış olabilir. Kayıtlar dizinine erişim izni yok! Maalesef, uygulama artık genel dizine erişemiyor.\nKayıt dosyasını telefonunuzun dosya yöneticisinde şu konumda bulabilirsiniz:\n%s Uyarı! Bu kaydı sil? - %s kaydını çöp kutusuna taşı? + %s kaydını çöp kutusuna taşı? %s kaydını sil? %d kaydı çöp kutusuna taşı? diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 474398ea3..2cf7be611 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -15,12 +15,12 @@ Доступне місце: %s Видалити всі записи Видалити все - Деякі записи не були знайдені в папці з записами. Можливо вони були видалені або переміщені. + Деякі записи не були знайдені в папці з записами. Можливо вони були видалені або переміщені. Немає доступу до файлу запису! На жаль, додаток більше не має доступу до загальнодоступного сховища Android.\nВи можете знайти файл запису на вашому смартфоні за такою адресою:\n%s Увага! Видалити цей запис? - Перемістити в кошик %s? + Перемістити в кошик %s? Видалити запис %s назавжди? Перемістити в кошик %d вибраний запис? diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 80c714e82..4c4bcc379 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -15,14 +15,14 @@ 可用空間:%s 刪除所有錄音 全部刪除 - 找不到資料夾內的部分錄音,可能已被移動或刪除。 + 找不到資料夾內的部分錄音,可能已被移動或刪除。 無法存取錄音檔案 很抱歉,應用程式無法繼續存取共用儲存空間。 \n您可在裝置中的下列位置找到錄音檔案: \n%s 警告 刪除這則錄音? - 將「%s」移至垃圾桶? + 將「%s」移至垃圾桶? 永久刪除錄音?\n「%s」 將選取的 %d 則錄音移至垃圾桶? diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 054f4da39..118caa31e 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -15,12 +15,12 @@ 可用空间: %s 删除所有录音 删除所有 - 在录音目录中找不到某些录音文件。可能是被移走了。 + 在录音目录中找不到某些录音文件。可能是被移走了。 无法访问录音文件! 不幸的是,应用程序不再能够访问Android公共存储。\n您可以在此位置上找到录音文件:\n%s 警告! 删除这个录音? - 移动到回收站 %s? + 移动到回收站 %s? 永久删除录音 %s? 移动 %d 个选择的录音到回收站? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d6f09acc..df5bf5ece 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,19 +9,80 @@ Rate app Store records in a public dir Record in Stereo - Keep screen ON while recording + + Dynamic theme colors + Dark theme + Record name format + Version %s + + + year + years + + + day + days + + + + Recording \'%1$s\' permanently deleted + + Recording \'%1$s\' moved to Trash + + %1$d of %2$d recordings moved to Trash + + Failed to move recording to Trash + + Failed to move recordings to Trash. + + Canceled recording moved to Trash + + Recording \'%1$s\' restored + + Recording \'%1$s\' renamed to \'%2$s\' + + Recording saved + + Recording reached 60-minute limit. Automatically continuing to a new file. + + Failed to save recording. + + File operation failed. Please try again + + Name cannot be empty + + Microphone permission is required to record audio. Please grant the permission to use this feature. + + UNDO + + Operation failed + + Recording Duration + Automatically start a new recording file when this limit is reached + Enter hours and minutes + Duration must be at least 1 minute + + Recordings longer than 2 hours are not recommended as you may lose all recorded progress if an error occurs + + Keep screen ON while recording Total recorded duration: %s Total records count: %d Available space: %s Delete all records Delete all - Some records files was not found in the records directory. Possibly they was removed or moved. + Some records files were not found in the records directory. Possibly they were removed or moved. No access to the record file! Unfortunately, the app no longer has access to the Android public storage.\nYou can find the record file on your smartphone at this location:\n%s Warning! Delete this record? - Move to trash %s? - Delete record %s forever? + Move to trash %s? + Delete the record %s? + Delete record %s forever? Move to trash %d selected record? Move to trash %d selected records? @@ -92,6 +153,7 @@ Failed to move %d records The record will be copied to the \'Downloads\' directory + The record \'%s\' will be copied to the \'Downloads\' directory Failed to move: %s Failed to copy: %s Failed to copy. File with name %s already exists @@ -143,6 +205,7 @@ Cancel Yes Ok + Got it No Play Deny @@ -152,6 +215,7 @@ Apply Reset Next + Confirm View View records Move records needed @@ -179,6 +243,18 @@ Stereo two separate channels are recorded. This means that each stereo speaker has a different sound signal. (recommended) \nMono one signal channel is recorded. It can be reproduced through several speakers, but all speakers are still reproducing the same copy of the signal. + + Stereo (Dual-Channel): Records two independent audio channels to create a sense of space and direction. (Recommended) +

Mono (Single-Channel): Records a single audio channel. The same signal is played through all speakers. +]]>
+ + Default: The manufacturer\'s recommended setting. It usually behaves like the Mic option but allows the device to apply its own optimized hardware tuning. +

Mic: The standard microphone setting. Best for general recording. It balances voice volume automatically while capturing the natural atmosphere of surroundings. +

Voice Communication: Specifically designed for clarity of the human voice. Best for Bluetooth as it activates noise and echo cancellation. +

Unprocessed: Captures raw audio without any system filters or adjustments. No background noise removal and no volume leveling. Best for high-fidelity or professional use. +]]>
3gp is a multimedia container format developed for mobile telecommunication services. Use it if you need to save space. M4a format is encoded with AAC audio codec has good quality and small size. (recommended) Wav is uncompressed audio data format. It takes much more space than other formats. It\'s needed for specific cases. @@ -201,6 +277,7 @@ Channel count: Sample rate: %s Mb/min expected size + %d kHz %d Hz %d kbps Stereo @@ -222,12 +299,13 @@ Unable to read sound file Permission denied Can\'t draw waveform - Some of your records was deleted or moved + Some of your records were deleted or moved No available space! Failed access to file storage Recording error! Failed to restore the record! Failed to delete the record! + Recording already started! Move public storage records is needed Later @@ -236,8 +314,12 @@ Record Stop + Pause Resume Finish + Switch to Legacy app + Try new Audio Recorder + Switch to a new Audio Recorder updated with improved features while keeping all your recordings. You can always go back to the old version. Mono - Start recording widget + + %1$dh %2$dm + + %1$dm + + Start recording widget + + Bluetooth microphone available + Audio Source + Default + Mic + Voice Communication + Unprocessed + + + Database update in progress diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index c8fe627d7..366a8ae74 100755 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -16,6 +16,8 @@ +